@stenographers/markdown 0.0.0-stage → 0.4.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/write.js ADDED
@@ -0,0 +1,251 @@
1
+ // The stenographers' write (the studio's notes/markdown-plan.md, "Stenographers" and "Escalation"): a node's new bytes, written over its old transcript in the author's style. A stenographer reads a node as the HTML it means (the schema's document serializer's element), never by a ProseMirror type name, and its old slice re-parsed alone, with the file's definitions after it so a reference resolves as it did in the file. Every node that still means what its old bytes meant keeps them; only typed text is escaped, and only for what it could be misread as. A stenographer that cannot write its node answers null, and its parent writes it: the list for an item, the flow for a list; the last step is the lane change, the node written as raw HTML.
2
+ import { parseMarkdown } from './parse.js';
3
+ import { domOf, means } from './html.js';
4
+ import { list, item } from './list.js';
5
+ import { figure } from './figure.js';
6
+ import { table } from './table.js';
7
+ import { quote } from './quote.js';
8
+ import { directive } from './directive.js';
9
+ import { pairDialect, freshDialect } from './dialect.js';
10
+
11
+ const BLOCKS = new Set(['address', 'article', 'aside', 'blockquote', 'details', 'div', 'dl', 'fieldset', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul']);
12
+ export const isBlock = (n) => n.nodeType === 1 && BLOCKS.has(n.localName);
13
+ export const span = (ctx, from, to) => ctx.src.slice(from, to);
14
+ export const start = (n) => n.position.start.offset;
15
+ export const end = (n) => n.position.end.offset;
16
+ export const sliceOf = (ctx, n) => span(ctx, start(n), end(n));
17
+
18
+ /**
19
+ * A node's new bytes, written over its old slice.
20
+ * @param {Element} el the node as the HTML it means
21
+ * @param {{ text: string, column: number, defs?: string, markdown?: import('./parse.js').Markdown }} old its old slice, the column the slice's first byte stands at in the file (the lines after the first carry the parent's prefix up to it), the file's definitions and footnote definitions as source, the venue's Markdown options
22
+ * @param {{ force?: boolean }} [opts] `force`: no node keeps its bytes for meaning what they meant, every one is written from its style (the identity check's mode)
23
+ * @returns {string | null} null when the node refuses, for its parent to write
24
+ */
25
+ export function writeNode(el, old, { force = false } = {}) {
26
+ const { body, prefixes } = dedent(old.text, old.column);
27
+ const tree = parseMarkdown(old.defs ? `${body}\n\n${old.defs}` : body, old.markdown);
28
+ let md = tree.children[0];
29
+ if (el.localName === 'li') md = md?.type === 'list' ? md.children[0] : null;
30
+ if (!md || start(md) !== 0) return null;
31
+ el = el.cloneNode(true);
32
+ el.normalize();
33
+ const out = block(el, md, { src: body, force, defs: old.defs, markdown: old.markdown });
34
+ return out == null ? null : reindent(out, prefixes, old.column);
35
+ }
36
+
37
+ // The parent's prefix on each line after the first (the indent of a list item's content), taken off so the node is written at column 0 and put back after. ponytail: spaces only; a tab or a `> ` prefix waits for round 3 (blockquote).
38
+ function dedent(text, column) {
39
+ const lines = text.split('\n');
40
+ const prefixes = [''];
41
+ for (let i = 1; i < lines.length; i++) {
42
+ const k = Math.min(column, /^ */.exec(lines[i])[0].length);
43
+ prefixes.push(lines[i].slice(0, k));
44
+ lines[i] = lines[i].slice(k);
45
+ }
46
+ return { body: lines.join('\n'), prefixes };
47
+ }
48
+ // Each line takes back its old prefix while the node has as many lines as it had (a lazy line keeps its shorter one); once the count changed, the old prefixes no longer belong to the lines at their index, and every line that is not blank takes the column.
49
+ const reindent = (text, prefixes, column) => {
50
+ const lines = text.split('\n');
51
+ const same = lines.length === prefixes.length;
52
+ return lines.map((line, i) => (i === 0 ? line : (same ? prefixes[i] : line ? ' '.repeat(column) : '') + line)).join('\n');
53
+ };
54
+
55
+ function block(el, md, ctx) {
56
+ const tag = el.localName;
57
+ if (!ctx.force && means(el, md)) return sliceOf(ctx, md);
58
+ if (tag === 'p' && md.type === 'paragraph') return paragraph(el, md, ctx);
59
+ if (/^h[1-6]$/.test(tag) && md.type === 'heading') return heading(el, md, ctx);
60
+ if ((tag === 'ul' || tag === 'ol') && md.type === 'list') return list(el, md, ctx);
61
+ if (tag === 'li' && md.type === 'listItem') return item(el, md, ctx);
62
+ if (tag === 'pre' && md.type === 'code') return code(el, md, ctx);
63
+ if (tag === 'figure' && md.type === 'paragraph') return figure(el, md, ctx);
64
+ // A container's writer that refuses leaves it to the rule below: its bytes kept while they mean it.
65
+ const container = tag === 'table' && md.type === 'table' ? table : tag === 'blockquote' && md.type === 'blockquote' ? quote : md.type === 'containerDirective' ? directive : null;
66
+ const written = container?.(el, md, ctx);
67
+ if (written != null) return written;
68
+ // A node no stenographer of this lane writes keeps its bytes while it means them, and refuses once it does not.
69
+ return means(el, md) ? sliceOf(ctx, md) : null;
70
+ }
71
+
72
+ // A textblock's bytes are read back before they are given. Bytes that do not mean the node are written once more with every colon of the touched text escaped (the plain parse reads `:name` as a directive unless a colon follows it, so escaping one colon can make the name before it one), and refused after that.
73
+ export function checked(el, ctx, write) {
74
+ for (const strict of [false, true]) {
75
+ const out = write({ ...ctx, strict });
76
+ const md = parseMarkdown(ctx.defs ? `${out}\n\n${ctx.defs}` : out, ctx.markdown).children[0];
77
+ if (md && means(el, md)) return out;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function paragraph(el, md, ctx) {
83
+ const kids = md.children;
84
+ if (!kids.length) return null;
85
+ return checked(el, ctx, (c) => span(c, start(md), start(kids[0])) + edges(inline([...el.childNodes], kids, c)) + span(c, end(kids.at(-1)), end(md)));
86
+ }
87
+
88
+ // A space at a textblock's edge, which Markdown strips, written as its character reference so it stays (Enter just before a space leaves one at the new paragraph's start).
89
+ export const edges = (s) => s.replace(/^ /, '&#32;').replace(/ $/, '&#32;');
90
+
91
+ function heading(el, md, ctx) {
92
+ const level = Number(el.localName[1]);
93
+ const kids = md.children;
94
+ const atx = /^ {0,3}#/.test(sliceOf(ctx, md));
95
+ if (!kids.length) return atx ? sliceOf(ctx, md).replace(/#+/, '#'.repeat(level)) : null;
96
+ const head = span(ctx, start(md), start(kids[0]));
97
+ const tail = span(ctx, end(kids.at(-1)), end(md));
98
+ return checked(el, ctx, (c) => {
99
+ const content = edges(inline([...el.childNodes], kids, c));
100
+ if (atx) return head.replace(/#+/, '#'.repeat(level)) + content + tail;
101
+ if (level === md.depth) return head + content + tail;
102
+ // A setext heading keeps its underline's length; past level 2 it becomes ATX.
103
+ if (level <= 2) return head + content + tail.replace(/[=-]+/, (u) => (level === 1 ? '=' : '-').repeat(u.length));
104
+ return `${'#'.repeat(level)} ${content.replace(/\n/g, ' ')}`;
105
+ });
106
+ }
107
+
108
+ // A fenced code block keeps its fence lines and writes its new body; its language, when it changed, is the opening fence's first word. An indented block, or a body with a line that would close the fence, refuses.
109
+ function code(el, md, ctx) {
110
+ const lines = sliceOf(ctx, md).split('\n');
111
+ const fence = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(lines[0]);
112
+ if (!fence) return null;
113
+ // A fenced body ends with a line end, which the read gives the code; without it (its last line end deleted) the block cannot be fenced.
114
+ if (el.textContent && !el.textContent.endsWith('\n')) return null;
115
+ const body = el.textContent.replace(/\n$/, '');
116
+ const closes = new RegExp(`^ {0,3}${fence[2][0] === '`' ? '`' : '~'}{${fence[2].length},}\\s*$`);
117
+ if (body.split('\n').some((line) => closes.test(line))) return null;
118
+ const was = /\blanguage-(\S+)/.exec(el.querySelector('code')?.className ?? '')?.[1] ?? '';
119
+ let open = lines[0];
120
+ if (was !== (md.lang ?? '')) open = `${fence[1]}${fence[2]}${was}${md.meta ? ` ${md.meta}` : ''}`;
121
+ const close = lines.length > 1 && closes.test(lines.at(-1)) ? `\n${lines.at(-1)}` : '';
122
+ return `${open}\n${body}${close}`;
123
+ }
124
+
125
+ // ---- inline content ----
126
+
127
+ const same = (n, o, ctx) => !ctx.force && means(n, o);
128
+
129
+ /** The bytes of new inline content over the old: the nodes that still mean what they meant keep their bytes, the rest are paired with the old in order and written in their style, or written fresh. */
130
+ export function inline(nodes, kids, ctx) {
131
+ const n = nodes.length, m = kids.length;
132
+ let i = 0;
133
+ while (i < n && i < m && same(nodes[i], kids[i], ctx)) i++;
134
+ let j = 0;
135
+ while (j < n - i && j < m - i && same(nodes[n - 1 - j], kids[m - 1 - j], ctx)) j++;
136
+ const from = start(kids[0]), to = end(kids[m - 1]);
137
+ const midFrom = i < m ? start(kids[i]) : to;
138
+ const midTo = j ? start(kids[m - j]) : to;
139
+ const nMid = nodes.slice(i, n - j), oMid = kids.slice(i, m - j);
140
+ const mid = nMid.length === oMid.length ? nMid.map((x, k) => pair(x, oMid[k], ctx)).join('') : nMid.map((x) => fresh(x)).join('');
141
+ return span(ctx, from, midFrom) + mid + span(ctx, midTo, to);
142
+ }
143
+
144
+ const WRAPS = { em: 'emphasis', strong: 'strong', del: 'delete' };
145
+
146
+ function pair(n, o, ctx) {
147
+ const notation = pairDialect(n, o, ctx);
148
+ if (notation !== undefined) return notation;
149
+ if (n.nodeType === 3) return o.type === 'text' ? text(n.data, o, ctx) : fresh(n);
150
+ if (n.nodeType !== 1) return fresh(n);
151
+ const tag = n.localName;
152
+ // A text directive keeps its name, label brackets and attributes; its label is inline content like any other.
153
+ if (tag === 'span' && o.type === 'textDirective') {
154
+ if (!o.children.length) return n.childNodes.length ? fresh(n) : sliceOf(ctx, o);
155
+ return span(ctx, start(o), start(o.children[0])) + inline([...n.childNodes], o.children, ctx) + span(ctx, end(o.children.at(-1)), end(o));
156
+ }
157
+ const wraps = (WRAPS[tag] === o.type || (tag === 'a' && (o.type === 'link' || o.type === 'linkReference'))) && o.children?.length;
158
+ if (wraps) {
159
+ const close = span(ctx, end(o.children.at(-1)), end(o));
160
+ if (tag === 'a' && !sameTarget(n, o)) {
161
+ if (o.type !== 'link' || sliceOf(ctx, o)[0] !== '[') return fresh(n);
162
+ return `${span(ctx, start(o), start(o.children[0]))}${inline([...n.childNodes], o.children, ctx)}](${target(n)})`;
163
+ }
164
+ return span(ctx, start(o), start(o.children[0])) + inline([...n.childNodes], o.children, ctx) + close;
165
+ }
166
+ if (tag === 'code' && o.type === 'inlineCode') return n.textContent === o.value ? sliceOf(ctx, o) : fresh(n);
167
+ if (tag === 'br' && o.type === 'break') return sliceOf(ctx, o);
168
+ // A footnote reference's text is its number, which the document counts: anything else typed into it is not Markdown's to say.
169
+ if (tag === 'sup' && o.type === 'footnoteReference') return sameTarget(n.querySelector('a') ?? n, o) && /^\d+$/.test(n.textContent) ? sliceOf(ctx, o) : fresh(n);
170
+ return means(n, o) ? sliceOf(ctx, o) : fresh(n);
171
+ }
172
+
173
+ // A link's (or a footnote reference's) target as the HTML says it, against what the old node meant: the reference resolved through the definitions.
174
+ function sameTarget(a, o) {
175
+ const dom = domOf(o.children ? { ...o, children: [] } : o)[0];
176
+ const was = dom?.localName === 'a' ? dom : dom?.querySelector?.('a');
177
+ return !!was && a.getAttribute('href') === was.getAttribute('href') && a.getAttribute('title') === was.getAttribute('title');
178
+ }
179
+ const target = (a) => `${a.getAttribute('href') ?? ''}${a.hasAttribute('title') ? ` "${a.getAttribute('title').replace(/"/g, '\\"')}"` : ''}`;
180
+
181
+ /** Typed text over an old text node: the common head and tail keep their bytes, the middle is escaped. */
182
+ function text(now, o, ctx) {
183
+ const raw = sliceOf(ctx, o);
184
+ if (now === o.value) return raw;
185
+ if (ctx.strict) return escape(now, o.position.start.column === 1).replace(/(?<!\\):/g, '\\:');
186
+ // ponytail: a text run whose bytes are not its value (an escape, an entity, an indented continuation line) is written fresh once touched; mapping value to bytes would keep the author's escapes.
187
+ if (raw !== o.value) return escape(now, o.position.start.column === 1);
188
+ let p = 0;
189
+ while (p < now.length && p < raw.length && now[p] === raw[p]) p++;
190
+ let s = 0;
191
+ while (s < now.length - p && s < raw.length - p && now[now.length - 1 - s] === raw[raw.length - 1 - s]) s++;
192
+ const lineStart = p === 0 ? o.position.start.column === 1 : raw[p - 1] === '\n';
193
+ let head = raw.slice(0, p), mid = escape(now.slice(p, now.length - s), lineStart);
194
+ const tail = raw.slice(raw.length - s);
195
+ // A word typed against a colon would make the two a text directive (`:name`), so the colon is escaped, the old one included.
196
+ if (/^\w/.test(mid) && /(^|[^\\]):$/.test(head)) head = `${head.slice(0, -1)}\\:`;
197
+ if (/:$/.test(mid) && /^\w/.test(tail)) mid = `${mid.slice(0, -1)}\\:`;
198
+ return head + mid + tail;
199
+ }
200
+
201
+ /** Text as Markdown that reads back as the same text: CommonMark's inline punctuation, BelType's notations (`:name` directives, `==` marks, `^` and `~` super- and subscript, `{…}` attributes, `$` math), and at a line's start what would open a block. */
202
+ export function escape(s, lineStart) {
203
+ let out = s.replace(/[\\`*_[\]<>{}$^~]/g, '\\$&').replace(/&(?=#?\w+;)/g, '\\&').replace(/:(?=\w)/g, '\\:').replace(/==/g, '\\=\\=');
204
+ out = out.replace(/\n(.*)/g, (_, line) => `\n${opener(line)}`);
205
+ return lineStart ? opener(out) : out;
206
+ }
207
+ // What would open a block at a line's start (a heading, a quote, a list item), escaped.
208
+ const opener = (t) => t.replace(/^( {0,3})([#>+-])/, '$1\\$2').replace(/^( {0,3}\d+)([.)])/, '$1\\$2');
209
+
210
+ /**
211
+ * A block written with no old bytes (a paragraph Enter made, a block inserted): a paragraph or a heading in Markdown's own syntax, read back before it is given; anything else, or what does not read back, as raw HTML (the lane change).
212
+ * @param {Element} el the block as the HTML it means
213
+ * @param {{ defs?: string, markdown?: import('./parse.js').Markdown }} [file] the file's definitions and Markdown options, for the read-back
214
+ */
215
+ export function writeFresh(el, { defs, markdown } = {}) {
216
+ if (el.nodeType === 8) return `<!--${el.data}-->`;
217
+ const tag = el.localName;
218
+ const inner = () => edges(opener([...el.childNodes].map(fresh).join('')));
219
+ const out = tag === 'p' ? inner() : /^h[1-6]$/.test(tag) ? `${'#'.repeat(Number(tag[1]))} ${inner()}` : null;
220
+ if (out) {
221
+ const md = parseMarkdown(defs ? `${out}\n\n${defs}` : out, markdown).children[0];
222
+ if (md && means(el, md)) return out;
223
+ }
224
+ return el.outerHTML;
225
+ }
226
+
227
+ /** An inline node written with no old bytes to keep: Markdown's own syntax where it has one, raw HTML where it has none (the lane change). */
228
+ export function fresh(n) {
229
+ // The line end mdast-util-to-hast puts after a `br` is the break's own, which `\` and a line end already write.
230
+ if (n.nodeType === 3) return escape(n.previousSibling?.localName === 'br' ? n.data.replace(/^\n/, '') : n.data, false);
231
+ if (n.nodeType !== 1) return '';
232
+ const notation = freshDialect(n);
233
+ if (notation !== undefined) return notation;
234
+ const inner = () => [...n.childNodes].map(fresh).join('');
235
+ switch (n.localName) {
236
+ case 'em': return `*${inner()}*`;
237
+ case 'strong': return `**${inner()}**`;
238
+ case 'del': return `~~${inner()}~~`;
239
+ case 'a': return n.hasAttribute('href') && n.attributes.length <= 2 ? `[${inner()}](${target(n)})` : n.outerHTML;
240
+ case 'br': return '\\\n';
241
+ case 'img': return n.attributes.length <= 3 ? `![${n.getAttribute('alt') ?? ''}](${target({ getAttribute: (k) => n.getAttribute(k === 'href' ? 'src' : k), hasAttribute: (k) => n.hasAttribute(k) })})` : n.outerHTML;
242
+ case 'code': {
243
+ const t = n.textContent;
244
+ const longest = Math.max(0, ...(t.match(/`+/g) ?? []).map((r) => r.length));
245
+ const fence = '`'.repeat(longest + 1);
246
+ const pad = /^`|`$/.test(t) ? ' ' : '';
247
+ return `${fence}${pad}${t}${pad}${fence}`;
248
+ }
249
+ default: return n.outerHTML;
250
+ }
251
+ }
package/src/writer.js ADDED
@@ -0,0 +1,138 @@
1
+ // Provenance's Markdown writer (the studio's notes/markdown-plan.md, "Saving through Provenance"): the editing half of a format plug-in, in Provenance's `Writer` shape and by shape only (nothing here imports Provenance). A path is a JSON Pointer over the heading outline: each step a heading's plain text, `~0` `~1` escaping `~` `/` as RFC 6901 does, a repeated sibling heading's step suffixed `@n`, and a heading whose own text ends in `@` and digits suffixed `@1` even when first, so a step's last `@n` is always its ordinal; the empty path is the preamble. A path addresses a section's own run: its heading line and the blocks after it, up to the next heading of any rank, so no two paths share a byte. The value is the run's blocks, each its HTML, the bytes it had at the base and whether it is unchanged (`writeRun`); the writer writes them with the stenographers over the run's bytes in the version it opened, a block finding its old self by those bytes. Only `set`: every edit is a new value for a run, structural ones included (a heading deleted is its run without it, a section deleted its run set to nothing). Bytes are counted here and nowhere else: the writer decodes UTF-8, works in string positions, and answers in byte positions.
2
+ import { parseMarkdown } from './parse.js';
3
+ import { writeFlow } from './flow.js';
4
+
5
+ const encoder = new TextEncoder();
6
+ const decoder = new TextDecoder('utf-8', { fatal: true });
7
+ // The mdast nodes that are the gaps' bytes, not blocks: nothing in the value stands for them (a comment between blocks is the gap's, as it is the file's, the studio's src/markdown/file.js).
8
+ const GAPS = new Set(['definition', 'footnoteDefinition', 'yaml']);
9
+ const gap = (n) => GAPS.has(n.type) || (n.type === 'html' && /^\s*<!--[\s\S]*?-->\s*$/.test(n.value));
10
+
11
+ const plain = (node) => ('value' in node && typeof node.value === 'string' ? node.value : (node.children ?? []).map(plain).join(''));
12
+ /** A heading's step: its plain text, whitespace collapsed, escaped as RFC 6901 escapes a step. */
13
+ export const step = (text) => text.replace(/\s+/g, ' ').trim().replace(/~/g, '~0').replace(/\//g, '~1');
14
+
15
+ /**
16
+ * The runs of a Markdown text, by path, in order: the preamble (after any frontmatter) and each top-level heading's run. Nesting follows the ranks as BelType's sectioning reads them: a heading is the child of the nearest earlier heading of a lower rank.
17
+ * @returns {{ runs: Map<string, { from: number, to: number, blocks: object[] }>, siblings: Map<string, number> }} the runs, and how many siblings share each parent and text (for `shifted`)
18
+ */
19
+ export function outline(tree, text) {
20
+ const top = tree.children;
21
+ const yaml = top[0]?.type === 'yaml' ? top[0] : null;
22
+ const headings = top.filter((n) => n.type === 'heading');
23
+ const runs = new Map(), siblings = new Map();
24
+ const blocksIn = (from, to) => top.filter((n) => !gap(n) && n.position.start.offset >= from && n.position.start.offset < to);
25
+ const first = headings[0]?.position.start.offset ?? text.length;
26
+ const preamble = yaml ? yaml.position.end.offset : 0;
27
+ runs.set('', { from: preamble, to: first, blocks: blocksIn(preamble, first) });
28
+ const stack = [];
29
+ headings.forEach((h, i) => {
30
+ while (stack.length && stack.at(-1).depth >= h.depth) stack.pop();
31
+ const parent = stack.map((s) => `/${s.step}`).join('');
32
+ const name = step(plain(h));
33
+ const key = `${parent}\0${name}`;
34
+ const n = (siblings.get(key) ?? 0) + 1;
35
+ siblings.set(key, n);
36
+ const own = n > 1 || /@\d+$/.test(name) ? `${name}@${n}` : name;
37
+ stack.push({ depth: h.depth, step: own });
38
+ const from = h.position.start.offset, to = headings[i + 1]?.position.start.offset ?? text.length;
39
+ runs.set(`${parent}/${own}`, { from, to, blocks: blocksIn(from, to), heading: true });
40
+ });
41
+ return { runs, siblings };
42
+ }
43
+
44
+ /**
45
+ * A run's new bytes over its old: the one function the file (the studio's src/markdown/file.js, against its base) and Provenance's writer (against whatever version it opened) both write with, so a preview is the file's working text. Each entry of the value is a block: its HTML, the bytes it had at the base the value was made against (null for a block that had none), whether it is that block unchanged, the bytes it had in the working text before this change (`prior`, null for none), and whether those stand as they are (`keep`: the block did not change since, or they are what the Markdown pane typed). A block finds its old block by its base bytes, in order; one whose bytes are not in this version of the run (the disk changed it) takes the next unpaired old block of its kind; a block with no bytes is new. A block that keeps its prior bytes is written as them; a changed one is written by its stenographer over its prior bytes, or over its old block's.
46
+ * @param {string} text the file's text
47
+ * @param {{ from: number, to: number, blocks: object[], heading?: boolean }} run the run in it, from `outline` (`heading`: a section's run, not the preamble)
48
+ * @param {[string, string | null, boolean, (string | null)?, boolean?][]} value
49
+ * @param {{ defs?: string, markdown?: import('./parse.js').Markdown }} file
50
+ * @returns {{ text: string, spans: [number, number][] }} the run's bytes, and each value block's span in them
51
+ */
52
+ export function writeRun(text, run, value, file) {
53
+ if (!value.length) return { text: '', spans: [] };
54
+ const olds = run.blocks.map((b) => [b.position.start.offset, b.position.end.offset]);
55
+ const bytes = olds.map((span) => text.slice(...span));
56
+ const holder = globalThis.document.createElement('template');
57
+ const used = new Set();
58
+ let next = 0;
59
+ const blocks = value.map(([html, was, same, prior = null, keep = false]) => {
60
+ holder.innerHTML = html;
61
+ const el = holder.content.firstElementChild;
62
+ let j = -1;
63
+ if (was != null) {
64
+ j = bytes.findIndex((b, k) => k >= next && !used.has(k) && b === was);
65
+ if (j < 0) j = bytes.findIndex((b, k) => !used.has(k) && b === was);
66
+ if (j < 0) for (let k = next; k < olds.length; k++) if (!used.has(k) && sameKind(el, run.blocks[k])) { j = k; break; }
67
+ }
68
+ if (j >= 0) { used.add(j); next = j + 1; }
69
+ return { el, j, same: j >= 0 && same && bytes[j] === was, bytes: keep && prior != null ? prior : null, over: prior };
70
+ });
71
+ const out = writeFlow(blocks, { text, spans: olds, from: run.from, to: run.to }, file);
72
+ // A section's run that lost its heading and now begins with a list puts that list next to whatever ends the run before, which this run cannot see: the run before may be staged too, and Provenance writes the two independently. So it always opens with an empty comment, which keeps two lists apart as the flow does; at worst a comment where none was needed, never two lists read as one.
73
+ if (run.heading && /^[uo]l$/.test(blocks[0].el.localName)) {
74
+ const sep = '<!-- -->\n\n';
75
+ return { text: sep + out.text, spans: out.spans.map(([a, b]) => [a + sep.length, b + sep.length]) };
76
+ }
77
+ return out;
78
+ }
79
+ const KIND = { paragraph: 'p', code: 'pre', blockquote: 'blockquote', table: 'table', thematicBreak: 'hr' };
80
+ const sameKind = (el, md) => {
81
+ if (md.type === 'heading') return /^h[1-6]$/.test(el.localName);
82
+ if (md.type === 'list') return el.localName === (md.ordered ? 'ol' : 'ul');
83
+ return KIND[md.type] === el.localName;
84
+ };
85
+ // Stenography's refusal rule: a wrong-typed path or value is the caller's bug and throws; a data problem is a refusal with its shared code.
86
+ const isEditData = (v) => v === null || ['string', 'number', 'bigint', 'boolean'].includes(typeof v) || (Array.isArray(v) && v.every(isEditData)) || (v instanceof Map && [...v].every(([k, x]) => typeof k === 'string' && isEditData(x)));
87
+ const pointer = (path) => {
88
+ if (typeof path !== 'string') throw new TypeError(`a path is a string, not ${typeof path}`);
89
+ return path === '' || (path.startsWith('/') && !/~(?![01])/.test(path));
90
+ };
91
+ const refuse = (code, reason) => ({ ok: false, code, reason });
92
+ const wellFormed = (value) => Array.isArray(value) && value.every((b) => Array.isArray(b) && typeof b[0] === 'string' && (b[1] === null || typeof b[1] === 'string') && typeof b[2] === 'boolean' && (b[3] == null || typeof b[3] === 'string') && (b[4] == null || typeof b[4] === 'boolean'));
93
+
94
+ /**
95
+ * @param {{ markdown?: import('./parse.js').Markdown, claims?: (location: string) => boolean }} [options]
96
+ */
97
+ export function markdownWriter({ markdown, claims = (location) => /\.md$/i.test(location) } = {}) {
98
+ return {
99
+ claims,
100
+ open(bytes) {
101
+ let text;
102
+ try { text = decoder.decode(bytes); } catch { return refuse('open-unreadable', 'the file is not UTF-8'); }
103
+ const tree = parseMarkdown(text, markdown);
104
+ const { runs, siblings } = outline(tree, text);
105
+ const defs = tree.children.filter((n) => n.type === 'definition' || n.type === 'footnoteDefinition').map((n) => text.slice(n.position.start.offset, n.position.end.offset)).join('\n\n');
106
+ const byte = (i) => encoder.encode(text.slice(0, i)).length;
107
+ const opened = {
108
+ siblings,
109
+ locate(path) {
110
+ if (!pointer(path)) return refuse('path-malformed', `${JSON.stringify(path)} is not a JSON Pointer`);
111
+ const run = runs.get(path);
112
+ return run ? { ok: true, start: byte(run.from), end: byte(run.to) } : refuse('edit-target-absent', `no section at ${JSON.stringify(path)}`);
113
+ },
114
+ write(path, value) {
115
+ if (!pointer(path)) return refuse('path-malformed', `${JSON.stringify(path)} is not a JSON Pointer`);
116
+ if (!isEditData(value)) throw new TypeError('a value is EditData: a scalar, an array or a Map, never a plain object');
117
+ const run = runs.get(path);
118
+ if (!run) return refuse('edit-target-absent', `no section at ${JSON.stringify(path)}`);
119
+ if (!wellFormed(value)) return refuse('edit-unrepresentable', 'the value of a section is its blocks, each [html, its bytes at the base or null, unchanged, its prior bytes or null, whether they stand]');
120
+ const out = writeRun(text, run, value, { defs, markdown }).text;
121
+ return { ok: true, edit: { start: byte(run.from), end: byte(run.to), bytes: encoder.encode(out) } };
122
+ },
123
+ // A list along the path changed length when a step with an ordinal names siblings whose count changed.
124
+ shifted(path, newer) {
125
+ let parent = '';
126
+ for (const s of path.split('/').slice(1)) {
127
+ const name = s.replace(/@\d+$/, '');
128
+ const key = `${parent}\0${name}`;
129
+ if (s !== name && siblings.get(key) !== newer.siblings?.get(key)) return true;
130
+ parent += `/${s}`;
131
+ }
132
+ return false;
133
+ }
134
+ };
135
+ return { ok: true, document: opened };
136
+ }
137
+ };
138
+ }