@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/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 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.
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
- 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.
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 createParser(documentState) {
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
- 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.
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 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
- });
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
- 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));
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
- 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.
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
- 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.
255
+ Returns { html, title, taskCount, scannedTaskCount, writable }.
102
256
  */
103
- function renderPage(sourceText, sourceName = "document.md", options = {}) {
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
- 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);
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:" + path.basename(sourceName, path.extname(sourceName)),
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 ${generatedDate} from ${escapeHtml(baseName)} with md-press`;
286
+ : `Generated${baseName ? ` from ${escapeHtml(baseName)}` : ""} with md-press.${sourceDate ? ` Source last changed ${sourceDate}.` : ""}`;
131
287
 
132
- return `<!doctype html>
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. Returns the path of the page it wrote.
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
- fileSystem.writeFileSync(outputPath, renderPage(sourceText, sourcePath));
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
- module.exports = { renderPage, buildFile, splitFrontmatter, escapeHtml };
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 };
@@ -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 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.
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 match = sourceText.match(frontmatterPattern);
13
- return match ? match[0].length : 0;
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 match = sourceText.match(frontmatterPattern);
18
- if (!match) return { metadata: {}, body: sourceText };
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: sourceText.slice(match[0].length) };
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
@@ -0,0 +1,10 @@
1
+ /*
2
+ Everything md-press exports for use from code: the page builder, the board
3
+ parser and editors, and the task scanner they share.
4
+ */
5
+
6
+ module.exports = {
7
+ ...require("./build.js"),
8
+ ...require("./board.js"),
9
+ ...require("./tasks.js"),
10
+ };
package/src/serve.js CHANGED
@@ -1,7 +1,9 @@
1
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.
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 { renderPage } = require("./build.js");
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
- const text = fileSystem.readFileSync(resolvedSourcePath, "utf8");
152
- return send(response, 200, renderPage(text, resolvedSourcePath, { live: { version: versionOf(text) } }), "text/html; charset=utf-8");
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. Resolves with the server and its address.
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;