Repository files navigation

keystroke logo

keystroke

a timer-boxed writing app. pick how long you have, write in markdown, and when the clock hits zero your post ships through a hook you provide. no extensions, no drafts rotting in a folder. the deadline is the editor.

how it works

  1. you point keystroke at an executable (the hook)
  2. you pick a duration: 10, 15, 30, 45, or 60 minutes
  3. you write, with an optional live markdown preview
  4. at zero, keystroke writes your post to a markdown file and runs your hook with it

the hook is required. keystroke will not let you write without one, because a post with nowhere to go is just a draft.

examples

editor

split preview

shipped

install

requires node >= 20.

git clone https://github.com/iteratedcomputing/keystroke.git
cd keystroke
npm install

run

just trying it out? start with the bundled word-count hook, which counts your words and publishes nothing:

make demo

for real use, create a hook (any executable works). start from the word-count example and overwrite it with your own publishing logic:

cp hooks/wordcount.sh hook
chmod +x hook

then start the app:

make dev

it opens http://localhost:7777 in your browser automatically. set KEYSTROKE_OPEN=0 to skip that.

your hook stays yours: ./hook and hooks/local/ are gitignored, so personal hooks never end up in the repo. set KEYSTROKE_HOOK to use hooks anywhere else, and PORT to change the port.

the hook

your hook receives the finished post as a markdown file path in $1, plus KEYSTROKE_TITLE, KEYSTROKE_SLUG, and KEYSTROKE_DURATION_MINUTES in the environment. exit 0 means published; anything else surfaces the failure in the ui with your draft path so nothing is lost.

chain hooks by separating paths with colons. they run in order, and each must exit 0 before the next starts:

export KEYSTROKE_HOOK=./hooks/front-matter.sh:./hooks/publish.sh

pass per-run values to your hooks with --x- flags instead of exporting env vars. --x-blog-tags=tech becomes KEYSTROKE_BLOG_TAGS=tech in the hook environment (uppercased, dashes to underscores). a bare flag like --x-blog-dry-run is set to 1:

node src/server.js --x-blog-tags=tech
make dev ARGS="--x-blog-tags=tech"

make treats ---prefixed words as its own options, so pass them through the ARGS variable rather than make dev --x-....

see docs/hooks.md for the full contract and a publish-to-blog example.

development

make format # prettier
make test# node --test
make build # no-op, nothing to compile
make dev # run the server
make demo # run the server with the bundled word-count hook

About

panic-driven writing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

keystroke logo

keystroke

a timer-boxed writing app. pick how long you have, write in markdown, and when the clock hits zero your post ships through a hook you provide. no extensions, no drafts rotting in a folder. the deadline is the editor.

how it works

  1. you point keystroke at an executable (the hook)
  2. you pick a duration: 10, 15, 30, 45, or 60 minutes
  3. you write, with an optional live markdown preview
  4. at zero, keystroke writes your post to a markdown file and runs your hook with it

the hook is required. keystroke will not let you write without one, because a post with nowhere to go is just a draft.

examples

editor

split preview

shipped

install

requires node >= 20.

git clone https://github.com/iteratedcomputing/keystroke.git
cd keystroke
npm install

run

just trying it out? start with the bundled word-count hook, which counts your words and publishes nothing:

make demo

for real use, create a hook (any executable works). start from the word-count example and overwrite it with your own publishing logic:

cp hooks/wordcount.sh hook
chmod +x hook

then start the app:

make dev

it opens http://localhost:7777 in your browser automatically. set KEYSTROKE_OPEN=0 to skip that.

your hook stays yours: ./hook and hooks/local/ are gitignored, so personal hooks never end up in the repo. set KEYSTROKE_HOOK to use hooks anywhere else, and PORT to change the port.

the hook

your hook receives the finished post as a markdown file path in $1, plus KEYSTROKE_TITLE, KEYSTROKE_SLUG, and KEYSTROKE_DURATION_MINUTES in the environment. exit 0 means published; anything else surfaces the failure in the ui with your draft path so nothing is lost.

chain hooks by separating paths with colons. they run in order, and each must exit 0 before the next starts:

export KEYSTROKE_HOOK=./hooks/front-matter.sh:./hooks/publish.sh

pass per-run values to your hooks with --x- flags instead of exporting env vars. --x-blog-tags=tech becomes KEYSTROKE_BLOG_TAGS=tech in the hook environment (uppercased, dashes to underscores). a bare flag like --x-blog-dry-run is set to 1:

node src/server.js --x-blog-tags=tech
make dev ARGS="--x-blog-tags=tech"

make treats ---prefixed words as its own options, so pass them through the ARGS variable rather than make dev --x-....

see docs/hooks.md for the full contract and a publish-to-blog example.

development

make format # prettier
make test# node --test
make build # no-op, nothing to compile
make dev # run the server
make demo # run the server with the bundled word-count hook

About

panic-driven writing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

keystroke logo

keystroke

a timer-boxed writing app. pick how long you have, write in markdown, and when the clock hits zero your post ships through a hook you provide. no extensions, no drafts rotting in a folder. the deadline is the editor.

how it works

  1. you point keystroke at an executable (the hook)
  2. you pick a duration: 10, 15, 30, 45, or 60 minutes
  3. you write, with an optional live markdown preview
  4. at zero, keystroke writes your post to a markdown file and runs your hook with it

the hook is required. keystroke will not let you write without one, because a post with nowhere to go is just a draft.

examples

editor

split preview

shipped

install

requires node >= 20.

git clone https://github.com/iteratedcomputing/keystroke.git
cd keystroke
npm install

run

just trying it out? start with the bundled word-count hook, which counts your words and publishes nothing:

make demo

for real use, create a hook (any executable works). start from the word-count example and overwrite it with your own publishing logic:

cp hooks/wordcount.sh hook
chmod +x hook

then start the app:

make dev

it opens http://localhost:7777 in your browser automatically. set KEYSTROKE_OPEN=0 to skip that.

your hook stays yours: ./hook and hooks/local/ are gitignored, so personal hooks never end up in the repo. set KEYSTROKE_HOOK to use hooks anywhere else, and PORT to change the port.

the hook

your hook receives the finished post as a markdown file path in $1, plus KEYSTROKE_TITLE, KEYSTROKE_SLUG, and KEYSTROKE_DURATION_MINUTES in the environment. exit 0 means published; anything else surfaces the failure in the ui with your draft path so nothing is lost.

chain hooks by separating paths with colons. they run in order, and each must exit 0 before the next starts:

export KEYSTROKE_HOOK=./hooks/front-matter.sh:./hooks/publish.sh

pass per-run values to your hooks with --x- flags instead of exporting env vars. --x-blog-tags=tech becomes KEYSTROKE_BLOG_TAGS=tech in the hook environment (uppercased, dashes to underscores). a bare flag like --x-blog-dry-run is set to 1:

node src/server.js --x-blog-tags=tech
make dev ARGS="--x-blog-tags=tech"

make treats ---prefixed words as its own options, so pass them through the ARGS variable rather than make dev --x-....

see docs/hooks.md for the full contract and a publish-to-blog example.

development

make format # prettier
make test# node --test
make build # no-op, nothing to compile
make dev # run the server
make demo # run the server with the bundled word-count hook

About

panic-driven writing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

keystroke logo

keystroke

a timer-boxed writing app. pick how long you have, write in markdown, and when the clock hits zero your post ships through a hook you provide. no extensions, no drafts rotting in a folder. the deadline is the editor.

how it works

  1. you point keystroke at an executable (the hook)
  2. you pick a duration: 10, 15, 30, 45, or 60 minutes
  3. you write, with an optional live markdown preview
  4. at zero, keystroke writes your post to a markdown file and runs your hook with it

the hook is required. keystroke will not let you write without one, because a post with nowhere to go is just a draft.

examples

editor

split preview

shipped

install

requires node >= 20.

git clone https://github.com/iteratedcomputing/keystroke.git
cd keystroke
npm install

run

just trying it out? start with the bundled word-count hook, which counts your words and publishes nothing:

make demo

for real use, create a hook (any executable works). start from the word-count example and overwrite it with your own publishing logic:

cp hooks/wordcount.sh hook
chmod +x hook

then start the app:

make dev

it opens http://localhost:7777 in your browser automatically. set KEYSTROKE_OPEN=0 to skip that.

your hook stays yours: ./hook and hooks/local/ are gitignored, so personal hooks never end up in the repo. set KEYSTROKE_HOOK to use hooks anywhere else, and PORT to change the port.

the hook

your hook receives the finished post as a markdown file path in $1, plus KEYSTROKE_TITLE, KEYSTROKE_SLUG, and KEYSTROKE_DURATION_MINUTES in the environment. exit 0 means published; anything else surfaces the failure in the ui with your draft path so nothing is lost.

chain hooks by separating paths with colons. they run in order, and each must exit 0 before the next starts:

export KEYSTROKE_HOOK=./hooks/front-matter.sh:./hooks/publish.sh

pass per-run values to your hooks with --x- flags instead of exporting env vars. --x-blog-tags=tech becomes KEYSTROKE_BLOG_TAGS=tech in the hook environment (uppercased, dashes to underscores). a bare flag like --x-blog-dry-run is set to 1:

node src/server.js --x-blog-tags=tech
make dev ARGS="--x-blog-tags=tech"

make treats ---prefixed words as its own options, so pass them through the ARGS variable rather than make dev --x-....

see docs/hooks.md for the full contract and a publish-to-blog example.

development

make format # prettier
make test# node --test
make build # no-op, nothing to compile
make dev # run the server
make demo # run the server with the bundled word-count hook

About

panic-driven writing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

keystroke logo

keystroke

a timer-boxed writing app. pick how long you have, write in markdown, and when the clock hits zero your post ships through a hook you provide. no extensions, no drafts rotting in a folder. the deadline is the editor.

how it works

  1. you point keystroke at an executable (the hook)
  2. you pick a duration: 10, 15, 30, 45, or 60 minutes
  3. you write, with an optional live markdown preview
  4. at zero, keystroke writes your post to a markdown file and runs your hook with it

the hook is required. keystroke will not let you write without one, because a post with nowhere to go is just a draft.

examples

editor

split preview

shipped

install

requires node >= 20.

git clone https://github.com/iteratedcomputing/keystroke.git
cd keystroke
npm install

run

just trying it out? start with the bundled word-count hook, which counts your words and publishes nothing:

make demo

for real use, create a hook (any executable works). start from the word-count example and overwrite it with your own publishing logic:

cp hooks/wordcount.sh hook
chmod +x hook

then start the app:

make dev

it opens http://localhost:7777 in your browser automatically. set KEYSTROKE_OPEN=0 to skip that.

your hook stays yours: ./hook and hooks/local/ are gitignored, so personal hooks never end up in the repo. set KEYSTROKE_HOOK to use hooks anywhere else, and PORT to change the port.

the hook

your hook receives the finished post as a markdown file path in $1, plus KEYSTROKE_TITLE, KEYSTROKE_SLUG, and KEYSTROKE_DURATION_MINUTES in the environment. exit 0 means published; anything else surfaces the failure in the ui with your draft path so nothing is lost.

chain hooks by separating paths with colons. they run in order, and each must exit 0 before the next starts:

export KEYSTROKE_HOOK=./hooks/front-matter.sh:./hooks/publish.sh

pass per-run values to your hooks with --x- flags instead of exporting env vars. --x-blog-tags=tech becomes KEYSTROKE_BLOG_TAGS=tech in the hook environment (uppercased, dashes to underscores). a bare flag like --x-blog-dry-run is set to 1:

node src/server.js --x-blog-tags=tech
make dev ARGS="--x-blog-tags=tech"

make treats ---prefixed words as its own options, so pass them through the ARGS variable rather than make dev --x-....

see docs/hooks.md for the full contract and a publish-to-blog example.

development

make format # prettier
make test# node --test
make build # no-op, nothing to compile
make dev # run the server
make demo # run the server with the bundled word-count hook

About

panic-driven writing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

keystroke logo

keystroke

a timer-boxed writing app. pick how long you have, write in markdown, and when the clock hits zero your post ships through a hook you provide. no extensions, no drafts rotting in a folder. the deadline is the editor.

how it works

  1. you point keystroke at an executable (the hook)
  2. you pick a duration: 10, 15, 30, 45, or 60 minutes
  3. you write, with an optional live markdown preview
  4. at zero, keystroke writes your post to a markdown file and runs your hook with it

the hook is required. keystroke will not let you write without one, because a post with nowhere to go is just a draft.

examples

editor

split preview

shipped

install

requires node >= 20.

git clone https://github.com/iteratedcomputing/keystroke.git
cd keystroke
npm install

run

just trying it out? start with the bundled word-count hook, which counts your words and publishes nothing:

make demo

for real use, create a hook (any executable works). start from the word-count example and overwrite it with your own publishing logic:

cp hooks/wordcount.sh hook
chmod +x hook

then start the app:

make dev

it opens http://localhost:7777 in your browser automatically. set KEYSTROKE_OPEN=0 to skip that.

your hook stays yours: ./hook and hooks/local/ are gitignored, so personal hooks never end up in the repo. set KEYSTROKE_HOOK to use hooks anywhere else, and PORT to change the port.

the hook

your hook receives the finished post as a markdown file path in $1, plus KEYSTROKE_TITLE, KEYSTROKE_SLUG, and KEYSTROKE_DURATION_MINUTES in the environment. exit 0 means published; anything else surfaces the failure in the ui with your draft path so nothing is lost.

chain hooks by separating paths with colons. they run in order, and each must exit 0 before the next starts:

export KEYSTROKE_HOOK=./hooks/front-matter.sh:./hooks/publish.sh

pass per-run values to your hooks with --x- flags instead of exporting env vars. --x-blog-tags=tech becomes KEYSTROKE_BLOG_TAGS=tech in the hook environment (uppercased, dashes to underscores). a bare flag like --x-blog-dry-run is set to 1:

node src/server.js --x-blog-tags=tech
make dev ARGS="--x-blog-tags=tech"

make treats ---prefixed words as its own options, so pass them through the ARGS variable rather than make dev --x-....

see docs/hooks.md for the full contract and a publish-to-blog example.

development

make format # prettier
make test# node --test
make build # no-op, nothing to compile
make dev # run the server
make demo # run the server with the bundled word-count hook

About

panic-driven writing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

keystroke logo

keystroke

a timer-boxed writing app. pick how long you have, write in markdown, and when the clock hits zero your post ships through a hook you provide. no extensions, no drafts rotting in a folder. the deadline is the editor.

how it works

  1. you point keystroke at an executable (the hook)
  2. you pick a duration: 10, 15, 30, 45, or 60 minutes
  3. you write, with an optional live markdown preview
  4. at zero, keystroke writes your post to a markdown file and runs your hook with it

the hook is required. keystroke will not let you write without one, because a post with nowhere to go is just a draft.

examples

editor

split preview

shipped

install

requires node >= 20.

git clone https://github.com/iteratedcomputing/keystroke.git
cd keystroke
npm install

run

just trying it out? start with the bundled word-count hook, which counts your words and publishes nothing:

make demo

for real use, create a hook (any executable works). start from the word-count example and overwrite it with your own publishing logic:

cp hooks/wordcount.sh hook
chmod +x hook

then start the app:

make dev

it opens http://localhost:7777 in your browser automatically. set KEYSTROKE_OPEN=0 to skip that.

your hook stays yours: ./hook and hooks/local/ are gitignored, so personal hooks never end up in the repo. set KEYSTROKE_HOOK to use hooks anywhere else, and PORT to change the port.

the hook

your hook receives the finished post as a markdown file path in $1, plus KEYSTROKE_TITLE, KEYSTROKE_SLUG, and KEYSTROKE_DURATION_MINUTES in the environment. exit 0 means published; anything else surfaces the failure in the ui with your draft path so nothing is lost.

chain hooks by separating paths with colons. they run in order, and each must exit 0 before the next starts:

export KEYSTROKE_HOOK=./hooks/front-matter.sh:./hooks/publish.sh

pass per-run values to your hooks with --x- flags instead of exporting env vars. --x-blog-tags=tech becomes KEYSTROKE_BLOG_TAGS=tech in the hook environment (uppercased, dashes to underscores). a bare flag like --x-blog-dry-run is set to 1:

node src/server.js --x-blog-tags=tech
make dev ARGS="--x-blog-tags=tech"

make treats ---prefixed words as its own options, so pass them through the ARGS variable rather than make dev --x-....

see docs/hooks.md for the full contract and a publish-to-blog example.

development

make format # prettier
make test# node --test
make build # no-op, nothing to compile
make dev # run the server
make demo # run the server with the bundled word-count hook

About

panic-driven writing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

keystroke logo

keystroke

a timer-boxed writing app. pick how long you have, write in markdown, and when the clock hits zero your post ships through a hook you provide. no extensions, no drafts rotting in a folder. the deadline is the editor.

how it works

  1. you point keystroke at an executable (the hook)
  2. you pick a duration: 10, 15, 30, 45, or 60 minutes
  3. you write, with an optional live markdown preview
  4. at zero, keystroke writes your post to a markdown file and runs your hook with it

the hook is required. keystroke will not let you write without one, because a post with nowhere to go is just a draft.

examples

editor

split preview

shipped

install

requires node >= 20.

git clone https://github.com/iteratedcomputing/keystroke.git
cd keystroke
npm install

run

just trying it out? start with the bundled word-count hook, which counts your words and publishes nothing:

make demo

for real use, create a hook (any executable works). start from the word-count example and overwrite it with your own publishing logic:

cp hooks/wordcount.sh hook
chmod +x hook

then start the app:

make dev

it opens http://localhost:7777 in your browser automatically. set KEYSTROKE_OPEN=0 to skip that.

your hook stays yours: ./hook and hooks/local/ are gitignored, so personal hooks never end up in the repo. set KEYSTROKE_HOOK to use hooks anywhere else, and PORT to change the port.

the hook

your hook receives the finished post as a markdown file path in $1, plus KEYSTROKE_TITLE, KEYSTROKE_SLUG, and KEYSTROKE_DURATION_MINUTES in the environment. exit 0 means published; anything else surfaces the failure in the ui with your draft path so nothing is lost.

chain hooks by separating paths with colons. they run in order, and each must exit 0 before the next starts:

export KEYSTROKE_HOOK=./hooks/front-matter.sh:./hooks/publish.sh

pass per-run values to your hooks with --x- flags instead of exporting env vars. --x-blog-tags=tech becomes KEYSTROKE_BLOG_TAGS=tech in the hook environment (uppercased, dashes to underscores). a bare flag like --x-blog-dry-run is set to 1:

node src/server.js --x-blog-tags=tech
make dev ARGS="--x-blog-tags=tech"

make treats ---prefixed words as its own options, so pass them through the ARGS variable rather than make dev --x-....

see docs/hooks.md for the full contract and a publish-to-blog example.

development

make format # prettier
make test# node --test
make build # no-op, nothing to compile
make dev # run the server
make demo # run the server with the bundled word-count hook

About

panic-driven writing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages