@mapled/vanilla 0.0.1 → 0.1.1
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/README.md +192 -5
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mapled.js +18 -0
- package/dist/mapled.js.sha384 +1 -0
- package/dist/markdown.d.ts +15 -0
- package/dist/markdown.js +577 -0
- package/dist/paths.d.ts +21 -0
- package/dist/paths.js +60 -0
- package/dist/runtime.d.ts +85 -0
- package/dist/runtime.js +725 -0
- package/dist/sinks.d.ts +41 -0
- package/dist/sinks.js +142 -0
- package/dist/url.d.ts +30 -0
- package/dist/url.js +66 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +3 -0
- package/package.json +40 -5
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/** Rich text → DOM. Mapled stores and serves rich text as Markdown; this
|
|
2
|
+
builds it into nodes — `createElement` with names from the list below,
|
|
3
|
+
text nodes, and three attributes it validates itself (`href`, `src`,
|
|
4
|
+
`alt`; `start` is a number it parsed, `loading` a constant). There is
|
|
5
|
+
no HTML string anywhere on the way, so whatever the input does to the
|
|
6
|
+
parser, the worst result is wrong formatting: raw HTML in the source
|
|
7
|
+
is shown as the text it is (DOM_SECURITY.md, rule 5).
|
|
8
|
+
|
|
9
|
+
The syntax is what Mapled's editor writes: paragraphs, headings,
|
|
10
|
+
bold / italic / strikethrough, inline code and fenced blocks, quotes,
|
|
11
|
+
bulleted and numbered lists (nested), links, images, rules, hard
|
|
12
|
+
breaks. Tables, reference links and footnotes are shown as text. */
|
|
13
|
+
import { safeUrl } from "./url.js";
|
|
14
|
+
/** Every element this module can create. */
|
|
15
|
+
export const MARKDOWN_ELEMENTS = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "blockquote", "pre", "code", "em", "strong", "del", "a", "img", "br", "hr"];
|
|
16
|
+
/** Past this the value is shown as plain paragraphs: nobody edits a text
|
|
17
|
+
this long by hand, and parsing time stays bounded. */
|
|
18
|
+
const MAX_SOURCE = 100_000;
|
|
19
|
+
const MAX_DEPTH = 8;
|
|
20
|
+
const MAX_ADDRESS = 2048;
|
|
21
|
+
const PUNCTUATION = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
|
|
22
|
+
const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0", copy: "©", reg: "®", trade: "™", mdash: "—", ndash: "–", hellip: "…", laquo: "«", raquo: "»" };
|
|
23
|
+
export function renderMarkdown(source, doc, base) {
|
|
24
|
+
const out = doc.createDocumentFragment();
|
|
25
|
+
if (typeof source !== "string" || source === "")
|
|
26
|
+
return out;
|
|
27
|
+
const text = source.replace(/\r\n?/g, "\n").replace(/\t/g, " ").replace(/\u0000/g, "\ufffd");
|
|
28
|
+
const ctx = { doc, base };
|
|
29
|
+
if (text.length > MAX_SOURCE) {
|
|
30
|
+
for (const block of text.split(/\n{2,}/)) {
|
|
31
|
+
if (block.trim() !== "")
|
|
32
|
+
make(ctx, "p", out).textContent = block.trim();
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
blocks(ctx, text.split("\n"), out, 0);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
function make(ctx, tag, parent) {
|
|
40
|
+
const el = ctx.doc.createElement(tag);
|
|
41
|
+
parent.appendChild(el);
|
|
42
|
+
return el;
|
|
43
|
+
}
|
|
44
|
+
/* ---- blocks ---- */
|
|
45
|
+
const blank = (line) => line.trim() === "";
|
|
46
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
47
|
+
/** `# Heading` — the level and the text, closing #s dropped. */
|
|
48
|
+
function heading(line) {
|
|
49
|
+
const start = indentOf(line);
|
|
50
|
+
if (start > 3)
|
|
51
|
+
return null;
|
|
52
|
+
let level = 0;
|
|
53
|
+
while (level < 7 && line[start + level] === "#")
|
|
54
|
+
level++;
|
|
55
|
+
if (level === 0 || level > 6)
|
|
56
|
+
return null;
|
|
57
|
+
const after = line[start + level];
|
|
58
|
+
if (after !== undefined && after !== " ")
|
|
59
|
+
return null;
|
|
60
|
+
let text = line.slice(start + level).trim();
|
|
61
|
+
let end = text.length;
|
|
62
|
+
while (end > 0 && text[end - 1] === "#")
|
|
63
|
+
end--;
|
|
64
|
+
if (end < text.length && (end === 0 || text[end - 1] === " "))
|
|
65
|
+
text = text.slice(0, end).trim();
|
|
66
|
+
return { level, text };
|
|
67
|
+
}
|
|
68
|
+
/** `---`, `***`, `___`, spaces allowed between. */
|
|
69
|
+
function rule(line) {
|
|
70
|
+
if (indentOf(line) > 3)
|
|
71
|
+
return false;
|
|
72
|
+
const marks = line.replace(/ /g, "");
|
|
73
|
+
const first = marks[0];
|
|
74
|
+
if (marks.length < 3 || (first !== "-" && first !== "*" && first !== "_"))
|
|
75
|
+
return false;
|
|
76
|
+
for (const ch of marks)
|
|
77
|
+
if (ch !== first)
|
|
78
|
+
return false;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
function fence(line) {
|
|
82
|
+
const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
83
|
+
if (!m)
|
|
84
|
+
return null;
|
|
85
|
+
const mark = m[1][0];
|
|
86
|
+
// an info string with a backtick is an inline code span, not a fence
|
|
87
|
+
if (mark === "`" && line.slice(m[0].length).includes("`"))
|
|
88
|
+
return null;
|
|
89
|
+
return { mark, size: m[1].length };
|
|
90
|
+
}
|
|
91
|
+
function marker(line) {
|
|
92
|
+
const m = /^( {0,3})([-*+]|\d{1,9}[.)])( +|$)/.exec(line);
|
|
93
|
+
if (!m)
|
|
94
|
+
return null;
|
|
95
|
+
const mark = m[2];
|
|
96
|
+
const spaces = m[3].length;
|
|
97
|
+
const ordered = mark.length > 1 || /\d/.test(mark);
|
|
98
|
+
return {
|
|
99
|
+
indent: m[1].length,
|
|
100
|
+
// text that starts five or more spaces in is an indented block: the content column is one past the marker
|
|
101
|
+
width: mark.length + (spaces >= 1 && spaces <= 4 ? spaces : 1),
|
|
102
|
+
ordered,
|
|
103
|
+
start: ordered ? parseInt(mark, 10) : 1,
|
|
104
|
+
rest: line.slice(m[0].length),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const quoted = (line) => /^ {0,3}>/.test(line);
|
|
108
|
+
/** Whether a line starts a block of its own — which ends a paragraph. A
|
|
109
|
+
numbered item does only when it is number 1: "2026. A good year." in
|
|
110
|
+
the middle of a paragraph is a sentence. */
|
|
111
|
+
function startsBlock(line) {
|
|
112
|
+
if (heading(line) !== null || fence(line) !== null || quoted(line) || rule(line))
|
|
113
|
+
return true;
|
|
114
|
+
const m = marker(line);
|
|
115
|
+
return m !== null && m.rest !== "" && (!m.ordered || m.start === 1);
|
|
116
|
+
}
|
|
117
|
+
function blocks(ctx, lines, parent, depth) {
|
|
118
|
+
if (depth >= MAX_DEPTH) {
|
|
119
|
+
const text = lines.join("\n").trim();
|
|
120
|
+
if (text !== "")
|
|
121
|
+
make(ctx, "p", parent).textContent = text;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let i = 0;
|
|
125
|
+
while (i < lines.length) {
|
|
126
|
+
const line = lines[i];
|
|
127
|
+
if (blank(line)) {
|
|
128
|
+
i++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const fenced = fence(line);
|
|
132
|
+
if (fenced) {
|
|
133
|
+
const body = [];
|
|
134
|
+
const indent = indentOf(line);
|
|
135
|
+
i++;
|
|
136
|
+
while (i < lines.length) {
|
|
137
|
+
const next = lines[i];
|
|
138
|
+
const closing = fence(next);
|
|
139
|
+
if (closing && closing.mark === fenced.mark && closing.size >= fenced.size && next.trim().length === closing.size)
|
|
140
|
+
break;
|
|
141
|
+
body.push(next.slice(Math.min(indent, indentOf(next))));
|
|
142
|
+
i++;
|
|
143
|
+
}
|
|
144
|
+
i++;
|
|
145
|
+
make(ctx, "code", make(ctx, "pre", parent)).textContent = body.length > 0 ? `${body.join("\n")}\n` : "";
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const head = heading(line);
|
|
149
|
+
if (head) {
|
|
150
|
+
inline(ctx, head.text, make(ctx, `h${head.level}`, parent), depth);
|
|
151
|
+
i++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (rule(line)) {
|
|
155
|
+
make(ctx, "hr", parent);
|
|
156
|
+
i++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (quoted(line)) {
|
|
160
|
+
const inner = [];
|
|
161
|
+
while (i < lines.length && quoted(lines[i])) {
|
|
162
|
+
inner.push(lines[i].replace(/^ {0,3}> ?/, ""));
|
|
163
|
+
i++;
|
|
164
|
+
}
|
|
165
|
+
blocks(ctx, inner, make(ctx, "blockquote", parent), depth + 1);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const first = marker(line);
|
|
169
|
+
if (first) {
|
|
170
|
+
i = list(ctx, lines, i, first, parent, depth);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const para = [line.trimStart()];
|
|
174
|
+
i++;
|
|
175
|
+
while (i < lines.length && !blank(lines[i]) && !startsBlock(lines[i])) {
|
|
176
|
+
para.push(lines[i].trimStart());
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
inline(ctx, para.join("\n"), make(ctx, "p", parent), depth);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** A list from line `i`; answers the index of the line after it. */
|
|
183
|
+
function list(ctx, lines, i, first, parent, depth) {
|
|
184
|
+
const el = make(ctx, first.ordered ? "ol" : "ul", parent);
|
|
185
|
+
if (first.ordered && first.start !== 1)
|
|
186
|
+
el.setAttribute("start", String(first.start));
|
|
187
|
+
const items = [];
|
|
188
|
+
let loose = false;
|
|
189
|
+
while (i < lines.length) {
|
|
190
|
+
const m = marker(lines[i]);
|
|
191
|
+
if (!m || m.ordered !== first.ordered || m.indent !== first.indent || rule(lines[i]))
|
|
192
|
+
break;
|
|
193
|
+
const column = m.indent + m.width;
|
|
194
|
+
const body = [m.rest];
|
|
195
|
+
i++;
|
|
196
|
+
let gap = false;
|
|
197
|
+
while (i < lines.length) {
|
|
198
|
+
const next = lines[i];
|
|
199
|
+
if (blank(next)) {
|
|
200
|
+
gap = true;
|
|
201
|
+
body.push("");
|
|
202
|
+
i++;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (indentOf(next) < column)
|
|
206
|
+
break;
|
|
207
|
+
if (gap && body.some((b) => b !== "") && !marker(next.slice(column)))
|
|
208
|
+
loose = true;
|
|
209
|
+
gap = false;
|
|
210
|
+
body.push(next.slice(column));
|
|
211
|
+
i++;
|
|
212
|
+
}
|
|
213
|
+
while (body.length > 0 && body[body.length - 1] === "")
|
|
214
|
+
body.pop();
|
|
215
|
+
// a blank line before the next item of this list makes it a loose one
|
|
216
|
+
if (gap && i < lines.length) {
|
|
217
|
+
const following = marker(lines[i]);
|
|
218
|
+
if (following && following.ordered === first.ordered && following.indent === first.indent)
|
|
219
|
+
loose = true;
|
|
220
|
+
}
|
|
221
|
+
items.push({ lines: body, el: make(ctx, "li", el) });
|
|
222
|
+
}
|
|
223
|
+
for (const item of items) {
|
|
224
|
+
blocks(ctx, item.lines, item.el, depth + 1);
|
|
225
|
+
if (loose)
|
|
226
|
+
continue;
|
|
227
|
+
// a tight list shows its text without paragraphs
|
|
228
|
+
for (const child of Array.from(item.el.childNodes)) {
|
|
229
|
+
if (child.nodeName !== "P")
|
|
230
|
+
continue;
|
|
231
|
+
while (child.firstChild)
|
|
232
|
+
item.el.insertBefore(child.firstChild, child);
|
|
233
|
+
item.el.removeChild(child);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return i;
|
|
237
|
+
}
|
|
238
|
+
/* ---- inlines ---- */
|
|
239
|
+
const isSpace = (ch) => ch === undefined || ch === " " || ch === "\n";
|
|
240
|
+
const isWord = (ch) => ch !== undefined && /[\p{L}\p{N}]/u.test(ch);
|
|
241
|
+
/** Where `ch` is, looking no further than `limit` and not past a character of `stop`; -1 if not there. */
|
|
242
|
+
function find(text, ch, from, limit, stop = "") {
|
|
243
|
+
const end = Math.min(text.length, limit);
|
|
244
|
+
for (let j = from; j < end; j++) {
|
|
245
|
+
if (text[j] === ch)
|
|
246
|
+
return j;
|
|
247
|
+
if (stop.includes(text[j]))
|
|
248
|
+
return -1;
|
|
249
|
+
}
|
|
250
|
+
return -1;
|
|
251
|
+
}
|
|
252
|
+
/** The index just past a run of one character. */
|
|
253
|
+
function runEnd(text, i, ch) {
|
|
254
|
+
let j = i;
|
|
255
|
+
while (text[j] === ch)
|
|
256
|
+
j++;
|
|
257
|
+
return j;
|
|
258
|
+
}
|
|
259
|
+
/** Skips what can't hold a delimiter: an escaped character, a code span. */
|
|
260
|
+
function skip(text, j) {
|
|
261
|
+
if (text[j] === "\\")
|
|
262
|
+
return j + 2;
|
|
263
|
+
if (text[j] === "`") {
|
|
264
|
+
const end = runEnd(text, j, "`");
|
|
265
|
+
const close = closingTicks(text, end, end - j);
|
|
266
|
+
return close === -1 ? end : close + (end - j);
|
|
267
|
+
}
|
|
268
|
+
return -1;
|
|
269
|
+
}
|
|
270
|
+
/** Where a run of exactly `size` backticks starts, from `from` on; -1 if none. */
|
|
271
|
+
function closingTicks(text, from, size) {
|
|
272
|
+
let j = text.indexOf("`", from);
|
|
273
|
+
while (j !== -1) {
|
|
274
|
+
const end = runEnd(text, j, "`");
|
|
275
|
+
if (end - j === size)
|
|
276
|
+
return j;
|
|
277
|
+
j = text.indexOf("`", end);
|
|
278
|
+
}
|
|
279
|
+
return -1;
|
|
280
|
+
}
|
|
281
|
+
/** Where the closing delimiter starts; -1 if there is none. A closer
|
|
282
|
+
follows a non-space; `_` also has to end a word. */
|
|
283
|
+
function closer(text, from, delim) {
|
|
284
|
+
const ch = delim[0];
|
|
285
|
+
let j = from;
|
|
286
|
+
while (j < text.length) {
|
|
287
|
+
const skipped = skip(text, j);
|
|
288
|
+
if (skipped !== -1) {
|
|
289
|
+
j = skipped;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (text[j] !== ch) {
|
|
293
|
+
j++;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const end = runEnd(text, j, ch);
|
|
297
|
+
const size = end - j;
|
|
298
|
+
// a run of two belongs to bold when italics are being closed, and the other way round
|
|
299
|
+
const fits = delim.length === 1 ? size === 1 || size >= 3 : size >= delim.length;
|
|
300
|
+
if (fits && j > from && !isSpace(text[j - 1]) && (ch !== "_" || !isWord(text[end]))) {
|
|
301
|
+
// the end of the run closes: what is left of it belongs to the text inside
|
|
302
|
+
return end - delim.length;
|
|
303
|
+
}
|
|
304
|
+
j = end;
|
|
305
|
+
}
|
|
306
|
+
return -1;
|
|
307
|
+
}
|
|
308
|
+
function decodeEntity(text, i) {
|
|
309
|
+
const semi = find(text, ";", i + 1, i + 34, " \n&");
|
|
310
|
+
if (semi === -1)
|
|
311
|
+
return null;
|
|
312
|
+
const name = text.slice(i + 1, semi);
|
|
313
|
+
if (name[0] === "#") {
|
|
314
|
+
const hex = name[1] === "x" || name[1] === "X";
|
|
315
|
+
const digits = name.slice(hex ? 2 : 1);
|
|
316
|
+
if (!(hex ? /^[0-9a-fA-F]{1,6}$/ : /^[0-9]{1,7}$/).test(digits))
|
|
317
|
+
return null;
|
|
318
|
+
const code = parseInt(digits, hex ? 16 : 10);
|
|
319
|
+
const valid = code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff);
|
|
320
|
+
return { char: valid ? String.fromCodePoint(code) : "\ufffd", end: semi + 1 };
|
|
321
|
+
}
|
|
322
|
+
const known = Object.prototype.hasOwnProperty.call(ENTITIES, name) ? ENTITIES[name] : undefined;
|
|
323
|
+
return known === undefined ? null : { char: known, end: semi + 1 };
|
|
324
|
+
}
|
|
325
|
+
const unescape = (text) => text.replace(/\\([!-/:-@[-`{-~])/g, "$1");
|
|
326
|
+
/** `(destination "title")` right after a link's text: the destination and
|
|
327
|
+
the index past the `)`, or null when this isn't one. */
|
|
328
|
+
function destination(text, open) {
|
|
329
|
+
if (text[open] !== "(")
|
|
330
|
+
return null;
|
|
331
|
+
const limit = Math.min(text.length, open + MAX_ADDRESS + 1024);
|
|
332
|
+
let j = open + 1;
|
|
333
|
+
while (j < limit && isSpace(text[j]) && text[j] !== undefined)
|
|
334
|
+
j++;
|
|
335
|
+
let address = "";
|
|
336
|
+
if (text[j] === "<") {
|
|
337
|
+
const end = find(text, ">", j + 1, limit, "\n<");
|
|
338
|
+
if (end === -1)
|
|
339
|
+
return null;
|
|
340
|
+
address = text.slice(j + 1, end);
|
|
341
|
+
j = end + 1;
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
const from = j;
|
|
345
|
+
let depth = 0;
|
|
346
|
+
while (j < limit) {
|
|
347
|
+
const ch = text[j];
|
|
348
|
+
if (ch === "\\" && j + 1 < limit) {
|
|
349
|
+
j += 2;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (ch === " " || ch === "\n")
|
|
353
|
+
break;
|
|
354
|
+
if (ch === "(")
|
|
355
|
+
depth++;
|
|
356
|
+
if (ch === ")") {
|
|
357
|
+
if (depth === 0)
|
|
358
|
+
break;
|
|
359
|
+
depth--;
|
|
360
|
+
}
|
|
361
|
+
j++;
|
|
362
|
+
}
|
|
363
|
+
address = text.slice(from, j);
|
|
364
|
+
}
|
|
365
|
+
while (j < limit && (text[j] === " " || text[j] === "\n"))
|
|
366
|
+
j++;
|
|
367
|
+
const quote = text[j];
|
|
368
|
+
if (quote === '"' || quote === "'" || quote === "(") {
|
|
369
|
+
// a title: read past it, don't use it
|
|
370
|
+
const end = find(text, quote === "(" ? ")" : quote, j + 1, limit);
|
|
371
|
+
if (end === -1)
|
|
372
|
+
return null;
|
|
373
|
+
j = end + 1;
|
|
374
|
+
while (j < limit && (text[j] === " " || text[j] === "\n"))
|
|
375
|
+
j++;
|
|
376
|
+
}
|
|
377
|
+
if (text[j] !== ")")
|
|
378
|
+
return null;
|
|
379
|
+
return { address: unescape(address), end: j + 1 };
|
|
380
|
+
}
|
|
381
|
+
/** `[text](address)` from the `[` at `open`: the text ends at the first
|
|
382
|
+
`]`, and a `[` before it means this one opened nothing. One pair may
|
|
383
|
+
nest — an image inside a link's text — and only one level of it. */
|
|
384
|
+
function bracketed(text, open, inside = false) {
|
|
385
|
+
let j = open + 1;
|
|
386
|
+
while (j < text.length) {
|
|
387
|
+
const skipped = skip(text, j);
|
|
388
|
+
if (skipped !== -1) {
|
|
389
|
+
j = skipped;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (!inside && text[j] === "!" && text[j + 1] === "[") {
|
|
393
|
+
const image = bracketed(text, j + 1, true);
|
|
394
|
+
if (!image)
|
|
395
|
+
return null;
|
|
396
|
+
j = image.end;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (text[j] === "[")
|
|
400
|
+
return null;
|
|
401
|
+
if (text[j] === "]")
|
|
402
|
+
break;
|
|
403
|
+
j++;
|
|
404
|
+
}
|
|
405
|
+
if (j >= text.length)
|
|
406
|
+
return null;
|
|
407
|
+
const dest = destination(text, j + 1);
|
|
408
|
+
return dest ? { label: text.slice(open + 1, j), address: dest.address, end: dest.end } : null;
|
|
409
|
+
}
|
|
410
|
+
const AUTOLINK = /^(?:[a-z][a-z0-9+.-]{1,31}:[^\s<>]*|[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+)$/i;
|
|
411
|
+
function inline(ctx, text, parent, depth) {
|
|
412
|
+
if (depth >= MAX_DEPTH) {
|
|
413
|
+
parent.appendChild(ctx.doc.createTextNode(text));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
let buffer = "";
|
|
417
|
+
const flush = () => {
|
|
418
|
+
if (buffer !== "")
|
|
419
|
+
parent.appendChild(ctx.doc.createTextNode(buffer));
|
|
420
|
+
buffer = "";
|
|
421
|
+
};
|
|
422
|
+
/** delimiters known to have no closer from here on: asked once, not once per opener */
|
|
423
|
+
const dead = new Set();
|
|
424
|
+
let i = 0;
|
|
425
|
+
while (i < text.length) {
|
|
426
|
+
const ch = text[i];
|
|
427
|
+
if (ch === "\\") {
|
|
428
|
+
const next = text[i + 1];
|
|
429
|
+
if (next === "\n") {
|
|
430
|
+
flush();
|
|
431
|
+
make(ctx, "br", parent);
|
|
432
|
+
i += 2;
|
|
433
|
+
while (text[i] === " ")
|
|
434
|
+
i++;
|
|
435
|
+
}
|
|
436
|
+
else if (next !== undefined && PUNCTUATION.includes(next)) {
|
|
437
|
+
buffer += next;
|
|
438
|
+
i += 2;
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
buffer += ch;
|
|
442
|
+
i++;
|
|
443
|
+
}
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (ch === "\n") {
|
|
447
|
+
const hard = buffer.endsWith(" ");
|
|
448
|
+
// trailing spaces go — read from the end: a pattern anchored there would try every
|
|
449
|
+
// space of a long run inside the line, once per soft break (quadratic, found by review)
|
|
450
|
+
let end = buffer.length;
|
|
451
|
+
while (end > 0 && buffer[end - 1] === " ")
|
|
452
|
+
end--;
|
|
453
|
+
buffer = buffer.slice(0, end);
|
|
454
|
+
if (hard) {
|
|
455
|
+
flush();
|
|
456
|
+
make(ctx, "br", parent);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
buffer += "\n";
|
|
460
|
+
}
|
|
461
|
+
i++;
|
|
462
|
+
while (text[i] === " ")
|
|
463
|
+
i++;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (ch === "`") {
|
|
467
|
+
const end = runEnd(text, i, "`");
|
|
468
|
+
const close = closingTicks(text, end, end - i);
|
|
469
|
+
if (close === -1) {
|
|
470
|
+
buffer += text.slice(i, end);
|
|
471
|
+
i = end;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
let code = text.slice(end, close).replace(/\n/g, " ");
|
|
475
|
+
if (code.length > 1 && code.startsWith(" ") && code.endsWith(" ") && code.trim() !== "")
|
|
476
|
+
code = code.slice(1, -1);
|
|
477
|
+
flush();
|
|
478
|
+
make(ctx, "code", parent).textContent = code;
|
|
479
|
+
i = close + (end - i);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (ch === "!" && text[i + 1] === "[") {
|
|
483
|
+
const found = bracketed(text, i + 1);
|
|
484
|
+
if (found) {
|
|
485
|
+
const alt = unescape(found.label);
|
|
486
|
+
const src = safeUrl(found.address.trim(), ctx.base, "media");
|
|
487
|
+
if (src === null) {
|
|
488
|
+
// an image that may not load is still what its author called it
|
|
489
|
+
buffer += alt;
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
flush();
|
|
493
|
+
const img = make(ctx, "img", parent);
|
|
494
|
+
img.setAttribute("src", src);
|
|
495
|
+
img.setAttribute("alt", alt);
|
|
496
|
+
img.setAttribute("loading", "lazy");
|
|
497
|
+
}
|
|
498
|
+
i = found.end;
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (ch === "[") {
|
|
503
|
+
const found = bracketed(text, i);
|
|
504
|
+
if (found) {
|
|
505
|
+
const href = safeUrl(found.address.trim(), ctx.base, "link");
|
|
506
|
+
flush();
|
|
507
|
+
// a link that may not be followed keeps its words
|
|
508
|
+
const target = href === null ? parent : make(ctx, "a", parent);
|
|
509
|
+
if (href !== null)
|
|
510
|
+
target.setAttribute("href", href);
|
|
511
|
+
inline(ctx, found.label, target, depth + 1);
|
|
512
|
+
i = found.end;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (ch === "<") {
|
|
517
|
+
const end = find(text, ">", i + 1, i + MAX_ADDRESS, " \n<");
|
|
518
|
+
const inside = end === -1 ? null : text.slice(i + 1, end);
|
|
519
|
+
if (inside !== null && AUTOLINK.test(inside)) {
|
|
520
|
+
const href = safeUrl(inside.includes(":") ? inside : `mailto:${inside}`, ctx.base, "link");
|
|
521
|
+
if (href !== null) {
|
|
522
|
+
flush();
|
|
523
|
+
const a = make(ctx, "a", parent);
|
|
524
|
+
a.setAttribute("href", href);
|
|
525
|
+
a.textContent = inside;
|
|
526
|
+
i = end + 1;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (ch === "&") {
|
|
532
|
+
const entity = decodeEntity(text, i);
|
|
533
|
+
if (entity) {
|
|
534
|
+
buffer += entity.char;
|
|
535
|
+
i = entity.end;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (ch === "*" || ch === "_" || ch === "~") {
|
|
540
|
+
const end = runEnd(text, i, ch);
|
|
541
|
+
const size = end - i;
|
|
542
|
+
const opens = !isSpace(text[end]) && (ch !== "_" || !isWord(text[i - 1]));
|
|
543
|
+
const shapes = ch === "~" ? (size >= 2 ? [["~~", ["del"]]] : []) : [...(size >= 3 ? [[ch.repeat(3), ["em", "strong"]]] : []), ...(size >= 2 ? [[ch.repeat(2), ["strong"]]] : []), [ch, ["em"]]];
|
|
544
|
+
let done = false;
|
|
545
|
+
if (opens) {
|
|
546
|
+
for (const [delim, tags] of shapes) {
|
|
547
|
+
if (dead.has(delim))
|
|
548
|
+
continue;
|
|
549
|
+
const from = i + delim.length;
|
|
550
|
+
const close = closer(text, from, delim);
|
|
551
|
+
if (close === -1) {
|
|
552
|
+
dead.add(delim);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (close === from)
|
|
556
|
+
continue;
|
|
557
|
+
flush();
|
|
558
|
+
let target = parent;
|
|
559
|
+
for (const tag of tags)
|
|
560
|
+
target = make(ctx, tag, target);
|
|
561
|
+
inline(ctx, text.slice(from, close), target, depth + 1);
|
|
562
|
+
i = close + delim.length;
|
|
563
|
+
done = true;
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (!done) {
|
|
568
|
+
buffer += text.slice(i, end);
|
|
569
|
+
i = end;
|
|
570
|
+
}
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
buffer += ch;
|
|
574
|
+
i++;
|
|
575
|
+
}
|
|
576
|
+
flush();
|
|
577
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Paths: how a page names a value — `home.headline` from the document,
|
|
2
|
+
`title` or `author.name` inside a record. A path is the page author's
|
|
3
|
+
text and is held to the grammar of Mapled's keys; what it walks is
|
|
4
|
+
content, read by own properties only (DOM_SECURITY.md, rule 7). */
|
|
5
|
+
/** The segments of a path, or null when it is outside the grammar. */
|
|
6
|
+
export declare function parsePath(text: string | null | undefined): string[] | null;
|
|
7
|
+
/** What paths resolve in: a record as Mapled serves it, or an item of a
|
|
8
|
+
repeatable group (a plain object of the record's data). */
|
|
9
|
+
export type Source = {
|
|
10
|
+
data: unknown;
|
|
11
|
+
expanded?: unknown;
|
|
12
|
+
};
|
|
13
|
+
/** The value at a path, or undefined when there is none. A relation the
|
|
14
|
+
read expanded is walked into: `author.name` reads the linked record —
|
|
15
|
+
one linked record only; a list of them has no single value. */
|
|
16
|
+
export declare function valueAt(source: Source, segments: readonly string[]): unknown;
|
|
17
|
+
/** Nothing to show: no value, or an empty one. */
|
|
18
|
+
export declare const isEmpty: (value: unknown) => boolean;
|
|
19
|
+
/** A value as the text a person reads, or null when it isn't one —
|
|
20
|
+
objects and lists of objects have no text of their own. */
|
|
21
|
+
export declare function asText(value: unknown): string | null;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Paths: how a page names a value — `home.headline` from the document,
|
|
2
|
+
`title` or `author.name` inside a record. A path is the page author's
|
|
3
|
+
text and is held to the grammar of Mapled's keys; what it walks is
|
|
4
|
+
content, read by own properties only (DOM_SECURITY.md, rule 7). */
|
|
5
|
+
const SEGMENT = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
6
|
+
const MAX_SEGMENTS = 8;
|
|
7
|
+
/** The segments of a path, or null when it is outside the grammar. */
|
|
8
|
+
export function parsePath(text) {
|
|
9
|
+
if (typeof text !== "string")
|
|
10
|
+
return null;
|
|
11
|
+
const segments = text.trim().split(".");
|
|
12
|
+
if (segments.length > MAX_SEGMENTS)
|
|
13
|
+
return null;
|
|
14
|
+
for (const segment of segments)
|
|
15
|
+
if (!SEGMENT.test(segment))
|
|
16
|
+
return null;
|
|
17
|
+
return segments;
|
|
18
|
+
}
|
|
19
|
+
const own = (value, key) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, key)
|
|
20
|
+
? value[key]
|
|
21
|
+
: undefined;
|
|
22
|
+
/** The value at a path, or undefined when there is none. A relation the
|
|
23
|
+
read expanded is walked into: `author.name` reads the linked record —
|
|
24
|
+
one linked record only; a list of them has no single value. */
|
|
25
|
+
export function valueAt(source, segments) {
|
|
26
|
+
let current = source.data;
|
|
27
|
+
let expanded = source.expanded;
|
|
28
|
+
for (let i = 0; i < segments.length; i++) {
|
|
29
|
+
const segment = segments[i];
|
|
30
|
+
const linked = i < segments.length - 1 ? own(expanded, segment) : undefined;
|
|
31
|
+
if (linked !== undefined) {
|
|
32
|
+
if (linked === null || Array.isArray(linked))
|
|
33
|
+
return undefined;
|
|
34
|
+
current = own(linked, "data");
|
|
35
|
+
expanded = own(linked, "expanded");
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
current = own(current, segment);
|
|
39
|
+
// below a field there are no records to walk into
|
|
40
|
+
expanded = undefined;
|
|
41
|
+
if (current === undefined)
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return current;
|
|
45
|
+
}
|
|
46
|
+
/** Nothing to show: no value, or an empty one. */
|
|
47
|
+
export const isEmpty = (value) => value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
|
|
48
|
+
/** A value as the text a person reads, or null when it isn't one —
|
|
49
|
+
objects and lists of objects have no text of their own. */
|
|
50
|
+
export function asText(value) {
|
|
51
|
+
if (typeof value === "string")
|
|
52
|
+
return value;
|
|
53
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
54
|
+
return String(value);
|
|
55
|
+
if (typeof value === "boolean")
|
|
56
|
+
return value ? "true" : "false";
|
|
57
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string" || typeof v === "number"))
|
|
58
|
+
return value.join(", ");
|
|
59
|
+
return null;
|
|
60
|
+
}
|