@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/tasks.js CHANGED
@@ -4,48 +4,72 @@ written back to the exact line it came from. It scans line by line in
4
4
  document order, which is the order the page renders checkboxes, and skips
5
5
  frontmatter and fenced code, where "- [ ]" is only text.
6
6
 
7
+ The task pattern follows marked, which renders the page: the brackets must be
8
+ followed by a space and then some text, so a bare "- [ ]" is not a task on
9
+ the page and is not one here either. A fence may be indented any amount, so
10
+ code inside nested lists is skipped too.
11
+
7
12
  This scanner is deliberately simple, so it is paired with a safety check:
8
13
  the server only allows writing when the number of tasks found here equals the
9
14
  number of checkboxes the page rendered. Any layout the scanner misreads turns
10
15
  into a read-only page instead of an edit to the wrong line.
16
+
17
+ The board view (src/board.js) is built on the same line scan, so a card's
18
+ checkbox and the page's checkbox for the same line always agree.
11
19
  */
12
20
 
13
- const { frontmatterLength } = require("./frontmatter.js");
21
+ const { frontmatterLength, byteOrderMarkLength } = require("./frontmatter.js");
14
22
 
15
- const taskLinePattern = /^((?:[ \t]*>)*[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+\[)([ xX])(\](?:\s|$))/;
16
- const fenceOpenPattern = /^(?:[ \t]*>)*[ \t]{0,3}(`{3,}|~{3,})/;
23
+ const taskLinePattern = /^((?:[ \t]*>)*[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+\[)([ xX])(\] (?=[ \t]*\S))/;
24
+ const fenceOpenPattern = /^(?:[ \t]*>)*[ \t]*(`{3,}|~{3,})/;
17
25
 
18
26
  /*
19
- Returns every task line as { lineIndex, checked }. Line indexes count from the
20
- start of the whole file, frontmatter included, so they can be used to edit it.
27
+ Every line of the Markdown body as { lineIndex, text, inCode }. Line indexes
28
+ count from the start of the whole file, frontmatter included, so they can be
29
+ used to edit it. Fence lines and the lines between them are marked inCode.
30
+ A byte order mark on the first line is left out of that line's text.
21
31
  */
22
- function findTaskLines(sourceText) {
32
+ function bodyLines(sourceText) {
23
33
  const lines = sourceText.split("\n");
24
34
  const firstBodyLine = sourceText.slice(0, frontmatterLength(sourceText)).split("\n").length - 1;
25
- const tasks = [];
35
+ const markLength = byteOrderMarkLength(sourceText);
36
+ const result = [];
26
37
  let openFence = null;
27
38
 
28
39
  for (let lineIndex = firstBodyLine; lineIndex < lines.length; lineIndex += 1) {
29
- const line = lines[lineIndex];
30
- const fenceMatch = line.match(fenceOpenPattern);
40
+ const text = lineIndex === 0 ? lines[0].slice(markLength) : lines[lineIndex];
41
+ const fenceMatch = text.match(fenceOpenPattern);
31
42
  if (openFence) {
32
43
  const closes = fenceMatch
33
44
  && fenceMatch[1][0] === openFence[0]
34
45
  && fenceMatch[1].length >= openFence.length
35
- && line.trim().replace(/^(>\s*)*/, "").replace(/[`~]/g, "") === "";
46
+ && text.trim().replace(/^(>\s*)*/, "").replace(/[`~]/g, "") === "";
36
47
  if (closes) openFence = null;
48
+ result.push({ lineIndex, text, inCode: true });
37
49
  continue;
38
50
  }
39
- if (fenceMatch) {
40
- openFence = fenceMatch[1];
41
- continue;
42
- }
43
- const taskMatch = line.match(taskLinePattern);
44
- if (taskMatch) tasks.push({ lineIndex, checked: taskMatch[2] !== " " });
51
+ if (fenceMatch) openFence = fenceMatch[1];
52
+ result.push({ lineIndex, text, inCode: Boolean(fenceMatch) });
53
+ }
54
+ return result;
55
+ }
56
+
57
+ /* Returns every task line as { lineIndex, checked }. */
58
+ function findTaskLines(sourceText) {
59
+ const tasks = [];
60
+ for (const line of bodyLines(sourceText)) {
61
+ if (line.inCode) continue;
62
+ const taskMatch = line.text.match(taskLinePattern);
63
+ if (taskMatch) tasks.push({ lineIndex: line.lineIndex, checked: taskMatch[2] !== " " });
45
64
  }
46
65
  return tasks;
47
66
  }
48
67
 
68
+ /* Returns one task line with its bracket character set. Nothing else changes. */
69
+ function setTaskLineState(lineText, checked) {
70
+ return lineText.replace(taskLinePattern, (_fullMatch, before, _state, after) => before + (checked ? "x" : " ") + after);
71
+ }
72
+
49
73
  /*
50
74
  Returns the source with one task set to checked or unchecked. Only the single
51
75
  character between the brackets changes, so spacing, line endings, and every
@@ -56,10 +80,9 @@ function setTaskState(sourceText, taskIndex, checked) {
56
80
  const task = tasks[taskIndex];
57
81
  if (!task) throw new RangeError(`No task at index ${taskIndex}`);
58
82
  const lines = sourceText.split("\n");
59
- lines[task.lineIndex] = lines[task.lineIndex].replace(taskLinePattern, (_fullMatch, before, _state, after) => {
60
- return before + (checked ? "x" : " ") + after;
61
- });
83
+ const markLength = task.lineIndex === 0 ? byteOrderMarkLength(sourceText) : 0;
84
+ lines[task.lineIndex] = lines[task.lineIndex].slice(0, markLength) + setTaskLineState(lines[task.lineIndex].slice(markLength), checked);
62
85
  return lines.join("\n");
63
86
  }
64
87
 
65
- module.exports = { findTaskLines, setTaskState };
88
+ module.exports = { findTaskLines, setTaskState, setTaskLineState, bodyLines, taskLinePattern };
@@ -0,0 +1,79 @@
1
+ /*
2
+ Board layout on top of the page stylesheet. Columns sit side by side and
3
+ scroll sideways on narrow screens. Cards are quiet paper on the column's
4
+ slightly darker track, with tag colors taken from each tag's name.
5
+ */
6
+ .board-body { display: flex; flex-direction: column; min-height: 100vh; }
7
+ .board-toolbar { gap: 0.4rem 1rem; padding-left: 1.25rem; padding-right: 1.25rem; }
8
+ .board-title { color: var(--ink); font-size: 1rem; }
9
+ .board-filter { display: flex; flex-wrap: wrap; gap: 0.3rem; }
10
+ .board-filter .tag { cursor: pointer; opacity: 0.55; }
11
+ .board-filter .tag:hover, .board-filter .tag.active { opacity: 1; }
12
+ .board-filter .tag.active { outline: 2px solid hsl(var(--hue) 50% 50%); outline-offset: 1px; }
13
+ .board-link { font: inherit; }
14
+
15
+ main.board {
16
+ flex: 1; max-width: none; margin: 0; padding: 1.25rem;
17
+ display: flex; align-items: flex-start; gap: 1rem;
18
+ overflow-x: auto; scroll-snap-type: x proximity;
19
+ font-family: var(--sans); font-size: 0.95rem; line-height: 1.5;
20
+ }
21
+ .column {
22
+ flex: 0 0 18rem; max-width: 85vw; scroll-snap-align: start;
23
+ background: var(--code-paper); border-radius: 8px; padding: 0.75rem;
24
+ }
25
+ .column h2 { font-size: 0.95rem; margin: 0 0.25rem 0.6rem; padding: 0; border: 0; display: flex; align-items: baseline; gap: 0.5rem; }
26
+ .column .count { font-weight: 400; color: var(--muted); font-size: 0.85rem; }
27
+ .cards { list-style: none; margin: 0; padding: 0; min-height: 2.5rem; }
28
+ .cards.drop-target { outline: 2px dashed var(--accent); outline-offset: 2px; border-radius: 6px; }
29
+ .card {
30
+ position: relative; margin: 0 0 0.5rem; padding: 0.6rem 0.75rem;
31
+ background: var(--paper); border: 1px solid var(--rule); border-radius: 6px;
32
+ cursor: grab; transition: box-shadow 0.15s;
33
+ }
34
+ .card:hover { box-shadow: 0 2px 8px rgb(0 0 0 / 0.08); }
35
+ .card.dragging { opacity: 0.4; cursor: grabbing; }
36
+ .card.hidden { display: none; }
37
+ .card.done .card-title { color: var(--muted); text-decoration: line-through; text-decoration-color: var(--rule); }
38
+ .card-main { display: flex; gap: 0.5rem; align-items: flex-start; cursor: pointer; padding-right: 1.5rem; }
39
+ .card-main input.task { flex: none; margin-top: 0.2em; }
40
+ .card-title code { font-size: 0.85em; }
41
+ .card-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.4rem; }
42
+ .tag {
43
+ --hue: 200;
44
+ font: 500 0.72rem/1.5 var(--sans); padding: 0 0.5em; border-radius: 999px;
45
+ color: hsl(var(--hue) 55% 30%); background: hsl(var(--hue) 60% 90%);
46
+ }
47
+ .card-notes { margin-top: 0.4rem; font-size: 0.85rem; color: var(--muted); }
48
+ .card-notes > :last-child { margin-bottom: 0; }
49
+ .card-notes ul, .card-notes ol { padding-left: 1.2em; margin-bottom: 0.4em; }
50
+ .card-notes li.task-item { margin-left: -1.2em; padding-left: 1.6em; }
51
+ .card-notes input.task { pointer-events: none; }
52
+ .card-move { position: absolute; top: 0.45rem; right: 0.5rem; width: 1.4rem; height: 1.4rem; overflow: hidden; border-radius: 4px; opacity: 0; }
53
+ .card:hover .card-move, .card:focus-within .card-move { opacity: 1; }
54
+ .card-move::before { content: "→"; position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); font-size: 0.9rem; pointer-events: none; }
55
+ .card-move select { width: 100%; height: 100%; opacity: 0; cursor: pointer; }
56
+ .add-card { display: flex; gap: 0.4rem; margin-top: 0.25rem; }
57
+ .add-card input {
58
+ flex: 1; min-width: 0; font: inherit; color: var(--ink); background: transparent;
59
+ border: 1px solid transparent; border-radius: 6px; padding: 0.35rem 0.5rem;
60
+ }
61
+ .add-card input::placeholder { color: var(--muted); }
62
+ .add-card input:focus { background: var(--paper); border-color: var(--rule); outline: none; }
63
+ .add-card button { font: inherit; color: var(--muted); background: none; border: 1px solid var(--rule); border-radius: 6px; padding: 0.2rem 0.7rem; cursor: pointer; opacity: 0; }
64
+ .add-card:focus-within button { opacity: 1; }
65
+ .board-empty { max-width: 42rem; color: var(--muted); }
66
+ .board-body footer { max-width: none; margin: 0; padding: 1rem 1.25rem 1.5rem; }
67
+
68
+ @media (prefers-color-scheme: dark) {
69
+ .tag { color: hsl(var(--hue) 60% 80%); background: hsl(var(--hue) 35% 22%); }
70
+ .card:hover { box-shadow: 0 2px 8px rgb(0 0 0 / 0.4); }
71
+ }
72
+ @media (hover: none) {
73
+ .card-move, .add-card button { opacity: 1; }
74
+ }
75
+ @media print {
76
+ main.board { display: block; }
77
+ .column { break-inside: avoid; margin-bottom: 1rem; }
78
+ .card-move, .add-card { display: none; }
79
+ }
@@ -0,0 +1,236 @@
1
+ /*
2
+ Runs inside the board page from md-press board. Drag a card to another
3
+ column or position, or pick a column from its move menu, and the change is
4
+ written to the Markdown file. Ticking a card's box saves its state. Typing
5
+ in a column's "Add a card" field adds a task line to that column.
6
+
7
+ Every save carries the file version the page was built from, and the server
8
+ refuses it if the file changed since, so the page reloads instead of
9
+ overwriting someone else's edit. The page also watches the version and
10
+ reloads, keeping its scroll position, whenever the file changes on disk.
11
+ After a successful change the page reloads too, so what it shows is always
12
+ what the file says.
13
+
14
+ Tag filters live in this tab's session storage so they survive those reloads.
15
+ */
16
+ (function () {
17
+ const live = mdPress.live;
18
+ const statusLabel = document.querySelector(".toolbar-status");
19
+ const board = document.querySelector("main.board");
20
+ const scrollKey = "md-press-board-scroll:" + location.pathname;
21
+ const filterKey = "md-press-board-filter:" + location.pathname;
22
+ let currentVersion = live.version;
23
+ let pendingSaves = 0;
24
+
25
+ function readStorage(key) {
26
+ try {
27
+ return sessionStorage.getItem(key);
28
+ } catch (_error) {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function writeStorage(key, value) {
34
+ try {
35
+ if (value === null) sessionStorage.removeItem(key);
36
+ else sessionStorage.setItem(key, value);
37
+ } catch (_error) {
38
+ return;
39
+ }
40
+ }
41
+
42
+ function setStatus(text, isError) {
43
+ statusLabel.textContent = text;
44
+ statusLabel.classList.toggle("error", Boolean(isError));
45
+ }
46
+
47
+ function reloadKeepingScroll() {
48
+ writeStorage(scrollKey, JSON.stringify({ x: board.scrollLeft, y: window.scrollY }));
49
+ location.reload();
50
+ }
51
+
52
+ const savedScroll = readStorage(scrollKey);
53
+ writeStorage(scrollKey, null);
54
+ if (savedScroll) {
55
+ try {
56
+ const { x, y } = JSON.parse(savedScroll);
57
+ board.scrollLeft = x;
58
+ window.scrollTo(0, y);
59
+ } catch (_error) {
60
+ // Nothing saved that can be used.
61
+ }
62
+ }
63
+
64
+ async function save(pathName, payload) {
65
+ pendingSaves += 1;
66
+ setStatus("Saving");
67
+ try {
68
+ const response = await fetch(pathName, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify(Object.assign({ version: currentVersion }, payload)),
72
+ });
73
+ if (response.status === 409) {
74
+ reloadKeepingScroll();
75
+ return null;
76
+ }
77
+ const result = await response.json();
78
+ if (!response.ok) throw new Error(result.error || ("Save failed with status " + response.status));
79
+ currentVersion = result.version;
80
+ setStatus("Saved to " + live.fileName);
81
+ return result;
82
+ } catch (error) {
83
+ setStatus(error.message === "Failed to fetch" ? "Could not save. Is md-press board still running?" : error.message, true);
84
+ return null;
85
+ } finally {
86
+ pendingSaves -= 1;
87
+ }
88
+ }
89
+
90
+ function setupCheckboxes() {
91
+ document.querySelectorAll(".card > .card-main > input.task").forEach(function (checkbox) {
92
+ checkbox.addEventListener("change", async function () {
93
+ const card = checkbox.closest(".card");
94
+ const result = await save("/api/task", { index: Number(card.dataset.task), checked: checkbox.checked });
95
+ if (result) card.classList.toggle("done", checkbox.checked);
96
+ else checkbox.checked = !checkbox.checked;
97
+ });
98
+ });
99
+ }
100
+
101
+ function positionOf(card) {
102
+ return { column: Number(card.parentElement.dataset.column), index: Array.from(card.parentElement.children).indexOf(card) };
103
+ }
104
+
105
+ async function moveCard(card, from, to) {
106
+ if (from.column === to.column && from.index === to.index) return;
107
+ const result = await save("/api/board", { action: "move", from: from, to: to });
108
+ if (result) reloadKeepingScroll();
109
+ }
110
+
111
+ function setupDragAndDrop() {
112
+ let dragged = null;
113
+ let origin = null;
114
+
115
+ document.querySelectorAll(".card").forEach(function (card) {
116
+ card.addEventListener("dragstart", function (event) {
117
+ dragged = card;
118
+ origin = { column: Number(card.dataset.column), index: Number(card.dataset.index) };
119
+ card.classList.add("dragging");
120
+ event.dataTransfer.effectAllowed = "move";
121
+ event.dataTransfer.setData("text/plain", card.querySelector(".card-title").textContent);
122
+ });
123
+ card.addEventListener("dragend", function () {
124
+ card.classList.remove("dragging");
125
+ document.querySelectorAll(".cards.drop-target").forEach(function (list) { list.classList.remove("drop-target"); });
126
+ dragged = null;
127
+ });
128
+ });
129
+
130
+ document.querySelectorAll(".cards").forEach(function (list) {
131
+ list.addEventListener("dragover", function (event) {
132
+ if (!dragged) return;
133
+ event.preventDefault();
134
+ event.dataTransfer.dropEffect = "move";
135
+ list.classList.add("drop-target");
136
+ const cards = Array.from(list.querySelectorAll(":scope > .card:not(.dragging)"));
137
+ const next = cards.find(function (card) {
138
+ const box = card.getBoundingClientRect();
139
+ return event.clientY < box.top + box.height / 2;
140
+ });
141
+ if (next) list.insertBefore(dragged, next);
142
+ else list.appendChild(dragged);
143
+ });
144
+ list.addEventListener("dragleave", function (event) {
145
+ if (!list.contains(event.relatedTarget)) list.classList.remove("drop-target");
146
+ });
147
+ list.addEventListener("drop", function (event) {
148
+ if (!dragged) return;
149
+ event.preventDefault();
150
+ list.classList.remove("drop-target");
151
+ moveCard(dragged, origin, positionOf(dragged));
152
+ });
153
+ });
154
+ }
155
+
156
+ function setupMoveMenus() {
157
+ document.querySelectorAll(".card-move select").forEach(function (select) {
158
+ select.addEventListener("change", function () {
159
+ if (select.value === "") return;
160
+ const from = { column: Number(select.dataset.column), index: Number(select.dataset.index) };
161
+ moveCard(select.closest(".card"), from, { column: Number(select.value), index: Infinity });
162
+ });
163
+ });
164
+ }
165
+
166
+ function setupAddForms() {
167
+ document.querySelectorAll(".add-card").forEach(function (form) {
168
+ form.addEventListener("submit", async function (event) {
169
+ event.preventDefault();
170
+ const input = form.querySelector("input");
171
+ const text = input.value.trim();
172
+ if (!text) return;
173
+ const result = await save("/api/board", { action: "add", column: Number(form.dataset.column), text: text });
174
+ if (result) {
175
+ input.value = "";
176
+ reloadKeepingScroll();
177
+ }
178
+ });
179
+ });
180
+ }
181
+
182
+ function setupTagFilter() {
183
+ const chips = Array.from(document.querySelectorAll(".board-filter .tag"));
184
+ if (chips.length === 0) return;
185
+ let active = [];
186
+ try {
187
+ active = JSON.parse(readStorage(filterKey)) || [];
188
+ } catch (_error) {
189
+ active = [];
190
+ }
191
+
192
+ function apply() {
193
+ chips.forEach(function (chip) { chip.classList.toggle("active", active.indexOf(chip.textContent) !== -1); });
194
+ document.querySelectorAll(".card").forEach(function (card) {
195
+ const tags = card.dataset.tags.split(" ");
196
+ card.classList.toggle("hidden", active.some(function (tag) { return tags.indexOf(tag) === -1; }));
197
+ });
198
+ writeStorage(filterKey, active.length ? JSON.stringify(active) : null);
199
+ }
200
+
201
+ chips.forEach(function (chip) {
202
+ chip.setAttribute("role", "button");
203
+ chip.tabIndex = 0;
204
+ function toggle() {
205
+ const tag = chip.textContent;
206
+ active = active.indexOf(tag) === -1 ? active.concat(tag) : active.filter(function (item) { return item !== tag; });
207
+ apply();
208
+ }
209
+ chip.addEventListener("click", toggle);
210
+ chip.addEventListener("keydown", function (event) {
211
+ if (event.key === "Enter" || event.key === " ") {
212
+ event.preventDefault();
213
+ toggle();
214
+ }
215
+ });
216
+ });
217
+ apply();
218
+ }
219
+
220
+ setInterval(async function () {
221
+ if (pendingSaves > 0) return;
222
+ try {
223
+ const response = await fetch("/api/version", { cache: "no-store" });
224
+ const { version } = await response.json();
225
+ if (version !== currentVersion) reloadKeepingScroll();
226
+ } catch (_error) {
227
+ setStatus("Server stopped. Run md-press board again to keep saving.", true);
228
+ }
229
+ }, 1500);
230
+
231
+ setupCheckboxes();
232
+ setupDragAndDrop();
233
+ setupMoveMenus();
234
+ setupAddForms();
235
+ setupTagFilter();
236
+ })();
@@ -2,7 +2,7 @@
2
2
  One quiet reading page. Cool paper and slate ink, a single deep teal accent,
3
3
  serif body for long reading and system sans for structure. The toolbar, with
4
4
  checklist progress and live status, is the only element that sits on top of
5
- the page.
5
+ the page. Callouts and code use the same small palette as the highlighter.
6
6
  */
7
7
  :root {
8
8
  --paper: #f6f7f9;
@@ -13,6 +13,11 @@ the page.
13
13
  --code-paper: #eceff4;
14
14
  --mark: #d7eeee;
15
15
  --danger: #b3261e;
16
+ --blue: #0b5cad;
17
+ --green: #2e7d32;
18
+ --purple: #7a3fb0;
19
+ --orange: #b25b00;
20
+ --red: #b3261e;
16
21
  --serif: Charter, "Bitstream Charter", "Iowan Old Style", "Sitka Text", Cambria, Georgia, serif;
17
22
  --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
18
23
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
@@ -31,6 +36,11 @@ the page.
31
36
  --code-paper: #1c2029;
32
37
  --mark: #1d3a3c;
33
38
  --danger: #f28b82;
39
+ --blue: #7fb8f5;
40
+ --green: #8fd694;
41
+ --purple: #c79bf2;
42
+ --orange: #f0b36b;
43
+ --red: #f28b82;
34
44
  }
35
45
  }
36
46
  *, *::before, *::after { box-sizing: inherit; }
@@ -41,9 +51,9 @@ body {
41
51
  font: 1.0625rem/1.7 var(--serif);
42
52
  -webkit-text-size-adjust: 100%;
43
53
  }
44
- main { max-width: 42rem; margin: 0 auto; padding: 3.5rem 1.25rem 2rem; }
54
+ main { max-width: 42rem; margin: 0 auto; padding: 3.5rem 1.25rem 2rem; overflow-wrap: break-word; }
45
55
 
46
- h1, h2, h3, h4, h5, h6 { font-family: var(--sans); line-height: 1.25; margin: 2.2em 0 0.6em; letter-spacing: -0.01em; }
56
+ h1, h2, h3, h4, h5, h6 { font-family: var(--sans); line-height: 1.25; margin: 2.2em 0 0.6em; letter-spacing: -0.01em; scroll-margin-top: 3.25rem; }
47
57
  h1 { font-size: 2.1rem; margin-top: 0; font-weight: 700; }
48
58
  h2 { font-size: 1.45rem; padding-bottom: 0.3em; border-bottom: 1px solid var(--rule); }
49
59
  h3 { font-size: 1.15rem; }
@@ -57,25 +67,52 @@ strong { font-weight: 700; }
57
67
  mark { background: var(--mark); color: inherit; padding: 0 0.15em; }
58
68
  hr { border: 0; border-top: 1px solid var(--rule); margin: 2.5em 0; }
59
69
  img { max-width: 100%; height: auto; }
70
+ summary { cursor: pointer; }
60
71
 
61
72
  ul, ol { padding-left: 1.4em; }
62
73
  li + li { margin-top: 0.25em; }
63
- li:has(> input.task) { list-style: none; margin-left: -1.4em; padding-left: 1.9em; text-indent: -1.9em; }
64
- input.task { width: 1.05em; height: 1.05em; margin: 0 0.75em 0 0; vertical-align: -0.12em; accent-color: var(--accent); cursor: pointer; text-indent: 0; }
74
+ li.task-item { list-style: none; position: relative; margin-left: -1.4em; padding-left: 1.9em; }
75
+ li.task-item > input.task, li.task-item > p:first-child > input.task { position: absolute; left: 0; top: 0.33em; margin: 0; }
76
+ input.task { width: 1.05em; height: 1.05em; accent-color: var(--accent); cursor: pointer; }
77
+ input.task:disabled { cursor: default; }
65
78
  li.done { color: var(--muted); }
66
79
 
67
80
  blockquote { margin-left: 0; padding: 0.1em 0 0.1em 1.1em; border-left: 3px solid var(--accent); color: var(--muted); }
68
81
 
82
+ .callout { --callout: var(--accent); margin: 0 0 1.1em; padding: 0.7em 1.1em; border-left: 3px solid var(--callout); border-radius: 0 6px 6px 0; background: color-mix(in srgb, var(--callout) 9%, transparent); }
83
+ .callout > :last-child { margin-bottom: 0; }
84
+ .callout-title { margin: 0 0 0.25em; font: 600 0.9rem/1.5 var(--sans); color: var(--callout); }
85
+ .callout-note { --callout: var(--blue); }
86
+ .callout-tip { --callout: var(--green); }
87
+ .callout-important { --callout: var(--purple); }
88
+ .callout-warning { --callout: var(--orange); }
89
+ .callout-caution { --callout: var(--red); }
90
+
69
91
  kbd { font: 0.82em var(--mono); padding: 0.1em 0.45em; border: 1px solid var(--rule); border-bottom-width: 2px; border-radius: 4px; background: var(--code-paper); }
70
92
  code { font: 0.88em var(--mono); background: var(--code-paper); padding: 0.12em 0.35em; border-radius: 4px; }
71
93
  pre { position: relative; background: var(--code-paper); border-radius: 6px; padding: 1em 1.1em; overflow-x: auto; line-height: 1.5; }
72
94
  pre code { background: none; padding: 0; font-size: 0.85rem; }
73
95
  pre[data-language]::after { content: attr(data-language); position: absolute; top: 0.4em; right: 0.7em; font: 0.72rem var(--sans); color: var(--muted); }
74
96
  pre.mermaid { background: none; text-align: center; font-family: var(--sans); }
97
+ .copy-code { position: absolute; top: 0.35em; right: 0.6em; font: 0.72rem var(--sans); color: var(--muted); background: var(--code-paper); border: 1px solid var(--rule); border-radius: 4px; padding: 0.1em 0.5em; cursor: pointer; opacity: 0; transition: opacity 0.15s; }
98
+ .copy-code:hover, .copy-code:focus-visible { color: var(--ink); }
99
+ pre:hover .copy-code, pre:focus-within .copy-code, .copy-code.copied { opacity: 1; }
100
+ @media (hover: none) {
101
+ .copy-code { opacity: 1; }
102
+ pre[data-language]::after { display: none; }
103
+ }
75
104
 
76
105
  table { display: block; overflow-x: auto; border-collapse: collapse; font: 0.95rem/1.5 var(--sans); }
77
106
  th, td { padding: 0.5em 0.9em; border-bottom: 1px solid var(--rule); text-align: left; vertical-align: top; }
78
107
  th { font-weight: 600; border-bottom-width: 2px; }
108
+ th[align="center"], td[align="center"] { text-align: center; }
109
+ th[align="right"], td[align="right"] { text-align: right; }
110
+
111
+ .footnotes { margin-top: 2.5em; padding-top: 1em; border-top: 1px solid var(--rule); font-size: 0.9rem; color: var(--muted); }
112
+ .footnotes ol { padding-left: 1.4em; }
113
+ a[data-footnote-ref] { font: 0.75em var(--sans); text-decoration: none; padding: 0 0.1em; }
114
+ a[data-footnote-backref] { text-decoration: none; }
115
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
79
116
 
80
117
  .progress {
81
118
  position: sticky; top: env(safe-area-inset-top, 0px); z-index: 1;
@@ -90,28 +127,23 @@ th { font-weight: 600; border-bottom-width: 2px; }
90
127
  .progress-reset:hover { color: var(--ink); }
91
128
  .toolbar-status { margin-left: auto; }
92
129
  .toolbar-status.error { color: var(--danger); }
93
- input.task:disabled { cursor: default; }
94
130
 
95
131
  footer { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1.25rem 3rem; font: 0.8rem var(--sans); color: var(--muted); border-top: 1px solid var(--rule); }
96
132
 
97
133
  .hljs-comment, .hljs-quote { color: var(--muted); font-style: italic; }
98
- .hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #7a3fb0; }
99
- .hljs-string, .hljs-attr, .hljs-template-tag, .hljs-addition { color: #2e7d32; }
100
- .hljs-number, .hljs-symbol, .hljs-variable, .hljs-template-variable { color: #b25b00; }
101
- .hljs-title, .hljs-section, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: #0b5cad; }
102
- .hljs-type, .hljs-meta, .hljs-deletion { color: #b3261e; }
103
- @media (prefers-color-scheme: dark) {
104
- .hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: #c79bf2; }
105
- .hljs-string, .hljs-attr, .hljs-template-tag, .hljs-addition { color: #8fd694; }
106
- .hljs-number, .hljs-symbol, .hljs-variable, .hljs-template-variable { color: #f0b36b; }
107
- .hljs-title, .hljs-section, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: #7fb8f5; }
108
- .hljs-type, .hljs-meta, .hljs-deletion { color: #f28b82; }
109
- }
134
+ .hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-literal { color: var(--purple); }
135
+ .hljs-string, .hljs-attr, .hljs-template-tag, .hljs-addition { color: var(--green); }
136
+ .hljs-number, .hljs-symbol, .hljs-variable, .hljs-template-variable { color: var(--orange); }
137
+ .hljs-title, .hljs-section, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: var(--blue); }
138
+ .hljs-type, .hljs-meta, .hljs-deletion { color: var(--red); }
110
139
 
111
- @media (prefers-reduced-motion: reduce) { .progress-fill { transition: none; } }
140
+ @media (prefers-reduced-motion: reduce) { .progress-fill, .copy-code { transition: none; } }
112
141
  @media print {
113
- .progress { display: none; }
142
+ .progress, .copy-code { display: none; }
114
143
  body { background: #fff; color: #000; }
115
144
  main { padding-top: 0; }
116
145
  a { color: inherit; }
146
+ pre { white-space: pre-wrap; overflow-wrap: anywhere; }
147
+ pre, table, blockquote, .callout, img { break-inside: avoid; }
148
+ h1, h2, h3, h4, h5, h6 { break-after: avoid; }
117
149
  }
@@ -1,7 +1,9 @@
1
1
  /*
2
- Runs inside every page that has a checklist, and inside every served page.
3
- The build step defines mdPress before this script: a storage key, plus a live
4
- object when the page comes from md-press serve.
2
+ Runs inside every page that has a checklist or a code block, and inside every
3
+ served page. The build step defines mdPress before this script: a storage
4
+ key, plus a live object when the page comes from md-press serve.
5
+
6
+ Code blocks get a Copy button. Checkboxes get labels and a progress bar.
5
7
 
6
8
  A built page is static, so checkbox state is saved in this browser only, keyed
7
9
  by label text (plus a counter for duplicate labels) so edits to the file do
@@ -41,16 +43,57 @@ storage call fails quietly: the page still works, it just forgets more.
41
43
  }
42
44
  }
43
45
 
46
+ function itemFor(checkbox) {
47
+ return checkbox.closest("li") || checkbox.parentElement;
48
+ }
49
+
50
+ /*
51
+ The item's own text, without any nested list, so renaming a sub-item does
52
+ not reset the parent's saved state.
53
+ */
54
+ function labelFor(checkbox) {
55
+ const item = itemFor(checkbox);
56
+ let text = "";
57
+ Array.from(item.childNodes).forEach(function (node) {
58
+ if (node.nodeName !== "UL" && node.nodeName !== "OL") text += node.textContent;
59
+ });
60
+ return text.trim().replace(/\s+/g, " ");
61
+ }
62
+
44
63
  function updateProgress() {
45
64
  if (!progressFill || checkboxes.length === 0) return;
46
65
  const doneCount = checkboxes.filter(function (checkbox) { return checkbox.checked; }).length;
47
- checkboxes.forEach(function (checkbox) { checkbox.parentElement.classList.toggle("done", checkbox.checked); });
66
+ checkboxes.forEach(function (checkbox) { itemFor(checkbox).classList.toggle("done", checkbox.checked); });
48
67
  progressFill.style.width = (doneCount / checkboxes.length * 100) + "%";
49
68
  progressLabel.textContent = doneCount + " of " + checkboxes.length + " done";
50
69
  }
51
70
 
52
- function labelFor(checkbox) {
53
- return checkbox.parentElement.textContent.trim().replace(/\s+/g, " ");
71
+ function setupCopyButtons() {
72
+ document.querySelectorAll("pre > code").forEach(function (code) {
73
+ const button = document.createElement("button");
74
+ button.type = "button";
75
+ button.className = "copy-code";
76
+ button.textContent = "Copy";
77
+ button.setAttribute("aria-label", "Copy code");
78
+ button.addEventListener("click", async function () {
79
+ try {
80
+ await navigator.clipboard.writeText(code.textContent);
81
+ button.textContent = "Copied";
82
+ } catch (_error) {
83
+ const range = document.createRange();
84
+ range.selectNodeContents(code);
85
+ getSelection().removeAllRanges();
86
+ getSelection().addRange(range);
87
+ button.textContent = "Selected, press copy";
88
+ }
89
+ button.classList.add("copied");
90
+ setTimeout(function () {
91
+ button.textContent = "Copy";
92
+ button.classList.remove("copied");
93
+ }, 1500);
94
+ });
95
+ code.parentElement.appendChild(button);
96
+ });
54
97
  }
55
98
 
56
99
  function setupStoredChecklist() {
@@ -160,8 +203,9 @@ storage call fails quietly: the page still works, it just forgets more.
160
203
  }, 1500);
161
204
  }
162
205
 
206
+ setupCopyButtons();
163
207
  checkboxes.forEach(function (checkbox) { checkbox.setAttribute("aria-label", labelFor(checkbox)); });
164
208
  if (mdPress.live) setupLiveFile();
165
- else setupStoredChecklist();
209
+ else if (checkboxes.length > 0) setupStoredChecklist();
166
210
  updateProgress();
167
211
  })();