@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/LICENSE +21 -0
- package/README.md +30 -2
- package/package.json +52 -4
- package/src/dialect.js +159 -0
- package/src/directive.js +180 -0
- package/src/figure.js +98 -0
- package/src/flow.js +46 -0
- package/src/html.js +80 -0
- package/src/list.js +119 -0
- package/src/parse.js +48 -0
- package/src/quote.js +51 -0
- package/src/table.js +117 -0
- package/src/write.js +251 -0
- package/src/writer.js +138 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Egildo Tagliareni
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @stenographers/markdown
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Write an edited document back into the Markdown it was read from, keeping the author's bytes. A *stenographer* is the writer of one kind of node with the old transcript in hand: it writes the node over its old slice in the author's style (the bullet character, the emphasis character, the indent, the fence), every node that still means what it meant keeps its bytes, and only typed text is escaped, for what its position could misread. It is not a serializer: `mdast-util-to-markdown` rewrites a document in its own style (measured on BelType's manual: 14.3 percent of the lines changed with the defaults), and a stenographer rewrites nothing it was not asked to.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @stenographers/markdown
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What it is made of
|
|
12
|
+
|
|
13
|
+
Five entries (`exports`, no index):
|
|
14
|
+
|
|
15
|
+
- `@stenographers/markdown/parse`: the plain parse, `parseMarkdown(text, markdown)`, `markdown` the venue's options in core's shape (`{ gfm: { autolinks, tables }, mark, supersub, figure, directives }`, `directives` the config's `directives:` bindings over core's built-ins, their `tag`, `className` and `defaultTitle`): mdast-util-from-markdown with BelType's four syntax extensions (frontmatter, math, directive, GFM's parts in the order BelType registers them) and nothing else, the only parse whose positions are exact.
|
|
16
|
+
- `@stenographers/markdown/html`: what Markdown means in HTML, mdast-util-to-hast's translation written as markup and parsed by the browser (`hastOf`, `htmlOf`, `domOf`), and `means(node, mdast)`, the test that lets an unchanged node keep its bytes (a footnote reference's number, the document's count, is left out of it).
|
|
17
|
+
- `@stenographers/markdown/write`: `writeNode(element, { text, column, defs, markdown })`, a node's new bytes over its old slice, for paragraph, heading, list (items inserted with their neighbour's marker, deleted with their gap, the list turned tight or loose, a task's box ticked), list item, figure, table (a cell rewritten alone, its padding kept where it fits; rows added from a neighbour's layout and deleted), blockquote (its `> ` prefixes taken off and put back), directive container (read as core renders it, its tag, class and title paragraph; its fences kept, its label, content and title written, the title inside its attribute's quotes, any other attribute changed, added or removed alone), fenced code and inline content (text, em, strong, del, code, link and reference link, break, image, footnote reference, text directive, and BelType's `==mark==`, `=k=mark==`, `~sub~` and `^sup^`); `writeFresh(element)` for a block with no old bytes; `escape`. A node is read as the HTML element it means, never by a type name. A stenographer that cannot write its node answers null and its parent writes it; the last step is the lane change, the node written as raw HTML. A paragraph's or a heading's bytes are read back before they are given.
|
|
18
|
+
- `@stenographers/markdown/flow`: `writeFlow(blocks, old, file)`, a flow of blocks over its old bytes: the parent owns the gaps (the old gap before a block that follows the one it followed, a blank line otherwise; a deleted block's gap kept when it holds a definition or a comment; two lists that would read as one kept apart by an empty comment).
|
|
19
|
+
- `@stenographers/markdown/writer`: Provenance's Markdown writer, by shape (`markdownWriter({ markdown })`: `claims`, `open` giving `locate`, `write`, `shifted`), with `outline` and `writeRun`. A path is a JSON Pointer over the heading outline addressing a section's own run (its heading line and the blocks after it, up to the next heading of any rank); the value is the run's blocks, each `[html, bytes at the base, unchanged, prior bytes, whether they stand]`; only `set`. Bytes are counted here and nowhere else.
|
|
20
|
+
|
|
21
|
+
## What it does not do
|
|
22
|
+
|
|
23
|
+
It holds no document, no file and no editor: what changed, and which old block a new one stands for, is the caller's (by node identity in an editor, by the bytes a block had in the writer). It imports no ProseMirror, no BelType and no Provenance: Provenance's writer is met by shape, as its spec asks. It needs a DOM (`document`, for `<template>` and element comparison): a page's, or in node one the caller installs. A table's columns and alignment do not change (the table changes lane) and a column is never re-padded; a directive binding's `render` hook is not run (its directive means the element core would give it without one), and a container's label is only its first paragraph after the title, told from the content by the content's count; a title holding markup changes lane; a parent's prefix is spaces only; a text run whose bytes are not its value (an escape, an entity) is written fresh once touched; a list's items pair with the old by meaning from both ends, so an item edited in the same change as an insertion beside it is written fresh, and an inserted item in an ordered list does not renumber the items below; a loose list turned tight keeps the blank lines inside its items, so it changes lane when they would keep it loose. The notations are read from values, as core reads them, so an escaped delimiter is a delimiter: typed text holding a pair of `^`, `~` or `==`, which no escape keeps text, makes its paragraph change lane.
|
|
24
|
+
|
|
25
|
+
## Extracted from
|
|
26
|
+
|
|
27
|
+
The studio, beltype-studio-experiment's `src/markdown/stenographers/` at commit `3a526b0` (branch `round/markdown-1`), as prosemirror-stores was: built and proved there against BelType's manual, the pages and Provenance, then moved when green. The plan is the studio's `notes/markdown-plan.md`, the record of its round `notes/markdown-results.md`. The studio keeps what is its own: the read into its schema, the file as a client of its authority, saving, the venue host and the Markdown pane.
|
|
28
|
+
|
|
29
|
+
## Proved by
|
|
30
|
+
|
|
31
|
+
`npm test`, under `node:test` with happy-dom as the DOM (a dev dependency only): 109 tests. Over BelType's manual (`fixtures/`, its nine files as they were on 2026-10-29, the manual's GFM options): every paragraph, heading, list and list item, written from its style alone, is its slice byte for byte, and every other top-level block keeps its bytes; a key typed at the end of every top-level paragraph and heading reads back as the edited node with only the typed bytes changed (after a colon, the colon escaped too). The writer: the outline's paths and their escapes and ordinals, `locate` in bytes over UTF-8, every run of a file written again from its own value equal to its bytes, one paragraph changed changing only it, a malformed value refused. The list (`test/list.test.js`): items inserted, deleted, ticked, a list turned tight and loose, each in exact bytes and read back, on small lists and on every tight list of the manual. The figure (`test/figure.test.js`): core's figure read, none in a tight list item, caption, alt, source and id each rewritten alone. The notations (`test/dialect.test.js`): core's elements and classes, positions through escapes, typing inside and beside them, what they cannot carry written as HTML. The containers (`test/table.test.js`, `test/quote.test.js`, `test/directive.test.js`): every table and directive of the manual with one cell or one paragraph changed writes only those bytes; directives read as core renders them, titles and attributes written alone; quotes on small cases, lazy lines and nesting; each with the element serialized without the line ends between its elements. `test/review.test.js`: the review's findings, one case each. `test/layers.test.js`: every import is a sibling or a package of the Markdown parse.
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stenographers/markdown",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Write an edited document back into the Markdown it was read from, keeping the author's bytes: a stenographer per node writes over the node's old slice in the author's style, every node that still means what it meant keeps its bytes, and only typed text is escaped. With a Markdown writer for Provenance, by shape.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./parse": "./src/parse.js",
|
|
8
|
+
"./html": "./src/html.js",
|
|
9
|
+
"./write": "./src/write.js",
|
|
10
|
+
"./flow": "./src/flow.js",
|
|
11
|
+
"./writer": "./src/writer.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test test/*.test.js"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"mdast-util-directive": "3.1.0",
|
|
23
|
+
"mdast-util-from-markdown": "2.0.2",
|
|
24
|
+
"mdast-util-frontmatter": "2.0.1",
|
|
25
|
+
"mdast-util-gfm-autolink-literal": "2.0.1",
|
|
26
|
+
"mdast-util-gfm-footnote": "2.1.0",
|
|
27
|
+
"mdast-util-gfm-strikethrough": "2.0.0",
|
|
28
|
+
"mdast-util-gfm-table": "2.0.0",
|
|
29
|
+
"mdast-util-gfm-task-list-item": "2.0.0",
|
|
30
|
+
"mdast-util-math": "3.0.0",
|
|
31
|
+
"mdast-util-to-hast": "13.2.1",
|
|
32
|
+
"micromark-extension-directive": "3.0.2",
|
|
33
|
+
"micromark-extension-frontmatter": "2.0.0",
|
|
34
|
+
"micromark-extension-gfm-autolink-literal": "2.1.0",
|
|
35
|
+
"micromark-extension-gfm-footnote": "2.1.0",
|
|
36
|
+
"micromark-extension-gfm-strikethrough": "2.1.0",
|
|
37
|
+
"micromark-extension-gfm-table": "2.1.1",
|
|
38
|
+
"micromark-extension-gfm-task-list-item": "2.1.0",
|
|
39
|
+
"micromark-extension-math": "3.1.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"happy-dom": "20.14.6",
|
|
43
|
+
"@stenographers/stenography": "0.1.0"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "https://codeberg.org/egildo/stenographers.git",
|
|
48
|
+
"directory": "packages/markdown"
|
|
49
|
+
},
|
|
50
|
+
"author": "Egildo Tagliareni",
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"homepage": "https://codeberg.org/egildo/stenographers",
|
|
53
|
+
"bugs": "https://codeberg.org/egildo/stenographers/issues"
|
|
54
|
+
}
|
package/src/dialect.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// BelType's notations on the plain parse (the studio's notes/markdown-round-3-orchestration.md, agent C): `==mark==` and `=k=mark==` (core's remark-flexible-markers 1.3.5), `~sub~` and `^sup^` (core's remark-supersub 1.0.0), found where core finds them, in text values, and positioned in the bytes through each text node's map from its value to its bytes; and their stenographers, which the inline stenographers of ./write.js ask first.
|
|
2
|
+
import { inline, fresh, span, start, end } from './write.js';
|
|
3
|
+
|
|
4
|
+
// core's letter table; a key letter gives `flexible-marker flexible-marker-<colour>`, no key `flexible-marker flexible-marker-default`.
|
|
5
|
+
const COLOURS = { a: 'amber', b: 'blue', c: 'cyan', d: 'brown', e: 'espresso', f: 'fuchsia', g: 'green', h: 'hotpink', i: 'indigo', j: 'jade', k: 'kiwi', l: 'lime', m: 'magenta', n: 'navyblue', o: 'orange', p: 'purple', q: 'pink', r: 'red', s: 'silver', t: 'teal', u: 'umber', v: 'violet', w: 'white', x: 'gray', y: 'yellow', z: 'black' };
|
|
6
|
+
const BASE = 'flexible-marker';
|
|
7
|
+
const KEYS = Object.fromEntries(Object.entries(COLOURS).map(([k, v]) => [`${BASE}-${v}`, k]));
|
|
8
|
+
// core's regexes: a mark in one text node; the opening and the closing of a mark across formatting.
|
|
9
|
+
const MARK = /=([a-z]?)=(?![\s=])([\s\S]*?)(?<![\s=])==/g;
|
|
10
|
+
const OPENING = /=([a-z]?)=(?![\s]|=+\s)/;
|
|
11
|
+
const CLOSING = /(?<!\s|\s=|\s==|\s===|\s====)==/;
|
|
12
|
+
|
|
13
|
+
// A text node's value against its bytes: where each character of the value stands in them (one past the last at the end), or null when they do not align. A byte the value does not hold is passed over: an escape's backslash (the character it escapes standing for itself), an entity's name (every character it decodes to standing at its `&`), a continuation line's prefix.
|
|
14
|
+
function align(raw, value) {
|
|
15
|
+
const map = [];
|
|
16
|
+
let r = 0;
|
|
17
|
+
for (let v = 0; v < value.length; v++) {
|
|
18
|
+
for (;;) {
|
|
19
|
+
if (r >= raw.length) return null;
|
|
20
|
+
if (raw[r] === '\\' && raw[r + 1] === value[v] && /[!-/:-@[-`{-~]/.test(value[v])) { map.push(r); r += 2; break; }
|
|
21
|
+
if (raw[r] === value[v]) { map.push(r++); break; }
|
|
22
|
+
const e = raw[r] === '&' && /^&(?:#\d+|#x[\da-f]+|\w+);/i.exec(raw.slice(r));
|
|
23
|
+
if (e) {
|
|
24
|
+
const t = document.createElement('textarea');
|
|
25
|
+
t.innerHTML = e[0];
|
|
26
|
+
if (value.startsWith(t.value, v)) { for (let i = 0; i < t.value.length; i++) map.push(r); v += t.value.length - 1; r += e[0].length; break; }
|
|
27
|
+
}
|
|
28
|
+
r++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
map.push(r);
|
|
32
|
+
return map;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Visits every parent's children in order; `f(parent, i)` answers whether it replaced child `i`, which is then looked at again.
|
|
36
|
+
function walk(parent, f) {
|
|
37
|
+
for (let i = 0; i < parent.children.length; i++) {
|
|
38
|
+
if (f(parent, i)) { i--; continue; }
|
|
39
|
+
if (parent.children[i].children) walk(parent.children[i], f);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Finds the notations in a tree of the plain parse, in place: each becomes an mdast node of its own (`mark`, `subscript`, `superscript`) whose `data.hName` mdast-util-to-hast reads, positioned in `text`. Marks are found as core finds them, in one text node or from an opening in one text node to a closing in a later sibling (across emphasis, code, links); an empty mark (`====`) is left as text. They are found in values, as core finds them, so an escaped delimiter is a delimiter (`\^2\^` is a superscript in core too): what this package reads is what core renders, and typed text holding a pair of delimiters, which no escape can keep text, reads back as the notation and refuses (the paragraph changes lane). Like core, a text node with an odd number of carets (or tildes) holds no superscript (subscript).
|
|
45
|
+
* @param {object} tree the plain parse's mdast
|
|
46
|
+
* @param {string} text the text it was parsed from
|
|
47
|
+
* @param {import('./parse.js').Markdown} markdown `mark: false` and `supersub: false` switch them off
|
|
48
|
+
*/
|
|
49
|
+
export function dialect(tree, text, markdown) {
|
|
50
|
+
const lines = [0];
|
|
51
|
+
for (let i = text.indexOf('\n'); i >= 0; i = text.indexOf('\n', i + 1)) lines.push(i + 1);
|
|
52
|
+
const maps = new Map();
|
|
53
|
+
const mapOf = (t) => { if (!maps.has(t)) maps.set(t, t.type === 'text' && t.position ? align(text.slice(t.position.start.offset, t.position.end.offset), t.value) : null); return maps.get(t); };
|
|
54
|
+
const ok = (t) => mapOf(t) != null;
|
|
55
|
+
// The point `k` characters into a text node's value, in the text.
|
|
56
|
+
const point = (t, k) => {
|
|
57
|
+
const offset = t.position.start.offset + mapOf(t)[k];
|
|
58
|
+
let lo = 0, hi = lines.length - 1;
|
|
59
|
+
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (lines[mid] <= offset) lo = mid; else hi = mid - 1; }
|
|
60
|
+
return { line: lo + 1, column: offset - lines[lo] + 1, offset };
|
|
61
|
+
};
|
|
62
|
+
// A new text node keeps a map of its own, the part of its parent's.
|
|
63
|
+
const piece = (t, a, b) => { if (a >= b) return null; const p = { type: 'text', value: t.value.slice(a, b), position: { start: point(t, a), end: point(t, b) } }; maps.set(p, mapOf(t).slice(a, b + 1).map((o) => o - mapOf(t)[a])); return p; };
|
|
64
|
+
const make = (type, hName, hProperties, t0, a, t1, b, children) => ({ type, data: { hName, hProperties }, children, position: { start: point(t0, a), end: point(t1, b) } });
|
|
65
|
+
const markOf = (key, ...at) => make('mark', 'mark', { className: [BASE, key ? `${BASE}-${COLOURS[key]}` : `${BASE}-default`] }, ...at);
|
|
66
|
+
const delim = (ch, type, name) => (parent, i) => {
|
|
67
|
+
const t = parent.children[i];
|
|
68
|
+
if (!ok(t)) return false;
|
|
69
|
+
const parts = t.value.split(ch);
|
|
70
|
+
if (parts.length < 3 || parts.length % 2 === 0 || parts.some((p, k) => k % 2 && !p)) return false;
|
|
71
|
+
let at = 0;
|
|
72
|
+
const out = parts.map((p, k) => {
|
|
73
|
+
if (k % 2 === 0) { at += p.length; return piece(t, at - p.length, at); }
|
|
74
|
+
at += p.length + 2;
|
|
75
|
+
return make(type, name, {}, t, at - p.length - 2, t, at, [piece(t, at - p.length - 1, at - 1)]);
|
|
76
|
+
});
|
|
77
|
+
parent.children.splice(i, 1, ...out.filter(Boolean));
|
|
78
|
+
return true;
|
|
79
|
+
};
|
|
80
|
+
if (markdown.mark !== false) {
|
|
81
|
+
walk(tree, (parent, i) => {
|
|
82
|
+
const t = parent.children[i];
|
|
83
|
+
if (!ok(t)) return false;
|
|
84
|
+
const ms = [...t.value.matchAll(MARK)];
|
|
85
|
+
if (!ms.length) return false;
|
|
86
|
+
let at = 0;
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const m of ms) {
|
|
89
|
+
const open = m[0].length - m[2].length - 2, b = m.index + m[0].length;
|
|
90
|
+
out.push(piece(t, at, m.index), markOf(m[1], t, m.index, t, b, [piece(t, m.index + open, b - 2)]));
|
|
91
|
+
at = b;
|
|
92
|
+
}
|
|
93
|
+
out.push(piece(t, at, t.value.length));
|
|
94
|
+
parent.children.splice(i, 1, ...out.filter(Boolean));
|
|
95
|
+
return true;
|
|
96
|
+
});
|
|
97
|
+
walk(tree, (parent, i) => {
|
|
98
|
+
const t = parent.children[i];
|
|
99
|
+
if (!ok(t)) return false;
|
|
100
|
+
const o = OPENING.exec(t.value);
|
|
101
|
+
const j = o ? parent.children.findIndex((c, k) => k > i && c.type === 'text' && CLOSING.test(c.value)) : -1;
|
|
102
|
+
if (j < 0 || !ok(parent.children[j])) return false;
|
|
103
|
+
const c = parent.children[j], e = CLOSING.exec(c.value);
|
|
104
|
+
const kids = [piece(t, o.index + o[0].length, t.value.length), ...parent.children.slice(i + 1, j), piece(c, 0, e.index)].filter(Boolean);
|
|
105
|
+
if (!kids.length) return false;
|
|
106
|
+
parent.children.splice(i, j - i + 1, ...[piece(t, 0, o.index), markOf(o[1], t, o.index, c, e.index + 2, kids), piece(c, e.index + 2, c.value.length)].filter(Boolean));
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (markdown.supersub !== false) {
|
|
111
|
+
walk(tree, delim('^', 'superscript', 'sup'));
|
|
112
|
+
walk(tree, delim('~', 'subscript', 'sub'));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const KINDS = { mark: 'mark', sub: 'subscript', sup: 'superscript' };
|
|
117
|
+
const texts = (n) => n.childNodes.length > 0 && [...n.childNodes].every((c) => c.nodeType === 3);
|
|
118
|
+
// A mark element's key letter ('' for the default colour), or null when it is not exactly core's output (another class, any other attribute).
|
|
119
|
+
function keyOf(n) {
|
|
120
|
+
const cls = (n.getAttribute('class') ?? '').split(/\s+/);
|
|
121
|
+
if (n.attributes.length !== 1 || cls.length !== 2 || cls[0] !== BASE) return null;
|
|
122
|
+
return cls[1] === `${BASE}-default` ? '' : (KEYS[cls[1]] ?? null);
|
|
123
|
+
}
|
|
124
|
+
const opening = (k) => (k ? `=${k}=` : '==');
|
|
125
|
+
// What the notation cannot carry, so that its bytes would read back as something else: a mark with a space at an edge or `==` in it; a sub- or superscript holding an element, a space or either delimiter.
|
|
126
|
+
const uncarried = (n) => (n.localName === 'mark' ? /^\s|\s$|==/.test(n.textContent) : !texts(n) || /[\s~^]/.test(n.textContent));
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* An element over an old inline node, when it is a notation's: its bytes, or `undefined` when it is not this file's to write (./write.js's `pair` goes on). The delimiters keep their bytes and the content is written as an emphasis's is; a mark whose colour changed has its key rewritten, and a notation that is no longer exactly core's output, or holds what its notation cannot carry, is raw HTML (that element alone, the paragraph keeping its bytes).
|
|
130
|
+
* @param {Node} n the element (or text) as the HTML it means
|
|
131
|
+
* @param {object} o the old mdast node it is paired with
|
|
132
|
+
* @param {object} ctx the stenographers' context (./write.js)
|
|
133
|
+
* @returns {string | undefined}
|
|
134
|
+
*/
|
|
135
|
+
export function pairDialect(n, o, ctx) {
|
|
136
|
+
if (n.nodeType !== 1 || KINDS[n.localName] !== o.type || !o.children?.length) return undefined;
|
|
137
|
+
if (!n.childNodes.length || uncarried(n)) return n.outerHTML;
|
|
138
|
+
let open = span(ctx, start(o), start(o.children[0]));
|
|
139
|
+
if (n.localName === 'mark') {
|
|
140
|
+
const k = keyOf(n);
|
|
141
|
+
if (k == null) return n.outerHTML;
|
|
142
|
+
open = opening(k);
|
|
143
|
+
}
|
|
144
|
+
return open + inline([...n.childNodes], o.children, ctx) + span(ctx, end(o.children.at(-1)), end(o));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* An inline element with no old bytes, when a notation writes it: its bytes, or `undefined` when it is not this file's (./write.js's `fresh` goes on). What the notation cannot carry is raw HTML: a mark with a class outside core's table, any other attribute, no content, or what the notation cannot carry.
|
|
149
|
+
* @param {Element} n
|
|
150
|
+
* @returns {string | undefined}
|
|
151
|
+
*/
|
|
152
|
+
export function freshDialect(n) {
|
|
153
|
+
if (!KINDS[n.localName]) return undefined;
|
|
154
|
+
const inner = [...n.childNodes].map(fresh).join('');
|
|
155
|
+
if (!n.childNodes.length || uncarried(n)) return n.outerHTML;
|
|
156
|
+
if (n.localName === 'mark') { const k = keyOf(n); return k == null ? n.outerHTML : `${opening(k)}${inner}==`; }
|
|
157
|
+
const ch = n.localName === 'sub' ? '~' : '^';
|
|
158
|
+
return ch + inner + ch;
|
|
159
|
+
}
|
package/src/directive.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The directive: core's rule (BelType's packages/core/src/plugins/directives.js) and the container's stenographer, which ./write.js's `block` asks for the element a container directive means. `directives` marks each container and leaf directive of a tree of the plain parse, in place, with the element core gives it (`data.hName`, `data.hProperties`): a built-in's or a binding's tag and class, any other name a `div` (a leaf a `span`) of class `directive directive-name`, the author's attributes over the class (an author's `.class` replaces it, as core's spread does). A binding's container takes its `title` attribute, or the binding's default title, as a first paragraph of class `directive-title` (`data.directiveTitle`, which ./html.js's `hastOf` draws), the attribute not written on the element. Its children and their positions are left as they are.
|
|
2
|
+
//
|
|
3
|
+
// The container's write: `:::name[label]{attributes}` … `:::` over its old bytes. Its first `p` after the title is the label when the directive has one (to-hast renders it as a paragraph flagged `directiveLabel`). The opening line and the closing fence keep their bytes; a label that changed is written inside its brackets; a title that changed is written inside its attribute's quotes, an attribute added or removed alone; the content is a flow (./flow.js) over the old blocks, paired with the new by meaning from both ends inward and in order in the middle when the counts match. A nested directive is one of those blocks, so writeNode routes it back here. The result is read back and refuses unless it means the element; an unclosed directive refuses.
|
|
4
|
+
import { isBlock, span, start, end, inline } from './write.js';
|
|
5
|
+
import { parseMarkdown } from './parse.js';
|
|
6
|
+
import { domOf, means, bare } from './html.js';
|
|
7
|
+
import { writeFlow } from './flow.js';
|
|
8
|
+
|
|
9
|
+
/** Core's built-ins, as its directives plugin has them. */
|
|
10
|
+
export const BUILT_IN = {
|
|
11
|
+
tip: { tag: 'div', className: 'alert alert-tip', defaultTitle: 'Tip' },
|
|
12
|
+
warning: { tag: 'div', className: 'alert alert-warning', defaultTitle: 'Warning' },
|
|
13
|
+
danger: { tag: 'div', className: 'alert alert-danger', defaultTitle: 'Danger' },
|
|
14
|
+
info: { tag: 'div', className: 'alert alert-info', defaultTitle: 'Info' },
|
|
15
|
+
aside: { tag: 'aside', className: 'sidebar' }
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Marks each container and leaf directive of a tree, in place, with the element core gives it.
|
|
20
|
+
* @param {object} tree the plain parse's mdast
|
|
21
|
+
* @param {Record<string, { tag?: string, className?: string, defaultTitle?: string }>} [bound] the venue's `directives:` bindings, over the built-ins (a binding's `render` hook is not run here)
|
|
22
|
+
*/
|
|
23
|
+
export function directives(tree, bound = {}) {
|
|
24
|
+
const table = { ...BUILT_IN, ...bound };
|
|
25
|
+
(function walk(node) {
|
|
26
|
+
if (node.type === 'containerDirective' || node.type === 'leafDirective') {
|
|
27
|
+
const b = Object.hasOwn(table, node.name) ? table[node.name] : null;
|
|
28
|
+
const attributes = { ...node.attributes };
|
|
29
|
+
const own = { className: b ? b.className : `directive directive-${node.name}` };
|
|
30
|
+
if (b && node.type === 'containerDirective') {
|
|
31
|
+
const title = attributes.title || b.defaultTitle;
|
|
32
|
+
delete attributes.title;
|
|
33
|
+
if (title) own.title = title;
|
|
34
|
+
own.defaultTitle = b.defaultTitle;
|
|
35
|
+
}
|
|
36
|
+
node.data = { ...node.data, hName: b ? b.tag || 'div' : node.type === 'leafDirective' ? 'span' : 'div', hProperties: { ...node.data?.hProperties, class: own.className, ...attributes }, directive: own };
|
|
37
|
+
if (own.title) node.data.directiveTitle = own.title;
|
|
38
|
+
}
|
|
39
|
+
node.children?.forEach(walk);
|
|
40
|
+
})(tree);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The attributes' tokens on the opening line: `#id`, `.class`, `key`, `key=value` quoted or not, each with its span.
|
|
44
|
+
const TOKEN = /\s*(?:#([^\s#.{}"'=]+)|\.([^\s#.{}"'=]+)|([^\s#.{}"'=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`{}]+)))?)/y;
|
|
45
|
+
function tokens(s, from) {
|
|
46
|
+
const out = [];
|
|
47
|
+
let to = from;
|
|
48
|
+
TOKEN.lastIndex = from;
|
|
49
|
+
for (let m; (m = TOKEN.exec(s)) && m[0].trim(); to = TOKEN.lastIndex) out.push({ key: m[1] != null ? 'id' : m[2] != null ? 'class' : m[3], short: m[1] != null || m[2] != null, quote: m[4] != null ? '"' : m[5] != null ? "'" : '', from: m.index, to: TOKEN.lastIndex });
|
|
50
|
+
return { list: out, to };
|
|
51
|
+
}
|
|
52
|
+
// A value as an attribute: quoted as the old one was, unquoted only when it was and still can be, a new one in double quotes; a character reference for the quote and for an ampersand that would start one.
|
|
53
|
+
function attr(key, value, quote) {
|
|
54
|
+
const q = quote || (/^[^\s"'=<>`{}]+$/.test(value) ? '' : '"');
|
|
55
|
+
const v = value.replace(/&(?=#?\w+;)/g, '&');
|
|
56
|
+
return q ? `${key}=${q}${q === '"' ? v.replace(/"/g, '"') : v.replace(/'/g, ''')}${q}` : `${key}=${v}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The opening line with the author's attributes made `want`, each attribute that kept its value keeping its bytes; null when the line's attributes cannot be read.
|
|
61
|
+
* @param {string} head the opening line
|
|
62
|
+
* @param {number} after where the attributes may start (past the name and label)
|
|
63
|
+
* @param {Record<string, string>} had the attributes as read
|
|
64
|
+
* @param {Record<string, string>} want
|
|
65
|
+
*/
|
|
66
|
+
function reattribute(head, after, had, want) {
|
|
67
|
+
const keys = new Set([...Object.keys(had), ...Object.keys(want)]);
|
|
68
|
+
const changed = [...keys].filter((k) => had[k] !== want[k]);
|
|
69
|
+
if (!changed.length) return head;
|
|
70
|
+
const open = head.slice(after).match(/^\s*\{/);
|
|
71
|
+
const fresh = (k) => (k === 'id' && /^[^\s#.{}"'=]+$/.test(want.id) ? `#${want.id}` : k === 'class' && /^[^#.{}"'=]+$/.test(want.class) ? want.class.trim().split(/\s+/).map((c) => `.${c}`).join(' ') : attr(k, want[k], '"'));
|
|
72
|
+
if (!open) {
|
|
73
|
+
if (!/^\s*$/.test(head.slice(after))) return null;
|
|
74
|
+
const added = changed.filter((k) => want[k] != null);
|
|
75
|
+
return `${head.slice(0, after)}{${added.map(fresh).join(' ')}}${head.slice(after)}`;
|
|
76
|
+
}
|
|
77
|
+
const { list, to } = tokens(head, after + open[0].length);
|
|
78
|
+
if (!/^\s*\}/.test(head.slice(to))) return null;
|
|
79
|
+
const close = to + head.slice(to).indexOf('}');
|
|
80
|
+
let out = '', at = after + open[0].length;
|
|
81
|
+
const done = new Set();
|
|
82
|
+
for (const t of list) {
|
|
83
|
+
out += head.slice(at, t.from);
|
|
84
|
+
at = t.to;
|
|
85
|
+
const k = t.key;
|
|
86
|
+
if (!changed.includes(k)) { out += head.slice(t.from, t.to); continue; }
|
|
87
|
+
// A changed attribute: its first token rewritten, quoted as it was (a class's tokens are one attribute, so the rest go), a removed one dropped with the space before it.
|
|
88
|
+
if (done.has(k) || want[k] == null) continue;
|
|
89
|
+
done.add(k);
|
|
90
|
+
const lead = head.slice(t.from, t.to).match(/^\s*/)[0];
|
|
91
|
+
out += lead + (t.short || k === 'class' || k === 'id' ? fresh(k) : attr(k, want[k], t.quote));
|
|
92
|
+
}
|
|
93
|
+
const added = changed.filter((k) => want[k] != null && !done.has(k) && !list.some((t) => t.key === k));
|
|
94
|
+
const tail = head.slice(at, close);
|
|
95
|
+
out += (added.length ? `${tail.replace(/\s+$/, '')}${list.length ? ' ' : ''}${added.map(fresh).join(' ')}${tail.match(/\s*$/)[0]}` : tail);
|
|
96
|
+
// Every attribute gone, the braces go.
|
|
97
|
+
return out.trim() ? `${head.slice(0, after + open[0].length)}${out}${head.slice(close)}` : `${head.slice(0, after)}${head.slice(close + 1)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A directive container's bytes over its old slice, or null (the lane change).
|
|
102
|
+
* @param {Element} el the directive as the HTML it means
|
|
103
|
+
* @param {object} md the containerDirective's mdast
|
|
104
|
+
* @param {object} ctx the stenographers' context (./write.js)
|
|
105
|
+
* @returns {string | null}
|
|
106
|
+
*/
|
|
107
|
+
export function directive(el, md, ctx) {
|
|
108
|
+
const text = ctx.src, a = start(md), b = end(md);
|
|
109
|
+
const own = md.data?.directive ?? {};
|
|
110
|
+
if (el.localName !== (md.data?.hName ?? 'div')) return null;
|
|
111
|
+
// The closing fence is the last line of the slice (never the opening line); without one the directive runs to the end of its parent.
|
|
112
|
+
const lastNl = text.lastIndexOf('\n', b - 1);
|
|
113
|
+
if (lastNl < a || !/^ {0,3}:{3,}[ \t]*$/.test(text.slice(lastNl + 1, b))) return null;
|
|
114
|
+
const label = md.children[0]?.data?.directiveLabel ? md.children[0] : null;
|
|
115
|
+
const old = md.children.slice(label ? 1 : 0);
|
|
116
|
+
const nodes = [...el.childNodes];
|
|
117
|
+
if (nodes.some((n) => (n.nodeType === 3 ? /\S/.test(n.data) : !isBlock(n)))) return null;
|
|
118
|
+
const news = nodes.filter((n) => n.nodeType === 1);
|
|
119
|
+
// The title: a binding's first paragraph, plain text, the attribute's value; gone, the attribute goes.
|
|
120
|
+
const titleEl = 'defaultTitle' in own && news[0]?.localName === 'p' && news[0].getAttribute('class') === 'directive-title' ? news.shift() : null;
|
|
121
|
+
if (titleEl && [...titleEl.childNodes].some((n) => n.nodeType !== 3)) return null;
|
|
122
|
+
if (label && news[0]?.localName !== 'p') return null;
|
|
123
|
+
// A label is only the first `p` of the content, so a label deleted would make the first paragraph of the content the label: when the blocks are the old content's in number and the first is the old first, the label is what went.
|
|
124
|
+
const dropped = label && old.length && news.length === old.length && means(news[0], old[0]);
|
|
125
|
+
const labelEl = label && !dropped ? news.shift() : null;
|
|
126
|
+
|
|
127
|
+
// The opening line: up to the first line end after the label (the attributes follow the label on the same line).
|
|
128
|
+
const open = text.indexOf('\n', label ? end(label) : a);
|
|
129
|
+
if (open < 0 || open > lastNl) return null;
|
|
130
|
+
let head = text.slice(a, open);
|
|
131
|
+
const name = /^ {0,3}:{3,}[^\s[{]*/.exec(head)[0].length;
|
|
132
|
+
// The author's attributes: the element's, its class the author's only when it is not the one core gives, the title the paragraph's.
|
|
133
|
+
const had = { ...md.attributes };
|
|
134
|
+
const want = Object.fromEntries([...el.attributes].map((x) => [x.name, x.value]));
|
|
135
|
+
if (want.class === (had.class ?? own.className)) want.class = had.class;
|
|
136
|
+
if (want.class == null || want.class === own.className) delete want.class;
|
|
137
|
+
for (const k of Object.keys(want)) if (want[k] == null) delete want[k];
|
|
138
|
+
if ('defaultTitle' in own) {
|
|
139
|
+
const t = titleEl?.textContent;
|
|
140
|
+
if (t === own.title) { if (had.title != null) want.title = had.title; }
|
|
141
|
+
else if (t != null && t !== '') want.title = t;
|
|
142
|
+
}
|
|
143
|
+
for (const k of Object.keys(had)) if (had[k] == null) delete had[k];
|
|
144
|
+
const after = label ? end(label) - a : name;
|
|
145
|
+
if (dropped) head = head.slice(0, start(label) - a) + head.slice(end(label) - a);
|
|
146
|
+
else if (label && !means(labelEl, label)) {
|
|
147
|
+
const kids = label.children;
|
|
148
|
+
if (!kids.length) return null;
|
|
149
|
+
head = head.slice(0, start(label) - a) + span(ctx, start(label), start(kids[0])) + inline([...labelEl.childNodes], kids, ctx) + span(ctx, end(kids.at(-1)), end(label)) + head.slice(end(label) - a);
|
|
150
|
+
}
|
|
151
|
+
head = reattribute(head, after + head.length - (open - a), had, want);
|
|
152
|
+
if (head == null) return null;
|
|
153
|
+
|
|
154
|
+
const same = (i, j) => !ctx.force && means(news[i], old[j]);
|
|
155
|
+
const n = news.length, m = old.length;
|
|
156
|
+
let h = 0;
|
|
157
|
+
while (h < n && h < m && same(h, h)) h++;
|
|
158
|
+
let t = 0;
|
|
159
|
+
while (t < n - h && t < m - h && same(n - 1 - t, m - 1 - t)) t++;
|
|
160
|
+
const blocks = news.map((e, i) => {
|
|
161
|
+
const edge = i < h || i >= n - t;
|
|
162
|
+
return { el: e, j: edge ? (i < h ? i : m - n + i) : n === m ? i : -1, same: edge };
|
|
163
|
+
});
|
|
164
|
+
const file = { defs: ctx.defs, markdown: ctx.markdown };
|
|
165
|
+
// No new blocks: nothing between the fences. Old blocks: the flow over the lines between them. None old: the flow alone.
|
|
166
|
+
let body = '';
|
|
167
|
+
if (n) body = (m ? writeFlow(blocks, { text, spans: old.map((k) => [start(k), end(k)]), from: open + 1, to: lastNl + 1 }, file) : writeFlow(blocks, { text: '', spans: [] }, file)).text;
|
|
168
|
+
const out = `${head}\n${body}${text.slice(lastNl + 1, b)}`;
|
|
169
|
+
|
|
170
|
+
// Read back as a whole; a title deleted reads back as the default one, which is what core shows.
|
|
171
|
+
const back = parseMarkdown(ctx.defs ? `${out}\n\n${ctx.defs}` : out, ctx.markdown).children[0];
|
|
172
|
+
const dom = back?.type === 'containerDirective' && end(back) === out.length && domOf(back).find((d) => d.nodeType === 1);
|
|
173
|
+
if (!dom) return null;
|
|
174
|
+
const mine = bare(el);
|
|
175
|
+
if (!titleEl?.textContent) {
|
|
176
|
+
if (titleEl) mine.firstElementChild.remove();
|
|
177
|
+
if (own.defaultTitle && dom.firstElementChild?.getAttribute('class') === 'directive-title') dom.firstElementChild.remove();
|
|
178
|
+
}
|
|
179
|
+
return bare(dom).isEqualNode(mine) ? out : null;
|
|
180
|
+
}
|
package/src/figure.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The figure: core's rule (BelType's packages/core/src/plugins/figure.js), a paragraph whose only meaningful child is an image with a title is a `figure` holding the image (its title taken off) and a `figcaption` of the title, the title's trailing `{#id}` the figure's id and not part of the caption; and the figure's stenographer, which ./write.js's `block` asks for a `figure` over a paragraph. `figures` marks the paragraph for mdast-util-to-hast and leaves its children and positions as they are, so the figure is written over the paragraph's own bytes: the caption is the text inside the title's quotes, the alt the text inside the brackets, the source the destination, and each is rewritten alone, the rest kept.
|
|
2
|
+
import { toHast } from 'mdast-util-to-hast';
|
|
3
|
+
import { checked, span, start, end, sliceOf } from './write.js';
|
|
4
|
+
import { domOf } from './html.js';
|
|
5
|
+
|
|
6
|
+
const LABEL = /\s*\{#([^}]+)\}\s*$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Marks each figure of a tree of the plain parse, in place, for mdast-util-to-hast (`data.hName`, `data.hProperties`, `data.hChildren` on the paragraph), leaving its children and their positions as they are.
|
|
10
|
+
* @param {object} tree the plain parse's mdast
|
|
11
|
+
*/
|
|
12
|
+
export function figures(tree) {
|
|
13
|
+
// A reference image takes its url and title from the definition, as to-hast gives them (the first definition of an identifier wins).
|
|
14
|
+
const defs = new Map();
|
|
15
|
+
(function find(node) {
|
|
16
|
+
if (node.type === 'definition' && !defs.has(node.identifier)) defs.set(node.identifier, node);
|
|
17
|
+
node.children?.forEach(find);
|
|
18
|
+
})(tree);
|
|
19
|
+
// A tight list's items hold their paragraphs unwrapped (mdast-util-to-hast's rule, the list's looseness), so core, which promotes a `p`, finds none there.
|
|
20
|
+
const loose = (list) => list.spread || list.children.some((i) => (i.spread == null ? i.children.length > 1 : i.spread));
|
|
21
|
+
(function walk(node, tight) {
|
|
22
|
+
if (node.type === 'paragraph' && !tight) {
|
|
23
|
+
const kids = node.children.filter((c) => !(c.type === 'text' && !c.value.trim()));
|
|
24
|
+
const k = kids.length === 1 ? kids[0] : null;
|
|
25
|
+
const def = k?.type === 'imageReference' ? defs.get(k.identifier) : null;
|
|
26
|
+
const img = k?.type === 'image' ? k : def ? { type: 'image', url: def.url, alt: k.alt, title: def.title } : null;
|
|
27
|
+
if (img?.title) {
|
|
28
|
+
const m = LABEL.exec(img.title);
|
|
29
|
+
const caption = m ? img.title.slice(0, m.index).trim() : img.title;
|
|
30
|
+
node.data = {
|
|
31
|
+
...node.data,
|
|
32
|
+
hName: 'figure',
|
|
33
|
+
hProperties: m ? { id: m[1] } : {},
|
|
34
|
+
hChildren: [toHast({ ...img, title: null }), { type: 'element', tagName: 'figcaption', properties: {}, children: [{ type: 'text', value: caption }] }]
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const items = node.type === 'list' && !loose(node);
|
|
39
|
+
node.children?.forEach((c) => walk(c, items ? 'item' : tight === 'item' && node.type === 'listItem'));
|
|
40
|
+
})(tree, false);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Backslash and the characters that would close the title, as Markdown escapes them.
|
|
44
|
+
const escTitle = (s, open) => s.replace(/\\/g, '\\\\').replace(open === '(' ? /[()]/g : new RegExp(open, 'g'), '\\$&').replace(/&(?=#?\w+;)/g, '\\&');
|
|
45
|
+
const escAlt = (s) => s.replace(/[\\[\]]/g, '\\$&');
|
|
46
|
+
// A source as the HTML has it (already percent-encoded but for whitespace).
|
|
47
|
+
const dest = (s) => s.replace(/\s/g, encodeURIComponent).replace(/[()\\]/g, '\\$&');
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A figure's bytes over the paragraph it was read from, or null (the lane change).
|
|
51
|
+
* @param {Element} el the figure as the HTML it means
|
|
52
|
+
* @param {object} md the paragraph's mdast
|
|
53
|
+
* @param {object} ctx the stenographers' context (./write.js)
|
|
54
|
+
* @returns {string | null}
|
|
55
|
+
*/
|
|
56
|
+
export function figure(el, md, ctx) {
|
|
57
|
+
const img = md.children.find((c) => c.type === 'image');
|
|
58
|
+
const old = domOf(md)[0];
|
|
59
|
+
if (!img || old?.localName !== 'figure') return null;
|
|
60
|
+
// Exactly an img and a figcaption of plain text (spaces between allowed), the figure carrying nothing but an id.
|
|
61
|
+
const kids = [...el.childNodes].filter((n) => n.nodeType !== 3 || n.data.trim());
|
|
62
|
+
const [pic, cap] = kids;
|
|
63
|
+
if (kids.length !== 2 || pic.localName !== 'img' || cap.localName !== 'figcaption' || [...cap.childNodes].some((n) => n.nodeType !== 3)) return null;
|
|
64
|
+
if ([...el.attributes].some((a) => a.name !== 'id') || cap.attributes.length || [...pic.attributes].some((a) => a.name !== 'src' && a.name !== 'alt')) return null;
|
|
65
|
+
// Untrimmed, as the caption is read: a title with no `{#id}` keeps its edge spaces in the figcaption.
|
|
66
|
+
const caption = cap.textContent;
|
|
67
|
+
if (!caption.trim() || /\n\s*\n/.test(caption)) return null;
|
|
68
|
+
const raw = sliceOf(ctx, img);
|
|
69
|
+
// The image's parts in its bytes: the alt up to the `]` that closes it, then `(destination title)`.
|
|
70
|
+
if (raw.slice(0, 2) !== '![') return null;
|
|
71
|
+
let i = 2, depth = 0;
|
|
72
|
+
for (; i < raw.length; i++) {
|
|
73
|
+
if (raw[i] === '\\') i++;
|
|
74
|
+
else if (raw[i] === '[') depth++;
|
|
75
|
+
else if (raw[i] === ']' && !depth--) break;
|
|
76
|
+
}
|
|
77
|
+
// No inline title in the bytes (a reference image, whose title is a definition's) refuses here.
|
|
78
|
+
const m = /^\]\((\s*(?:<[^>\n]*>|\S*))(\s+(["'(]))([\s\S]*)([")'])(\s*)\)$/.exec(raw.slice(i));
|
|
79
|
+
if (!m) return null;
|
|
80
|
+
const [, to, gap, open, title, , tail] = m;
|
|
81
|
+
const alt = pic.getAttribute('alt') ?? '';
|
|
82
|
+
const src = pic.getAttribute('src') ?? '';
|
|
83
|
+
const oldImg = old.querySelector('img');
|
|
84
|
+
const label = LABEL.exec(img.title);
|
|
85
|
+
const oldCaption = label ? img.title.slice(0, label.index).trim() : img.title;
|
|
86
|
+
// The title's bytes: the caption's (kept when unchanged), then the label's, its spacing kept and only the id inside the braces rewritten.
|
|
87
|
+
const rawLabel = /\s*\{#[^}]*\}\s*$/.exec(title);
|
|
88
|
+
const id = el.getAttribute('id') ?? '';
|
|
89
|
+
const keptCaption = rawLabel ? title.slice(0, rawLabel.index) : title;
|
|
90
|
+
const suffix = !id ? '' : rawLabel ? rawLabel[0].replace(/\{#[^}]*\}/, () => `{#${id}}`) : ` {#${id}}`;
|
|
91
|
+
const titleBytes = (caption === oldCaption ? keptCaption : escTitle(caption, open)) + suffix;
|
|
92
|
+
const close = open === '(' ? ')' : open;
|
|
93
|
+
return checked(el, ctx, (c) => {
|
|
94
|
+
const altBytes = alt === (oldImg.getAttribute('alt') ?? '') ? raw.slice(2, i) : escAlt(alt);
|
|
95
|
+
const dst = src === oldImg.getAttribute('src') ? to : dest(src);
|
|
96
|
+
return `${span(c, start(md), start(img))}${span(c, end(img), end(md))}`;
|
|
97
|
+
});
|
|
98
|
+
}
|
package/src/flow.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// A flow of blocks written over its old bytes (the studio's notes/markdown-plan.md, "Escalation": the parent owns the gaps). The caller says which old block each new one stands for, by identity (the file, the studio's src/markdown/file.js) or by meaning (Provenance's writer, ./writer.js); this writes them. A block that is its old one unchanged keeps its bytes, and so does one whose bytes the caller gives as they stand (what the Markdown pane typed); one that stands for an old block is written by its stenographer over that slice, or over the bytes the caller gives it, raw HTML if it refuses; one that stands for none is written fresh. The separator before a block is the old gap when it follows the block it followed, a blank line otherwise; an old block no new one stands for takes its bytes and the gap before it, unless that gap holds something of its own (a link definition, a comment), which stays. Two lists that were not neighbours and would read as one (the same kind, the same marker) are kept apart by an empty comment, as mdast-util-to-markdown does.
|
|
2
|
+
import { writeFresh, writeNode } from './write.js';
|
|
3
|
+
|
|
4
|
+
const marker = (bytes) => /^\s*(?:([-*+])|\d+([.)]))/.exec(bytes)?.slice(1).join('') ?? null;
|
|
5
|
+
const merges = (a, aBytes, b, bBytes) => a.localName === b.localName && /^[uo]l$/.test(a.localName) && marker(aBytes) != null && marker(aBytes) === marker(bBytes);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {{ el: Element, j: number, same: boolean, bytes?: string | null, over?: string | null }[]} blocks the new blocks in order: the HTML each means, the index of the old block it stands for (-1: none), whether it is that block unchanged; `bytes`, the block's bytes as they stand (written as they are); `over`, the bytes a changed block is written over (its old block's by default)
|
|
9
|
+
* @param {{ text: string, spans: [number, number][], from?: number, to?: number }} old the old text, its blocks' spans in it in order, and the stretch of it they lie in (all of it by default)
|
|
10
|
+
* @param {{ defs?: string, markdown?: import('./parse.js').Markdown }} [file] the file's definitions and Markdown options, for the stenographers' re-parse
|
|
11
|
+
* @returns {{ text: string, spans: [number, number][], report: { kept: number, written: number, fresh: number, deleted: number } }} the stretch's new bytes, and each new block's span in them
|
|
12
|
+
*/
|
|
13
|
+
export function writeFlow(blocks, old, file = {}) {
|
|
14
|
+
const { text, spans } = old;
|
|
15
|
+
const from = old.from ?? 0, to = old.to ?? text.length;
|
|
16
|
+
const report = { kept: 0, written: 0, fresh: 0, deleted: 0 };
|
|
17
|
+
const used = new Set(blocks.filter((b) => b.j >= 0).map((b) => b.j));
|
|
18
|
+
const slice = (k) => text.slice(...spans[k]);
|
|
19
|
+
// The bytes between old block k-1 and old block k; the lead holds what is before the first.
|
|
20
|
+
const gap = (k) => (k === 0 ? '' : text.slice(spans[k - 1][1], spans[k][0]));
|
|
21
|
+
// A held gap brings its own blank lines either side; where another gap follows it, its trailing ones give way, so a removed block leaves no blank line of its own behind.
|
|
22
|
+
const join = (h, g) => (h && /^\s/.test(g) ? h.trimEnd() + g : h + g);
|
|
23
|
+
const held = (a, b) => { let kept = ''; for (let k = a + 1; k < b; k++) if (!used.has(k) && /\S/.test(gap(k))) kept = join(kept, gap(k)); return kept; };
|
|
24
|
+
let out = text.slice(from, spans[0]?.[0] ?? to);
|
|
25
|
+
const next = [];
|
|
26
|
+
let prev = -1, last = '';
|
|
27
|
+
for (const [i, { el, j, same, bytes: given, over }] of blocks.entries()) {
|
|
28
|
+
let bytes;
|
|
29
|
+
if (given != null) { bytes = given; report.kept++; }
|
|
30
|
+
else if (same) { bytes = slice(j); report.kept++; }
|
|
31
|
+
else if (j < 0 && over == null) { bytes = writeFresh(el, file); report.fresh++; }
|
|
32
|
+
else { bytes = writeNode(el, { text: over ?? slice(j), column: 0, ...file }) ?? el.outerHTML; report.written++; }
|
|
33
|
+
const adjacent = i > 0 && j >= 0 && j === prev + 1 && blocks[i - 1].j === prev;
|
|
34
|
+
let sep = i === 0 ? (j >= 0 ? held(-1, j) : '') : j > prev ? join(held(prev, j), gap(j)) || '\n\n' : '\n\n';
|
|
35
|
+
if (i > 0 && !adjacent && !/\S/.test(sep) && merges(blocks[i - 1].el, last, el, bytes)) sep = '\n\n<!-- -->\n\n';
|
|
36
|
+
out += sep + bytes;
|
|
37
|
+
last = bytes;
|
|
38
|
+
next.push([out.length - bytes.length, out.length]);
|
|
39
|
+
if (j >= 0) prev = j;
|
|
40
|
+
}
|
|
41
|
+
out += join(held(prev, spans.length), spans.length ? text.slice(spans.at(-1)[1], to) : '');
|
|
42
|
+
// A stretch that had no blocks has no gap after them: the new ones are separated from what follows.
|
|
43
|
+
if (!spans.length && blocks.length) out += to < text.length ? '\n\n' : '\n';
|
|
44
|
+
report.deleted = spans.length - used.size;
|
|
45
|
+
return { text: out, spans: next, report };
|
|
46
|
+
}
|