@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 +21 -0
- package/README.md +109 -0
- package/bin/md-press.js +133 -0
- package/package.json +54 -0
- package/src/build.js +165 -0
- package/src/frontmatter.js +30 -0
- package/src/serve.js +194 -0
- package/src/tasks.js +65 -0
- package/src/template/page.css +117 -0
- package/src/template/page.js +167 -0
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
|
+
[](https://github.com/sirasitxp/md-press/actions/workflows/test.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@sirux/md-press)
|
|
5
|
+
[](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 `` 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).
|
package/bin/md-press.js
ADDED
|
@@ -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, "&")
|
|
28
|
+
.replace(/</g, "<")
|
|
29
|
+
.replace(/>/g, ">")
|
|
30
|
+
.replace(/"/g, """);
|
|
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
|
+
})();
|