@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/src/html.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// What a piece of Markdown means in HTML: mdast-util-to-hast's translation (the one remark-rehype makes in BelType's belt), written as markup and parsed by the browser, so raw HTML split across several mdast nodes (`<span>`, text, `</span>`) becomes the one element it is.
|
|
2
|
+
import { toHast } from 'mdast-util-to-hast';
|
|
3
|
+
|
|
4
|
+
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
|
|
5
|
+
const escText = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
6
|
+
const escAttr = (s) => s.replace(/&/g, '&').replace(/"/g, '"');
|
|
7
|
+
// hast's property names as HTML's attribute names, for the properties mdast-util-to-hast writes.
|
|
8
|
+
const attrName = (k) => (k === 'className' ? 'class' : k === 'htmlFor' ? 'for' : k.startsWith('aria') ? `aria-${k.slice(4).toLowerCase()}` : k.startsWith('data') ? k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`) : k.toLowerCase());
|
|
9
|
+
|
|
10
|
+
/** The markup of a hast tree. */
|
|
11
|
+
export function htmlOf(node) {
|
|
12
|
+
if (node.type === 'text') return escText(node.value);
|
|
13
|
+
if (node.type === 'raw') return node.value;
|
|
14
|
+
if (node.type === 'comment') return `<!--${node.value}-->`;
|
|
15
|
+
const inner = (node.children ?? []).map(htmlOf).join('');
|
|
16
|
+
if (node.type !== 'element') return inner;
|
|
17
|
+
const attrs = Object.entries(node.properties ?? {}).filter(([, v]) => v != null && v !== false)
|
|
18
|
+
.map(([k, v]) => (v === true ? ` ${attrName(k)}=""` : ` ${attrName(k)}="${escAttr(Array.isArray(v) ? v.join(' ') : String(v))}"`)).join('');
|
|
19
|
+
return VOID.has(node.tagName) ? `<${node.tagName}${attrs}>` : `<${node.tagName}${attrs}>${inner}</${node.tagName}>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A container directive as to-hast draws an unknown node (its element from `data`, its children), with core's title paragraph first when ./directive.js found one.
|
|
23
|
+
function containerDirective(state, node) {
|
|
24
|
+
const children = state.all(node);
|
|
25
|
+
if (node.data?.directiveTitle != null) children.unshift({ type: 'element', tagName: 'p', properties: { className: ['directive-title'] }, children: [{ type: 'text', value: node.data.directiveTitle }] });
|
|
26
|
+
const el = { type: 'element', tagName: 'div', properties: {}, children };
|
|
27
|
+
state.patch(node, el);
|
|
28
|
+
return state.applyData(node, el);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The hast of an mdast tree. A text directive has no HTML of its own here (BelType's directive handlers give it one; the plan's round 3), so it is a `span`: inline, it stays in its paragraph, where to-hast's default would make it a `div` the browser closes the paragraph for. */
|
|
32
|
+
export function hastOf(mdast) {
|
|
33
|
+
(function inline(node) {
|
|
34
|
+
if (node.type === 'textDirective') node.data = { ...node.data, hName: node.data?.hName ?? 'span' };
|
|
35
|
+
node.children?.forEach(inline);
|
|
36
|
+
})(mdast);
|
|
37
|
+
return toHast(mdast, { allowDangerousHtml: true, handlers: { containerDirective } });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The DOM nodes an mdast node stands for in HTML. */
|
|
41
|
+
export function domOf(mdast) {
|
|
42
|
+
const t = document.createElement('template');
|
|
43
|
+
t.innerHTML = htmlOf(hastOf(mdast));
|
|
44
|
+
return [...t.content.childNodes];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether a DOM node is what an mdast node means in HTML (the test that lets an unchanged node keep its bytes), the whitespace between its blocks aside (`bare`). */
|
|
48
|
+
export function means(node, mdast) {
|
|
49
|
+
const dom = domOf(mdast).filter((n) => n.nodeType !== 3 || /\S/.test(n.data) || n.data === node.data);
|
|
50
|
+
return dom.length === 1 && bare(dom[0]).isEqualNode(bare(node));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The elements whose neighbours' whitespace is layout, not text: blocks and the parts of lists and tables.
|
|
54
|
+
const STRUCTURE = new Set(['address', 'article', 'aside', 'blockquote', 'details', 'div', 'dl', 'dd', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'menu', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'caption', 'ul']);
|
|
55
|
+
const layout = (n) => !n || (n.nodeType === 1 && STRUCTURE.has(n.localName));
|
|
56
|
+
// Layout whitespace is HTML's own (space, tab, line ends); a no-break space or an em space is text.
|
|
57
|
+
const LAYOUT = /^[ \t\n\r\f]+$/;
|
|
58
|
+
|
|
59
|
+
/** A copy of an element, its count left out (`uncounted`), without the whitespace between its blocks, which mdast-util-to-hast writes as line ends and an editor's serializer need not write at all: what a container's read-back compares. Whitespace with no element beside it (a cell's or a paragraph's whole text) is kept: it is content. */
|
|
60
|
+
export function bare(el) {
|
|
61
|
+
const copy = uncounted(el).cloneNode(true);
|
|
62
|
+
copy.normalize?.();
|
|
63
|
+
// A preformatted block's whitespace is its text.
|
|
64
|
+
(function walk(p) {
|
|
65
|
+
if (p.localName === 'pre') return;
|
|
66
|
+
for (const c of [...p.childNodes]) {
|
|
67
|
+
if (c.nodeType === 3 && LAYOUT.test(c.data) && STRUCTURE.has(p.localName) && (c.previousSibling || c.nextSibling) && layout(c.previousSibling) && layout(c.nextSibling)) c.remove();
|
|
68
|
+
else if (c.nodeType === 1) walk(c);
|
|
69
|
+
}
|
|
70
|
+
})(copy);
|
|
71
|
+
return copy;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A footnote reference's number and id are the document's count, not the reference's meaning: a paragraph read alone counts from one.
|
|
75
|
+
export function uncounted(node) {
|
|
76
|
+
if (node.nodeType !== 1 || !node.querySelector('a[data-footnote-ref]')) return node;
|
|
77
|
+
const copy = node.cloneNode(true);
|
|
78
|
+
for (const a of copy.querySelectorAll('a[data-footnote-ref]')) { a.textContent = ''; a.removeAttribute('id'); }
|
|
79
|
+
return copy;
|
|
80
|
+
}
|
package/src/list.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The list's stenographers (the studio's notes/markdown-plan.md, "The list item, worked through"): a list over its old items, an item over its old blocks. A list pairs its new items with the old (the common head and tail by meaning, the middle in order when the counts match) and writes each pair over the old slice. An item without a partner is written fresh at the marker and content indent of the item above it (the one below when it is first), numbered one past the one above in an ordered list, the items below left as they are; an old item without a partner is gone with its gap. A list turned tight or loose gets a blank line between its items or loses them, and nothing else of the gaps changes. An item's task box is written over the old box's bytes when its state changed.
|
|
2
|
+
import { isBlock, sliceOf, span, start, end, writeNode, writeFresh } from './write.js';
|
|
3
|
+
import { parseMarkdown } from './parse.js';
|
|
4
|
+
import { domOf, bare } from './html.js';
|
|
5
|
+
|
|
6
|
+
// Tight or loose, as mdast-util-to-hast decides it: a list is loose when it or any item is spread, an item whose `spread` is unknown when it holds more than one child.
|
|
7
|
+
const looseItem = (item) => (item.spread == null ? item.children.length > 1 : item.spread);
|
|
8
|
+
const looseList = (list) => list.spread || list.children.some(looseItem);
|
|
9
|
+
const boxOf = (li) => (li.classList.contains('task-list-item') ? li.querySelector('input[type="checkbox"]') : null);
|
|
10
|
+
// An item's marker as its first line holds it, with the spaces up to its content.
|
|
11
|
+
const markerOf = (k, ctx) => { const m = /^(?:[-*+]|\d{1,9}[.)])(?: {1,4})?/.exec(sliceOf(ctx, k))[0]; return /\s$/.test(m) ? m : `${m} `; };
|
|
12
|
+
// The marker after another's: an ordered list counts on, keeping the delimiter and the spaces.
|
|
13
|
+
const bump = (marker) => marker.replace(/^\d+/, (d) => +d + 1);
|
|
14
|
+
|
|
15
|
+
export function list(el, md, ctx) {
|
|
16
|
+
const items = [...el.children].filter((c) => c.localName === 'li');
|
|
17
|
+
const old = md.children;
|
|
18
|
+
const n = items.length, m = old.length;
|
|
19
|
+
if (!n) return null;
|
|
20
|
+
const loose = items.some((li) => [...li.children].some((c) => c.localName === 'p'));
|
|
21
|
+
const turned = loose !== looseList(md);
|
|
22
|
+
// The old items as the HTML of the list turned tight or loose as it now is, so an item that did not change is the same element in both.
|
|
23
|
+
const oldLis = [...domOf({ ...md, spread: loose, children: old.map((k) => ({ ...k, spread: loose })) })[0].children].filter((c) => c.localName === 'li');
|
|
24
|
+
const same = (i, j) => !ctx.force && bare(items[i]).isEqualNode(bare(oldLis[j]));
|
|
25
|
+
let h = 0;
|
|
26
|
+
while (h < n && h < m && same(h, h)) h++;
|
|
27
|
+
let t = 0;
|
|
28
|
+
while (t < n - h && t < m - h && same(n - 1 - t, m - 1 - t)) t++;
|
|
29
|
+
// Each new item with its old partner or null; a middle that does not pair has no partners, and its old items are out.
|
|
30
|
+
const pairs = items.map((li, i) => [li, i < h ? old[i] : i >= n - t ? old[m - n + i] : n === m ? old[i] : null]);
|
|
31
|
+
const gaps = old.slice(1).map((k, i) => span(ctx, end(old[i]), start(k)));
|
|
32
|
+
const defaultGap = looseList(md) ? '\n\n' : '\n';
|
|
33
|
+
const below = pairs.find(([, k]) => k)?.[1] ?? old[0];
|
|
34
|
+
let out = span(ctx, start(md), start(old[0])), gap = gaps[0] ?? defaultGap, last = null;
|
|
35
|
+
for (const [i, [li, k]] of pairs.entries()) {
|
|
36
|
+
let w, marker;
|
|
37
|
+
if (k) {
|
|
38
|
+
w = writeNode(li, { text: sliceOf(ctx, k), column: k.position.start.column - 1, defs: ctx.defs, markdown: ctx.markdown }, ctx);
|
|
39
|
+
if (w == null) return null;
|
|
40
|
+
marker = markerOf(k, ctx);
|
|
41
|
+
// The last old item has no gap after it; the one before it has the list's last.
|
|
42
|
+
gap = gaps[old.indexOf(k)] ?? gaps.at(-1) ?? defaultGap;
|
|
43
|
+
} else {
|
|
44
|
+
marker = last ? bump(last) : markerOf(below, ctx);
|
|
45
|
+
w = freshItem(li, marker, loose, ctx);
|
|
46
|
+
}
|
|
47
|
+
last = marker;
|
|
48
|
+
// Tight to loose: a blank line in a gap that has none; loose to tight: the blank lines out.
|
|
49
|
+
const g = !turned ? gap : loose ? (/\n[ \t]*\n/.test(gap) ? gap : gap.replace('\n', '\n\n')) : gap.replace(/\n[ \t]*(?=\n)/g, '');
|
|
50
|
+
out += w + (i + 1 < n ? g : '');
|
|
51
|
+
}
|
|
52
|
+
// Read back as a whole: a gap inside an item can keep a list loose that was turned tight, and one item cannot be loose at all.
|
|
53
|
+
// ponytail: such a list changes lane; removing the blank lines between an item's own blocks would keep it Markdown.
|
|
54
|
+
out += span(ctx, end(old.at(-1)), end(md));
|
|
55
|
+
const back = parseMarkdown(ctx.defs ? `${out}\n\n${ctx.defs}` : out, ctx.markdown).children[0];
|
|
56
|
+
const dom = back && domOf(back).find((d) => d.nodeType === 1);
|
|
57
|
+
return dom && bare(dom).isEqualNode(bare(el)) ? out : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// An item with no old bytes: its neighbour's marker, its blocks written fresh (a tight item's text is the one paragraph), the lines after the first at the content indent.
|
|
61
|
+
function freshItem(li, marker, loose, ctx) {
|
|
62
|
+
li = li.cloneNode(true);
|
|
63
|
+
const box = boxOf(li);
|
|
64
|
+
const head = box ? `[${box.hasAttribute('checked') ? 'x' : ' '}] ` : '';
|
|
65
|
+
const body = blocksOf(li).map((b) => lane(b, writeFresh(b, ctx))).join(loose ? '\n\n' : '\n');
|
|
66
|
+
return (head + body ? marker + head + body : marker.trimEnd()).replace(/\n(?=[^\n])/g, `\n${' '.repeat(marker.length)}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The paragraphs `blocksOf` makes of a tight item's text, which the HTML does not have: one that changes lane is its content as HTML, not a `p` that would make the item loose.
|
|
70
|
+
const MADE = new WeakSet();
|
|
71
|
+
const lane = (part, bytes) => (bytes === part.outerHTML && MADE.has(part) ? part.innerHTML : bytes);
|
|
72
|
+
|
|
73
|
+
/** An item's content as blocks: a stretch of inline content (a tight item's text) is one paragraph, the whitespace between blocks is the gap's, a task item's checkbox is its marker's. */
|
|
74
|
+
function blocksOf(li) {
|
|
75
|
+
// `li` is writeNode's copy, so it is taken apart in place.
|
|
76
|
+
const box = li.classList.contains('task-list-item') && li.querySelector('input[type="checkbox"]');
|
|
77
|
+
if (box) {
|
|
78
|
+
const after = box.nextSibling;
|
|
79
|
+
box.remove();
|
|
80
|
+
if (after?.nodeType === 3 && after.data.startsWith(' ')) after.data = after.data.slice(1);
|
|
81
|
+
}
|
|
82
|
+
const out = [], runs = [];
|
|
83
|
+
let run = null;
|
|
84
|
+
for (const n of [...li.childNodes]) {
|
|
85
|
+
if (isBlock(n)) { run = null; out.push(n); continue; }
|
|
86
|
+
// Whitespace between blocks is the gap's; whitespace that starts a stretch of text is the text's.
|
|
87
|
+
if (n.nodeType === 3 && !/\S/.test(n.data) && !run && (!n.nextSibling || isBlock(n.nextSibling))) continue;
|
|
88
|
+
if (!run) { run = document.createElement('p'); MADE.add(run); out.push(run); runs.push(run); }
|
|
89
|
+
run.append(n);
|
|
90
|
+
}
|
|
91
|
+
// The line end mdast-util-to-hast puts between a tight item's text and its sublist is the gap's, not the text's.
|
|
92
|
+
for (const r of runs) {
|
|
93
|
+
const last = r.lastChild;
|
|
94
|
+
if (last?.nodeType === 3) last.data = last.data.replace(/\n$/, '');
|
|
95
|
+
if (last?.nodeType === 3 && !last.data) last.remove();
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function item(el, md, ctx) {
|
|
101
|
+
const kids = md.children;
|
|
102
|
+
// A task item stays one: the box is the marker's, and its state is written over the old box's bytes, an `x` or `X` kept while it stays ticked.
|
|
103
|
+
const box = boxOf(el);
|
|
104
|
+
if ((md.checked != null) !== !!box) return null;
|
|
105
|
+
const ticked = box?.hasAttribute('checked');
|
|
106
|
+
const mark = (s) => (box && ticked !== md.checked ? s.replace(/\[[ xX]\]/, ticked ? '[x]' : '[ ]') : s);
|
|
107
|
+
const parts = blocksOf(el);
|
|
108
|
+
if (!kids.length) return parts.length ? null : mark(sliceOf(ctx, md));
|
|
109
|
+
if (parts.length !== kids.length) return null;
|
|
110
|
+
let out = mark(span(ctx, start(md), start(kids[0])));
|
|
111
|
+
for (const [i, part] of parts.entries()) {
|
|
112
|
+
const k = kids[i];
|
|
113
|
+
const w = writeNode(part, { text: sliceOf(ctx, k), column: k.position.start.column - 1, defs: ctx.defs, markdown: ctx.markdown }, ctx);
|
|
114
|
+
// A part no stenographer writes changes lane, at the item's content indent so it stays in the item.
|
|
115
|
+
out += (w ?? lane(part, part.outerHTML).replace(/\n/g, `\n${' '.repeat(k.position.start.column - 1)}`)) + span(ctx, end(k), i + 1 < kids.length ? start(kids[i + 1]) : end(md));
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
package/src/parse.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The plain parse (the studio's notes/markdown-plan.md, "The root parser"): BelType's four syntax extensions (frontmatter, math, directive, GFM's parts) and no belt plugin, because it is the only parse whose positions are exact (notes/unified-write-back.md); BelType's notations and figures are found on its tree afterwards, by this package's own code, which keeps every position exact. GFM's parts are registered in the order BelType's own `plugins/gfm.js` registers them, autolink literals and tables off unless asked for, as there.
|
|
2
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
3
|
+
import { frontmatter } from 'micromark-extension-frontmatter';
|
|
4
|
+
import { frontmatterFromMarkdown } from 'mdast-util-frontmatter';
|
|
5
|
+
import { math } from 'micromark-extension-math';
|
|
6
|
+
import { mathFromMarkdown } from 'mdast-util-math';
|
|
7
|
+
import { directive } from 'micromark-extension-directive';
|
|
8
|
+
import { directiveFromMarkdown } from 'mdast-util-directive';
|
|
9
|
+
import { gfmAutolinkLiteral } from 'micromark-extension-gfm-autolink-literal';
|
|
10
|
+
import { gfmAutolinkLiteralFromMarkdown } from 'mdast-util-gfm-autolink-literal';
|
|
11
|
+
import { gfmFootnote } from 'micromark-extension-gfm-footnote';
|
|
12
|
+
import { gfmFootnoteFromMarkdown } from 'mdast-util-gfm-footnote';
|
|
13
|
+
import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough';
|
|
14
|
+
import { gfmStrikethroughFromMarkdown } from 'mdast-util-gfm-strikethrough';
|
|
15
|
+
import { gfmTable } from 'micromark-extension-gfm-table';
|
|
16
|
+
import { gfmTableFromMarkdown } from 'mdast-util-gfm-table';
|
|
17
|
+
import { gfmTaskListItem } from 'micromark-extension-gfm-task-list-item';
|
|
18
|
+
import { gfmTaskListItemFromMarkdown } from 'mdast-util-gfm-task-list-item';
|
|
19
|
+
import { dialect } from './dialect.js';
|
|
20
|
+
import { figures } from './figure.js';
|
|
21
|
+
import { directives } from './directive.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{ gfm?: { autolinks?: boolean, tables?: boolean }, mark?: boolean, supersub?: boolean, figure?: boolean, directives?: Record<string, { tag?: string, className?: string, defaultTitle?: string }> }} Markdown
|
|
25
|
+
* the venue's `markdown:` options, in core's shape and under core's names: GFM's autolink literals and tables, off unless asked for (BelType's manual has both on); `==mark==`, `~sub~` and `^sup^`, and a titled image alone in its paragraph as a figure, on unless switched off; the directives' bindings over core's built-ins (the config's `directives:`, a `render` hook not run)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The mdast of a Markdown text, every node positioned in it, with BelType's notations (./dialect.js), figures (./figure.js) and directives' elements (./directive.js) found on it.
|
|
30
|
+
* @param {string} text
|
|
31
|
+
* @param {Markdown} [markdown]
|
|
32
|
+
*/
|
|
33
|
+
export function parseMarkdown(text, markdown = {}) {
|
|
34
|
+
const { autolinks = false, tables = false } = markdown.gfm ?? {};
|
|
35
|
+
const extensions = [frontmatter(), math(), directive()];
|
|
36
|
+
const mdastExtensions = [frontmatterFromMarkdown(), mathFromMarkdown(), directiveFromMarkdown()];
|
|
37
|
+
if (autolinks) { extensions.push(gfmAutolinkLiteral()); mdastExtensions.push(gfmAutolinkLiteralFromMarkdown()); }
|
|
38
|
+
extensions.push(gfmFootnote(), gfmStrikethrough({ singleTilde: false }));
|
|
39
|
+
mdastExtensions.push(gfmFootnoteFromMarkdown(), gfmStrikethroughFromMarkdown());
|
|
40
|
+
if (tables) { extensions.push(gfmTable()); mdastExtensions.push(gfmTableFromMarkdown()); }
|
|
41
|
+
extensions.push(gfmTaskListItem());
|
|
42
|
+
mdastExtensions.push(gfmTaskListItemFromMarkdown());
|
|
43
|
+
const tree = fromMarkdown(text, { extensions, mdastExtensions });
|
|
44
|
+
dialect(tree, text, markdown);
|
|
45
|
+
if (markdown.figure !== false) figures(tree);
|
|
46
|
+
directives(tree, markdown.directives);
|
|
47
|
+
return tree;
|
|
48
|
+
}
|
package/src/quote.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The blockquote (the studio's notes/markdown-round-3-orchestration.md, round 3b): a quote over its old bytes, which ./write.js's `block` asks for a `blockquote` over a blockquote. Each line's `> ` prefix is taken off (remembering it exactly; a lazy line has none) and the body parsed alone, the file's definitions after it, so the blocks are written the way a document's are: the new blocks are paired with the old by meaning (the common head and tail, the middle in order when the counts match), a pair is written over its old slice by `writeNode`, a block without a partner is written fresh, an old one without a partner goes with its gap, and the gaps are the body's. The prefixes go back line by line while the line count is unchanged; once it changed every line takes the first line's prefix (a blank one the prefix trimmed). The result is read back and must mean the element, whitespace between its blocks aside, or the quote refuses. A body holding a definition refuses (to-hast has no element for it to pair).
|
|
2
|
+
import { sliceOf, span, start, end, writeNode, writeFresh, isBlock } from './write.js';
|
|
3
|
+
import { parseMarkdown } from './parse.js';
|
|
4
|
+
import { domOf, means, bare } from './html.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A blockquote's bytes over its old slice, or null (the lane change).
|
|
8
|
+
* @param {Element} el the quote as the HTML it means
|
|
9
|
+
* @param {object} md the blockquote's mdast
|
|
10
|
+
* @param {object} ctx the stenographers' context (./write.js)
|
|
11
|
+
* @returns {string | null}
|
|
12
|
+
*/
|
|
13
|
+
export function quote(el, md, ctx) {
|
|
14
|
+
const kids = [...el.childNodes].filter((n) => n.nodeType !== 3 || /\S/.test(n.data));
|
|
15
|
+
// Blocks and comments (a comment is a block of raw HTML in the quote, written as itself).
|
|
16
|
+
if (!kids.length || kids.some((n) => n.nodeType !== 1 && n.nodeType !== 8)) return null;
|
|
17
|
+
const lines = sliceOf(ctx, md).split('\n');
|
|
18
|
+
const prefixes = lines.map((l) => /^ {0,3}> ?/.exec(l)?.[0] ?? '');
|
|
19
|
+
const body = lines.map((l, i) => l.slice(prefixes[i].length)).join('\n');
|
|
20
|
+
const tree = parseMarkdown(ctx.defs ? `${body}\n\n${ctx.defs}` : body, ctx.markdown);
|
|
21
|
+
// The file's definitions, appended after the body, are not the quote's blocks.
|
|
22
|
+
const old = tree.children.filter((k) => start(k) < body.length);
|
|
23
|
+
if (!old.length || old.some((k) => k.type === 'definition' || k.type === 'footnoteDefinition')) return null;
|
|
24
|
+
const b = { src: body };
|
|
25
|
+
const n = kids.length, m = old.length;
|
|
26
|
+
const same = (i, j) => !ctx.force && means(kids[i], old[j]);
|
|
27
|
+
let h = 0;
|
|
28
|
+
while (h < n && h < m && same(h, h)) h++;
|
|
29
|
+
let t = 0;
|
|
30
|
+
while (t < n - h && t < m - h && same(n - 1 - t, m - 1 - t)) t++;
|
|
31
|
+
const pairs = kids.map((c, i) => [c, i < h ? old[i] : i >= n - t ? old[m - n + i] : n === m ? old[i] : null]);
|
|
32
|
+
const gaps = old.slice(1).map((k, i) => span(b, end(old[i]), start(k)));
|
|
33
|
+
let out = span(b, 0, start(old[0])), gap = gaps[0] ?? '\n\n';
|
|
34
|
+
for (const [i, [c, k]] of pairs.entries()) {
|
|
35
|
+
let w;
|
|
36
|
+
if (k) {
|
|
37
|
+
// A block its old one's stenographer cannot write (it changed kind: a paragraph made a heading) is written fresh, Markdown where it can be, raw HTML where not.
|
|
38
|
+
w = writeNode(c, { text: sliceOf(b, k), column: 0, defs: ctx.defs, markdown: ctx.markdown }, ctx) ?? writeFresh(c, ctx);
|
|
39
|
+
gap = gaps[old.indexOf(k)] ?? gaps.at(-1) ?? '\n\n';
|
|
40
|
+
} else w = writeFresh(c, ctx);
|
|
41
|
+
out += w + (i + 1 < n ? gap : '');
|
|
42
|
+
}
|
|
43
|
+
out += span(b, end(old.at(-1)), body.length);
|
|
44
|
+
const outLines = out.split('\n');
|
|
45
|
+
const first = prefixes[0];
|
|
46
|
+
// A blank line never takes a lazy line's empty prefix: without its `>` it would end the quote.
|
|
47
|
+
const result = outLines.map((l, i) => (outLines.length === lines.length && (l || prefixes[i]) ? prefixes[i] : l ? first : first.trimEnd()) + l).join('\n');
|
|
48
|
+
const back = parseMarkdown(ctx.defs ? `${result}\n\n${ctx.defs}` : result, ctx.markdown).children[0];
|
|
49
|
+
const dom = back?.type === 'blockquote' && domOf(back).find((d) => d.nodeType === 1);
|
|
50
|
+
return dom && bare(dom).isEqualNode(bare(el)) ? result : null;
|
|
51
|
+
}
|
package/src/table.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The table (the studio's notes/markdown-round-3-orchestration.md, round 3b): a GFM table over its old bytes, which ./write.js's `block` asks for a `table` over a table. The header row and the divider row are kept, as is the column count and every alignment (a change of either refuses, for the lane change). The body rows pair with the old by meaning from both ends inward, as a list's items do (the middle in order when the counts match); a paired row keeps the bytes of every cell that did not change and writes the cells that did over their old width (the author's padding is kept where the new content fits, otherwise the cell is `one space, content, one space` and the row is left ragged), an inserted row copies the row beside it (its pipes, its padding), a deleted row goes with its line. A cell holding a block or a line break refuses. The result is read back as a whole and must mean the element. Whitespace between the table's elements is ignored everywhere, as an editor's serializer need not write what mdast-util-to-hast does.
|
|
2
|
+
import { isBlock, span, start, end, sliceOf, inline, fresh } from './write.js';
|
|
3
|
+
import { parseMarkdown } from './parse.js';
|
|
4
|
+
import { domOf, bare } from './html.js';
|
|
5
|
+
|
|
6
|
+
const rowsOf = (table) => [...table.querySelectorAll('tr')];
|
|
7
|
+
const cellsOf = (tr) => [...tr.children];
|
|
8
|
+
|
|
9
|
+
// A row's cells as the bytes hold them: mdast gives a cell its leading pipe and, for the last, its trailing one, so a cell is `lead`, `seg` (the padding and the content) and, last, `trail`.
|
|
10
|
+
function cutRow(row, ctx) {
|
|
11
|
+
return row.children.map((cell, i) => {
|
|
12
|
+
const raw = sliceOf(ctx, cell);
|
|
13
|
+
const lead = raw.startsWith('|') ? '|' : '';
|
|
14
|
+
const last = i === row.children.length - 1;
|
|
15
|
+
const trail = last && raw.length > lead.length && /(^|[^\\])(\\\\)*\|$/.test(raw) ? '|' : '';
|
|
16
|
+
return { lead, seg: raw.slice(lead.length, raw.length - trail.length), trail };
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// `content` in a cell of `old`'s width (`open`: the last cell of a row with no closing pipe): the old leading padding and a trailing space where it had one, filled up to the old width; if it does not fit, one space each side.
|
|
21
|
+
function fit(old, content, open) {
|
|
22
|
+
const blank = !old.trim();
|
|
23
|
+
const lead = blank ? Math.min(1, old.length >> 1) : /^ */.exec(old)[0].length;
|
|
24
|
+
const trail = blank ? lead : /\s$/.test(old) ? 1 : 0;
|
|
25
|
+
// A last cell with no closing pipe has no width to fill.
|
|
26
|
+
if (open) return ' '.repeat(lead) + content;
|
|
27
|
+
return lead + content.length + trail <= old.length ? ' '.repeat(lead) + content.padEnd(old.length - lead) : ` ${content} `;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A cell's content as Markdown, `|` escaped (typed or not, one that is not already is a cell's end); null when it holds what a cell cannot.
|
|
31
|
+
function cellBytes(td, old, ctx) {
|
|
32
|
+
if ([...td.querySelectorAll('*')].some((e) => isBlock(e) || e.localName === 'br')) return null;
|
|
33
|
+
const nodes = [...td.childNodes];
|
|
34
|
+
const out = old?.children.length ? inline(nodes, old.children, ctx) : nodes.map(fresh).join('');
|
|
35
|
+
return /\n/.test(out) ? null : out.replace(/(\\*)\|/g, (m, b) => (b.length % 2 ? m : `${b}\\|`));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A table's bytes over its old slice, or null (the lane change).
|
|
40
|
+
* @param {Element} el the table as the HTML it means
|
|
41
|
+
* @param {object} md the table's mdast
|
|
42
|
+
* @param {object} ctx the stenographers' context (./write.js)
|
|
43
|
+
* @returns {string | null}
|
|
44
|
+
*/
|
|
45
|
+
export function table(el, md, ctx) {
|
|
46
|
+
const now = bare(el);
|
|
47
|
+
const was = bare(domOf(md).find((n) => n.nodeType === 1));
|
|
48
|
+
const rows = rowsOf(now), oldRows = rowsOf(was);
|
|
49
|
+
const old = md.children;
|
|
50
|
+
const cols = md.align.length;
|
|
51
|
+
// The header is the first row, alone in a thead; every cell is as wide as the columns and aligned as the divider says.
|
|
52
|
+
if (now.querySelectorAll('thead tr').length !== 1 || rows[0].parentNode.localName !== 'thead' || rows.slice(1).some((tr) => tr.parentNode.localName === 'thead')) return null;
|
|
53
|
+
if (cellsOf(rows[0]).length !== cols) return null;
|
|
54
|
+
for (const tr of rows) {
|
|
55
|
+
for (const [i, c] of cellsOf(tr).entries()) if (i >= cols || (c.getAttribute('align') ?? null) !== (md.align[i] ?? null)) return null;
|
|
56
|
+
}
|
|
57
|
+
const n = rows.length - 1, m = old.length - 1;
|
|
58
|
+
const same = (a, b) => !ctx.force && bare(rows[a]).isEqualNode(bare(oldRows[b]));
|
|
59
|
+
let h = 1;
|
|
60
|
+
while (h <= n && h <= m && same(h, h)) h++;
|
|
61
|
+
let t = 0;
|
|
62
|
+
while (t < n - h + 1 && t < m - h + 1 && same(n - t, m - t)) t++;
|
|
63
|
+
// Each new row with its old partner or null; the header's is the old header. A middle that does not pair has none, and its old rows are out.
|
|
64
|
+
const pairs = rows.map((tr, i) => [tr, i < h ? old[i] : i > n - t ? old[m - n + i] : n === m ? old[i] : null]);
|
|
65
|
+
const divider = old.length > 1 ? span(ctx, end(old[0]), start(old[1])) : `${span(ctx, end(old[0]), end(md))}\n`;
|
|
66
|
+
const gaps = old.slice(2).map((k, i) => span(ctx, end(old[i + 1]), start(k)));
|
|
67
|
+
const defaultGap = `\n${/\n( *)/.exec(divider)?.[1] ?? ''}`;
|
|
68
|
+
const written = [];
|
|
69
|
+
for (const [i, [tr, k]] of pairs.entries()) {
|
|
70
|
+
const cells = cellsOf(tr);
|
|
71
|
+
let bytes;
|
|
72
|
+
if (k) {
|
|
73
|
+
// A row longer than the columns has cells to-hast leaves out; they keep their bytes.
|
|
74
|
+
const oldCells = cellsOf(oldRows[old.indexOf(k)]);
|
|
75
|
+
if (cells.length !== oldCells.length) return null;
|
|
76
|
+
const cut = cutRow(k, ctx);
|
|
77
|
+
bytes = '';
|
|
78
|
+
for (const [j, { lead, seg, trail }] of cut.entries()) {
|
|
79
|
+
const c = cells[j];
|
|
80
|
+
let s = seg;
|
|
81
|
+
if (c && (ctx.force || !bare(c).isEqualNode(bare(oldCells[j])))) {
|
|
82
|
+
const content = cellBytes(c, k.children[j], ctx);
|
|
83
|
+
if (content == null) return null;
|
|
84
|
+
s = fit(seg, content, j === cut.length - 1 && !trail);
|
|
85
|
+
}
|
|
86
|
+
bytes += lead + s + trail;
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
// A row with no old bytes copies the one above it (the one below when it is the first body row) down to the pipes and the padding.
|
|
90
|
+
const near = pairs.slice(0, i).reverse().find(([, o]) => o && o !== old[0]) ?? pairs.slice(i + 1).find(([, o]) => o);
|
|
91
|
+
let cut = near && near[1].children.length === cells.length ? cutRow(near[1], ctx) : null;
|
|
92
|
+
cut ??= cutRow(old[0], ctx);
|
|
93
|
+
if (cells.length !== cols) return null;
|
|
94
|
+
bytes = '';
|
|
95
|
+
for (const [j, c] of cells.entries()) {
|
|
96
|
+
const content = cellBytes(c, null, ctx);
|
|
97
|
+
if (content == null) return null;
|
|
98
|
+
bytes += cut[j].lead + fit(cut[j].seg, content, j === cut.length - 1 && !cut[j].trail) + cut[j].trail;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
written.push([bytes, k]);
|
|
102
|
+
}
|
|
103
|
+
let out = span(ctx, start(md), start(old[0])) + written[0][0];
|
|
104
|
+
if (written.length === 1) out += divider.trimEnd();
|
|
105
|
+
else {
|
|
106
|
+
out += divider;
|
|
107
|
+
for (const [i, [bytes, k]] of written.entries()) {
|
|
108
|
+
if (!i) continue;
|
|
109
|
+
out += bytes + (i < written.length - 1 ? (k && gaps[old.indexOf(k) - 1]) || gaps.at(-1) || defaultGap : '');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (old.length > 1) out += span(ctx, end(old.at(-1)), end(md));
|
|
113
|
+
// Read back as a whole.
|
|
114
|
+
const back = parseMarkdown(ctx.defs ? `${out}\n\n${ctx.defs}` : out, ctx.markdown).children[0];
|
|
115
|
+
const dom = back?.type === 'table' && end(back) === out.length && domOf(back).find((d) => d.nodeType === 1);
|
|
116
|
+
return dom && bare(dom).isEqualNode(now) ? out : null;
|
|
117
|
+
}
|