@sirux/md-press 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sirasit Thitirattanakorn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # md-press
2
+
3
+ [![Test](https://github.com/sirasitxp/md-press/actions/workflows/test.yml/badge.svg)](https://github.com/sirasitxp/md-press/actions/workflows/test.yml)
4
+ [![npm](https://img.shields.io/npm/v/@sirux/md-press.svg)](https://www.npmjs.com/package/@sirux/md-press)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ Press Markdown into clean pages. Pure code: no AI, no tokens, no build setup.
8
+
9
+ ```sh
10
+ npm install -g @sirux/md-press
11
+ md-press notes.md # build notes.html, a page you can share anywhere
12
+ md-press serve notes.md # open notes.md live, checkboxes save into the file
13
+ ```
14
+
15
+ ## Why
16
+
17
+ Notes, plans, and trackers live best as Markdown. They read best as a page. md-press gives the file a better view without taking it away from you. The file stays the source of truth, readable and editable by people, editors, and agents alike.
18
+
19
+ ## Two ways to use it
20
+
21
+ ### Build: a page to share
22
+
23
+ ```sh
24
+ md-press notes.md # writes notes.html next to notes.md
25
+ md-press docs/*.md --out pages # builds many files into one folder
26
+ npx @sirux/md-press notes.md # runs without installing
27
+ ```
28
+
29
+ You get one HTML file with its styles and scripts inlined, ready to open, send, print, or host anywhere. Checklist progress on a built page is saved in the viewer's browser.
30
+
31
+ ### Serve: a live page for working
32
+
33
+ ```sh
34
+ md-press serve notes.md
35
+ ```
36
+
37
+ Opens `notes.md` at `http://localhost:5180`.
38
+
39
+ - **Checking a box saves `[x]` into the file.** Only that one character changes.
40
+ - **Edit the file anywhere**, in an editor or with an agent, and the page updates on its own, keeping your scroll position.
41
+ - **Safe with other editors.** If the file changed since the page loaded, a save is refused and the page reloads, so no one's edit is overwritten.
42
+ - **Images next to the file show up**, so relative links like `![](screenshot.png)` work.
43
+
44
+ Stop it with Ctrl+C.
45
+
46
+ ## Options
47
+
48
+ | Option | Mode | What it does |
49
+ | --- | --- | --- |
50
+ | `--out <folder>` | build | Writes pages into this folder instead of next to each file |
51
+ | `--port <number>` | serve | Port to start from. Default 5180, and the next 9 are tried if it is busy |
52
+ | `--no-open` | serve | Does not open the browser |
53
+ | `-v`, `--version` | any | Shows the version |
54
+ | `-h`, `--help` | any | Shows help |
55
+
56
+ `md-press build notes.md` works too, same as `md-press notes.md`.
57
+
58
+ ## What every page gets
59
+
60
+ - **Light and dark mode** that follow the system setting, plus clean print styles.
61
+ - **Readable on phones.** Wide tables and code scroll inside themselves, never the page.
62
+ - **Code highlighting**, done when the page is made, so pages stay fast.
63
+ - **Checklists.** `- [ ]` items become real checkboxes with a progress bar.
64
+ - **Smart titles.** Uses frontmatter `title:`, then the first `#` heading, then the file name.
65
+ - **Diagrams.** ` ```mermaid ` blocks render as diagrams. This is the only feature that loads anything from the network, and only on pages that have a diagram. Offline, the diagram source shows instead.
66
+
67
+ See [`examples/showcase.md`](examples/showcase.md) for every feature on one page. Try it both ways.
68
+
69
+ ## Frontmatter
70
+
71
+ ```markdown
72
+ ---
73
+ title: Q4 launch plan
74
+ description: Owners, dates, and open risks
75
+ ---
76
+ ```
77
+
78
+ `title` sets the browser tab title. `description` sets the page's meta description. Other keys are ignored for now.
79
+
80
+ ## Use it from code
81
+
82
+ ```js
83
+ const { renderPage, buildFile } = require("@sirux/md-press");
84
+
85
+ const html = renderPage("# Hello\n\n- [ ] Ship it", "hello.md");
86
+ buildFile("notes.md", "pages");
87
+ ```
88
+
89
+ ## Good to know
90
+
91
+ - **Built pages save progress per browser**, keyed by file name. Only `serve` writes to the file.
92
+ - **A served page is read-only if md-press cannot match every checkbox to its line**, for example when a task sits inside an indented code block. It says so in the toolbar rather than risk editing the wrong line.
93
+ - **Raw HTML is kept**, so `<kbd>` and `<details>` work. To show a tag as text, wrap it in backticks. Only press or serve files you trust, the same as opening any HTML file.
94
+ - **`serve` answers only this computer.** It is not reachable from your phone or network.
95
+ - **The package is `@sirux/md-press`, the command is `md-press`.** npm reserves plain `md-press` because it is close to an older, unrelated package.
96
+ - **Requires Node 20 or newer.**
97
+
98
+ ## Docs
99
+
100
+ - [How it works](docs/architecture.md)
101
+ - [Decisions](docs/decisions.md)
102
+ - [Roadmap](docs/roadmap.md)
103
+ - [Contributing](CONTRIBUTING.md)
104
+ - [Security](SECURITY.md)
105
+ - [Changelog](CHANGELOG.md)
106
+
107
+ ## License
108
+
109
+ MIT © Sirasit Thitirattanakorn. Made by [Sirux](https://sirux.io).
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ Command line entry for md-press. Two modes share one parser for options:
4
+ "build" (the default, so a bare file list builds pages) and "serve", which
5
+ opens one file as a live page on localhost. The command stays thin; all
6
+ behavior worth testing lives in src/.
7
+ */
8
+
9
+ const fileSystem = require("fs");
10
+ const { spawn } = require("child_process");
11
+ const { buildFile } = require("../src/build.js");
12
+ const { startServer } = require("../src/serve.js");
13
+ const { version } = require("../package.json");
14
+
15
+ const usageText = `md-press ${version}
16
+ Press Markdown into clean pages, to share or to work in.
17
+
18
+ Usage:
19
+ md-press <file.md> [more.md ...] [--out folder] Build static pages
20
+ md-press serve <file.md> [--port 5180] Open a live page that saves checkboxes to the file
21
+
22
+ Options:
23
+ --out <folder> Build: write pages into this folder instead of next to each file
24
+ --port <number> Serve: port to start from (default 5180, tries the next 9 if busy)
25
+ --no-open Serve: do not open the browser
26
+ -v, --version Show the version
27
+ -h, --help Show this help`;
28
+
29
+ function exitWithError(message) {
30
+ console.error(`md-press: ${message}`);
31
+ console.error("Run md-press --help for usage.");
32
+ process.exit(1);
33
+ }
34
+
35
+ /*
36
+ Splits raw arguments into file paths and options. Options that do not apply
37
+ to the chosen mode, and unknown flags, are rejected rather than silently
38
+ ignored or treated as file names.
39
+ */
40
+ function readArguments(rawArguments, mode) {
41
+ const sourcePaths = [];
42
+ const options = { outputFolder: null, port: 5180, openBrowser: true };
43
+ for (let index = 0; index < rawArguments.length; index += 1) {
44
+ const argument = rawArguments[index];
45
+ const nextArgument = rawArguments[index + 1];
46
+ if (argument === "-h" || argument === "--help") {
47
+ console.log(usageText);
48
+ process.exit(0);
49
+ } else if (argument === "-v" || argument === "--version") {
50
+ console.log(version);
51
+ process.exit(0);
52
+ } else if (argument === "--out" && mode === "build") {
53
+ if (!nextArgument || nextArgument.startsWith("-")) exitWithError("--out needs a folder name");
54
+ options.outputFolder = nextArgument;
55
+ index += 1;
56
+ } else if (argument === "--port" && mode === "serve") {
57
+ const port = Number(nextArgument);
58
+ if (!Number.isInteger(port) || port < 1 || port > 65535) exitWithError("--port needs a number from 1 to 65535");
59
+ options.port = port;
60
+ index += 1;
61
+ } else if (argument === "--no-open" && mode === "serve") {
62
+ options.openBrowser = false;
63
+ } else if (argument.startsWith("-")) {
64
+ exitWithError(`unknown option ${argument} for ${mode}`);
65
+ } else {
66
+ sourcePaths.push(argument);
67
+ }
68
+ }
69
+ return { sourcePaths, options };
70
+ }
71
+
72
+ function isReadableFile(filePath) {
73
+ return fileSystem.existsSync(filePath) && fileSystem.statSync(filePath).isFile();
74
+ }
75
+
76
+ function runBuild(rawArguments) {
77
+ const { sourcePaths, options } = readArguments(rawArguments, "build");
78
+ if (sourcePaths.length === 0) {
79
+ console.log(usageText);
80
+ process.exit(1);
81
+ }
82
+ if (options.outputFolder) fileSystem.mkdirSync(options.outputFolder, { recursive: true });
83
+
84
+ for (const sourcePath of sourcePaths) {
85
+ if (!isReadableFile(sourcePath)) {
86
+ console.error(`Skipped ${sourcePath}: file not found`);
87
+ process.exitCode = 1;
88
+ continue;
89
+ }
90
+ const outputPath = buildFile(sourcePath, options.outputFolder);
91
+ console.log(`${sourcePath} -> ${outputPath}`);
92
+ }
93
+ }
94
+
95
+ /*
96
+ Opens the page in the default browser. Failure is harmless because the URL
97
+ is also printed, so errors from the opener are ignored.
98
+ */
99
+ function openInBrowser(url) {
100
+ const opener = process.platform === "darwin"
101
+ ? ["open", [url]]
102
+ : process.platform === "win32"
103
+ ? ["cmd", ["/c", "start", "", url]]
104
+ : ["xdg-open", [url]];
105
+ const child = spawn(opener[0], opener[1], { stdio: "ignore", detached: true });
106
+ child.on("error", () => {});
107
+ child.unref();
108
+ }
109
+
110
+ async function runServe(rawArguments) {
111
+ const { sourcePaths, options } = readArguments(rawArguments, "serve");
112
+ if (sourcePaths.length !== 1) exitWithError("serve takes exactly one Markdown file");
113
+ const [sourcePath] = sourcePaths;
114
+ if (!isReadableFile(sourcePath)) exitWithError(`${sourcePath}: file not found`);
115
+
116
+ try {
117
+ const { url } = await startServer(sourcePath, { port: options.port });
118
+ console.log(`Serving ${sourcePath} at ${url}`);
119
+ console.log("Checkbox changes save to the file. Press Ctrl+C to stop.");
120
+ if (options.openBrowser) openInBrowser(url);
121
+ } catch (error) {
122
+ exitWithError(`could not start the server: ${error.message}`);
123
+ }
124
+ }
125
+
126
+ function main() {
127
+ const rawArguments = process.argv.slice(2);
128
+ if (rawArguments[0] === "serve") return runServe(rawArguments.slice(1));
129
+ if (rawArguments[0] === "build") return runBuild(rawArguments.slice(1));
130
+ return runBuild(rawArguments);
131
+ }
132
+
133
+ main();
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@sirux/md-press",
3
+ "version": "0.1.0",
4
+ "description": "Press Markdown into clean pages. Build self-contained HTML, or serve it live and save checkboxes back to the file. No AI, no tokens.",
5
+ "keywords": [
6
+ "markdown",
7
+ "html",
8
+ "cli",
9
+ "static-site",
10
+ "checklist",
11
+ "offline",
12
+ "live",
13
+ "todo"
14
+ ],
15
+ "homepage": "https://github.com/sirasitxp/md-press#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/sirasitxp/md-press/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/sirasitxp/md-press.git"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Sirasit Thitirattanakorn (Sirux) <sirasit@sirux.io>",
25
+ "main": "src/build.js",
26
+ "bin": {
27
+ "md-press": "bin/md-press.js"
28
+ },
29
+ "files": [
30
+ "bin/",
31
+ "src/"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test",
38
+ "lint": "eslint .",
39
+ "check": "npm run lint && npm test",
40
+ "prepublishOnly": "npm run check"
41
+ },
42
+ "dependencies": {
43
+ "highlight.js": "^11.12.0",
44
+ "marked": "^18.0.14"
45
+ },
46
+ "devDependencies": {
47
+ "@eslint/js": "^9.39.5",
48
+ "eslint": "^9.39.5",
49
+ "globals": "^17.12.0"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
package/src/build.js ADDED
@@ -0,0 +1,165 @@
1
+ /*
2
+ Turns Markdown text into one self-contained, styled HTML page. No network
3
+ calls and no AI. Markdown is parsed with marked, code blocks are highlighted
4
+ at build time with highlight.js, and the stylesheet and checklist script are
5
+ inlined so every page is a single file that opens anywhere. The only outside
6
+ request is Mermaid, loaded from a CDN only on pages that contain a diagram.
7
+
8
+ The same builder renders pages for md-press serve. There the page script
9
+ saves checkboxes to the Markdown file instead of the browser, and the toolbar
10
+ shows whether the page is live.
11
+ */
12
+
13
+ const fileSystem = require("fs");
14
+ const path = require("path");
15
+ const { Marked } = require("marked");
16
+ const highlighter = require("highlight.js");
17
+ const { splitFrontmatter } = require("./frontmatter.js");
18
+ const { findTaskLines } = require("./tasks.js");
19
+
20
+ const templateFolder = path.join(__dirname, "template");
21
+ const pageStyles = fileSystem.readFileSync(path.join(templateFolder, "page.css"), "utf8");
22
+ const pageScript = fileSystem.readFileSync(path.join(templateFolder, "page.js"), "utf8");
23
+ const mermaidScriptUrl = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
24
+
25
+ function escapeHtml(text) {
26
+ return String(text)
27
+ .replace(/&/g, "&amp;")
28
+ .replace(/</g, "&lt;")
29
+ .replace(/>/g, "&gt;")
30
+ .replace(/"/g, "&quot;");
31
+ }
32
+
33
+ /*
34
+ Builds a marked instance with one change from the defaults: fenced code gets
35
+ highlighted, and mermaid fences are kept as diagram source for the browser.
36
+ */
37
+ function createParser(documentState) {
38
+ const markdownParser = new Marked();
39
+ markdownParser.use({
40
+ renderer: {
41
+ code({ text, lang }) {
42
+ const language = (lang || "").trim().split(/\s+/)[0];
43
+ if (language === "mermaid") {
44
+ documentState.hasMermaid = true;
45
+ return `<pre class="mermaid">${escapeHtml(text)}</pre>\n`;
46
+ }
47
+ const highlighted = language && highlighter.getLanguage(language)
48
+ ? highlighter.highlight(text, { language }).value
49
+ : escapeHtml(text);
50
+ const languageLabel = language ? ` data-language="${escapeHtml(language)}"` : "";
51
+ return `<pre${languageLabel}><code class="hljs">${highlighted}</code></pre>\n`;
52
+ },
53
+ },
54
+ });
55
+ return markdownParser;
56
+ }
57
+
58
+ /*
59
+ marked renders task list items as disabled checkboxes. This swaps them for
60
+ live ones the inlined page script can save, and counts them so the page only
61
+ gets the progress bar and script when it needs them.
62
+ */
63
+ function enableTaskCheckboxes(html, documentState) {
64
+ return html.replace(/<input (checked="" )?disabled="" type="checkbox">/g, (_fullMatch, checkedAttribute) => {
65
+ documentState.taskCount += 1;
66
+ return `<input type="checkbox" class="task"${checkedAttribute ? " checked" : ""}>`;
67
+ });
68
+ }
69
+
70
+ function findTitle(metadata, body, sourceName) {
71
+ if (metadata.title) return metadata.title;
72
+ const heading = body.match(/^#\s+(.+)$/m);
73
+ if (heading) return heading[1].replace(/[*_`]/g, "").trim();
74
+ return path.basename(sourceName, path.extname(sourceName));
75
+ }
76
+
77
+ /*
78
+ Builds the bar pinned to the top of the page. Static pages show it only when
79
+ they have tasks, with a progress track and a reset button. Served pages always
80
+ show it, with a status line and no reset, since reset would rewrite the file.
81
+ */
82
+ function renderToolbar(taskCount, live) {
83
+ if (taskCount === 0 && !live) return "";
84
+ const progress = taskCount > 0
85
+ ? `<div class="progress-track"><div class="progress-fill"></div></div><span class="progress-label"></span>`
86
+ : "";
87
+ const status = live
88
+ ? `<span class="toolbar-status">${live.writable ? `Live, saving to ${escapeHtml(live.fileName)}` : ""}</span>`
89
+ : "";
90
+ const reset = taskCount > 0 && !live ? `<button type="button" class="progress-reset">Reset</button>` : "";
91
+ return `<div class="progress" role="status" aria-live="polite">${progress}${status}${reset}</div>`;
92
+ }
93
+
94
+ /*
95
+ Assembles the final page from Markdown text. The source name sets the
96
+ fallback title, the footer, and the checklist storage key, which is based on
97
+ the file name so saved progress survives edits and rebuilds of the same file.
98
+
99
+ Passing options.live with the file's version renders a page for md-press
100
+ serve. It is only writable when the task scanner finds exactly as many tasks
101
+ as the page rendered, so a checkbox can never be saved to the wrong line.
102
+ */
103
+ function renderPage(sourceText, sourceName = "document.md", options = {}) {
104
+ const { metadata, body } = splitFrontmatter(sourceText);
105
+ const documentState = { hasMermaid: false, taskCount: 0 };
106
+ const markdownParser = createParser(documentState);
107
+ const content = enableTaskCheckboxes(markdownParser.parse(body), documentState);
108
+ const title = findTitle(metadata, body, sourceName);
109
+ const baseName = path.basename(sourceName);
110
+ const generatedDate = new Date().toISOString().slice(0, 10);
111
+
112
+ const live = options.live
113
+ ? {
114
+ version: options.live.version,
115
+ fileName: baseName,
116
+ writable: findTaskLines(sourceText).length === documentState.taskCount,
117
+ }
118
+ : null;
119
+ const pageSettings = {
120
+ storageKey: "md-press:" + path.basename(sourceName, path.extname(sourceName)),
121
+ live,
122
+ };
123
+ const needsPageScript = documentState.taskCount > 0 || live;
124
+ const scripts = [
125
+ needsPageScript ? `<script>const mdPress = ${JSON.stringify(pageSettings).replace(/</g, "\\u003c")};\n${pageScript}</script>` : "",
126
+ documentState.hasMermaid ? `<script src="${mermaidScriptUrl}"></script><script>if (window.mermaid) mermaid.initialize({ startOnLoad: true, theme: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "neutral" });</script>` : "",
127
+ ].filter(Boolean).join("\n");
128
+ const footerText = live
129
+ ? `Serving ${escapeHtml(baseName)} with md-press`
130
+ : `Generated ${generatedDate} from ${escapeHtml(baseName)} with md-press`;
131
+
132
+ return `<!doctype html>
133
+ <html lang="en">
134
+ <head>
135
+ <meta charset="utf-8">
136
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
137
+ <title>${escapeHtml(title)}</title>
138
+ ${metadata.description ? `<meta name="description" content="${escapeHtml(metadata.description)}">` : ""}
139
+ <style>${pageStyles}</style>
140
+ </head>
141
+ <body>
142
+ ${renderToolbar(documentState.taskCount, live)}
143
+ <main>
144
+ ${content}
145
+ </main>
146
+ <footer>${footerText}</footer>
147
+ ${scripts}
148
+ </body>
149
+ </html>
150
+ `;
151
+ }
152
+
153
+ /*
154
+ Reads one Markdown file and writes its page next to it, or into the output
155
+ folder when one is given. Returns the path of the page it wrote.
156
+ */
157
+ function buildFile(sourcePath, outputFolder) {
158
+ const sourceText = fileSystem.readFileSync(sourcePath, "utf8");
159
+ const outputName = path.basename(sourcePath, path.extname(sourcePath)) + ".html";
160
+ const outputPath = path.join(outputFolder || path.dirname(sourcePath), outputName);
161
+ fileSystem.writeFileSync(outputPath, renderPage(sourceText, sourcePath));
162
+ return outputPath;
163
+ }
164
+
165
+ module.exports = { renderPage, buildFile, splitFrontmatter, escapeHtml };
@@ -0,0 +1,30 @@
1
+ /*
2
+ Reads the optional frontmatter block at the top of a Markdown file. Only
3
+ simple "key: value" lines are supported, which covers title and description.
4
+ Values wrapped in matching quotes lose those quotes; quotes inside a value
5
+ are kept. Shared by the page builder and the task scanner so both agree on
6
+ where the Markdown body starts.
7
+ */
8
+
9
+ const frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
10
+
11
+ function frontmatterLength(sourceText) {
12
+ const match = sourceText.match(frontmatterPattern);
13
+ return match ? match[0].length : 0;
14
+ }
15
+
16
+ function splitFrontmatter(sourceText) {
17
+ const match = sourceText.match(frontmatterPattern);
18
+ if (!match) return { metadata: {}, body: sourceText };
19
+ const metadata = {};
20
+ for (const line of match[1].split(/\r?\n/)) {
21
+ const separatorIndex = line.indexOf(":");
22
+ if (separatorIndex === -1) continue;
23
+ const key = line.slice(0, separatorIndex).trim();
24
+ const value = line.slice(separatorIndex + 1).trim().replace(/^(["'])(.*)\1$/, "$2");
25
+ if (key) metadata[key] = value;
26
+ }
27
+ return { metadata, body: sourceText.slice(match[0].length) };
28
+ }
29
+
30
+ module.exports = { splitFrontmatter, frontmatterLength };
package/src/serve.js ADDED
@@ -0,0 +1,194 @@
1
+ /*
2
+ Serves one Markdown file as a live page on localhost. The file stays the
3
+ source of truth: people, editors, and agents can change it while the page is
4
+ open, and the page reloads to match.
5
+
6
+ Writes are guarded three ways. Every save carries the version (a hash of the
7
+ file) that the page was built from, and is refused with 409 if the file has
8
+ changed since. Each write goes to a temporary file that is then renamed over
9
+ the original, so the file is never left half written. Only the character
10
+ between a task's brackets changes, through setTaskState.
11
+
12
+ The server binds to 127.0.0.1 and answers only requests addressed to
13
+ localhost, which blocks DNS rebinding. Saves must be JSON, which a page on
14
+ another site cannot send without a CORS preflight that this server never
15
+ allows. Files next to the Markdown file (images, for example) are served so
16
+ relative links work, but never dotfiles, never anything outside that folder.
17
+ */
18
+
19
+ const fileSystem = require("fs");
20
+ const fileSystemPromises = require("fs/promises");
21
+ const http = require("http");
22
+ const path = require("path");
23
+ const crypto = require("crypto");
24
+ const { renderPage } = require("./build.js");
25
+ const { setTaskState, findTaskLines } = require("./tasks.js");
26
+
27
+ const maximumRequestBytes = 64 * 1024;
28
+ const allowedHostNames = new Set(["localhost", "127.0.0.1"]);
29
+ const contentTypes = {
30
+ ".css": "text/css; charset=utf-8",
31
+ ".gif": "image/gif",
32
+ ".html": "text/html; charset=utf-8",
33
+ ".ico": "image/x-icon",
34
+ ".jpeg": "image/jpeg",
35
+ ".jpg": "image/jpeg",
36
+ ".js": "text/javascript; charset=utf-8",
37
+ ".json": "application/json; charset=utf-8",
38
+ ".md": "text/markdown; charset=utf-8",
39
+ ".mp4": "video/mp4",
40
+ ".pdf": "application/pdf",
41
+ ".png": "image/png",
42
+ ".svg": "image/svg+xml",
43
+ ".txt": "text/plain; charset=utf-8",
44
+ ".webm": "video/webm",
45
+ ".webp": "image/webp",
46
+ };
47
+
48
+ function versionOf(text) {
49
+ return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
50
+ }
51
+
52
+ function send(response, status, body, contentType = "application/json; charset=utf-8") {
53
+ response.writeHead(status, { "Content-Type": contentType, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
54
+ response.end(typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body));
55
+ }
56
+
57
+ async function readRequestBody(request) {
58
+ let body = "";
59
+ for await (const chunk of request) {
60
+ body += chunk;
61
+ if (body.length > maximumRequestBytes) throw Object.assign(new Error("Request too large"), { status: 413 });
62
+ }
63
+ return body;
64
+ }
65
+
66
+ /*
67
+ Writes through a temporary file in the same folder and renames it over the
68
+ original. A rename within one folder is atomic, so readers see either the old
69
+ file or the new one, never a mix. It is synchronous on purpose: see
70
+ handleTaskSave.
71
+ */
72
+ function writeFileAtomically(filePath, text) {
73
+ const temporaryPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.md-press-${process.pid}.tmp`);
74
+ fileSystem.writeFileSync(temporaryPath, text, "utf8");
75
+ fileSystem.renameSync(temporaryPath, filePath);
76
+ }
77
+
78
+ /*
79
+ Applies one checkbox change from the page. Reading the file, checking its
80
+ version, and writing it back are all synchronous, with no await in between,
81
+ so on this single-threaded server two saves can never interleave and one can
82
+ never overwrite the other.
83
+ */
84
+ async function handleTaskSave(request, response, sourcePath) {
85
+ if (!(request.headers["content-type"] || "").startsWith("application/json")) {
86
+ return send(response, 415, { error: "Expected application/json" });
87
+ }
88
+ let payload;
89
+ try {
90
+ payload = JSON.parse(await readRequestBody(request));
91
+ } catch (error) {
92
+ return send(response, error.status || 400, { error: error.status ? error.message : "Invalid JSON" });
93
+ }
94
+ const { index, checked, version } = payload || {};
95
+ if (!Number.isInteger(index) || index < 0 || typeof checked !== "boolean" || typeof version !== "string") {
96
+ return send(response, 400, { error: "Expected { index, checked, version }" });
97
+ }
98
+
99
+ const currentText = fileSystem.readFileSync(sourcePath, "utf8");
100
+ const currentVersion = versionOf(currentText);
101
+ if (currentVersion !== version) {
102
+ return send(response, 409, { error: "The file changed on disk. Reload to see it.", version: currentVersion });
103
+ }
104
+ if (index >= findTaskLines(currentText).length) {
105
+ return send(response, 400, { error: `No task at index ${index}` });
106
+ }
107
+ const updatedText = setTaskState(currentText, index, checked);
108
+ if (updatedText !== currentText) writeFileAtomically(sourcePath, updatedText);
109
+ return send(response, 200, { version: versionOf(updatedText) });
110
+ }
111
+
112
+ /*
113
+ Serves a file from the Markdown file's folder. The resolved path must stay
114
+ inside that folder, and any path segment starting with a dot is refused so
115
+ files like .env or .git are never exposed.
116
+ */
117
+ async function handleFolderFile(response, sourceFolder, requestPath) {
118
+ let relativePath;
119
+ try {
120
+ relativePath = decodeURIComponent(requestPath).replace(/^\/+/, "");
121
+ } catch (_error) {
122
+ return send(response, 400, { error: "Bad path" });
123
+ }
124
+ const segments = relativePath.split(/[\\/]/);
125
+ if (!relativePath || segments.some((segment) => segment.startsWith(".") || segment === "")) {
126
+ return send(response, 404, { error: "Not found" });
127
+ }
128
+ const filePath = path.resolve(sourceFolder, relativePath);
129
+ if (!filePath.startsWith(sourceFolder + path.sep)) return send(response, 404, { error: "Not found" });
130
+ try {
131
+ const stats = await fileSystemPromises.stat(filePath);
132
+ if (!stats.isFile()) return send(response, 404, { error: "Not found" });
133
+ const contentType = contentTypes[path.extname(filePath).toLowerCase()] || "application/octet-stream";
134
+ return send(response, 200, await fileSystemPromises.readFile(filePath), contentType);
135
+ } catch (_error) {
136
+ return send(response, 404, { error: "Not found" });
137
+ }
138
+ }
139
+
140
+ function createServeHandler(sourcePath) {
141
+ const resolvedSourcePath = path.resolve(sourcePath);
142
+ const sourceFolder = path.dirname(resolvedSourcePath);
143
+
144
+ return async function handleRequest(request, response) {
145
+ const hostName = (request.headers.host || "").replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
146
+ if (!allowedHostNames.has(hostName)) return send(response, 403, { error: "Forbidden" });
147
+
148
+ try {
149
+ const url = new URL(request.url, "http://localhost");
150
+ if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
151
+ const text = fileSystem.readFileSync(resolvedSourcePath, "utf8");
152
+ return send(response, 200, renderPage(text, resolvedSourcePath, { live: { version: versionOf(text) } }), "text/html; charset=utf-8");
153
+ }
154
+ if (request.method === "GET" && url.pathname === "/api/version") {
155
+ return send(response, 200, { version: versionOf(fileSystem.readFileSync(resolvedSourcePath, "utf8")) });
156
+ }
157
+ if (request.method === "POST" && url.pathname === "/api/task") {
158
+ return await handleTaskSave(request, response, resolvedSourcePath);
159
+ }
160
+ if (request.method === "GET") return await handleFolderFile(response, sourceFolder, url.pathname);
161
+ return send(response, 405, { error: "Method not allowed" });
162
+ } catch (error) {
163
+ return send(response, 500, { error: error.message });
164
+ }
165
+ };
166
+ }
167
+
168
+ /*
169
+ Starts the server on the first free port, beginning at the requested one and
170
+ trying the next nine. Resolves with the server and its address.
171
+ */
172
+ function startServer(sourcePath, { port = 5180, attempts = 10 } = {}) {
173
+ const server = http.createServer(createServeHandler(sourcePath));
174
+ return new Promise((resolve, reject) => {
175
+ let attemptPort = port;
176
+ let remainingAttempts = attempts;
177
+ server.on("error", (error) => {
178
+ if (error.code === "EADDRINUSE" && port !== 0 && remainingAttempts > 1) {
179
+ remainingAttempts -= 1;
180
+ attemptPort += 1;
181
+ server.listen(attemptPort, "127.0.0.1");
182
+ } else {
183
+ reject(error);
184
+ }
185
+ });
186
+ server.on("listening", () => {
187
+ const address = server.address();
188
+ resolve({ server, url: `http://localhost:${address.port}` });
189
+ });
190
+ server.listen(attemptPort, "127.0.0.1");
191
+ });
192
+ }
193
+
194
+ module.exports = { startServer, versionOf };
package/src/tasks.js ADDED
@@ -0,0 +1,65 @@
1
+ /*
2
+ Finds task list items in Markdown source so a checkbox on the page can be
3
+ written back to the exact line it came from. It scans line by line in
4
+ document order, which is the order the page renders checkboxes, and skips
5
+ frontmatter and fenced code, where "- [ ]" is only text.
6
+
7
+ This scanner is deliberately simple, so it is paired with a safety check:
8
+ the server only allows writing when the number of tasks found here equals the
9
+ number of checkboxes the page rendered. Any layout the scanner misreads turns
10
+ into a read-only page instead of an edit to the wrong line.
11
+ */
12
+
13
+ const { frontmatterLength } = require("./frontmatter.js");
14
+
15
+ const taskLinePattern = /^((?:[ \t]*>)*[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+\[)([ xX])(\](?:\s|$))/;
16
+ const fenceOpenPattern = /^(?:[ \t]*>)*[ \t]{0,3}(`{3,}|~{3,})/;
17
+
18
+ /*
19
+ Returns every task line as { lineIndex, checked }. Line indexes count from the
20
+ start of the whole file, frontmatter included, so they can be used to edit it.
21
+ */
22
+ function findTaskLines(sourceText) {
23
+ const lines = sourceText.split("\n");
24
+ const firstBodyLine = sourceText.slice(0, frontmatterLength(sourceText)).split("\n").length - 1;
25
+ const tasks = [];
26
+ let openFence = null;
27
+
28
+ for (let lineIndex = firstBodyLine; lineIndex < lines.length; lineIndex += 1) {
29
+ const line = lines[lineIndex];
30
+ const fenceMatch = line.match(fenceOpenPattern);
31
+ if (openFence) {
32
+ const closes = fenceMatch
33
+ && fenceMatch[1][0] === openFence[0]
34
+ && fenceMatch[1].length >= openFence.length
35
+ && line.trim().replace(/^(>\s*)*/, "").replace(/[`~]/g, "") === "";
36
+ if (closes) openFence = null;
37
+ continue;
38
+ }
39
+ if (fenceMatch) {
40
+ openFence = fenceMatch[1];
41
+ continue;
42
+ }
43
+ const taskMatch = line.match(taskLinePattern);
44
+ if (taskMatch) tasks.push({ lineIndex, checked: taskMatch[2] !== " " });
45
+ }
46
+ return tasks;
47
+ }
48
+
49
+ /*
50
+ Returns the source with one task set to checked or unchecked. Only the single
51
+ character between the brackets changes, so spacing, line endings, and every
52
+ other byte of the file stay exactly as they were.
53
+ */
54
+ function setTaskState(sourceText, taskIndex, checked) {
55
+ const tasks = findTaskLines(sourceText);
56
+ const task = tasks[taskIndex];
57
+ if (!task) throw new RangeError(`No task at index ${taskIndex}`);
58
+ const lines = sourceText.split("\n");
59
+ lines[task.lineIndex] = lines[task.lineIndex].replace(taskLinePattern, (_fullMatch, before, _state, after) => {
60
+ return before + (checked ? "x" : " ") + after;
61
+ });
62
+ return lines.join("\n");
63
+ }
64
+
65
+ module.exports = { findTaskLines, setTaskState };
@@ -0,0 +1,117 @@
1
+ /*
2
+ One quiet reading page. Cool paper and slate ink, a single deep teal accent,
3
+ serif body for long reading and system sans for structure. The toolbar, with
4
+ checklist progress and live status, is the only element that sits on top of
5
+ the page.
6
+ */
7
+ :root {
8
+ --paper: #f6f7f9;
9
+ --ink: #1d2330;
10
+ --muted: #5a6273;
11
+ --rule: #d9dee6;
12
+ --accent: #0e6b70;
13
+ --code-paper: #eceff4;
14
+ --mark: #d7eeee;
15
+ --danger: #b3261e;
16
+ --serif: Charter, "Bitstream Charter", "Iowan Old Style", "Sitka Text", Cambria, Georgia, serif;
17
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
18
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
19
+ color-scheme: light dark;
20
+ box-sizing: border-box;
21
+ padding-top: env(safe-area-inset-top, 0px);
22
+ padding-bottom: env(safe-area-inset-bottom, 0px);
23
+ }
24
+ @media (prefers-color-scheme: dark) {
25
+ :root {
26
+ --paper: #14171d;
27
+ --ink: #e3e6eb;
28
+ --muted: #9aa2b1;
29
+ --rule: #2c323d;
30
+ --accent: #62c3c8;
31
+ --code-paper: #1c2029;
32
+ --mark: #1d3a3c;
33
+ --danger: #f28b82;
34
+ }
35
+ }
36
+ *, *::before, *::after { box-sizing: inherit; }
37
+ body {
38
+ margin: 0;
39
+ background: var(--paper);
40
+ color: var(--ink);
41
+ font: 1.0625rem/1.7 var(--serif);
42
+ -webkit-text-size-adjust: 100%;
43
+ }
44
+ main { max-width: 42rem; margin: 0 auto; padding: 3.5rem 1.25rem 2rem; }
45
+
46
+ h1, h2, h3, h4, h5, h6 { font-family: var(--sans); line-height: 1.25; margin: 2.2em 0 0.6em; letter-spacing: -0.01em; }
47
+ h1 { font-size: 2.1rem; margin-top: 0; font-weight: 700; }
48
+ h2 { font-size: 1.45rem; padding-bottom: 0.3em; border-bottom: 1px solid var(--rule); }
49
+ h3 { font-size: 1.15rem; }
50
+ h4, h5, h6 { font-size: 1rem; color: var(--muted); }
51
+ p, ul, ol, blockquote, table, pre { margin: 0 0 1.1em; }
52
+
53
+ a { color: var(--accent); text-underline-offset: 0.15em; }
54
+ a:hover { text-decoration-thickness: 2px; }
55
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
56
+ strong { font-weight: 700; }
57
+ mark { background: var(--mark); color: inherit; padding: 0 0.15em; }
58
+ hr { border: 0; border-top: 1px solid var(--rule); margin: 2.5em 0; }
59
+ img { max-width: 100%; height: auto; }
60
+
61
+ ul, ol { padding-left: 1.4em; }
62
+ li + li { margin-top: 0.25em; }
63
+ li:has(> input.task) { list-style: none; margin-left: -1.4em; padding-left: 1.9em; text-indent: -1.9em; }
64
+ input.task { width: 1.05em; height: 1.05em; margin: 0 0.75em 0 0; vertical-align: -0.12em; accent-color: var(--accent); cursor: pointer; text-indent: 0; }
65
+ li.done { color: var(--muted); }
66
+
67
+ blockquote { margin-left: 0; padding: 0.1em 0 0.1em 1.1em; border-left: 3px solid var(--accent); color: var(--muted); }
68
+
69
+ kbd { font: 0.82em var(--mono); padding: 0.1em 0.45em; border: 1px solid var(--rule); border-bottom-width: 2px; border-radius: 4px; background: var(--code-paper); }
70
+ code { font: 0.88em var(--mono); background: var(--code-paper); padding: 0.12em 0.35em; border-radius: 4px; }
71
+ pre { position: relative; background: var(--code-paper); border-radius: 6px; padding: 1em 1.1em; overflow-x: auto; line-height: 1.5; }
72
+ pre code { background: none; padding: 0; font-size: 0.85rem; }
73
+ pre[data-language]::after { content: attr(data-language); position: absolute; top: 0.4em; right: 0.7em; font: 0.72rem var(--sans); color: var(--muted); }
74
+ pre.mermaid { background: none; text-align: center; font-family: var(--sans); }
75
+
76
+ table { display: block; overflow-x: auto; border-collapse: collapse; font: 0.95rem/1.5 var(--sans); }
77
+ th, td { padding: 0.5em 0.9em; border-bottom: 1px solid var(--rule); text-align: left; vertical-align: top; }
78
+ th { font-weight: 600; border-bottom-width: 2px; }
79
+
80
+ .progress {
81
+ position: sticky; top: env(safe-area-inset-top, 0px); z-index: 1;
82
+ display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.9rem;
83
+ padding: 0.6rem max(1.25rem, calc((100% - 42rem) / 2 + 1.25rem));
84
+ background: var(--paper); border-bottom: 1px solid var(--rule);
85
+ font: 0.85rem var(--sans); color: var(--muted);
86
+ }
87
+ .progress-track { flex: 1 1 8rem; height: 6px; background: var(--rule); border-radius: 3px; overflow: hidden; }
88
+ .progress-fill { height: 100%; width: 0; background: var(--accent); transition: width 0.25s ease; }
89
+ .progress-reset { font: inherit; color: var(--muted); background: none; border: 1px solid var(--rule); border-radius: 4px; padding: 0.15em 0.6em; cursor: pointer; }
90
+ .progress-reset:hover { color: var(--ink); }
91
+ .toolbar-status { margin-left: auto; }
92
+ .toolbar-status.error { color: var(--danger); }
93
+ input.task:disabled { cursor: default; }
94
+
95
+ footer { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1.25rem 3rem; font: 0.8rem var(--sans); color: var(--muted); border-top: 1px solid var(--rule); }
96
+
97
+ .hljs-comment, .hljs-quote { color: var(--muted); font-style: italic; }
98
+ .hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #7a3fb0; }
99
+ .hljs-string, .hljs-attr, .hljs-template-tag, .hljs-addition { color: #2e7d32; }
100
+ .hljs-number, .hljs-symbol, .hljs-variable, .hljs-template-variable { color: #b25b00; }
101
+ .hljs-title, .hljs-section, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: #0b5cad; }
102
+ .hljs-type, .hljs-meta, .hljs-deletion { color: #b3261e; }
103
+ @media (prefers-color-scheme: dark) {
104
+ .hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #c79bf2; }
105
+ .hljs-string, .hljs-attr, .hljs-template-tag, .hljs-addition { color: #8fd694; }
106
+ .hljs-number, .hljs-symbol, .hljs-variable, .hljs-template-variable { color: #f0b36b; }
107
+ .hljs-title, .hljs-section, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: #7fb8f5; }
108
+ .hljs-type, .hljs-meta, .hljs-deletion { color: #f28b82; }
109
+ }
110
+
111
+ @media (prefers-reduced-motion: reduce) { .progress-fill { transition: none; } }
112
+ @media print {
113
+ .progress { display: none; }
114
+ body { background: #fff; color: #000; }
115
+ main { padding-top: 0; }
116
+ a { color: inherit; }
117
+ }
@@ -0,0 +1,167 @@
1
+ /*
2
+ Runs inside every page that has a checklist, and inside every served page.
3
+ The build step defines mdPress before this script: a storage key, plus a live
4
+ object when the page comes from md-press serve.
5
+
6
+ A built page is static, so checkbox state is saved in this browser only, keyed
7
+ by label text (plus a counter for duplicate labels) so edits to the file do
8
+ not scramble saved progress. Reset returns to what the file says.
9
+
10
+ A served page writes each checkbox to the Markdown file itself. Every save
11
+ carries the file version the page was built from, and the server refuses it if
12
+ the file changed since, so the page reloads instead of overwriting someone
13
+ else's edit. The page also watches the version and reloads, keeping its scroll
14
+ position, whenever the file changes on disk.
15
+
16
+ Browser storage can be blocked, for example in private windows, so every
17
+ storage call fails quietly: the page still works, it just forgets more.
18
+ */
19
+ (function () {
20
+ const checkboxes = Array.from(document.querySelectorAll("input.task"));
21
+ const progressFill = document.querySelector(".progress-fill");
22
+ const progressLabel = document.querySelector(".progress-label");
23
+ const statusLabel = document.querySelector(".toolbar-status");
24
+ const resetButton = document.querySelector(".progress-reset");
25
+ const scrollStorageKey = "md-press-scroll:" + location.pathname;
26
+
27
+ function readStorage(storage, key) {
28
+ try {
29
+ return storage.getItem(key);
30
+ } catch (_error) {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function writeStorage(storage, key, value) {
36
+ try {
37
+ if (value === null) storage.removeItem(key);
38
+ else storage.setItem(key, value);
39
+ } catch (_error) {
40
+ return;
41
+ }
42
+ }
43
+
44
+ function updateProgress() {
45
+ if (!progressFill || checkboxes.length === 0) return;
46
+ const doneCount = checkboxes.filter(function (checkbox) { return checkbox.checked; }).length;
47
+ checkboxes.forEach(function (checkbox) { checkbox.parentElement.classList.toggle("done", checkbox.checked); });
48
+ progressFill.style.width = (doneCount / checkboxes.length * 100) + "%";
49
+ progressLabel.textContent = doneCount + " of " + checkboxes.length + " done";
50
+ }
51
+
52
+ function labelFor(checkbox) {
53
+ return checkbox.parentElement.textContent.trim().replace(/\s+/g, " ");
54
+ }
55
+
56
+ function setupStoredChecklist() {
57
+ const seenLabels = {};
58
+ let savedStates = {};
59
+ try {
60
+ savedStates = JSON.parse(readStorage(localStorage, mdPress.storageKey)) || {};
61
+ } catch (_error) {
62
+ savedStates = {};
63
+ }
64
+
65
+ function saveStates() {
66
+ const states = {};
67
+ checkboxes.forEach(function (checkbox) { states[checkbox.dataset.key] = checkbox.checked; });
68
+ writeStorage(localStorage, mdPress.storageKey, JSON.stringify(states));
69
+ updateProgress();
70
+ }
71
+
72
+ checkboxes.forEach(function (checkbox) {
73
+ const labelText = labelFor(checkbox);
74
+ seenLabels[labelText] = (seenLabels[labelText] || 0) + 1;
75
+ checkbox.dataset.key = labelText + "#" + seenLabels[labelText];
76
+ checkbox.dataset.default = checkbox.checked ? "1" : "0";
77
+ if (checkbox.dataset.key in savedStates) checkbox.checked = savedStates[checkbox.dataset.key];
78
+ checkbox.addEventListener("change", saveStates);
79
+ });
80
+
81
+ if (resetButton) {
82
+ resetButton.addEventListener("click", function () {
83
+ if (!confirm("Clear saved progress and restore the checklist from the file?")) return;
84
+ writeStorage(localStorage, mdPress.storageKey, null);
85
+ checkboxes.forEach(function (checkbox) { checkbox.checked = checkbox.dataset.default === "1"; });
86
+ updateProgress();
87
+ });
88
+ }
89
+ }
90
+
91
+ function setupLiveFile() {
92
+ const live = mdPress.live;
93
+ let currentVersion = live.version;
94
+ let pendingSaves = 0;
95
+ let serverReachable = true;
96
+
97
+ function setStatus(text, isError) {
98
+ statusLabel.textContent = text;
99
+ statusLabel.classList.toggle("error", Boolean(isError));
100
+ }
101
+
102
+ function reloadKeepingScroll() {
103
+ writeStorage(sessionStorage, scrollStorageKey, String(window.scrollY));
104
+ location.reload();
105
+ }
106
+
107
+ const savedScroll = Number(readStorage(sessionStorage, scrollStorageKey));
108
+ writeStorage(sessionStorage, scrollStorageKey, null);
109
+ if (savedScroll > 0) window.scrollTo(0, savedScroll);
110
+
111
+ if (!live.writable) {
112
+ checkboxes.forEach(function (checkbox) { checkbox.disabled = true; });
113
+ setStatus("Read-only: some checkboxes could not be matched to lines in " + live.fileName, true);
114
+ }
115
+
116
+ async function saveCheckbox(checkbox, taskIndex) {
117
+ pendingSaves += 1;
118
+ checkbox.disabled = true;
119
+ setStatus("Saving");
120
+ try {
121
+ const response = await fetch("/api/task", {
122
+ method: "POST",
123
+ headers: { "Content-Type": "application/json" },
124
+ body: JSON.stringify({ index: taskIndex, checked: checkbox.checked, version: currentVersion }),
125
+ });
126
+ if (response.status === 409) return reloadKeepingScroll();
127
+ if (!response.ok) throw new Error("Save failed with status " + response.status);
128
+ currentVersion = (await response.json()).version;
129
+ setStatus("Saved to " + live.fileName);
130
+ } catch (_error) {
131
+ checkbox.checked = !checkbox.checked;
132
+ setStatus("Could not save. Is md-press serve still running?", true);
133
+ } finally {
134
+ pendingSaves -= 1;
135
+ checkbox.disabled = !live.writable;
136
+ updateProgress();
137
+ }
138
+ }
139
+
140
+ if (live.writable) {
141
+ checkboxes.forEach(function (checkbox, taskIndex) {
142
+ checkbox.addEventListener("change", function () { saveCheckbox(checkbox, taskIndex); });
143
+ });
144
+ }
145
+
146
+ setInterval(async function () {
147
+ if (pendingSaves > 0) return;
148
+ try {
149
+ const response = await fetch("/api/version", { cache: "no-store" });
150
+ const { version } = await response.json();
151
+ if (!serverReachable) {
152
+ serverReachable = true;
153
+ setStatus("Live, saving to " + live.fileName);
154
+ }
155
+ if (version !== currentVersion) reloadKeepingScroll();
156
+ } catch (_error) {
157
+ serverReachable = false;
158
+ setStatus("Server stopped. Run md-press serve again to keep saving.", true);
159
+ }
160
+ }, 1500);
161
+ }
162
+
163
+ checkboxes.forEach(function (checkbox) { checkbox.setAttribute("aria-label", labelFor(checkbox)); });
164
+ if (mdPress.live) setupLiveFile();
165
+ else setupStoredChecklist();
166
+ updateProgress();
167
+ })();