@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 CHANGED
@@ -10,23 +10,25 @@ Press Markdown into clean pages. Pure code: no AI, no tokens, no build setup.
10
10
  npm install -g @sirux/md-press
11
11
  md-press notes.md # build notes.html, a page you can share anywhere
12
12
  md-press serve notes.md # open notes.md live, checkboxes save into the file
13
+ md-press board backlog.md # open backlog.md as a Kanban board that writes back to it
13
14
  ```
14
15
 
15
16
  ## Why
16
17
 
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
+ Notes, plans, and trackers live best as Markdown. They read best as a page, and a backlog works best as a board. 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
 
19
- ## Two ways to use it
20
+ ## Three ways to use it
20
21
 
21
22
  ### Build: a page to share
22
23
 
23
24
  ```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
25
+ md-press notes.md # writes notes.html next to notes.md
26
+ md-press docs --out pages # builds every .md in docs/, keeping its folders
27
+ md-press - < notes.md > notes.html # reads standard input, writes standard output
28
+ npx @sirux/md-press notes.md # runs without installing
27
29
  ```
28
30
 
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.
31
+ You get one HTML file with its styles, scripts, and images inlined, ready to open, send, print, or host anywhere. Links to other `.md` files point at the pages they become. Checklist progress on a built page is saved in the viewer's browser.
30
32
 
31
33
  ### Serve: a live page for working
32
34
 
@@ -43,13 +45,45 @@ Opens `notes.md` at `http://localhost:5180`.
43
45
 
44
46
  Stop it with Ctrl+C.
45
47
 
48
+ ### Board: a live Kanban board
49
+
50
+ ```sh
51
+ md-press board backlog.md
52
+ ```
53
+
54
+ Opens `backlog.md` as a board at `http://localhost:5180`. Nothing about the file has to change to make it one:
55
+
56
+ ```markdown
57
+ # Launch backlog
58
+
59
+ ## Todo
60
+ - [ ] Write the announcement `docs`
61
+ - [ ] Fix the login redirect `bug` `p1`
62
+ - happens only with single sign-on
63
+
64
+ ## Doing
65
+ - [ ] Ship 0.2 `release`
66
+
67
+ ## Done
68
+ - [x] Set up CI `ops`
69
+ ```
70
+
71
+ - **`##` headings are columns.** The `- [ ]` items directly under one are its cards. Indented lines under a card are its notes and move with it.
72
+ - **Trailing `` `code` `` spans are tags.** They get a color from their name. Click a tag at the top to filter the board.
73
+ - **Drag a card** to another column or position, or pick a column from the arrow menu on the card. Only the card's own lines move in the file. Move it back and the file is byte for byte what it was.
74
+ - **The last column counts as done.** A card moved into it is checked, a card moved out is unchecked. Tick a card's box to check it where it is. A board with a single column has no done column.
75
+ - **Add a card** by typing at the bottom of a column. It becomes a `- [ ]` line there.
76
+ - **Same server, same rules as serve.** The board reloads when the file changes, a stale save is refused, and writes are atomic. `/page` shows the reading view of the same file, and `md-press serve` has `/board`.
77
+
78
+ Try it on [`examples/backlog.md`](examples/backlog.md).
79
+
46
80
  ## Options
47
81
 
48
82
  | Option | Mode | What it does |
49
83
  | --- | --- | --- |
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 |
84
+ | `--out <folder>` | build | Writes pages into this folder instead of next to each file. A folder given as input keeps its structure |
85
+ | `--port <number>` | serve, board | Port to start from. Default 5180, and the next 9 are tried if it is busy |
86
+ | `--no-open` | serve, board | Does not open the browser |
53
87
  | `-v`, `--version` | any | Shows the version |
54
88
  | `-h`, `--help` | any | Shows help |
55
89
 
@@ -58,10 +92,14 @@ Stop it with Ctrl+C.
58
92
  ## What every page gets
59
93
 
60
94
  - **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.
95
+ - **Readable on phones.** Wide tables and code scroll inside themselves, long links and words wrap, and the page itself never scrolls sideways.
96
+ - **Code highlighting**, done when the page is made, so pages stay fast. Every block has a Copy button.
63
97
  - **Checklists.** `- [ ]` items become real checkboxes with a progress bar.
64
- - **Smart titles.** Uses frontmatter `title:`, then the first `#` heading, then the file name.
98
+ - **Anchor links.** Every heading gets a GitHub-style id, so `[see below](#install)` and hand-written tables of contents work.
99
+ - **Callouts.** `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, and `[!CAUTION]` render the way GitHub shows them.
100
+ - **Footnotes.** `[^1]` in the text, `[^1]: The note` anywhere in the file.
101
+ - **Table alignment** from `:---:` and `---:`.
102
+ - **Smart titles.** Uses frontmatter `title:`, then the first `#` heading (underlined headings count too), then the first heading of any level, then the file name. A `#` inside a code block is never a title.
65
103
  - **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
104
 
67
105
  See [`examples/showcase.md`](examples/showcase.md) for every feature on one page. Try it both ways.
@@ -72,26 +110,35 @@ See [`examples/showcase.md`](examples/showcase.md) for every feature on one page
72
110
  ---
73
111
  title: Q4 launch plan
74
112
  description: Owners, dates, and open risks
113
+ lang: th
75
114
  ---
76
115
  ```
77
116
 
78
- `title` sets the browser tab title. `description` sets the page's meta description. Other keys are ignored for now.
117
+ `title` sets the browser tab title. `description` sets the page's meta description. `lang` sets the page language, which browsers use for line breaking and fonts; the default is `en`. Other keys are ignored for now.
79
118
 
80
119
  ## Use it from code
81
120
 
82
121
  ```js
83
- const { renderPage, buildFile } = require("@sirux/md-press");
122
+ const { renderPage, buildFile, parseBoard, moveCard, addCard } = require("@sirux/md-press");
84
123
 
85
124
  const html = renderPage("# Hello\n\n- [ ] Ship it", "hello.md");
86
125
  buildFile("notes.md", "pages");
126
+
127
+ const board = parseBoard(backlogText); // { title, columns: [{ title, cards }] }
128
+ const moved = moveCard(backlogText, { column: 0, index: 1 }, { column: 2, index: 0 });
129
+ const added = addCard(backlogText, 0, "Write the announcement `docs`");
87
130
  ```
88
131
 
132
+ `renderDocument` returns the page with its title and task counts. `moveCard` and `addCard` return the new file text and change nothing but the lines involved.
133
+
89
134
  ## Good to know
90
135
 
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.
136
+ - **Built pages save progress per browser**, keyed by file name. Only `serve` and `board` write to the file.
137
+ - **Images are inlined up to 4 MB each.** Bigger or missing ones stay as links, and the build says so.
138
+ - **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, and the terminal says how many task lines and checkboxes it found.
139
+ - **On a board, only top-level `- [ ]` items under `##` headings are cards.** Tasks before the first `##` heading and nested tasks show on the page but not on the board.
93
140
  - **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.
141
+ - **`serve` and `board` answer only this computer.** They are not reachable from your phone or network.
95
142
  - **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
143
  - **Requires Node 20 or newer.**
97
144
 
package/bin/md-press.js CHANGED
@@ -1,14 +1,16 @@
1
1
  #!/usr/bin/env node
2
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/.
3
+ Command line entry for md-press. Three modes share one parser for options:
4
+ "build" (the default, so a bare file list builds pages), "serve", which
5
+ opens one file as a live page on localhost, and "board", which opens it as a
6
+ live Kanban board on the same server. The command stays thin; all behavior
7
+ worth testing lives in src/.
7
8
  */
8
9
 
9
10
  const fileSystem = require("fs");
11
+ const path = require("path");
10
12
  const { spawn } = require("child_process");
11
- const { buildFile } = require("../src/build.js");
13
+ const { buildFile, renderPage } = require("../src/build.js");
12
14
  const { startServer } = require("../src/serve.js");
13
15
  const { version } = require("../package.json");
14
16
 
@@ -16,15 +18,23 @@ const usageText = `md-press ${version}
16
18
  Press Markdown into clean pages, to share or to work in.
17
19
 
18
20
  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
+ md-press <file.md|folder> [more ...] [--out folder] Build static pages
22
+ md-press - < notes.md > notes.html Build from standard input
23
+ md-press serve <file.md> [--port 5180] Open a live page that saves checkboxes to the file
24
+ md-press board <file.md> [--port 5180] Open the file as a live Kanban board
21
25
 
22
26
  Options:
23
27
  --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
28
+ --port <number> Serve, board: port to start from (default 5180, tries the next 9 if busy)
29
+ --no-open Serve, board: do not open the browser
26
30
  -v, --version Show the version
27
- -h, --help Show this help`;
31
+ -h, --help Show this help
32
+
33
+ A folder builds every .md file inside it, subfolders included. With --out,
34
+ the folder's structure is kept.
35
+
36
+ On a board, ## headings are columns, top-level - [ ] items are cards, and
37
+ trailing \`code\` spans are tags. Moving a card moves its lines in the file.`;
28
38
 
29
39
  function exitWithError(message) {
30
40
  console.error(`md-press: ${message}`);
@@ -35,7 +45,7 @@ function exitWithError(message) {
35
45
  /*
36
46
  Splits raw arguments into file paths and options. Options that do not apply
37
47
  to the chosen mode, and unknown flags, are rejected rather than silently
38
- ignored or treated as file names.
48
+ ignored or treated as file names. A lone "-" means standard input.
39
49
  */
40
50
  function readArguments(rawArguments, mode) {
41
51
  const sourcePaths = [];
@@ -53,14 +63,14 @@ function readArguments(rawArguments, mode) {
53
63
  if (!nextArgument || nextArgument.startsWith("-")) exitWithError("--out needs a folder name");
54
64
  options.outputFolder = nextArgument;
55
65
  index += 1;
56
- } else if (argument === "--port" && mode === "serve") {
66
+ } else if (argument === "--port" && mode !== "build") {
57
67
  const port = Number(nextArgument);
58
68
  if (!Number.isInteger(port) || port < 1 || port > 65535) exitWithError("--port needs a number from 1 to 65535");
59
69
  options.port = port;
60
70
  index += 1;
61
- } else if (argument === "--no-open" && mode === "serve") {
71
+ } else if (argument === "--no-open" && mode !== "build") {
62
72
  options.openBrowser = false;
63
- } else if (argument.startsWith("-")) {
73
+ } else if (argument.startsWith("-") && argument !== "-") {
64
74
  exitWithError(`unknown option ${argument} for ${mode}`);
65
75
  } else {
66
76
  sourcePaths.push(argument);
@@ -73,22 +83,90 @@ function isReadableFile(filePath) {
73
83
  return fileSystem.existsSync(filePath) && fileSystem.statSync(filePath).isFile();
74
84
  }
75
85
 
86
+ function isFolder(filePath) {
87
+ return fileSystem.existsSync(filePath) && fileSystem.statSync(filePath).isDirectory();
88
+ }
89
+
90
+ function pagePathFor(sourcePath) {
91
+ return path.join(path.dirname(sourcePath), path.basename(sourcePath, path.extname(sourcePath)) + ".html");
92
+ }
93
+
94
+ /*
95
+ Every Markdown file in a folder and its subfolders, in a stable order.
96
+ Dotfolders and node_modules are skipped.
97
+ */
98
+ function findMarkdownFiles(folder) {
99
+ const found = [];
100
+ const entries = fileSystem.readdirSync(folder, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
101
+ for (const entry of entries) {
102
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
103
+ const entryPath = path.join(folder, entry.name);
104
+ if (entry.isDirectory()) found.push(...findMarkdownFiles(entryPath));
105
+ else if (entry.isFile() && /\.(md|markdown)$/i.test(entry.name)) found.push(entryPath);
106
+ }
107
+ return found;
108
+ }
109
+
110
+ /*
111
+ Works out which page each input becomes before anything is written. Files
112
+ named on the command line go flat into --out; files found inside a folder
113
+ keep their path under it. Two inputs that would write the same page stop
114
+ the build, because the second would silently replace the first.
115
+ */
116
+ function planBuild(sourcePaths, outputFolder) {
117
+ const jobs = [];
118
+ for (const sourcePath of sourcePaths) {
119
+ if (isFolder(sourcePath)) {
120
+ const files = findMarkdownFiles(sourcePath);
121
+ if (files.length === 0) {
122
+ console.error(`Skipped ${sourcePath}: no Markdown files in it`);
123
+ process.exitCode = 1;
124
+ }
125
+ for (const filePath of files) {
126
+ const outputPath = outputFolder ? path.join(outputFolder, path.relative(sourcePath, pagePathFor(filePath))) : pagePathFor(filePath);
127
+ jobs.push({ sourcePath: filePath, outputPath });
128
+ }
129
+ } else if (isReadableFile(sourcePath)) {
130
+ const outputPath = outputFolder ? path.join(outputFolder, path.basename(pagePathFor(sourcePath))) : pagePathFor(sourcePath);
131
+ jobs.push({ sourcePath, outputPath });
132
+ } else {
133
+ console.error(`Skipped ${sourcePath}: file not found`);
134
+ process.exitCode = 1;
135
+ }
136
+ }
137
+
138
+ const sourcesByOutput = new Map();
139
+ for (const job of jobs) {
140
+ const key = path.resolve(job.outputPath);
141
+ sourcesByOutput.set(key, [...(sourcesByOutput.get(key) || []), job.sourcePath]);
142
+ }
143
+ for (const [outputPath, sources] of sourcesByOutput) {
144
+ if (sources.length > 1) {
145
+ exitWithError(`${sources.join(" and ")} would both write ${path.relative(process.cwd(), outputPath)}. Build them into different folders or rename one.`);
146
+ }
147
+ }
148
+ return jobs;
149
+ }
150
+
76
151
  function runBuild(rawArguments) {
77
152
  const { sourcePaths, options } = readArguments(rawArguments, "build");
78
153
  if (sourcePaths.length === 0) {
79
154
  console.log(usageText);
80
155
  process.exit(1);
81
156
  }
82
- if (options.outputFolder) fileSystem.mkdirSync(options.outputFolder, { recursive: true });
157
+ if (sourcePaths.includes("-")) {
158
+ if (sourcePaths.length > 1) exitWithError("- (standard input) must be the only input");
159
+ if (options.outputFolder) exitWithError("--out does not apply when reading standard input; the page goes to standard output");
160
+ process.stdout.write(renderPage(fileSystem.readFileSync(0, "utf8"), "-"));
161
+ return;
162
+ }
83
163
 
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}`);
164
+ for (const job of planBuild(sourcePaths, options.outputFolder)) {
165
+ const outputPath = buildFile(job.sourcePath, null, {
166
+ outputPath: job.outputPath,
167
+ onWarning: (message) => console.error(`${job.sourcePath}: ${message}`),
168
+ });
169
+ console.log(`${job.sourcePath} -> ${outputPath}`);
92
170
  }
93
171
  }
94
172
 
@@ -107,16 +185,18 @@ function openInBrowser(url) {
107
185
  child.unref();
108
186
  }
109
187
 
110
- async function runServe(rawArguments) {
111
- const { sourcePaths, options } = readArguments(rawArguments, "serve");
112
- if (sourcePaths.length !== 1) exitWithError("serve takes exactly one Markdown file");
188
+ async function runServe(rawArguments, mode) {
189
+ const { sourcePaths, options } = readArguments(rawArguments, mode);
190
+ if (sourcePaths.length !== 1 || sourcePaths[0] === "-") exitWithError(`${mode} takes exactly one Markdown file`);
113
191
  const [sourcePath] = sourcePaths;
114
192
  if (!isReadableFile(sourcePath)) exitWithError(`${sourcePath}: file not found`);
115
193
 
116
194
  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.");
195
+ const { url } = await startServer(sourcePath, { port: options.port, mode });
196
+ console.log(`Serving ${sourcePath} as a ${mode} at ${url}`);
197
+ console.log(mode === "board"
198
+ ? "Moving cards and ticking boxes save to the file. Press Ctrl+C to stop."
199
+ : "Checkbox changes save to the file. Press Ctrl+C to stop.");
120
200
  if (options.openBrowser) openInBrowser(url);
121
201
  } catch (error) {
122
202
  exitWithError(`could not start the server: ${error.message}`);
@@ -125,7 +205,8 @@ async function runServe(rawArguments) {
125
205
 
126
206
  function main() {
127
207
  const rawArguments = process.argv.slice(2);
128
- if (rawArguments[0] === "serve") return runServe(rawArguments.slice(1));
208
+ if (rawArguments[0] === "serve") return runServe(rawArguments.slice(1), "serve");
209
+ if (rawArguments[0] === "board") return runServe(rawArguments.slice(1), "board");
129
210
  if (rawArguments[0] === "build") return runBuild(rawArguments.slice(1));
130
211
  return runBuild(rawArguments);
131
212
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "0.2.0",
4
+ "description": "Press Markdown into clean pages. Build self-contained HTML, serve it live and save checkboxes back to the file, or open it as a Kanban board. No AI, no tokens.",
5
5
  "keywords": [
6
6
  "markdown",
7
7
  "html",
@@ -10,7 +10,10 @@
10
10
  "checklist",
11
11
  "offline",
12
12
  "live",
13
- "todo"
13
+ "todo",
14
+ "kanban",
15
+ "board",
16
+ "backlog"
14
17
  ],
15
18
  "homepage": "https://github.com/sirasitxp/md-press#readme",
16
19
  "bugs": {
@@ -22,7 +25,7 @@
22
25
  },
23
26
  "license": "MIT",
24
27
  "author": "Sirasit Thitirattanakorn (Sirux) <sirasit@sirux.io>",
25
- "main": "src/build.js",
28
+ "main": "src/index.js",
26
29
  "bin": {
27
30
  "md-press": "bin/md-press.js"
28
31
  },
@@ -41,7 +44,8 @@
41
44
  },
42
45
  "dependencies": {
43
46
  "highlight.js": "^11.12.0",
44
- "marked": "^18.0.14"
47
+ "marked": "^18.0.14",
48
+ "marked-footnote": "^1.4.0"
45
49
  },
46
50
  "devDependencies": {
47
51
  "@eslint/js": "^9.39.5",
package/src/board.js ADDED
@@ -0,0 +1,273 @@
1
+ /*
2
+ Reads a Markdown backlog as a Kanban board and writes changes back to it.
3
+
4
+ The shape is the one people already write: "##" headings are columns, and
5
+ the top-level task items ("- [ ] Ship it") under a heading are its cards, in
6
+ order. A card owns its own line plus every line under it that is indented or
7
+ a continuation, so nested notes travel with the card. Trailing `code` spans
8
+ on the card's line are tags. With two or more columns, the last one counts
9
+ as done: a card moved into it is checked, a card moved out is unchecked.
10
+
11
+ Every change is a line operation on the file. Moving a card cuts its lines
12
+ and pastes them at the target, adding a card appends one line, and nothing
13
+ else in the file changes. Moving a card away and back gives the same bytes.
14
+
15
+ Rendering the board page lives here too, next to the parser it depends on.
16
+ */
17
+
18
+ const fileSystem = require("fs");
19
+ const path = require("path");
20
+ const { Marked } = require("marked");
21
+ const { bodyLines, findTaskLines, taskLinePattern, setTaskLineState } = require("./tasks.js");
22
+ const { splitFrontmatter } = require("./frontmatter.js");
23
+ const { escapeHtml, markdownToText } = require("./build.js");
24
+
25
+ const templateFolder = path.join(__dirname, "template");
26
+ const pageStyles = fileSystem.readFileSync(path.join(templateFolder, "page.css"), "utf8");
27
+ const boardStyles = fileSystem.readFileSync(path.join(templateFolder, "board.css"), "utf8");
28
+ const boardScript = fileSystem.readFileSync(path.join(templateFolder, "board.js"), "utf8");
29
+
30
+ const headingPattern = /^ {0,3}(#{1,2})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/;
31
+ const listLinePattern = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/;
32
+ const blockStartPattern = /^(?:#{1,6}[ \t]|(?:[-*+]|\d{1,9}[.)])[ \t]|```|~~~|>|(?:[-*_][ \t]*){3,}$)/;
33
+ const trailingTagsPattern = /(?:[ \t]+`[^`\n]+`)+[ \t]*$/;
34
+
35
+ function isBlank(text) {
36
+ return text.trim() === "";
37
+ }
38
+
39
+ /* Splits "Fix login `bug` `p1`" into { title: "Fix login", tags: ["bug", "p1"] }. */
40
+ function splitTags(text) {
41
+ const match = text.match(trailingTagsPattern);
42
+ if (!match) return { title: text.trim(), tags: [] };
43
+ const tags = Array.from(match[0].matchAll(/`([^`]+)`/g), (tagMatch) => tagMatch[1].trim());
44
+ return { title: text.slice(0, match.index).trim(), tags };
45
+ }
46
+
47
+ /*
48
+ Parses the board. Returns { title, columns }, where each column is
49
+ { title, lineIndex, cards, loose } and each card is
50
+ { title, tags, checked, lineIndex, lineCount, taskIndex, notes }.
51
+
52
+ taskIndex is the card's position among all task lines in the file, the same
53
+ index the page and /api/task use. notes is the card's extra lines, dedented,
54
+ as Markdown. loose is true when the column's cards are separated by nothing
55
+ but blank lines, so a card added to it gets the same spacing.
56
+ */
57
+ function parseBoard(sourceText) {
58
+ const lines = bodyLines(sourceText);
59
+ const taskIndexByLine = new Map(findTaskLines(sourceText).map((task, index) => [task.lineIndex, index]));
60
+ const columns = [];
61
+ let title = "";
62
+ let column = null;
63
+ let card = null;
64
+
65
+ function closeCard() {
66
+ if (!card) return;
67
+ while (card.lineCount > 1 && isBlank(lines[card.position + card.lineCount - 1].text)) card.lineCount -= 1;
68
+ const extra = lines.slice(card.position + 1, card.position + card.lineCount).map((line) => line.text);
69
+ const indent = Math.min(...extra.filter((text) => !isBlank(text)).map((text) => text.match(/^[ \t]*/)[0].length), Infinity);
70
+ card.notes = extra.map((text) => text.slice(Math.min(indent, text.length))).join("\n").trim();
71
+ card = null;
72
+ }
73
+
74
+ lines.forEach((line, position) => {
75
+ const { text } = line;
76
+ const headingMatch = line.inCode ? null : text.match(headingPattern);
77
+ if (headingMatch && headingMatch[1] === "#") {
78
+ closeCard();
79
+ column = null;
80
+ if (!title) title = markdownToText(headingMatch[2]);
81
+ return;
82
+ }
83
+ if (headingMatch) {
84
+ closeCard();
85
+ column = { title: markdownToText(headingMatch[2]), lineIndex: line.lineIndex, cards: [], loose: false };
86
+ columns.push(column);
87
+ return;
88
+ }
89
+ if (!column) return;
90
+
91
+ const taskMatch = line.inCode || /^[ \t>]/.test(text) ? null : text.match(taskLinePattern);
92
+ if (taskMatch) {
93
+ closeCard();
94
+ const previous = column.cards[column.cards.length - 1];
95
+ const between = previous ? lines.slice(previous.position + previous.lineCount, position) : [];
96
+ if (between.length > 0 && between.every((line) => isBlank(line.text))) column.loose = true;
97
+ const { title: cardTitle, tags } = splitTags(text.slice(taskMatch[0].length));
98
+ card = {
99
+ title: cardTitle,
100
+ tags,
101
+ checked: taskMatch[2] !== " ",
102
+ lineIndex: line.lineIndex,
103
+ lineCount: 1,
104
+ taskIndex: taskIndexByLine.get(line.lineIndex),
105
+ notes: "",
106
+ position,
107
+ };
108
+ column.cards.push(card);
109
+ return;
110
+ }
111
+ if (!card) return;
112
+
113
+ const previousText = lines[position - 1].text;
114
+ const continues = line.inCode
115
+ || isBlank(text)
116
+ || /^[ \t]/.test(text)
117
+ || (!isBlank(previousText) && !blockStartPattern.test(text));
118
+ if (continues) card.lineCount += 1;
119
+ else closeCard();
120
+ });
121
+ closeCard();
122
+ columns.forEach((item) => item.cards.forEach((entry) => { delete entry.position; }));
123
+ return { title, columns };
124
+ }
125
+
126
+ /*
127
+ Removes a run of lines. A blank line left touching another blank line, where
128
+ the card used to be, is dropped too, so the file does not gain empty space.
129
+ */
130
+ function removeLines(lines, start, count) {
131
+ lines.splice(start, count);
132
+ if (start > 0 && start < lines.length && isBlank(lines[start - 1]) && isBlank(lines[start])) lines.splice(start, 1);
133
+ }
134
+
135
+ /*
136
+ Inserts card lines into a column, before the card at cardIndex or at the end
137
+ of the column when cardIndex is past the last card. Spacing follows the
138
+ column: a blank line after a heading or paragraph, and between cards when
139
+ the column already spaces its cards that way. A move passes the spacing of
140
+ the column as it was before the card left, since a column with one card
141
+ left cannot show it.
142
+ */
143
+ function insertCardLines(lines, board, columnIndex, cardLines, cardIndex, loose = board.columns[columnIndex].loose) {
144
+ const column = board.columns[columnIndex];
145
+ const nextColumn = board.columns[columnIndex + 1];
146
+ const before = column.cards[cardIndex];
147
+ let insertAt;
148
+ if (before) {
149
+ insertAt = before.lineIndex;
150
+ } else {
151
+ insertAt = nextColumn ? nextColumn.lineIndex : lines.length;
152
+ while (insertAt > column.lineIndex + 1 && isBlank(lines[insertAt - 1])) insertAt -= 1;
153
+ }
154
+ const previous = lines[insertAt - 1];
155
+ const gapBefore = !isBlank(previous) && (!listLinePattern.test(previous) || (loose && !before));
156
+ const gapAfter = Boolean(before) && loose;
157
+ lines.splice(insertAt, 0, ...(gapBefore ? [""] : []), ...cardLines, ...(gapAfter ? [""] : []));
158
+ }
159
+
160
+ function findCard(board, columnIndex, cardIndex) {
161
+ const card = board.columns[columnIndex]?.cards[cardIndex];
162
+ if (!card) throw new RangeError(`No card at column ${columnIndex}, index ${cardIndex}`);
163
+ return card;
164
+ }
165
+
166
+ function checkColumn(board, columnIndex) {
167
+ if (!board.columns[columnIndex]) throw new RangeError(`No column at index ${columnIndex}`);
168
+ }
169
+
170
+ /*
171
+ Moves one card. from and to are { column, index }; to.index is the card's
172
+ final position in the target column. Returns the new source text.
173
+ */
174
+ function moveCard(sourceText, from, to) {
175
+ const board = parseBoard(sourceText);
176
+ const card = findCard(board, from.column, from.index);
177
+ checkColumn(board, to.column);
178
+ const lastColumn = board.columns.length > 1 ? board.columns.length - 1 : -1;
179
+ const lines = sourceText.split("\n");
180
+ const cardLines = lines.slice(card.lineIndex, card.lineIndex + card.lineCount);
181
+ if (to.column === lastColumn && from.column !== lastColumn) cardLines[0] = setTaskLineState(cardLines[0], true);
182
+ if (from.column === lastColumn && to.column !== lastColumn) cardLines[0] = setTaskLineState(cardLines[0], false);
183
+ removeLines(lines, card.lineIndex, card.lineCount);
184
+ insertCardLines(lines, parseBoard(lines.join("\n")), to.column, cardLines, to.index, board.columns[to.column].loose);
185
+ return lines.join("\n");
186
+ }
187
+
188
+ /* Adds a card with the given text at the end of a column. Returns the new source text. */
189
+ function addCard(sourceText, columnIndex, text) {
190
+ const title = String(text).replace(/\s+/g, " ").trim();
191
+ if (!title) throw new RangeError("A card needs some text");
192
+ const board = parseBoard(sourceText);
193
+ checkColumn(board, columnIndex);
194
+ const checked = board.columns.length > 1 && columnIndex === board.columns.length - 1;
195
+ const lines = sourceText.split("\n");
196
+ insertCardLines(lines, board, columnIndex, [`- [${checked ? "x" : " "}] ${title}`], Infinity);
197
+ return lines.join("\n");
198
+ }
199
+
200
+ /* A stable hue for a tag name, so the same tag looks the same everywhere. */
201
+ function hueFor(tag) {
202
+ let hash = 7;
203
+ for (const character of tag.toLowerCase()) hash = ((hash * 33) ^ character.codePointAt(0)) >>> 0;
204
+ return hash % 360;
205
+ }
206
+
207
+ function renderTag(tag) {
208
+ return `<span class="tag" style="--hue: ${hueFor(tag)}">${escapeHtml(tag)}</span>`;
209
+ }
210
+
211
+ /*
212
+ Renders the board as a live page. Cards carry their column, index, task
213
+ index, and tags as data attributes for the board script.
214
+ */
215
+ function renderBoard(sourceText, sourceName, { live }) {
216
+ const board = parseBoard(sourceText);
217
+ const { metadata } = splitFrontmatter(sourceText);
218
+ const markdown = new Marked();
219
+ const baseName = path.basename(sourceName);
220
+ const title = metadata.title || board.title || path.basename(baseName, path.extname(baseName));
221
+ const allTags = Array.from(new Set(board.columns.flatMap((column) => column.cards.flatMap((card) => card.tags)))).sort();
222
+ const columnOptions = board.columns.map((column, index) => `<option value="${index}">${escapeHtml(column.title)}</option>`).join("");
223
+
224
+ const columnsHtml = board.columns.map((column, columnIndex) => {
225
+ const cards = column.cards.map((card, cardIndex) => `<li class="card${card.checked ? " done" : ""}" draggable="true" data-column="${columnIndex}" data-index="${cardIndex}" data-task="${card.taskIndex}" data-tags="${escapeHtml(card.tags.join(" "))}">
226
+ <label class="card-main"><input type="checkbox" class="task"${card.checked ? " checked" : ""} aria-label="${escapeHtml(card.title)}"> <span class="card-title">${markdown.parseInline(card.title)}</span></label>
227
+ ${card.tags.length ? `<div class="card-tags">${card.tags.map(renderTag).join("")}</div>` : ""}
228
+ ${card.notes ? `<div class="card-notes">${markdown.parse(card.notes)}</div>` : ""}
229
+ <label class="card-move"><span class="sr-only">Move to</span><select data-column="${columnIndex}" data-index="${cardIndex}"><option value="">Move to…</option>${columnOptions}</select></label>
230
+ </li>`).join("\n");
231
+ return `<section class="column" aria-label="${escapeHtml(column.title)}">
232
+ <h2>${escapeHtml(column.title)} <span class="count">${column.cards.length}</span></h2>
233
+ <ul class="cards" data-column="${columnIndex}">
234
+ ${cards}
235
+ </ul>
236
+ <form class="add-card" data-column="${columnIndex}"><input type="text" name="text" placeholder="Add a card" aria-label="New card in ${escapeHtml(column.title)}" autocomplete="off"><button type="submit">Add</button></form>
237
+ </section>`;
238
+ }).join("\n");
239
+
240
+ const empty = board.columns.length === 0
241
+ ? `<p class="board-empty">No columns yet. Add <code>## Heading</code> lines to ${escapeHtml(baseName)} for columns, and <code>- [ ] Task</code> lines under them for cards.</p>`
242
+ : "";
243
+ const pageSettings = { live: { version: live.version, fileName: baseName } };
244
+
245
+ return `<!doctype html>
246
+ <html lang="${escapeHtml(metadata.lang || "en")}">
247
+ <head>
248
+ <meta charset="utf-8">
249
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
250
+ <title>${escapeHtml(title)}</title>
251
+ <style>${pageStyles}
252
+ ${boardStyles}</style>
253
+ </head>
254
+ <body class="board-body">
255
+ <div class="progress board-toolbar" role="status" aria-live="polite">
256
+ <strong class="board-title">${escapeHtml(title)}</strong>
257
+ ${allTags.length ? `<div class="board-filter" aria-label="Filter by tag">${allTags.map(renderTag).join("")}</div>` : ""}
258
+ <a class="board-link" href="/page">Page view</a>
259
+ <span class="toolbar-status">Live, saving to ${escapeHtml(baseName)}</span>
260
+ </div>
261
+ <main class="board">
262
+ ${columnsHtml}
263
+ ${empty}
264
+ </main>
265
+ <footer>Board of ${escapeHtml(baseName)} with md-press. Columns are <code>##</code> headings, cards are <code>- [ ]</code> items, tags are trailing <code>\`code\`</code>.</footer>
266
+ <script>const mdPress = ${JSON.stringify(pageSettings).replace(/</g, "\\u003c")};
267
+ ${boardScript}</script>
268
+ </body>
269
+ </html>
270
+ `;
271
+ }
272
+
273
+ module.exports = { parseBoard, moveCard, addCard, renderBoard, splitTags, hueFor };