@sirux/md-press 0.1.0 → 0.2.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/README.md +64 -17
- package/bin/md-press.js +111 -30
- package/package.json +9 -5
- package/src/board.js +273 -0
- package/src/build.js +221 -48
- package/src/frontmatter.js +18 -10
- package/src/index.js +10 -0
- package/src/serve.js +84 -10
- package/src/tasks.js +43 -20
- package/src/template/board.css +79 -0
- package/src/template/board.js +236 -0
- package/src/template/page.css +52 -20
- package/src/template/page.js +51 -7
package/src/build.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/*
|
|
2
2
|
Turns Markdown text into one self-contained, styled HTML page. No network
|
|
3
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
|
|
5
|
-
inlined so every page is a single file that opens anywhere.
|
|
6
|
-
|
|
4
|
+
at build time with highlight.js, and the stylesheet and page script are
|
|
5
|
+
inlined so every page is a single file that opens anywhere. Images next to
|
|
6
|
+
the source are inlined too, so a built page can be sent on its own. The only
|
|
7
|
+
outside request is Mermaid, loaded from a CDN only on pages that contain a
|
|
8
|
+
diagram.
|
|
9
|
+
|
|
10
|
+
Beyond plain GitHub-flavored Markdown, pages get heading ids for anchor
|
|
11
|
+
links, GitHub-style callouts ("> [!NOTE]"), and footnotes ("[^1]").
|
|
7
12
|
|
|
8
13
|
The same builder renders pages for md-press serve. There the page script
|
|
9
14
|
saves checkboxes to the Markdown file instead of the browser, and the toolbar
|
|
@@ -12,7 +17,8 @@ shows whether the page is live.
|
|
|
12
17
|
|
|
13
18
|
const fileSystem = require("fs");
|
|
14
19
|
const path = require("path");
|
|
15
|
-
const { Marked } = require("marked");
|
|
20
|
+
const { Marked, Lexer } = require("marked");
|
|
21
|
+
const markedFootnote = require("marked-footnote");
|
|
16
22
|
const highlighter = require("highlight.js");
|
|
17
23
|
const { splitFrontmatter } = require("./frontmatter.js");
|
|
18
24
|
const { findTaskLines } = require("./tasks.js");
|
|
@@ -22,6 +28,19 @@ const pageStyles = fileSystem.readFileSync(path.join(templateFolder, "page.css")
|
|
|
22
28
|
const pageScript = fileSystem.readFileSync(path.join(templateFolder, "page.js"), "utf8");
|
|
23
29
|
const mermaidScriptUrl = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
|
|
24
30
|
|
|
31
|
+
const inlineImageLimit = 4 * 1024 * 1024;
|
|
32
|
+
const imageTypes = {
|
|
33
|
+
".avif": "image/avif",
|
|
34
|
+
".gif": "image/gif",
|
|
35
|
+
".jpeg": "image/jpeg",
|
|
36
|
+
".jpg": "image/jpeg",
|
|
37
|
+
".png": "image/png",
|
|
38
|
+
".svg": "image/svg+xml",
|
|
39
|
+
".webp": "image/webp",
|
|
40
|
+
};
|
|
41
|
+
const calloutTitles = { note: "Note", tip: "Tip", important: "Important", warning: "Warning", caution: "Caution" };
|
|
42
|
+
const calloutPattern = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*(?:\n|$)/;
|
|
43
|
+
|
|
25
44
|
function escapeHtml(text) {
|
|
26
45
|
return String(text)
|
|
27
46
|
.replace(/&/g, "&")
|
|
@@ -31,47 +50,172 @@ function escapeHtml(text) {
|
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
/*
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
The text of inline tokens with all markup removed, for the page title and
|
|
54
|
+
heading ids. A link keeps its text, an image its alt text, raw HTML nothing.
|
|
55
|
+
*/
|
|
56
|
+
function plainText(tokens) {
|
|
57
|
+
return (tokens || []).map((token) => {
|
|
58
|
+
if (token.type === "html" || token.type === "checkbox") return "";
|
|
59
|
+
if (token.type === "image") return token.text;
|
|
60
|
+
if (token.tokens) return plainText(token.tokens);
|
|
61
|
+
if (token.type === "text" || token.type === "escape" || token.type === "codespan") return token.text;
|
|
62
|
+
return token.raw || "";
|
|
63
|
+
}).join("");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
GitHub-style heading ids: lowercase, punctuation dropped, spaces to hyphens.
|
|
68
|
+
Letters in any script are kept, so Thai or Japanese headings get ids too.
|
|
36
69
|
*/
|
|
37
|
-
function
|
|
70
|
+
function slugify(text) {
|
|
71
|
+
return text.toLowerCase().trim().replace(/[^\p{L}\p{N}\p{M}\s_-]/gu, "").replace(/\s/g, "-");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isRelativeUrl(href) {
|
|
75
|
+
return Boolean(href) && !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith("/") && !href.startsWith("#");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
Reads an image next to the source file and returns it as a data URL, or null
|
|
80
|
+
when it should stay a link: not an image type, missing, or too large.
|
|
81
|
+
*/
|
|
82
|
+
function inlineImage(href, folder, warn) {
|
|
83
|
+
const [filePart] = href.split(/[?#]/);
|
|
84
|
+
let filePath;
|
|
85
|
+
try {
|
|
86
|
+
filePath = path.resolve(folder, decodeURIComponent(filePart));
|
|
87
|
+
} catch (_error) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const contentType = imageTypes[path.extname(filePath).toLowerCase()];
|
|
91
|
+
if (!contentType) return null;
|
|
92
|
+
let stats;
|
|
93
|
+
try {
|
|
94
|
+
stats = fileSystem.statSync(filePath);
|
|
95
|
+
} catch (_error) {
|
|
96
|
+
warn(`image not found: ${filePart}`);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if (!stats.isFile()) return null;
|
|
100
|
+
if (stats.size > inlineImageLimit) {
|
|
101
|
+
warn(`left ${filePart} as a link: ${(stats.size / 1048576).toFixed(1)} MB is over the ${inlineImageLimit / 1048576} MB limit for inlining`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return `data:${contentType};base64,${fileSystem.readFileSync(filePath).toString("base64")}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
Turns a blockquote whose first line is "[!NOTE]" (or TIP, IMPORTANT, WARNING,
|
|
109
|
+
CAUTION) into a callout token, the way GitHub renders it. The marker is
|
|
110
|
+
removed from the first paragraph; the rest of the quote is the callout body.
|
|
111
|
+
*/
|
|
112
|
+
function markCallout(token) {
|
|
113
|
+
const paragraph = token.tokens[0];
|
|
114
|
+
if (!paragraph || !["paragraph", "text"].includes(paragraph.type) || !paragraph.tokens) return;
|
|
115
|
+
const match = paragraph.text.match(calloutPattern);
|
|
116
|
+
const firstInline = paragraph.tokens[0];
|
|
117
|
+
if (!match || !firstInline || firstInline.type !== "text" || !calloutPattern.test(firstInline.text)) return;
|
|
118
|
+
token.type = "callout";
|
|
119
|
+
token.calloutType = match[1].toLowerCase();
|
|
120
|
+
paragraph.text = paragraph.text.replace(calloutPattern, "");
|
|
121
|
+
firstInline.text = firstInline.text.replace(calloutPattern, "");
|
|
122
|
+
firstInline.raw = firstInline.text;
|
|
123
|
+
if (firstInline.text === "") paragraph.tokens.shift();
|
|
124
|
+
if (paragraph.tokens.length === 0) token.tokens.shift();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
Builds a marked instance for one document. Fenced code is highlighted and
|
|
129
|
+
mermaid fences are kept as diagram source. Headings get ids. Task items are
|
|
130
|
+
marked on their <li> and their checkboxes rendered live, so the page script
|
|
131
|
+
can find and save them. On built pages, relative links to other Markdown
|
|
132
|
+
files point at the pages they become, and images are inlined.
|
|
133
|
+
*/
|
|
134
|
+
function createParser(documentState, options) {
|
|
38
135
|
const markdownParser = new Marked();
|
|
136
|
+
const slugCounts = new Map();
|
|
137
|
+
markdownParser.use(markedFootnote());
|
|
39
138
|
markdownParser.use({
|
|
139
|
+
hooks: {
|
|
140
|
+
processAllTokens(tokens) {
|
|
141
|
+
documentState.tokens = tokens;
|
|
142
|
+
return tokens;
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
walkTokens(token) {
|
|
146
|
+
if (token.type === "blockquote") markCallout(token);
|
|
147
|
+
if (token.type === "link" && options.rewriteMarkdownLinks && isRelativeUrl(token.href)) {
|
|
148
|
+
token.href = token.href.replace(/\.(?:md|markdown)(?=$|[#?])/i, ".html");
|
|
149
|
+
}
|
|
150
|
+
if (token.type === "image" && options.inlineImagesFrom && isRelativeUrl(token.href)) {
|
|
151
|
+
const dataUrl = inlineImage(token.href, options.inlineImagesFrom, options.warn);
|
|
152
|
+
if (dataUrl) token.href = dataUrl;
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
extensions: [{
|
|
156
|
+
name: "callout",
|
|
157
|
+
renderer(token) {
|
|
158
|
+
const title = calloutTitles[token.calloutType];
|
|
159
|
+
return `<div class="callout callout-${token.calloutType}" role="note"><p class="callout-title">${title}</p>\n${this.parser.parse(token.tokens)}</div>\n`;
|
|
160
|
+
},
|
|
161
|
+
}],
|
|
40
162
|
renderer: {
|
|
163
|
+
heading({ tokens, depth }) {
|
|
164
|
+
const slug = slugify(plainText(tokens));
|
|
165
|
+
const seen = slugCounts.get(slug) || 0;
|
|
166
|
+
slugCounts.set(slug, seen + 1);
|
|
167
|
+
const id = seen === 0 ? slug : `${slug}-${seen}`;
|
|
168
|
+
return `<h${depth}${id ? ` id="${escapeHtml(id)}"` : ""}>${this.parser.parseInline(tokens)}</h${depth}>\n`;
|
|
169
|
+
},
|
|
41
170
|
code({ text, lang }) {
|
|
42
171
|
const language = (lang || "").trim().split(/\s+/)[0];
|
|
43
172
|
if (language === "mermaid") {
|
|
44
173
|
documentState.hasMermaid = true;
|
|
45
174
|
return `<pre class="mermaid">${escapeHtml(text)}</pre>\n`;
|
|
46
175
|
}
|
|
176
|
+
documentState.codeBlockCount += 1;
|
|
47
177
|
const highlighted = language && highlighter.getLanguage(language)
|
|
48
178
|
? highlighter.highlight(text, { language }).value
|
|
49
179
|
: escapeHtml(text);
|
|
50
180
|
const languageLabel = language ? ` data-language="${escapeHtml(language)}"` : "";
|
|
51
181
|
return `<pre${languageLabel}><code class="hljs">${highlighted}</code></pre>\n`;
|
|
52
182
|
},
|
|
183
|
+
listitem(item) {
|
|
184
|
+
return `<li${item.task ? ' class="task-item"' : ""}>${this.parser.parse(item.tokens)}</li>\n`;
|
|
185
|
+
},
|
|
186
|
+
checkbox({ checked }) {
|
|
187
|
+
documentState.taskCount += 1;
|
|
188
|
+
return `<input type="checkbox" class="task"${checked ? " checked" : ""}> `;
|
|
189
|
+
},
|
|
53
190
|
},
|
|
54
191
|
});
|
|
55
192
|
return markdownParser;
|
|
56
193
|
}
|
|
57
194
|
|
|
58
195
|
/*
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
196
|
+
The title comes from frontmatter, then the first level-one heading, then the
|
|
197
|
+
first heading of any level, then the file name. Headings are read from the
|
|
198
|
+
parsed tokens, so a "#" inside a code block is never mistaken for one and
|
|
199
|
+
setext headings (underlined with =) count too.
|
|
62
200
|
*/
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
201
|
+
function findTitle(metadata, tokens, fallback) {
|
|
202
|
+
if (metadata.title) return metadata.title;
|
|
203
|
+
const headings = tokens.filter((token) => token.type === "heading");
|
|
204
|
+
const heading = headings.find((token) => token.depth === 1) || headings[0];
|
|
205
|
+
return (heading && plainText(heading.tokens).trim()) || fallback;
|
|
68
206
|
}
|
|
69
207
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
208
|
+
/*
|
|
209
|
+
The date shown in the footer of a built page: the source file's last change,
|
|
210
|
+
in local time, so rebuilding an unchanged file gives an identical page.
|
|
211
|
+
SOURCE_DATE_EPOCH, the convention for reproducible builds, overrides it.
|
|
212
|
+
*/
|
|
213
|
+
function formatSourceDate(date) {
|
|
214
|
+
const epoch = process.env.SOURCE_DATE_EPOCH;
|
|
215
|
+
if (epoch && /^\d+$/.test(epoch)) return new Date(Number(epoch) * 1000).toISOString().slice(0, 10);
|
|
216
|
+
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return "";
|
|
217
|
+
const pad = (number) => String(number).padStart(2, "0");
|
|
218
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
75
219
|
}
|
|
76
220
|
|
|
77
221
|
/*
|
|
@@ -92,45 +236,57 @@ function renderToolbar(taskCount, live) {
|
|
|
92
236
|
}
|
|
93
237
|
|
|
94
238
|
/*
|
|
95
|
-
|
|
96
|
-
fallback title, the footer, and the checklist storage key, which is
|
|
97
|
-
the file name so saved progress survives edits and rebuilds of the
|
|
239
|
+
Renders Markdown text into a page and reports what it found. The source name
|
|
240
|
+
sets the fallback title, the footer, and the checklist storage key, which is
|
|
241
|
+
based on the file name so saved progress survives edits and rebuilds of the
|
|
242
|
+
same file. A source name of "-" means standard input, with no file to name.
|
|
243
|
+
|
|
244
|
+
Options:
|
|
245
|
+
live { version } renders a page for md-press serve. It is only
|
|
246
|
+
writable when the task scanner finds exactly as many tasks
|
|
247
|
+
as the page rendered, so a checkbox can never be saved to
|
|
248
|
+
the wrong line. Live pages keep links to .md files as they
|
|
249
|
+
are; built pages point them at .html.
|
|
250
|
+
inlineImagesFrom Folder to read relative images from and inline them.
|
|
251
|
+
sourceModified Date of the source file's last change, for the footer.
|
|
252
|
+
onWarning Called with a message for each image that could not be
|
|
253
|
+
inlined.
|
|
98
254
|
|
|
99
|
-
|
|
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.
|
|
255
|
+
Returns { html, title, taskCount, scannedTaskCount, writable }.
|
|
102
256
|
*/
|
|
103
|
-
function
|
|
257
|
+
function renderDocument(sourceText, sourceName = "document.md", options = {}) {
|
|
104
258
|
const { metadata, body } = splitFrontmatter(sourceText);
|
|
105
|
-
const documentState = { hasMermaid: false, taskCount: 0 };
|
|
106
|
-
const markdownParser = createParser(documentState
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
259
|
+
const documentState = { hasMermaid: false, taskCount: 0, codeBlockCount: 0, tokens: [] };
|
|
260
|
+
const markdownParser = createParser(documentState, {
|
|
261
|
+
rewriteMarkdownLinks: !options.live,
|
|
262
|
+
inlineImagesFrom: options.inlineImagesFrom,
|
|
263
|
+
warn: options.onWarning || (() => {}),
|
|
264
|
+
});
|
|
265
|
+
const content = markdownParser.parse(body);
|
|
266
|
+
const baseName = sourceName === "-" ? "" : path.basename(sourceName);
|
|
267
|
+
const stem = baseName ? path.basename(baseName, path.extname(baseName)) : "";
|
|
268
|
+
const title = findTitle(metadata, documentState.tokens, stem || "Untitled");
|
|
269
|
+
const scannedTaskCount = findTaskLines(sourceText).length;
|
|
111
270
|
|
|
112
271
|
const live = options.live
|
|
113
|
-
? {
|
|
114
|
-
version: options.live.version,
|
|
115
|
-
fileName: baseName,
|
|
116
|
-
writable: findTaskLines(sourceText).length === documentState.taskCount,
|
|
117
|
-
}
|
|
272
|
+
? { version: options.live.version, fileName: baseName, writable: scannedTaskCount === documentState.taskCount }
|
|
118
273
|
: null;
|
|
119
274
|
const pageSettings = {
|
|
120
|
-
storageKey: "md-press:" +
|
|
275
|
+
storageKey: "md-press:" + (stem || slugify(title) || "untitled"),
|
|
121
276
|
live,
|
|
122
277
|
};
|
|
123
|
-
const needsPageScript = documentState.taskCount > 0 || live;
|
|
278
|
+
const needsPageScript = documentState.taskCount > 0 || documentState.codeBlockCount > 0 || live;
|
|
124
279
|
const scripts = [
|
|
125
280
|
needsPageScript ? `<script>const mdPress = ${JSON.stringify(pageSettings).replace(/</g, "\\u003c")};\n${pageScript}</script>` : "",
|
|
126
281
|
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
282
|
].filter(Boolean).join("\n");
|
|
283
|
+
const sourceDate = formatSourceDate(options.sourceModified);
|
|
128
284
|
const footerText = live
|
|
129
285
|
? `Serving ${escapeHtml(baseName)} with md-press`
|
|
130
|
-
: `Generated
|
|
286
|
+
: `Generated${baseName ? ` from ${escapeHtml(baseName)}` : ""} with md-press.${sourceDate ? ` Source last changed ${sourceDate}.` : ""}`;
|
|
131
287
|
|
|
132
|
-
|
|
133
|
-
<html lang="en">
|
|
288
|
+
const html = `<!doctype html>
|
|
289
|
+
<html lang="${escapeHtml(metadata.lang || "en")}">
|
|
134
290
|
<head>
|
|
135
291
|
<meta charset="utf-8">
|
|
136
292
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
@@ -148,18 +304,35 @@ ${scripts}
|
|
|
148
304
|
</body>
|
|
149
305
|
</html>
|
|
150
306
|
`;
|
|
307
|
+
return { html, title, taskCount: documentState.taskCount, scannedTaskCount, writable: live ? live.writable : true };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function renderPage(sourceText, sourceName, options) {
|
|
311
|
+
return renderDocument(sourceText, sourceName, options).html;
|
|
151
312
|
}
|
|
152
313
|
|
|
153
314
|
/*
|
|
154
315
|
Reads one Markdown file and writes its page next to it, or into the output
|
|
155
|
-
folder when one is given
|
|
316
|
+
folder when one is given, or to options.outputPath. Images next to the file
|
|
317
|
+
are inlined. Returns the path of the page it wrote.
|
|
156
318
|
*/
|
|
157
|
-
function buildFile(sourcePath, outputFolder) {
|
|
319
|
+
function buildFile(sourcePath, outputFolder, options = {}) {
|
|
158
320
|
const sourceText = fileSystem.readFileSync(sourcePath, "utf8");
|
|
159
321
|
const outputName = path.basename(sourcePath, path.extname(sourcePath)) + ".html";
|
|
160
|
-
const outputPath = path.join(outputFolder || path.dirname(sourcePath), outputName);
|
|
161
|
-
|
|
322
|
+
const outputPath = options.outputPath || path.join(outputFolder || path.dirname(sourcePath), outputName);
|
|
323
|
+
const { html } = renderDocument(sourceText, sourcePath, {
|
|
324
|
+
inlineImagesFrom: path.dirname(path.resolve(sourcePath)),
|
|
325
|
+
sourceModified: fileSystem.statSync(sourcePath).mtime,
|
|
326
|
+
onWarning: options.onWarning,
|
|
327
|
+
});
|
|
328
|
+
fileSystem.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
329
|
+
fileSystem.writeFileSync(outputPath, html);
|
|
162
330
|
return outputPath;
|
|
163
331
|
}
|
|
164
332
|
|
|
165
|
-
|
|
333
|
+
/* The plain text of a line of inline Markdown, with markup removed. */
|
|
334
|
+
function markdownToText(markdown) {
|
|
335
|
+
return plainText(Lexer.lexInline(markdown)).trim();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
module.exports = { renderPage, renderDocument, buildFile, splitFrontmatter, escapeHtml, markdownToText, slugify };
|
package/src/frontmatter.js
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
/*
|
|
2
2
|
Reads the optional frontmatter block at the top of a Markdown file. Only
|
|
3
|
-
simple "key: value" lines are supported, which covers title
|
|
4
|
-
Values wrapped in matching quotes lose those quotes; quotes inside
|
|
5
|
-
are kept.
|
|
6
|
-
|
|
3
|
+
simple "key: value" lines are supported, which covers title, description,
|
|
4
|
+
and lang. Values wrapped in matching quotes lose those quotes; quotes inside
|
|
5
|
+
a value are kept. A byte order mark before the block, which some Windows
|
|
6
|
+
editors write, is tolerated and dropped from the body. Shared by the page
|
|
7
|
+
builder and the task scanner so both agree on where the Markdown body starts.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
const frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
10
11
|
|
|
12
|
+
function byteOrderMarkLength(sourceText) {
|
|
13
|
+
return sourceText.charCodeAt(0) === 0xfeff ? 1 : 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* Length of the byte order mark and frontmatter block, so the body can be found. */
|
|
11
17
|
function frontmatterLength(sourceText) {
|
|
12
|
-
const
|
|
13
|
-
|
|
18
|
+
const markLength = byteOrderMarkLength(sourceText);
|
|
19
|
+
const match = sourceText.slice(markLength).match(frontmatterPattern);
|
|
20
|
+
return markLength + (match ? match[0].length : 0);
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
function splitFrontmatter(sourceText) {
|
|
17
|
-
const
|
|
18
|
-
|
|
24
|
+
const text = sourceText.slice(byteOrderMarkLength(sourceText));
|
|
25
|
+
const match = text.match(frontmatterPattern);
|
|
26
|
+
if (!match) return { metadata: {}, body: text };
|
|
19
27
|
const metadata = {};
|
|
20
28
|
for (const line of match[1].split(/\r?\n/)) {
|
|
21
29
|
const separatorIndex = line.indexOf(":");
|
|
@@ -24,7 +32,7 @@ function splitFrontmatter(sourceText) {
|
|
|
24
32
|
const value = line.slice(separatorIndex + 1).trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
25
33
|
if (key) metadata[key] = value;
|
|
26
34
|
}
|
|
27
|
-
return { metadata, body:
|
|
35
|
+
return { metadata, body: text.slice(match[0].length) };
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
module.exports = { splitFrontmatter, frontmatterLength };
|
|
38
|
+
module.exports = { splitFrontmatter, frontmatterLength, byteOrderMarkLength };
|
package/src/index.js
ADDED
package/src/serve.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/*
|
|
2
|
-
Serves one Markdown file as a live page
|
|
3
|
-
source of truth: people, editors, and agents
|
|
4
|
-
open, and the page reloads to match.
|
|
2
|
+
Serves one Markdown file as a live page, or as a live Kanban board, on
|
|
3
|
+
localhost. The file stays the source of truth: people, editors, and agents
|
|
4
|
+
can change it while the page is open, and the page reloads to match. Both
|
|
5
|
+
views are always available: / shows the one the command asked for, /page
|
|
6
|
+
and /board show the other.
|
|
5
7
|
|
|
6
8
|
Writes are guarded three ways. Every save carries the version (a hash of the
|
|
7
9
|
file) that the page was built from, and is refused with 409 if the file has
|
|
@@ -9,6 +11,10 @@ changed since. Each write goes to a temporary file that is then renamed over
|
|
|
9
11
|
the original, so the file is never left half written. Only the character
|
|
10
12
|
between a task's brackets changes, through setTaskState.
|
|
11
13
|
|
|
14
|
+
When a page opens read-only, because the task scanner and the page disagree
|
|
15
|
+
on how many tasks the file has, the reason is printed once per file version
|
|
16
|
+
so the user can fix the file.
|
|
17
|
+
|
|
12
18
|
The server binds to 127.0.0.1 and answers only requests addressed to
|
|
13
19
|
localhost, which blocks DNS rebinding. Saves must be JSON, which a page on
|
|
14
20
|
another site cannot send without a CORS preflight that this server never
|
|
@@ -21,8 +27,9 @@ const fileSystemPromises = require("fs/promises");
|
|
|
21
27
|
const http = require("http");
|
|
22
28
|
const path = require("path");
|
|
23
29
|
const crypto = require("crypto");
|
|
24
|
-
const {
|
|
30
|
+
const { renderDocument } = require("./build.js");
|
|
25
31
|
const { setTaskState, findTaskLines } = require("./tasks.js");
|
|
32
|
+
const { renderBoard, moveCard, addCard } = require("./board.js");
|
|
26
33
|
|
|
27
34
|
const maximumRequestBytes = 64 * 1024;
|
|
28
35
|
const allowedHostNames = new Set(["localhost", "127.0.0.1"]);
|
|
@@ -109,6 +116,51 @@ async function handleTaskSave(request, response, sourcePath) {
|
|
|
109
116
|
return send(response, 200, { version: versionOf(updatedText) });
|
|
110
117
|
}
|
|
111
118
|
|
|
119
|
+
/*
|
|
120
|
+
Applies one board change: moving a card between or within columns, or
|
|
121
|
+
adding a card. Guarded the same way as a checkbox save: JSON only, the
|
|
122
|
+
version must match, and the write is atomic.
|
|
123
|
+
*/
|
|
124
|
+
async function handleBoardChange(request, response, sourcePath) {
|
|
125
|
+
if (!(request.headers["content-type"] || "").startsWith("application/json")) {
|
|
126
|
+
return send(response, 415, { error: "Expected application/json" });
|
|
127
|
+
}
|
|
128
|
+
let payload;
|
|
129
|
+
try {
|
|
130
|
+
payload = JSON.parse(await readRequestBody(request));
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return send(response, error.status || 400, { error: error.status ? error.message : "Invalid JSON" });
|
|
133
|
+
}
|
|
134
|
+
const { action, version } = payload || {};
|
|
135
|
+
if (typeof version !== "string") return send(response, 400, { error: "Expected a version" });
|
|
136
|
+
|
|
137
|
+
const currentText = fileSystem.readFileSync(sourcePath, "utf8");
|
|
138
|
+
const currentVersion = versionOf(currentText);
|
|
139
|
+
if (currentVersion !== version) {
|
|
140
|
+
return send(response, 409, { error: "The file changed on disk. Reload to see it.", version: currentVersion });
|
|
141
|
+
}
|
|
142
|
+
let updatedText;
|
|
143
|
+
try {
|
|
144
|
+
if (action === "move") {
|
|
145
|
+
const { from, to } = payload;
|
|
146
|
+
const isPosition = (value) => value && Number.isInteger(value.column) && value.column >= 0 && (Number.isInteger(value.index) || value.index === null) ;
|
|
147
|
+
if (!isPosition(from) || !isPosition(to) || from.index === null) return send(response, 400, { error: "Expected from and to as { column, index }" });
|
|
148
|
+
updatedText = moveCard(currentText, from, { column: to.column, index: to.index === null ? Infinity : to.index });
|
|
149
|
+
} else if (action === "add") {
|
|
150
|
+
const { column, text } = payload;
|
|
151
|
+
if (!Number.isInteger(column) || column < 0 || typeof text !== "string") return send(response, 400, { error: "Expected column and text" });
|
|
152
|
+
updatedText = addCard(currentText, column, text);
|
|
153
|
+
} else {
|
|
154
|
+
return send(response, 400, { error: "Expected action move or add" });
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error instanceof RangeError) return send(response, 400, { error: error.message });
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
if (updatedText !== currentText) writeFileAtomically(sourcePath, updatedText);
|
|
161
|
+
return send(response, 200, { version: versionOf(updatedText) });
|
|
162
|
+
}
|
|
163
|
+
|
|
112
164
|
/*
|
|
113
165
|
Serves a file from the Markdown file's folder. The resolved path must stay
|
|
114
166
|
inside that folder, and any path segment starting with a dot is refused so
|
|
@@ -137,9 +189,26 @@ async function handleFolderFile(response, sourceFolder, requestPath) {
|
|
|
137
189
|
}
|
|
138
190
|
}
|
|
139
191
|
|
|
140
|
-
function createServeHandler(sourcePath) {
|
|
192
|
+
function createServeHandler(sourcePath, mode = "page") {
|
|
141
193
|
const resolvedSourcePath = path.resolve(sourcePath);
|
|
142
194
|
const sourceFolder = path.dirname(resolvedSourcePath);
|
|
195
|
+
let warnedVersion = null;
|
|
196
|
+
|
|
197
|
+
function renderLivePage(response) {
|
|
198
|
+
const text = fileSystem.readFileSync(resolvedSourcePath, "utf8");
|
|
199
|
+
const version = versionOf(text);
|
|
200
|
+
const page = renderDocument(text, resolvedSourcePath, { live: { version } });
|
|
201
|
+
if (!page.writable && warnedVersion !== version) {
|
|
202
|
+
warnedVersion = version;
|
|
203
|
+
console.error(`${path.basename(resolvedSourcePath)} opened read-only: the file has ${page.scannedTaskCount} task lines but the page shows ${page.taskCount} checkboxes. A task inside a code block, raw HTML, or a comment is the usual cause.`);
|
|
204
|
+
}
|
|
205
|
+
return send(response, 200, page.html, "text/html; charset=utf-8");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function renderLiveBoard(response) {
|
|
209
|
+
const text = fileSystem.readFileSync(resolvedSourcePath, "utf8");
|
|
210
|
+
return send(response, 200, renderBoard(text, resolvedSourcePath, { live: { version: versionOf(text) } }), "text/html; charset=utf-8");
|
|
211
|
+
}
|
|
143
212
|
|
|
144
213
|
return async function handleRequest(request, response) {
|
|
145
214
|
const hostName = (request.headers.host || "").replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
|
|
@@ -148,8 +217,12 @@ function createServeHandler(sourcePath) {
|
|
|
148
217
|
try {
|
|
149
218
|
const url = new URL(request.url, "http://localhost");
|
|
150
219
|
if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
|
|
151
|
-
|
|
152
|
-
|
|
220
|
+
return mode === "board" ? renderLiveBoard(response) : renderLivePage(response);
|
|
221
|
+
}
|
|
222
|
+
if (request.method === "GET" && url.pathname === "/page") return renderLivePage(response);
|
|
223
|
+
if (request.method === "GET" && url.pathname === "/board") return renderLiveBoard(response);
|
|
224
|
+
if (request.method === "POST" && url.pathname === "/api/board") {
|
|
225
|
+
return await handleBoardChange(request, response, resolvedSourcePath);
|
|
153
226
|
}
|
|
154
227
|
if (request.method === "GET" && url.pathname === "/api/version") {
|
|
155
228
|
return send(response, 200, { version: versionOf(fileSystem.readFileSync(resolvedSourcePath, "utf8")) });
|
|
@@ -167,10 +240,11 @@ function createServeHandler(sourcePath) {
|
|
|
167
240
|
|
|
168
241
|
/*
|
|
169
242
|
Starts the server on the first free port, beginning at the requested one and
|
|
170
|
-
trying the next nine.
|
|
243
|
+
trying the next nine. mode is "page" or "board" and picks what / shows.
|
|
244
|
+
Resolves with the server and its address.
|
|
171
245
|
*/
|
|
172
|
-
function startServer(sourcePath, { port = 5180, attempts = 10 } = {}) {
|
|
173
|
-
const server = http.createServer(createServeHandler(sourcePath));
|
|
246
|
+
function startServer(sourcePath, { port = 5180, attempts = 10, mode = "page" } = {}) {
|
|
247
|
+
const server = http.createServer(createServeHandler(sourcePath, mode));
|
|
174
248
|
return new Promise((resolve, reject) => {
|
|
175
249
|
let attemptPort = port;
|
|
176
250
|
let remainingAttempts = attempts;
|