@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/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
|
-
##
|
|
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
|
|
25
|
-
md-press docs
|
|
26
|
-
|
|
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
|
|
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,
|
|
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
|
-
- **
|
|
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`
|
|
92
|
-
- **
|
|
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`
|
|
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.
|
|
4
|
-
"build" (the default, so a bare file list builds pages)
|
|
5
|
-
opens one file as a live page on localhost
|
|
6
|
-
|
|
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
|
|
20
|
-
md-press
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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,
|
|
112
|
-
if (sourcePaths.length !== 1) exitWithError(
|
|
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(
|
|
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.
|
|
4
|
-
"description": "Press Markdown into clean pages. Build self-contained HTML,
|
|
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/
|
|
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 };
|