rapier-html 1.2.31 → 1.2.33
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/BUILD.json +4 -4
- package/README.md +9 -9
- package/dist/ledger/carried.mjs +14 -2
- package/dist/ledger/document-settings.mjs +429 -0
- package/dist/ledger/frontmatter.mjs +29 -0
- package/dist/ledger/md-marks.mjs +731 -0
- package/dist/ledger/md-source.mjs +153 -0
- package/dist/ledger/template-page.mjs +2 -4
- package/dist/rapier.html +51 -51
- package/dist/wrap.mjs +8 -2
- package/package.json +1 -1
package/BUILD.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "rapier-html",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.33",
|
|
4
4
|
"path": "dist/rapier.html",
|
|
5
|
-
"bytes":
|
|
6
|
-
"sha256": "
|
|
7
|
-
"builtAt": "2026-10-
|
|
5
|
+
"bytes": 5764398,
|
|
6
|
+
"sha256": "5f1d5fc07e6d302c33f0ac297ea2b21e179d5b458353e108a5da7f83fd04e889",
|
|
7
|
+
"builtAt": "2026-10-22T21:58:20.465Z",
|
|
8
8
|
"node": "v22.23.3",
|
|
9
9
|
"canonical": true,
|
|
10
10
|
"packing": "zopfli",
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Turn Markdown into one offline HTML file that contains the full Rapier editor. It opens in any browser, on a phone or
|
|
4
4
|
computer, with no account. People read, edit, draw and paint; agents hand over documents, show what changed and get
|
|
5
|
-
the edited copy back. Start with `npx rapier-html@1.2.
|
|
5
|
+
the edited copy back. Start with `npx rapier-html@1.2.33 notes.md`.
|
|
6
6
|
|
|
7
7
|
Use it for a plan to rearrange, a guide to annotate, a draft to keep writing, or a revision to review. The file works
|
|
8
8
|
without any hosted workspace.
|
|
@@ -17,14 +17,14 @@ without any hosted workspace.
|
|
|
17
17
|
Needs Node 22 or newer.
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
|
-
npx -- rapier-html@1.2.
|
|
21
|
-
npx -- rapier-html@1.2.
|
|
22
|
-
npx -- rapier-html@1.2.
|
|
23
|
-
npx -- rapier-html@1.2.
|
|
24
|
-
npx -- rapier-html@1.2.
|
|
25
|
-
npx -- rapier-html@1.2.
|
|
26
|
-
npx -- rapier-html@1.2.
|
|
27
|
-
npm install rapier-html@1.2.
|
|
20
|
+
npx -- rapier-html@1.2.33 notes.md # the editor, notes.md inside it, written beside the file
|
|
21
|
+
npx -- rapier-html@1.2.33 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
|
|
22
|
+
npx -- rapier-html@1.2.33 notes.md --include draw,math # carries Draw and Math (default: what the document needs)
|
|
23
|
+
npx -- rapier-html@1.2.33 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
|
|
24
|
+
npx -- rapier-html@1.2.33 revised.md --compare original.md # opens showing what changed
|
|
25
|
+
npx -- rapier-html@1.2.33 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
|
|
26
|
+
npx -- rapier-html@1.2.33 notes.md out.html # a named output
|
|
27
|
+
npm install rapier-html@1.2.33 # as a library: writePage, unwrap
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
The first `--` is for npm, so `--help` reaches the command. It never overwrites a file.
|
package/dist/ledger/carried.mjs
CHANGED
|
@@ -50,15 +50,27 @@ const OPEN = '\n\n<!-- rapier-carried/1\n', CLOSE = '\n-->\n';
|
|
|
50
50
|
export function documentTrailer(text, options) {
|
|
51
51
|
const parts = validateParts(text, options);
|
|
52
52
|
if (!parts.ledger && !parts.authorship) return '';
|
|
53
|
-
const json = JSON.stringify(parts).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
|
53
|
+
const json = JSON.stringify(parts).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/-/g, '\\u002d');
|
|
54
54
|
return OPEN + json + CLOSE;
|
|
55
55
|
}
|
|
56
56
|
export function writeDocument(text, options) {
|
|
57
57
|
return text + documentTrailer(text, options);
|
|
58
58
|
}
|
|
59
|
+
function unfinishedTrailer(source) {
|
|
60
|
+
const at = source.lastIndexOf(OPEN);
|
|
61
|
+
if (at < 0) return false;
|
|
62
|
+
let fence = null;
|
|
63
|
+
for (const line of source.slice(0, at).split('\n')) {
|
|
64
|
+
const found = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
65
|
+
if (!found) continue;
|
|
66
|
+
if (!fence) fence = found[1];
|
|
67
|
+
else if (found[1][0] === fence[0] && found[1].length >= fence.length && !line.slice(found[0].length).trim()) fence = null;
|
|
68
|
+
}
|
|
69
|
+
return !fence;
|
|
70
|
+
}
|
|
59
71
|
export function readDocument(source) {
|
|
60
72
|
if (!source.endsWith(CLOSE)) {
|
|
61
|
-
if (source
|
|
73
|
+
if (unfinishedTrailer(source)) throw fault('unfinished document metadata');
|
|
62
74
|
return {text: source, ledger: null, authorship: null};
|
|
63
75
|
}
|
|
64
76
|
const at = source.lastIndexOf(OPEN);
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {frontMatterFrame, frontMatterLine} from './frontmatter.mjs';
|
|
3
|
+
import {normalizeFontFamily, fontFamilyCss, FONT_STACKS} from './md-marks.mjs';
|
|
4
|
+
|
|
5
|
+
const NUMBER = /^(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
6
|
+
export const DOCUMENT_LEADING = 1.75 / 1.1;
|
|
7
|
+
|
|
8
|
+
export const SPACING_TYPES = Object.freeze(['paragraph', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
9
|
+
'list', 'item', 'table', 'picture', 'drawing', 'quote', 'code', 'rule', 'footnote', 'toc']);
|
|
10
|
+
export const SPACING_PRESETS = Object.freeze({compact: .7, standard: 1, roomy: 1.5});
|
|
11
|
+
const SPACING_SELECTORS = Object.freeze({paragraph: 'p', h1: 'h1', h2: 'h2', h3: 'h3', h4: 'h4', h5: 'h5', h6: 'h6',
|
|
12
|
+
list: ':is(ul,ol)', item: 'li', table: ':is(table,.table-scroll-wrap)', picture: 'p[data-md-picture]',
|
|
13
|
+
drawing: ':is(.diagram-block,p[data-md-picture]:has(img[data-md-svg],img[src^="data:image/svg+xml"]))', quote: 'blockquote',
|
|
14
|
+
code: 'pre', rule: 'hr:not(.footnotes-sep):not([data-md-break])', footnote: '.footnotes', toc: 'ul.rapier-jump-list'});
|
|
15
|
+
const HEADING_LINE = [1.75 / 2.2, 1.5 / 1.75, 1.2 / 1.5, 1 / 1.25, .9 / 1.1, .82];
|
|
16
|
+
|
|
17
|
+
export function normalizeDocumentSpacing(value) {
|
|
18
|
+
if (typeof value === 'string') {
|
|
19
|
+
if (Object.hasOwn(SPACING_PRESETS, value)) value = {preset: value};
|
|
20
|
+
else { try { value = JSON.parse(value); } catch (_) { return null; } }
|
|
21
|
+
}
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
23
|
+
const result = {};
|
|
24
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
25
|
+
if (key === 'preset') {
|
|
26
|
+
if (!Object.hasOwn(SPACING_PRESETS, entry)) return null;
|
|
27
|
+
result.preset = entry; continue;
|
|
28
|
+
}
|
|
29
|
+
if (!SPACING_TYPES.includes(key) || !entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
|
30
|
+
const row = {};
|
|
31
|
+
for (const [field, number] of Object.entries(entry)) {
|
|
32
|
+
if (!['before', 'after', 'line'].includes(field) || typeof number !== 'number' || !Number.isFinite(number) ||
|
|
33
|
+
number < (field === 'line' ? .5 : 0) || number > (field === 'line' ? 4 : 720)) return null;
|
|
34
|
+
row[field] = number;
|
|
35
|
+
}
|
|
36
|
+
result[key] = Object.freeze(row);
|
|
37
|
+
}
|
|
38
|
+
return Object.freeze(result);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function documentSpacing(settings, kind = 'paragraph') {
|
|
42
|
+
const type = SPACING_TYPES.includes(kind) ? kind : 'paragraph', spacing = settings?.spacing;
|
|
43
|
+
const preset = spacing?.preset || 'standard', scale = SPACING_PRESETS[preset];
|
|
44
|
+
const size = (settings?.fontsize?.unit || 1.1) / 1.1;
|
|
45
|
+
const heading = /^h([1-6])$/.exec(type), leading = settings?.linestretch?.factor ??
|
|
46
|
+
(preset === 'compact' ? 1.3 : preset === 'roomy' ? 1.85 : DOCUMENT_LEADING);
|
|
47
|
+
const before = heading ? 15 : type === 'footnote' ? 9 : 0;
|
|
48
|
+
const after = heading ? 3 : type === 'paragraph' ? 6 : type === 'item' ? 3 : type === 'footnote' ? 0 : 9;
|
|
49
|
+
const line = heading ? leading * HEADING_LINE[Number(heading[1]) - 1] : type === 'code' ? leading * .75 / .7386 : leading;
|
|
50
|
+
if (settings?.word) return {before: (heading ? 12 : type === 'footnote' ? 9 : 0) * scale,
|
|
51
|
+
after: (heading ? 6 : type === 'paragraph' ? 8 : type === 'item' || type === 'footnote' ? 0 : 9) * scale,
|
|
52
|
+
line: leading, ...spacing?.[type]};
|
|
53
|
+
return {before: before * scale * size, after: after * scale * size, line, ...spacing?.[type]};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function wordDocumentSettings(settings) {
|
|
57
|
+
return {...settings, word: true, fontsize: settings?.fontsize || fontSize('11pt'),
|
|
58
|
+
mainfont: settings?.mainfont || documentFont('Calibri'), sansfont: settings?.sansfont || documentFont('Calibri'),
|
|
59
|
+
linestretch: settings?.linestretch || leading('1.08')};
|
|
60
|
+
}
|
|
61
|
+
export function documentHeadingSize(settings, level) {
|
|
62
|
+
const body = (settings?.fontsize?.unit || 1.1) * 12;
|
|
63
|
+
return body * (settings?.word ? [16/11,13/11,12/11,1,1,1] : [2.2,1.75,1.5,1.25,1.1,1])[level - 1];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function documentSpacingCss(settings, scope = '.md-render', defaults = false) {
|
|
67
|
+
if (!defaults && !settings?.spacing && !settings?.linestretch && !settings?.fontsize) return '';
|
|
68
|
+
const length = points => 'calc(' + Number((points / 12).toFixed(8)) + ' * var(--md-unit))';
|
|
69
|
+
let css = '';
|
|
70
|
+
for (const type of SPACING_TYPES) {
|
|
71
|
+
const row = documentSpacing(settings, type), selector = scope + ' ' + SPACING_SELECTORS[type];
|
|
72
|
+
css += selector + '{margin-block:' + length(row.before) + ' ' + length(row.after) + ';line-height:' + row.line + '}';
|
|
73
|
+
if (settings?.word && /^h[1-6]$/.test(type)) css += selector + '{font-size:' + length(documentHeadingSize(settings, Number(type[1]))) + '}';
|
|
74
|
+
}
|
|
75
|
+
css += scope + '>:first-child,' + scope + ' :is(blockquote,li,td,th,dd,.callout,details)>:first-child:not(input){margin-top:0}' +
|
|
76
|
+
scope + ' :is(li,td,th,blockquote,details,dd)>:last-child:not(input){margin-bottom:0}' +
|
|
77
|
+
scope + ' :is(p,h1,h2,h3,h4,h5,h6)[data-md-before]{margin-top:var(--md-before)}' +
|
|
78
|
+
scope + ' :is(p,h1,h2,h3,h4,h5,h6)[data-md-after]{margin-bottom:var(--md-after)}' +
|
|
79
|
+
scope + ' li:last-child{margin-bottom:0}' + scope + ' li>:is(ul,ol){margin-bottom:0}' +
|
|
80
|
+
scope + ' .table-scroll-wrap>table{margin-block:0}' +
|
|
81
|
+
scope + ' pre{font-size:calc(var(--md-text-body) * .7386)}' +
|
|
82
|
+
scope + ' pre code{line-height:inherit}' + scope + ' .footnotes{padding-top:0}' +
|
|
83
|
+
scope + ' .footnotes-list{margin-block:0}' + scope + ' blockquote:not(.callout){padding-block:0}';
|
|
84
|
+
const item = documentSpacing(settings, 'item');
|
|
85
|
+
css += scope + ' li{--md-item-gap:' + length(Math.max(item.before, item.after)) + '}';
|
|
86
|
+
const list = documentSpacing(settings, 'list'), heading = documentSpacing(settings, 'h2');
|
|
87
|
+
css += scope + '{--md-list-heading-before:' + length(Math.max(list.after, heading.before)) + ';--md-list-heading-after:' + length(Math.max(heading.after, list.before)) + '}';
|
|
88
|
+
css += scope + ' ul.rapier-jump-list>li{--md-jump-pad:0px;margin-block:' + length(item.before) + ' ' + length(item.after) + '}' +
|
|
89
|
+
scope + ' ul.rapier-jump-list>li:last-child{margin-bottom:0}';
|
|
90
|
+
for (const [type, selector] of [['item','li>p'], ['table',':is(td,th)>p'], ['quote','blockquote>p'], ['footnote','.footnotes p'], ['toc','.rapier-jump-list li']])
|
|
91
|
+
css += scope + ' ' + selector + '{line-height:' + documentSpacing(settings, type).line + '}';
|
|
92
|
+
return css;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function measure(value, positive = false) {
|
|
96
|
+
const text = unquote(value);
|
|
97
|
+
const number = NUMBER.test(text) ? Number(text) : NaN;
|
|
98
|
+
return Number.isFinite(number) && number >= 0 && (!positive || number > 0) ? number : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fontSize(value) {
|
|
102
|
+
const match = /^(\d+(?:\.\d*)?|\.\d+)\s*(pt|px)$/i.exec(unquote(value));
|
|
103
|
+
if (!match) return null;
|
|
104
|
+
const number = measure(match[1], true), unit = match[2].toLowerCase();
|
|
105
|
+
if (number === null) return null;
|
|
106
|
+
const points = unit === 'pt' ? number : number * .75;
|
|
107
|
+
return Object.freeze({step: number + unit, unit: points / 12,
|
|
108
|
+
css: 'calc(' + number + ' / ' + (unit === 'pt' ? 12 : 16) + ' * var(--md-unit))', halfPoints: points * 2});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function leading(value) {
|
|
112
|
+
const factor = measure(value, true);
|
|
113
|
+
return factor === null ? null : Object.freeze({step: String(factor), factor,
|
|
114
|
+
css: 'calc(var(--md-text-body) * ' + factor + ')', line: Math.round(factor * 240)});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const PAPERS = Object.freeze({
|
|
118
|
+
letter: Object.freeze({name: 'letter', width: 12240, height: 15840, css: 'letter'}),
|
|
119
|
+
a4: Object.freeze({name: 'a4', width: 11906, height: 16838, css: 'A4'}),
|
|
120
|
+
a5: Object.freeze({name: 'a5', width: 8391, height: 11906, css: 'A5'}),
|
|
121
|
+
legal: Object.freeze({name: 'legal', width: 12240, height: 20160, css: 'legal'}),
|
|
122
|
+
tabloid: Object.freeze({name: 'tabloid', width: 15840, height: 24480, css: '11in 17in'}),
|
|
123
|
+
a3: Object.freeze({name: 'a3', width: 16838, height: 23811, css: 'A3'}),
|
|
124
|
+
});
|
|
125
|
+
function paperOf(value) {
|
|
126
|
+
const name = String(value || '').toLowerCase(), named = Object.hasOwn(PAPERS, name) ? PAPERS[name] : null;
|
|
127
|
+
if (named) return named;
|
|
128
|
+
const pixels = /^(\d{3,5})x(\d{3,5})px$/.exec(name), width = Number(pixels?.[1]), height = Number(pixels?.[2]);
|
|
129
|
+
return pixels && width <= 10000 && height <= 10000 ? Object.freeze({name, width: width * 15, height: height * 15, css: width + 'px ' + height + 'px'}) : null;
|
|
130
|
+
}
|
|
131
|
+
export const pageSizeOf = settings => settings?.papersize || PAPERS.letter;
|
|
132
|
+
export const PAGE_SIZES = Object.freeze(['letter', 'legal', 'tabloid', 'a3', 'a4', 'a5', '1080x1080px', '1080x1350px', '1920x1080px', '1200x630px']);
|
|
133
|
+
const EMPTY = Object.freeze({
|
|
134
|
+
title: null, subtitle: null, lang: null, fontsize: null, mainfont: null, sansfont: null, monofont: null, defaultfont: null, linestretch: null,
|
|
135
|
+
papersize: null, geometry: null, pagestyle: null, spacing: null, template: null,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
function unquote(value) {
|
|
139
|
+
const text = String(value == null ? '' : value).trim();
|
|
140
|
+
return text[0] === '"' || text[0] === "'" ? quotedValue(text) ?? text : text;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function marginOf(value) {
|
|
144
|
+
const lengths = new Map();
|
|
145
|
+
for (const part of unquote(value).split(',')) {
|
|
146
|
+
const match = /^\s*(margin|top|right|bottom|left)\s*=\s*(\d+(?:\.\d+)?)(cm|mm|in|pt)\s*$/i.exec(part);
|
|
147
|
+
if (!match) return null;
|
|
148
|
+
const key = match[1].toLowerCase(), number = Number(match[2]), unit = match[3].toLowerCase();
|
|
149
|
+
const twips = Math.round(unit === 'in' ? number * 1440 : unit === 'pt' ? number * 20 : unit === 'cm' ? number * 1440 / 2.54 : number * 1440 / 25.4);
|
|
150
|
+
if (lengths.has(key) || !Number.isSafeInteger(twips) || twips < 0 || twips > 1440 * 6) return null;
|
|
151
|
+
lengths.set(key, {css: match[2] + unit, twips});
|
|
152
|
+
}
|
|
153
|
+
const sides = ['top', 'right', 'bottom', 'left'].map(key => lengths.get(key) || lengths.get('margin'));
|
|
154
|
+
if (sides.some(side => !side)) return null;
|
|
155
|
+
const [top, right, bottom, left] = sides.map(side => side.twips);
|
|
156
|
+
const uniform = sides.every(side => side.twips === top);
|
|
157
|
+
return Object.freeze({margin: uniform ? sides[0].css : sides.map(side => side.css).join(' '),
|
|
158
|
+
twips: uniform ? top : null, top, right, bottom, left});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function quotedEnd(text, start = 0, limit = text.length) {
|
|
162
|
+
const quote = text[start];
|
|
163
|
+
for (let at = start + 1; at < limit; at++) {
|
|
164
|
+
if (text[at] === '\\' && quote === '"') at++;
|
|
165
|
+
else if (text[at] === quote) {
|
|
166
|
+
if (quote === "'" && text[at + 1] === "'") at++;
|
|
167
|
+
else return at + 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function quotedValue(text) {
|
|
174
|
+
if (quotedEnd(text) !== text.length) return null;
|
|
175
|
+
if (text[0] === "'") return text.slice(1, -1).replace(/''/g, "'");
|
|
176
|
+
const escapes = {'0': '\0', a: '\x07', b: '\b', t: '\t', n: '\n', v: '\v', f: '\f', r: '\r', e: '\x1b', ' ': ' ', '"': '"', '/': '/', '\\': '\\', N: '\x85', _: '\xa0', L: '\u2028', P: '\u2029'};
|
|
177
|
+
let value = '';
|
|
178
|
+
for (let at = 1; at < text.length - 1; at++) {
|
|
179
|
+
if (text[at] !== '\\') { value += text[at]; continue; }
|
|
180
|
+
const code = text[++at], width = code === 'x' ? 2 : code === 'u' ? 4 : code === 'U' ? 8 : 0;
|
|
181
|
+
if (width) {
|
|
182
|
+
const digits = text.slice(at + 1, at + width + 1), point = Number.parseInt(digits, 16);
|
|
183
|
+
if (digits.length !== width || !/^[\da-f]+$/i.test(digits) || point > 0x10ffff || point >= 0xd800 && point <= 0xdfff) return null;
|
|
184
|
+
value += String.fromCodePoint(point); at += width;
|
|
185
|
+
} else if (Object.hasOwn(escapes, code)) value += escapes[code];
|
|
186
|
+
else return null;
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function keyOf(text) {
|
|
192
|
+
return (text[0] === '"' || text[0] === "'" ? quotedValue(text) : text)?.toLowerCase() ?? null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function flowEnd(text, start, limit = text.length) {
|
|
196
|
+
const stack = [text[start] === '{' ? '}' : ']'];
|
|
197
|
+
for (let at = start + 1; at < limit; at++) {
|
|
198
|
+
const char = text[at];
|
|
199
|
+
if ((char === '"' || char === "'") && /[\s:[{,]/.test(text[at - 1])) {
|
|
200
|
+
const end = quotedEnd(text, at, limit);
|
|
201
|
+
if (end === null) return null;
|
|
202
|
+
at = end - 1;
|
|
203
|
+
} else if (char === '#' && /\s/.test(text[at - 1])) {
|
|
204
|
+
while (at < limit && text[at] !== '\r' && text[at] !== '\n') at++;
|
|
205
|
+
} else if (char === '[' || char === '{') stack.push(char === '{' ? '}' : ']');
|
|
206
|
+
else if (char === ']' || char === '}') {
|
|
207
|
+
if (stack.pop() !== char) return null;
|
|
208
|
+
if (!stack.length) return at + 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function fieldLine(line, indented = false) {
|
|
215
|
+
const indent = /^ */.exec(line.text)[0].length;
|
|
216
|
+
if (indented ? !indent : !!indent) return null;
|
|
217
|
+
let end = indent, key;
|
|
218
|
+
if (line.text[end] === '"' || line.text[end] === "'") {
|
|
219
|
+
end = quotedEnd(line.text, end);
|
|
220
|
+
if (end === null) return null;
|
|
221
|
+
key = keyOf(line.text.slice(indent, end));
|
|
222
|
+
while (/[ \t]/.test(line.text[end] || 'x')) end++;
|
|
223
|
+
if (line.text[end] !== ':') return null;
|
|
224
|
+
} else {
|
|
225
|
+
end = line.text.indexOf(':', indent);
|
|
226
|
+
if (end < 0 || end + 1 < line.text.length && !/[\s\[\]{},]/.test(line.text[end + 1])) return null;
|
|
227
|
+
const raw = line.text.slice(indent, end).trimEnd();
|
|
228
|
+
if (!raw || /^[?\[\]{},&*!|>]/.test(raw) || /\s#/.test(raw)) return null;
|
|
229
|
+
key = keyOf(raw);
|
|
230
|
+
}
|
|
231
|
+
let start = line.start + end + 1;
|
|
232
|
+
while (start < line.end && /[ \t]/.test(line.text[start - line.start])) start++;
|
|
233
|
+
return {key, start, end: start, line, indent, children: [], opaque: key === null, invalid: false};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function valueSpan(source, field, limit) {
|
|
237
|
+
let at = field.start;
|
|
238
|
+
const first = source[at];
|
|
239
|
+
if (first === '&' || first === '*' || first === '!') {
|
|
240
|
+
field.opaque = true;
|
|
241
|
+
while (at < field.line.end && /[&*!]/.test(source[at])) {
|
|
242
|
+
while (at < field.line.end && !/\s/.test(source[at])) at++;
|
|
243
|
+
while (at < field.line.end && /[ \t]/.test(source[at])) at++;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const quoted = source[at] === '"' || source[at] === "'", flow = source[at] === '[' || source[at] === '{';
|
|
247
|
+
let end;
|
|
248
|
+
if (quoted || flow) {
|
|
249
|
+
end = quoted ? quotedEnd(source, at, limit) : flowEnd(source, at, limit);
|
|
250
|
+
if (end === null) { field.invalid = true; field.end = field.line.end; return field; }
|
|
251
|
+
const tailLine = frontMatterLine(source, end);
|
|
252
|
+
if (!/^[ \t]*(?:#.*)?$/.test(source.slice(end, Math.min(limit, tailLine.end)))) field.invalid = true;
|
|
253
|
+
field.multiline = end > field.line.end;
|
|
254
|
+
} else {
|
|
255
|
+
end = field.line.end;
|
|
256
|
+
for (let i = at; i < end; i++) if (source[i] === '#' && (i === at || /\s/.test(source[i - 1]))) { end = i; break; }
|
|
257
|
+
while (end > at && /[ \t]/.test(source[end - 1])) end--;
|
|
258
|
+
}
|
|
259
|
+
field.end = end;
|
|
260
|
+
field.block = /^[|>][+\-1-9]*$/.test(source.slice(at, field.end));
|
|
261
|
+
return field;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function mapFields(source, lines, indent) {
|
|
265
|
+
const fields = [];
|
|
266
|
+
let invalid = false, current = null;
|
|
267
|
+
const limit = lines.at(-1)?.end ?? 0;
|
|
268
|
+
for (const line of lines) {
|
|
269
|
+
if (current?.multiline && line.start < current.end) continue;
|
|
270
|
+
if (!line.text.trim() || /^\s*#/.test(line.text)) continue;
|
|
271
|
+
const ownIndent = /^ */.exec(line.text)[0].length;
|
|
272
|
+
if (ownIndent > indent && current) { current.children.push(line); continue; }
|
|
273
|
+
if (ownIndent !== indent) { invalid = true; continue; }
|
|
274
|
+
const field = fieldLine(line, indent > 0);
|
|
275
|
+
if (!field) { invalid = true; continue; }
|
|
276
|
+
current = valueSpan(source, field, limit);
|
|
277
|
+
if (field.invalid || field.key === null) invalid = true;
|
|
278
|
+
fields.push(field);
|
|
279
|
+
}
|
|
280
|
+
return {fields, invalid};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function structureIsClosed(source, lines) {
|
|
284
|
+
let blockIndent = null, through = 0;
|
|
285
|
+
const limit = lines.at(-1)?.end ?? 0;
|
|
286
|
+
for (const line of lines) {
|
|
287
|
+
if (line.start < through || !line.text.trim() || /^\s*#/.test(line.text)) continue;
|
|
288
|
+
const indent = /^ */.exec(line.text)[0].length;
|
|
289
|
+
if (blockIndent !== null && indent > blockIndent) continue;
|
|
290
|
+
blockIndent = null;
|
|
291
|
+
let offset = indent;
|
|
292
|
+
while (/^-\s/.test(line.text.slice(offset))) offset += /^-[ \t]*/.exec(line.text.slice(offset))[0].length;
|
|
293
|
+
const content = {...line, start: line.start + offset, text: line.text.slice(offset)};
|
|
294
|
+
const field = fieldLine(content) || {start: content.start, line: content, children: []};
|
|
295
|
+
const span = valueSpan(source, field, limit);
|
|
296
|
+
if (span.invalid || field.key === null) return false;
|
|
297
|
+
if (span.block) blockIndent = indent;
|
|
298
|
+
if (span.multiline) through = span.end;
|
|
299
|
+
}
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function fieldsOf(source) {
|
|
304
|
+
const frame = frontMatterFrame(source), lines = [];
|
|
305
|
+
if (frame.closing) for (let at = frame.opening.next; at < frame.closing.start;) {
|
|
306
|
+
const line = frontMatterLine(source, at); lines.push(line); at = line.next;
|
|
307
|
+
}
|
|
308
|
+
const parsed = mapFields(source, lines, 0);
|
|
309
|
+
return {frame, fields: parsed.fields, invalid: parsed.invalid || !structureIsClosed(source, lines)};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function documentFont(value) {
|
|
313
|
+
const family = normalizeFontFamily(value);
|
|
314
|
+
if (!family) return null;
|
|
315
|
+
const named = Object.hasOwn(FONT_STACKS, family.toLowerCase()) ? FONT_STACKS[family.toLowerCase()] : null;
|
|
316
|
+
return Object.freeze(named ? {...named, family} : {family, stack: 'named', css: fontFamilyCss(family), word: family});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function writeDocumentFontSetting(markdown, key, value) {
|
|
320
|
+
if (!['mainfont', 'sansfont', 'monofont', 'defaultfont'].includes(key)) throw new TypeError('font: unknown document key');
|
|
321
|
+
const family = value == null ? null : normalizeFontFamily(value);
|
|
322
|
+
if (value != null && !family) throw new TypeError('font: use one family name');
|
|
323
|
+
return updateDocumentSettings(markdown, {[key]: family});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function languageTag(value) {
|
|
327
|
+
if (typeof value !== 'string' || value.length > 128 || !/^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/i.test(value)) return null;
|
|
328
|
+
try { return Intl.getCanonicalLocales(value)[0] || null; }
|
|
329
|
+
catch (_) { return null; }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function documentLanguage(settings, device = globalThis.navigator?.language) {
|
|
333
|
+
return languageTag(settings?.lang) || languageTag(device) || 'en';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function readDocumentSettings(markdown) {
|
|
337
|
+
const source = String(markdown ?? ''), {frame, fields} = fieldsOf(source);
|
|
338
|
+
if (!frame.closing) return EMPTY;
|
|
339
|
+
const field = name => { const matches = fields.filter(item => item.key === name); return matches.length === 1 ? matches[0] : null; };
|
|
340
|
+
const take = name => { const item = field(name); return item && !item.children.length && !item.opaque && !item.invalid && !item.multiline && !item.block ? unquote(source.slice(item.start, item.end)) : null; };
|
|
341
|
+
const fontsize = fontSize(take('fontsize'));
|
|
342
|
+
const mainfont = documentFont(take('mainfont'));
|
|
343
|
+
const sansfont = documentFont(take('sansfont'));
|
|
344
|
+
const monofont = documentFont(take('monofont'));
|
|
345
|
+
const defaultfont = documentFont(take('defaultfont'));
|
|
346
|
+
const linestretch = leading(take('linestretch'));
|
|
347
|
+
const papersize = paperOf(take('papersize'));
|
|
348
|
+
const geometry = take('geometry') != null ? marginOf(take('geometry')) : null;
|
|
349
|
+
const page = String(take('pagestyle') || '').toLowerCase();
|
|
350
|
+
return Object.freeze({
|
|
351
|
+
title: take('title') || null, subtitle: take('subtitle') || null, lang: languageTag(take('lang')),
|
|
352
|
+
fontsize, mainfont, sansfont, monofont, defaultfont, linestretch, papersize, geometry,
|
|
353
|
+
spacing: normalizeDocumentSpacing(take('spacing')),
|
|
354
|
+
pagestyle: page === 'plain' || page === 'empty' ? page : null,
|
|
355
|
+
template: String(take('template') || '').toLowerCase() === 'page' ? 'page' : null,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function updateDocumentSettings(markdown, patch) {
|
|
360
|
+
const source = String(markdown ?? ''), parsed = fieldsOf(source), {frame, fields} = parsed, before = readDocumentSettings(source);
|
|
361
|
+
const changes = [], append = [];
|
|
362
|
+
const eol = frame.closing ? frame.opening.eol : (source.match(/\r\n|\r|\n/) || ['\n'])[0];
|
|
363
|
+
const same = (left, right) => Math.abs(left - right) <= Number.EPSILON * Math.max(Math.abs(left), Math.abs(right)) * 4;
|
|
364
|
+
const refuse = key => { throw new RangeError('Edit this setting in source: ' + key); };
|
|
365
|
+
const removeLine = (line, end) => {
|
|
366
|
+
const suffix = source.slice(end, line.end);
|
|
367
|
+
changes.push({start: line.start, end: line.next, text: suffix.trimStart().startsWith('#') ? suffix + line.eol : ''});
|
|
368
|
+
};
|
|
369
|
+
const replaceValue = (field, text) => changes.push({start: field.start, end: field.end,
|
|
370
|
+
text: (source[field.start - 1] === ':' ? ' ' : '') + text + (source[field.end] === '#' ? ' ' : '')});
|
|
371
|
+
for (const [key, supplied] of Object.entries(patch || {})) {
|
|
372
|
+
const font = ['mainfont', 'sansfont', 'monofont', 'defaultfont'].includes(key);
|
|
373
|
+
if (!font && !['fontsize', 'linestretch', 'spacing', 'geometry', 'papersize', 'pagestyle'].includes(key)) throw new TypeError('Unknown document setting: ' + key);
|
|
374
|
+
const matches = fields.filter(item => item.key === key);
|
|
375
|
+
if (matches.length > 1) throw new RangeError('Duplicate document setting: ' + key);
|
|
376
|
+
const field = matches[0];
|
|
377
|
+
const value = supplied === null ? null : font ? documentFont(supplied) : key === 'fontsize' ? fontSize(supplied) : key === 'spacing' ? normalizeDocumentSpacing(supplied) : key === 'geometry' ? marginOf(supplied) : key === 'papersize' ? paperOf(supplied) : key === 'pagestyle' ? (['plain', 'empty'].includes(supplied) ? {name: supplied} : null) : leading(supplied);
|
|
378
|
+
if (supplied !== null && !value) throw new TypeError('Invalid document setting: ' + key);
|
|
379
|
+
const encoded = value === null ? '' : font ? JSON.stringify(value.family) : key === 'fontsize' ? value.step : key === 'spacing' ? JSON.stringify(value) : key === 'geometry' ? unquote(supplied).trim() : key === 'papersize' || key === 'pagestyle' ? value.name : String(value.factor);
|
|
380
|
+
if (value !== null && before[key] && (font ? before[key].family === value.family : key === 'fontsize' ? same(before.fontsize.unit, value.unit) : key === 'spacing' ? JSON.stringify(before.spacing) === encoded : key === 'geometry' ? ['top', 'right', 'bottom', 'left'].every(side => before.geometry[side] === value[side]) : key === 'papersize' ? before.papersize.name === value.name : key === 'pagestyle' ? before.pagestyle === value.name : before.linestretch.factor === value.factor)) continue;
|
|
381
|
+
if (key === 'fontsize' && value !== null && fontSize(encoded)?.unit !== value.unit) refuse(key);
|
|
382
|
+
if (!field) { if (value !== null) append.push(key + ': ' + encoded + eol); continue; }
|
|
383
|
+
if (field.opaque || field.invalid || field.multiline || field.block) refuse(key);
|
|
384
|
+
if (field.children.length) refuse(key);
|
|
385
|
+
if (value === null) removeLine(field.line, field.end);
|
|
386
|
+
else replaceValue(field, encoded);
|
|
387
|
+
}
|
|
388
|
+
if (!changes.length && !append.length) return source;
|
|
389
|
+
if (parsed.invalid) throw new RangeError('Edit this front matter in source before changing settings');
|
|
390
|
+
if (!frame.closing) {
|
|
391
|
+
if (/^--- *$/.test(frame.opening.text) && frame.opening.eol) throw new RangeError('Close the front matter before changing settings');
|
|
392
|
+
return frame.bom + '---' + eol + append.join('') + '---' + eol + source.slice(frame.bom.length);
|
|
393
|
+
}
|
|
394
|
+
if (append.length) changes.push({start: frame.closing.start, end: frame.closing.start, text: append.join('')});
|
|
395
|
+
let next = source;
|
|
396
|
+
for (const change of changes.sort((a, b) => b.start - a.start || b.end - a.end)) next = next.slice(0, change.start) + change.text + next.slice(change.end);
|
|
397
|
+
return next;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function documentTitle(settings) {
|
|
401
|
+
if (!settings || !settings.title) return '';
|
|
402
|
+
return settings.subtitle ? settings.title + ' \u2014 ' + settings.subtitle : settings.title;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function documentSettingsFonts(settings, defaultFont) {
|
|
406
|
+
const inherited = defaultFont === undefined ? settings?.defaultfont : documentFont(defaultFont);
|
|
407
|
+
const main = settings?.mainfont || inherited;
|
|
408
|
+
return {main, sans: settings?.sansfont || main, mono: settings?.monofont};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function documentSettingsStyle(settings, defaultFont) {
|
|
412
|
+
if (!settings) return '';
|
|
413
|
+
const decls = [];
|
|
414
|
+
if (settings.fontsize) decls.push('--md-text-body:' + settings.fontsize.css);
|
|
415
|
+
if (settings.linestretch || settings.fontsize) decls.push('--md-line:calc(var(--md-text-body) * ' + (settings.linestretch?.factor ?? DOCUMENT_LEADING) + ')');
|
|
416
|
+
const {main, sans, mono} = documentSettingsFonts(settings, defaultFont);
|
|
417
|
+
if (sans) decls.push('--md-font-sans:' + (sans.css || fontFamilyCss(sans.family)));
|
|
418
|
+
if (main) decls.push('font-family:' + (main.css || fontFamilyCss(main.family)));
|
|
419
|
+
if (mono) decls.push('--md-font-mono:' + (mono.css || fontFamilyCss(mono.family)));
|
|
420
|
+
return (decls.length ? '.md-render{' + decls.join(';') + '}' : '') + documentSpacingCss(settings);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function documentSettingsPageRule(settings) {
|
|
424
|
+
if (!settings || (!settings.papersize && !settings.geometry && settings.pagestyle !== 'plain')) return null;
|
|
425
|
+
const size = settings.papersize ? settings.papersize.css : 'auto';
|
|
426
|
+
const margin = settings.geometry ? settings.geometry.margin : '12mm 14mm 14mm';
|
|
427
|
+
const numbers = settings.pagestyle === 'plain' ? '@bottom-center{content:counter(page)}' : '';
|
|
428
|
+
return '@page{size:' + size + ';margin:' + margin + (numbers ? ';' + numbers : '') + '}';
|
|
429
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
export function frontMatterLine(text, start) {
|
|
3
|
+
let end = start;
|
|
4
|
+
while (end < text.length && text[end] !== '\r' && text[end] !== '\n') end++;
|
|
5
|
+
const eol = text[end] === '\r' && text[end + 1] === '\n' ? '\r\n' : text[end] === '\r' || text[end] === '\n' ? text[end] : '';
|
|
6
|
+
return {start, end, next: end + eol.length, text: text.slice(start, end), eol};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function frontMatterFrame(text) {
|
|
10
|
+
if (typeof text !== 'string') throw new TypeError('Front matter needs text');
|
|
11
|
+
const bom = text[0] === '\uFEFF' ? '\uFEFF' : '';
|
|
12
|
+
const opening = frontMatterLine(text, bom.length);
|
|
13
|
+
let closing = null;
|
|
14
|
+
if (/^--- *$/.test(opening.text) && opening.eol) {
|
|
15
|
+
for (let at = opening.next; at < text.length;) {
|
|
16
|
+
const line = frontMatterLine(text, at);
|
|
17
|
+
if (/^(?:---|\.\.\.) *$/.test(line.text)) { closing = line; break; }
|
|
18
|
+
at = line.next;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return {bom, opening, closing};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function splitOpeningFrontmatter(markdown) {
|
|
25
|
+
const source = String(markdown == null ? '' : markdown);
|
|
26
|
+
const {closing} = frontMatterFrame(source);
|
|
27
|
+
const bodyOffset = closing ? closing.next : 0;
|
|
28
|
+
return {frontmatter: closing ? source.slice(0, bodyOffset) : null, body: source.slice(bodyOffset), bodyOffset};
|
|
29
|
+
}
|