@hajimism/dek 0.5.1 → 0.6.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/CHANGELOG.md +18 -0
- package/package.json +10 -3
- package/src/cli/build.ts +3 -1
- package/src/cli/commands.ts +24 -10
- package/src/cli/contract.ts +28 -2
- package/src/cli/flags.ts +5 -0
- package/src/cli/lint.ts +53 -1
- package/src/cli/sync.ts +21 -6
- package/src/cli/text.ts +20 -2
- package/src/core/build.ts +4 -0
- package/src/core/config-keys.ts +7 -2
- package/src/core/config.ts +63 -0
- package/src/core/diagnostic.ts +9 -1
- package/src/core/document.ts +28 -3
- package/src/core/page.ts +1 -0
- package/src/core/plugin-host.ts +669 -0
- package/src/core/plugin.ts +436 -0
- package/src/core/sarif.ts +47 -5
- package/src/core/sync.ts +3 -1
- package/src/plugin-api.ts +126 -0
- package/src/plugins/study/check.ts +128 -0
- package/src/plugins/study/main.ts +47 -0
- package/src/plugins/study/manifest.ts +32 -0
- package/src/plugins/study/page.ts +295 -0
- package/src/plugins/study/place.ts +88 -0
- package/src/plugins/study/sarif.ts +30 -0
- package/src/plugins/study/study.ts +137 -0
- package/src/plugins/study/text.ts +55 -0
- package/src/plugins/study/yaml.ts +226 -0
- package/src/plugins/words/check.ts +109 -0
- package/src/plugins/words/lines.ts +26 -0
- package/src/plugins/words/list.ts +288 -0
- package/src/plugins/words/main.ts +50 -0
- package/src/plugins/words/manifest.ts +36 -0
- package/src/plugins/words/match.ts +60 -0
- package/src/plugins/words/sarif.ts +37 -0
- package/src/plugins/words/text.ts +352 -0
- package/src/plugins/words/yaml.ts +225 -0
- package/src/runtime/player.ts +18 -2
- package/src/runtime/plugins.ts +236 -0
- package/src/runtime/start.ts +16 -1
- package/src/runtime/window.ts +3 -0
- package/src/server/deck-snapshot.ts +32 -1
- package/src/server/dev.ts +7 -1
- package/src/server/pages.ts +9 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The words of a deck as dek-words looks in them, each with the place it is written. A block is one
|
|
3
|
+
* run of text a reader takes as a whole: a heading, a paragraph, an item, a cell. A word is only
|
|
4
|
+
* found inside one block, never across two.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** One block's text, with each run of whitespace as one space; `at[i]` is where `text[i]` starts in the source. */
|
|
8
|
+
export type Block = { text: string; at: number[] };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a run of whitespace holds: spaces alone, a line break of the source, or a break written as
|
|
12
|
+
* one, such as `<br>`, which the reader sees whatever is around it.
|
|
13
|
+
*/
|
|
14
|
+
type Gap = "space" | "break" | "written";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Letters written with no space between words, which a browser joins across a line break of the
|
|
18
|
+
* source, as CSS Text 3 has it: Han, kana, and their full-width punctuation, but not Hangul.
|
|
19
|
+
*/
|
|
20
|
+
const UNSPACED =
|
|
21
|
+
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\u3000-\u303f\u30fc\uff01-\uff60\uffe0-\uffe6]$/u;
|
|
22
|
+
|
|
23
|
+
/** Builds blocks one character at a time, folding each run of whitespace into one space. */
|
|
24
|
+
class Blocks {
|
|
25
|
+
readonly done: Block[] = [];
|
|
26
|
+
private text = "";
|
|
27
|
+
private at: number[] = [];
|
|
28
|
+
private last = "";
|
|
29
|
+
private space: { offset: number; gap: Gap } | undefined;
|
|
30
|
+
|
|
31
|
+
/** `char` written at `offset`: a code unit or two, one character either way. */
|
|
32
|
+
add(char: string, offset: number): void {
|
|
33
|
+
if (/^\s$/u.test(char)) {
|
|
34
|
+
this.gap(offset, char === "\n" || char === "\r" ? "break" : "space");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// A line break between two such letters is no space to the reader: 解像 and 度 are 解像度.
|
|
38
|
+
const joined = this.space?.gap === "break" && UNSPACED.test(this.last) && UNSPACED.test(char);
|
|
39
|
+
if (this.space !== undefined && this.text !== "" && !joined) {
|
|
40
|
+
this.text += " ";
|
|
41
|
+
this.at.push(this.space.offset);
|
|
42
|
+
}
|
|
43
|
+
this.space = undefined;
|
|
44
|
+
this.text += char;
|
|
45
|
+
this.last = char;
|
|
46
|
+
for (let unit = 0; unit < char.length; unit += 1) {
|
|
47
|
+
this.at.push(offset);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whitespace at `offset`, which joins the run before it; a written break wins over the rest. */
|
|
52
|
+
gap(offset: number, gap: Gap): void {
|
|
53
|
+
if (this.space === undefined) {
|
|
54
|
+
this.space = { offset, gap };
|
|
55
|
+
} else if (gap !== "space" && this.space.gap !== "written") {
|
|
56
|
+
this.space.gap = gap;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Ends the block being built, if it has any text. */
|
|
61
|
+
end(): void {
|
|
62
|
+
if (this.text !== "") {
|
|
63
|
+
this.done.push({ text: this.text, at: this.at });
|
|
64
|
+
}
|
|
65
|
+
this.text = "";
|
|
66
|
+
this.at = [];
|
|
67
|
+
this.last = "";
|
|
68
|
+
this.space = undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ENTITIES: Record<string, string> = {
|
|
73
|
+
amp: "&",
|
|
74
|
+
lt: "<",
|
|
75
|
+
gt: ">",
|
|
76
|
+
quot: '"',
|
|
77
|
+
apos: "'",
|
|
78
|
+
nbsp: " ",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Adds `text`, written at `offset`, reading character references as the characters they stand for. */
|
|
82
|
+
function addDecoded(blocks: Blocks, text: string, offset: number): void {
|
|
83
|
+
for (let index = 0; index < text.length; ) {
|
|
84
|
+
const reference =
|
|
85
|
+
text[index] === "&" ? /^&(#x[0-9a-f]+|#\d+|[a-z]+);/i.exec(text.slice(index)) : null;
|
|
86
|
+
const decoded = reference ? decodeReference(reference[1] as string) : undefined;
|
|
87
|
+
if (reference && decoded !== undefined) {
|
|
88
|
+
blocks.add(decoded, offset + index);
|
|
89
|
+
index += reference[0].length;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const char = String.fromCodePoint(text.codePointAt(index) as number);
|
|
93
|
+
blocks.add(char, offset + index);
|
|
94
|
+
index += char.length;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function decodeReference(name: string): string | undefined {
|
|
99
|
+
if (name.startsWith("#")) {
|
|
100
|
+
const code =
|
|
101
|
+
name[1] === "x" || name[1] === "X"
|
|
102
|
+
? Number.parseInt(name.slice(2), 16)
|
|
103
|
+
: Number(name.slice(1));
|
|
104
|
+
return Number.isInteger(code) && code >= 0 && code <= 0x10ffff
|
|
105
|
+
? String.fromCodePoint(code)
|
|
106
|
+
: undefined;
|
|
107
|
+
}
|
|
108
|
+
return ENTITIES[name.toLowerCase()];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Elements whose content no reader sees as words on the slide, as dek-study has them. */
|
|
112
|
+
const UNSEEN = new Set(["script", "style", "template", "noscript"]);
|
|
113
|
+
|
|
114
|
+
/** Elements that sit inside a line of text; every other element starts and ends a block. */
|
|
115
|
+
const INLINE = new Set([
|
|
116
|
+
"a",
|
|
117
|
+
"abbr",
|
|
118
|
+
"b",
|
|
119
|
+
"bdi",
|
|
120
|
+
"bdo",
|
|
121
|
+
"cite",
|
|
122
|
+
"code",
|
|
123
|
+
"data",
|
|
124
|
+
"del",
|
|
125
|
+
"dfn",
|
|
126
|
+
"em",
|
|
127
|
+
"i",
|
|
128
|
+
"img",
|
|
129
|
+
"ins",
|
|
130
|
+
"kbd",
|
|
131
|
+
"label",
|
|
132
|
+
"mark",
|
|
133
|
+
"q",
|
|
134
|
+
"rb",
|
|
135
|
+
"rp",
|
|
136
|
+
"rt",
|
|
137
|
+
"rtc",
|
|
138
|
+
"ruby",
|
|
139
|
+
"s",
|
|
140
|
+
"samp",
|
|
141
|
+
"small",
|
|
142
|
+
"span",
|
|
143
|
+
"strong",
|
|
144
|
+
"sub",
|
|
145
|
+
"sup",
|
|
146
|
+
"time",
|
|
147
|
+
"tspan",
|
|
148
|
+
"u",
|
|
149
|
+
"var",
|
|
150
|
+
"wbr",
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
const HTML_TOKEN =
|
|
154
|
+
/<!--[\s\S]*?(?:-->|$)|<(\/?)([a-zA-Z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>|[^<]+|</g;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Ruby's readings, set over a word and not part of it. Their end tags may be left out, so a
|
|
158
|
+
* reading ends at the next one, at a base, or where the ruby or the block ends.
|
|
159
|
+
*/
|
|
160
|
+
const READINGS = new Set(["rt", "rp", "rtc"]);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A slide's words as a reader sees them, in document order: not inside `script`, `style`,
|
|
164
|
+
* `template`, or `noscript`, nor in comments or ruby's readings. A `<br>` is a space, and a line
|
|
165
|
+
* break of the source between Japanese letters is none.
|
|
166
|
+
*/
|
|
167
|
+
export function slideBlocks(html: string): Block[] {
|
|
168
|
+
const blocks = new Blocks();
|
|
169
|
+
let reading = false;
|
|
170
|
+
HTML_TOKEN.lastIndex = 0;
|
|
171
|
+
for (let token = HTML_TOKEN.exec(html); token; token = HTML_TOKEN.exec(html)) {
|
|
172
|
+
const [whole, closing, rawName] = token;
|
|
173
|
+
if (rawName === undefined) {
|
|
174
|
+
if (!whole.startsWith("<!--") && !reading) {
|
|
175
|
+
addDecoded(blocks, whole, token.index);
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const name = rawName.toLowerCase();
|
|
180
|
+
if (name === "br") {
|
|
181
|
+
blocks.gap(token.index, "written");
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (READINGS.has(name)) {
|
|
185
|
+
reading = closing === "";
|
|
186
|
+
} else if (name === "rb" || name === "ruby" || !INLINE.has(name)) {
|
|
187
|
+
reading = false;
|
|
188
|
+
}
|
|
189
|
+
if (!INLINE.has(name)) {
|
|
190
|
+
blocks.end();
|
|
191
|
+
}
|
|
192
|
+
if (closing === "" && UNSEEN.has(name)) {
|
|
193
|
+
const close = new RegExp(`</${name}\\s*>`, "gi");
|
|
194
|
+
close.lastIndex = HTML_TOKEN.lastIndex;
|
|
195
|
+
const found = close.exec(html);
|
|
196
|
+
HTML_TOKEN.lastIndex = found ? close.lastIndex : html.length;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
blocks.end();
|
|
200
|
+
return blocks.done;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A line's text with the source offset of each code unit, which inline syntax is taken out of. */
|
|
204
|
+
type Run = { text: string; at: number[] };
|
|
205
|
+
|
|
206
|
+
/** `run` with every match of `pattern` replaced by its first group, or by nothing. */
|
|
207
|
+
function strip(source: Run, pattern: RegExp): Run {
|
|
208
|
+
let text = "";
|
|
209
|
+
const at: number[] = [];
|
|
210
|
+
let last = 0;
|
|
211
|
+
for (const match of source.text.matchAll(pattern)) {
|
|
212
|
+
const start = match.index;
|
|
213
|
+
text += source.text.slice(last, start);
|
|
214
|
+
at.push(...source.at.slice(last, start));
|
|
215
|
+
const kept = match[1];
|
|
216
|
+
if (kept !== undefined) {
|
|
217
|
+
const from = start + match[0].indexOf(kept);
|
|
218
|
+
text += kept;
|
|
219
|
+
at.push(...source.at.slice(from, from + kept.length));
|
|
220
|
+
}
|
|
221
|
+
last = start + match[0].length;
|
|
222
|
+
}
|
|
223
|
+
text += source.text.slice(last);
|
|
224
|
+
at.push(...source.at.slice(last));
|
|
225
|
+
return { text, at };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Inline Markdown that is not words: code, images, a link's target, emphasis, and tags. */
|
|
229
|
+
const INLINE_SYNTAX: RegExp[] = [
|
|
230
|
+
// A group is what `strip` keeps, so code has none: a span of two backticks, then of one.
|
|
231
|
+
/``.*?``/g,
|
|
232
|
+
/`[^`]*`/g,
|
|
233
|
+
/!\[[^\]]*\]\([^)]*\)/g,
|
|
234
|
+
/\[([^\]]*)\]\([^)]*\)/g,
|
|
235
|
+
/<\/?[a-zA-Z][^>]*>/g,
|
|
236
|
+
/\*+|~~/g,
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The words of script.md: headings without their `{#id}`, paragraphs and list items, each with the
|
|
241
|
+
* lines that continue it, and table cells. Not the frontmatter, code, HTML comments, or
|
|
242
|
+
* blockquotes, which are the speaker's directions and never reach the audience.
|
|
243
|
+
*/
|
|
244
|
+
export function scriptBlocks(markdown: string): Block[] {
|
|
245
|
+
const blocks = new Blocks();
|
|
246
|
+
const lines = markdown.split("\n");
|
|
247
|
+
let offset = 0;
|
|
248
|
+
let front = lines[0]?.trim() === "---";
|
|
249
|
+
let fence: string | undefined;
|
|
250
|
+
let comment = false;
|
|
251
|
+
// A paragraph or an item is open, and a line of plain text after it goes on with it.
|
|
252
|
+
let open = false;
|
|
253
|
+
const close = () => {
|
|
254
|
+
blocks.end();
|
|
255
|
+
open = false;
|
|
256
|
+
};
|
|
257
|
+
lines.forEach((raw, index) => {
|
|
258
|
+
const start = offset;
|
|
259
|
+
offset += raw.length + 1;
|
|
260
|
+
const line = raw.replace(/\r$/, "");
|
|
261
|
+
if (front) {
|
|
262
|
+
if (index > 0 && /^(---|\.\.\.)\s*$/.test(line)) {
|
|
263
|
+
front = false;
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (fence !== undefined) {
|
|
268
|
+
if (line.trim().startsWith(fence)) {
|
|
269
|
+
fence = undefined;
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const opens = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
|
|
274
|
+
if (opens) {
|
|
275
|
+
close();
|
|
276
|
+
fence = opens[1];
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// A comment can start, end, or sit inside a line.
|
|
280
|
+
let kept = "";
|
|
281
|
+
const at: number[] = [];
|
|
282
|
+
for (let column = 0; column < line.length; ) {
|
|
283
|
+
if (comment) {
|
|
284
|
+
const end = line.indexOf("-->", column);
|
|
285
|
+
if (end === -1) {
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
comment = false;
|
|
289
|
+
column = end + 3;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const open = line.indexOf("<!--", column);
|
|
293
|
+
const until = open === -1 ? line.length : open;
|
|
294
|
+
kept += line.slice(column, until);
|
|
295
|
+
for (let unit = column; unit < until; unit += 1) {
|
|
296
|
+
at.push(start + unit);
|
|
297
|
+
}
|
|
298
|
+
if (open === -1) {
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
comment = true;
|
|
302
|
+
column = open + 4;
|
|
303
|
+
}
|
|
304
|
+
let text: Run = { text: kept, at };
|
|
305
|
+
if (/^\s{0,3}>/.test(text.text) || text.text.trim() === "") {
|
|
306
|
+
close();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/.test(text.text)) {
|
|
310
|
+
close();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const heading = /^\s{0,3}#{1,6}(?:\s|$)/.test(text.text);
|
|
314
|
+
const item = /^\s*(?:[-*+]|\d+[.)])\s+/.test(text.text);
|
|
315
|
+
const table = text.text.trimStart().startsWith("|");
|
|
316
|
+
if (open && !heading && !item && !table) {
|
|
317
|
+
// The line break before this line, so 解像 and 度 on two lines are 解像度.
|
|
318
|
+
blocks.gap(start - 1, "break");
|
|
319
|
+
} else {
|
|
320
|
+
close();
|
|
321
|
+
}
|
|
322
|
+
text = strip(text, /^\s{0,3}#{1,6}\s+|\s*\{#[^}]*\}\s*$|\s+#+\s*$/g);
|
|
323
|
+
text = strip(text, /^\s*(?:[-*+]|\d+[.)])\s+/g);
|
|
324
|
+
const cells = table ? splitCells(text) : [text];
|
|
325
|
+
for (const cell of cells) {
|
|
326
|
+
const words = INLINE_SYNTAX.reduce(strip, cell);
|
|
327
|
+
[...words.text].reduce((unit, char) => {
|
|
328
|
+
blocks.add(char, words.at[unit] as number);
|
|
329
|
+
return unit + char.length;
|
|
330
|
+
}, 0);
|
|
331
|
+
if (heading || table) {
|
|
332
|
+
blocks.end();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
open = !heading && !table;
|
|
336
|
+
});
|
|
337
|
+
blocks.end();
|
|
338
|
+
return blocks.done;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** A table row's cells, split at each `|` that is not escaped. */
|
|
342
|
+
function splitCells(row: Run): Run[] {
|
|
343
|
+
const cells: Run[] = [];
|
|
344
|
+
let from = 0;
|
|
345
|
+
for (let index = 0; index <= row.text.length; index += 1) {
|
|
346
|
+
if (index === row.text.length || (row.text[index] === "|" && row.text[index - 1] !== "\\")) {
|
|
347
|
+
cells.push({ text: row.text.slice(from, index), at: row.at.slice(from, index) });
|
|
348
|
+
from = index + 1;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return cells;
|
|
352
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The YAML words.yaml is written in: block mappings and sequences of plain words as keys, and
|
|
3
|
+
* scalars that are plain, single-quoted, or double-quoted, `[]`, or `{}`. Anything else is refused
|
|
4
|
+
* with its line. A copy of dek-study's reader, since an official plugin shares no code (ADR 34).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** YAML this reader does not read, and the line it starts on. */
|
|
8
|
+
export class YamlError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
message: string,
|
|
11
|
+
readonly line: number,
|
|
12
|
+
) {
|
|
13
|
+
super(message);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Line = { indent: number; text: string; line: number };
|
|
18
|
+
|
|
19
|
+
/** The value, and the line each mapping in it starts on. */
|
|
20
|
+
export function parseYaml(source: string): {
|
|
21
|
+
value: unknown;
|
|
22
|
+
lineOf: (mapping: object) => number | undefined;
|
|
23
|
+
} {
|
|
24
|
+
const lines = toLines(source);
|
|
25
|
+
const starts = new WeakMap<object, number>();
|
|
26
|
+
const lineOf = (mapping: object) => starts.get(mapping);
|
|
27
|
+
const first = lines[0];
|
|
28
|
+
if (first === undefined) {
|
|
29
|
+
return { value: null, lineOf };
|
|
30
|
+
}
|
|
31
|
+
const [value, next] = block(lines, 0, first.indent, starts);
|
|
32
|
+
const extra = lines[next];
|
|
33
|
+
if (extra !== undefined) {
|
|
34
|
+
throw new YamlError("this line is indented less than the lines before it", extra.line);
|
|
35
|
+
}
|
|
36
|
+
return { value, lineOf };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toLines(source: string): Line[] {
|
|
40
|
+
// A byte order mark, as Notepad on Windows saves, is no part of the first line.
|
|
41
|
+
return source
|
|
42
|
+
.replace(/^\uFEFF/, "")
|
|
43
|
+
.split(/\r?\n/)
|
|
44
|
+
.flatMap((raw, index): Line[] => {
|
|
45
|
+
const line = index + 1;
|
|
46
|
+
const text = withoutComment(raw);
|
|
47
|
+
if (text.trim() === "") {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const indent = text.length - text.trimStart().length;
|
|
51
|
+
if (/^\s*\t/.test(text) || text.slice(0, indent).includes("\t")) {
|
|
52
|
+
throw new YamlError("indent with spaces, not tabs", line);
|
|
53
|
+
}
|
|
54
|
+
return [{ indent, text: text.trim(), line }];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The line up to a `#` that starts a comment: at the start, or after a space, outside quotes. A
|
|
60
|
+
* quote opens a quoted value only where a value starts, so `don't` in a plain value opens none.
|
|
61
|
+
*/
|
|
62
|
+
function withoutComment(raw: string): string {
|
|
63
|
+
let quote: string | undefined;
|
|
64
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
65
|
+
const char = raw[i];
|
|
66
|
+
if (quote === '"' && char === "\\") {
|
|
67
|
+
i += 1;
|
|
68
|
+
} else if (quote === "'" && char === "'" && raw[i + 1] === "'") {
|
|
69
|
+
i += 1;
|
|
70
|
+
} else if (quote !== undefined) {
|
|
71
|
+
if (char === quote) {
|
|
72
|
+
quote = undefined;
|
|
73
|
+
}
|
|
74
|
+
} else if ((char === '"' || char === "'") && VALUE_START.test(raw.slice(0, i))) {
|
|
75
|
+
quote = char;
|
|
76
|
+
} else if (char === "#" && (i === 0 || /\s/.test(raw[i - 1] ?? ""))) {
|
|
77
|
+
return raw.slice(0, i);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return raw;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What comes before a value: the line's start, a key's colon, or an item's dash. */
|
|
84
|
+
const VALUE_START = /(?:^|:|(?:^|\s)-)\s*$/;
|
|
85
|
+
|
|
86
|
+
const ITEM = /^-(?:\s+(.*))?$/;
|
|
87
|
+
const KEY = /^([A-Za-z0-9_-]+)\s*:(?:\s+(.*))?$/;
|
|
88
|
+
|
|
89
|
+
function block(
|
|
90
|
+
lines: Line[],
|
|
91
|
+
start: number,
|
|
92
|
+
indent: number,
|
|
93
|
+
starts: WeakMap<object, number>,
|
|
94
|
+
): [unknown, number] {
|
|
95
|
+
const line = lines[start] as Line;
|
|
96
|
+
return ITEM.test(line.text)
|
|
97
|
+
? sequence(lines, start, indent, starts)
|
|
98
|
+
: mapping(lines, start, indent, starts);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sequence(
|
|
102
|
+
lines: Line[],
|
|
103
|
+
start: number,
|
|
104
|
+
indent: number,
|
|
105
|
+
starts: WeakMap<object, number>,
|
|
106
|
+
): [unknown[], number] {
|
|
107
|
+
const items: unknown[] = [];
|
|
108
|
+
let i = start;
|
|
109
|
+
for (let line = lines[i]; line?.indent === indent; line = lines[i]) {
|
|
110
|
+
const match = line.text.match(ITEM);
|
|
111
|
+
if (!match) {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
const rest = match[1];
|
|
115
|
+
if (rest === undefined) {
|
|
116
|
+
// `-` alone: the item is the block below it.
|
|
117
|
+
const below = lines[i + 1];
|
|
118
|
+
if (below !== undefined && below.indent > indent) {
|
|
119
|
+
const [value, next] = block(lines, i + 1, below.indent, starts);
|
|
120
|
+
items.push(value);
|
|
121
|
+
i = next;
|
|
122
|
+
} else {
|
|
123
|
+
items.push(null);
|
|
124
|
+
i += 1;
|
|
125
|
+
}
|
|
126
|
+
} else if (KEY.test(rest)) {
|
|
127
|
+
// `- key: value` starts a mapping whose keys line up after the dash.
|
|
128
|
+
const column = indent + (line.text.length - rest.length);
|
|
129
|
+
const shifted = [...lines];
|
|
130
|
+
shifted[i] = { indent: column, text: rest, line: line.line };
|
|
131
|
+
const [value, next] = mapping(shifted, i, column, starts);
|
|
132
|
+
items.push(value);
|
|
133
|
+
i = next;
|
|
134
|
+
} else {
|
|
135
|
+
items.push(scalar(rest, line.line));
|
|
136
|
+
i += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return [items, i];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function mapping(
|
|
143
|
+
lines: Line[],
|
|
144
|
+
start: number,
|
|
145
|
+
indent: number,
|
|
146
|
+
starts: WeakMap<object, number>,
|
|
147
|
+
): [Record<string, unknown>, number] {
|
|
148
|
+
const result: Record<string, unknown> = {};
|
|
149
|
+
starts.set(result, (lines[start] as Line).line);
|
|
150
|
+
let i = start;
|
|
151
|
+
for (let line = lines[i]; line?.indent === indent; line = lines[i]) {
|
|
152
|
+
const match = line.text.match(KEY);
|
|
153
|
+
if (!match) {
|
|
154
|
+
throw new YamlError("expected `key: value`", line.line);
|
|
155
|
+
}
|
|
156
|
+
const key = match[1] as string;
|
|
157
|
+
if (Object.hasOwn(result, key)) {
|
|
158
|
+
throw new YamlError(`\`${key}\` is written twice`, line.line);
|
|
159
|
+
}
|
|
160
|
+
const rest = match[2];
|
|
161
|
+
const below = lines[i + 1];
|
|
162
|
+
if (rest !== undefined) {
|
|
163
|
+
result[key] = scalar(rest, line.line);
|
|
164
|
+
i += 1;
|
|
165
|
+
} else if (below !== undefined && below.indent > indent) {
|
|
166
|
+
[result[key], i] = block(lines, i + 1, below.indent, starts);
|
|
167
|
+
} else if (below !== undefined && below.indent === indent && ITEM.test(below.text)) {
|
|
168
|
+
// A sequence may sit at its key's own indent.
|
|
169
|
+
[result[key], i] = sequence(lines, i + 1, indent, starts);
|
|
170
|
+
} else {
|
|
171
|
+
result[key] = null;
|
|
172
|
+
i += 1;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const deeper = lines[i];
|
|
176
|
+
if (deeper !== undefined && deeper.indent > indent) {
|
|
177
|
+
throw new YamlError("this line is indented more than the lines before it", deeper.line);
|
|
178
|
+
}
|
|
179
|
+
return [result, i];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scalar(text: string, line: number): unknown {
|
|
183
|
+
if (text.startsWith('"')) {
|
|
184
|
+
return quoted(text, line, /^"((?:[^"\\]|\\.)*)"$/, (inner) => JSON.parse(`"${inner}"`));
|
|
185
|
+
}
|
|
186
|
+
if (text.startsWith("'")) {
|
|
187
|
+
return quoted(text, line, /^'((?:[^']|'')*)'$/, (inner) => inner.replaceAll("''", "'"));
|
|
188
|
+
}
|
|
189
|
+
if (text === "[]") {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
if (text === "{}") {
|
|
193
|
+
return {};
|
|
194
|
+
}
|
|
195
|
+
if (/^[[{&*!|>%@`]/.test(text)) {
|
|
196
|
+
throw new YamlError(`dek-words reads no YAML that starts a value with ${text[0]}`, line);
|
|
197
|
+
}
|
|
198
|
+
if (/^-?\d+$/.test(text)) {
|
|
199
|
+
return Number(text);
|
|
200
|
+
}
|
|
201
|
+
if (text === "null" || text === "~") {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
if (text === "true" || text === "false") {
|
|
205
|
+
return text === "true";
|
|
206
|
+
}
|
|
207
|
+
return text;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function quoted(
|
|
211
|
+
text: string,
|
|
212
|
+
line: number,
|
|
213
|
+
pattern: RegExp,
|
|
214
|
+
read: (inner: string) => string,
|
|
215
|
+
): string {
|
|
216
|
+
const inner = text.match(pattern)?.[1];
|
|
217
|
+
if (inner === undefined) {
|
|
218
|
+
throw new YamlError("a quoted value must end with its quote on the same line", line);
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
return read(inner);
|
|
222
|
+
} catch {
|
|
223
|
+
throw new YamlError("a double-quoted value has an escape dek-words does not read", line);
|
|
224
|
+
}
|
|
225
|
+
}
|
package/src/runtime/player.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
1
|
+
import { isAbsolute, join } from "node:path";
|
|
2
|
+
import { bundledPageEntries } from "../core/plugin-host.ts";
|
|
2
3
|
|
|
3
4
|
export { liveReloadScript } from "./live-reload.ts";
|
|
4
5
|
|
|
@@ -27,9 +28,24 @@ export function annotateScript(): Promise<string> {
|
|
|
27
28
|
return annotate;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
let pluginPages: Promise<Record<string, string>> | undefined;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The page of each plugin dek ships, compiled for the browser, by name: what the player and the
|
|
35
|
+
* build carry for a project that pinned the plugin (ADR 34).
|
|
36
|
+
*/
|
|
37
|
+
export function pluginPageScripts(): Promise<Record<string, string>> {
|
|
38
|
+
pluginPages ??= Promise.all(
|
|
39
|
+
Object.entries(bundledPageEntries()).map(
|
|
40
|
+
async ([name, entry]) => [name, await compile(entry, `the ${name} plugin's page`)] as const,
|
|
41
|
+
),
|
|
42
|
+
).then(Object.fromEntries);
|
|
43
|
+
return pluginPages;
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
async function compile(entry: string, what: string): Promise<string> {
|
|
31
47
|
const result = await Bun.build({
|
|
32
|
-
entrypoints: [join(import.meta.dir, entry)],
|
|
48
|
+
entrypoints: [isAbsolute(entry) ? entry : join(import.meta.dir, entry)],
|
|
33
49
|
target: "browser",
|
|
34
50
|
format: "iife",
|
|
35
51
|
minify: true,
|