@texuli/deck 0.0.0-stage → 0.5.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.
Files changed (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +183 -2
  3. package/dist/authoring/builder.d.ts +351 -0
  4. package/dist/authoring/builder.js +64 -0
  5. package/dist/authoring/index.d.ts +6 -0
  6. package/dist/authoring/index.js +6 -0
  7. package/dist/authoring/lint.d.ts +36 -0
  8. package/dist/authoring/lint.js +325 -0
  9. package/dist/authoring/outlines.d.ts +34 -0
  10. package/dist/authoring/outlines.js +255 -0
  11. package/dist/authoring/patch.d.ts +23 -0
  12. package/dist/authoring/patch.js +166 -0
  13. package/dist/authoring/select-layouts.d.ts +19 -0
  14. package/dist/authoring/select-layouts.js +90 -0
  15. package/dist/engine/auto-slide.d.ts +32 -0
  16. package/dist/engine/auto-slide.js +67 -0
  17. package/dist/engine/canvas.d.ts +56 -0
  18. package/dist/engine/canvas.js +48 -0
  19. package/dist/engine/controller.d.ts +126 -0
  20. package/dist/engine/controller.js +248 -0
  21. package/dist/engine/controls.d.ts +128 -0
  22. package/dist/engine/controls.js +460 -0
  23. package/dist/engine/hash.d.ts +42 -0
  24. package/dist/engine/hash.js +86 -0
  25. package/dist/engine/index.d.ts +88 -0
  26. package/dist/engine/index.js +88 -0
  27. package/dist/engine/messages.d.ts +138 -0
  28. package/dist/engine/messages.js +140 -0
  29. package/dist/engine/motion.d.ts +64 -0
  30. package/dist/engine/motion.js +112 -0
  31. package/dist/engine/navigation.d.ts +87 -0
  32. package/dist/engine/navigation.js +156 -0
  33. package/dist/engine/presenter.d.ts +58 -0
  34. package/dist/engine/presenter.js +157 -0
  35. package/dist/index.d.ts +9 -0
  36. package/dist/index.js +9 -0
  37. package/dist/markdown/attributes.d.ts +24 -0
  38. package/dist/markdown/attributes.js +94 -0
  39. package/dist/markdown/attrs-closed.d.ts +52 -0
  40. package/dist/markdown/attrs-closed.js +520 -0
  41. package/dist/markdown/blocks.d.ts +112 -0
  42. package/dist/markdown/blocks.js +766 -0
  43. package/dist/markdown/frontmatter.d.ts +30 -0
  44. package/dist/markdown/frontmatter.js +165 -0
  45. package/dist/markdown/index.d.ts +29 -0
  46. package/dist/markdown/index.js +29 -0
  47. package/dist/markdown/issues.d.ts +68 -0
  48. package/dist/markdown/issues.js +83 -0
  49. package/dist/markdown/json.d.ts +21 -0
  50. package/dist/markdown/json.js +236 -0
  51. package/dist/markdown/limits.d.ts +32 -0
  52. package/dist/markdown/limits.js +32 -0
  53. package/dist/markdown/parse.d.ts +31 -0
  54. package/dist/markdown/parse.js +283 -0
  55. package/dist/markdown/scan.d.ts +26 -0
  56. package/dist/markdown/scan.js +138 -0
  57. package/dist/markdown/slides.d.ts +32 -0
  58. package/dist/markdown/slides.js +854 -0
  59. package/dist/markdown/text.d.ts +76 -0
  60. package/dist/markdown/text.js +447 -0
  61. package/dist/markdown/twin.d.ts +41 -0
  62. package/dist/markdown/twin.js +374 -0
  63. package/dist/markdown/write.d.ts +17 -0
  64. package/dist/markdown/write.js +688 -0
  65. package/dist/math/index.d.ts +14 -0
  66. package/dist/math/index.js +14 -0
  67. package/dist/math/parse.d.ts +9 -0
  68. package/dist/math/parse.js +648 -0
  69. package/dist/math/symbols.d.ts +44 -0
  70. package/dist/math/symbols.js +333 -0
  71. package/dist/math/types.d.ts +59 -0
  72. package/dist/math/types.js +69 -0
  73. package/dist/model/backgrounds/contrast.d.ts +77 -0
  74. package/dist/model/backgrounds/contrast.js +186 -0
  75. package/dist/model/backgrounds/index.d.ts +7 -0
  76. package/dist/model/backgrounds/index.js +7 -0
  77. package/dist/model/backgrounds/resolve.d.ts +86 -0
  78. package/dist/model/backgrounds/resolve.js +215 -0
  79. package/dist/model/backgrounds/schema.d.ts +178 -0
  80. package/dist/model/backgrounds/schema.js +203 -0
  81. package/dist/model/codes.d.ts +73 -0
  82. package/dist/model/codes.js +67 -0
  83. package/dist/model/deck.d.ts +392 -0
  84. package/dist/model/deck.js +338 -0
  85. package/dist/model/index.d.ts +19 -0
  86. package/dist/model/index.js +19 -0
  87. package/dist/model/layouts/catalog.d.ts +433 -0
  88. package/dist/model/layouts/catalog.js +495 -0
  89. package/dist/model/layouts/index.d.ts +5 -0
  90. package/dist/model/layouts/index.js +5 -0
  91. package/dist/model/layouts/props.d.ts +302 -0
  92. package/dist/model/layouts/props.js +272 -0
  93. package/dist/model/loader.d.ts +37 -0
  94. package/dist/model/loader.js +317 -0
  95. package/dist/model/migrate.d.ts +34 -0
  96. package/dist/model/migrate.js +453 -0
  97. package/dist/model/motion/index.d.ts +6 -0
  98. package/dist/model/motion/index.js +6 -0
  99. package/dist/model/motion/transition.d.ts +96 -0
  100. package/dist/model/motion/transition.js +127 -0
  101. package/dist/model/parse.d.ts +57 -0
  102. package/dist/model/parse.js +307 -0
  103. package/dist/model/plain.d.ts +40 -0
  104. package/dist/model/plain.js +275 -0
  105. package/dist/model/shared.d.ts +63 -0
  106. package/dist/model/shared.js +86 -0
  107. package/dist/model/steps/index.d.ts +7 -0
  108. package/dist/model/steps/index.js +7 -0
  109. package/dist/model/steps/phase.d.ts +34 -0
  110. package/dist/model/steps/phase.js +50 -0
  111. package/dist/model/steps/slide-steps.d.ts +129 -0
  112. package/dist/model/steps/slide-steps.js +256 -0
  113. package/dist/model/style/index.d.ts +8 -0
  114. package/dist/model/style/index.js +8 -0
  115. package/dist/model/style/resolve.d.ts +87 -0
  116. package/dist/model/style/resolve.js +174 -0
  117. package/dist/model/style/schema.d.ts +85 -0
  118. package/dist/model/style/schema.js +53 -0
  119. package/dist/model/style/theme.d.ts +69 -0
  120. package/dist/model/style/theme.js +84 -0
  121. package/dist/schema/builders.d.ts +146 -0
  122. package/dist/schema/builders.js +285 -0
  123. package/dist/schema/diagnostics.d.ts +78 -0
  124. package/dist/schema/diagnostics.js +81 -0
  125. package/dist/schema/index.d.ts +15 -0
  126. package/dist/schema/index.js +14 -0
  127. package/dist/schema/internal.d.ts +40 -0
  128. package/dist/schema/internal.js +209 -0
  129. package/dist/schema/json-schema.d.ts +12 -0
  130. package/dist/schema/json-schema.js +213 -0
  131. package/dist/schema/types.d.ts +166 -0
  132. package/dist/schema/types.js +1 -0
  133. package/dist/schema/validate.d.ts +51 -0
  134. package/dist/schema/validate.js +765 -0
  135. package/dist/templates/index.d.ts +2 -0
  136. package/dist/templates/index.js +2 -0
  137. package/dist/templates/templates.d.ts +11 -0
  138. package/dist/templates/templates.js +47 -0
  139. package/package.json +60 -4
@@ -0,0 +1,766 @@
1
+ /**
2
+ * The block reader: one slide's lines → a flat list of nodes with source
3
+ * positions. It recognizes the dialect's block constructs (headings,
4
+ * paragraphs, lists, quotes, pipe tables, image lines, rules, code and math
5
+ * fences, `:::` directives, attribute comments and `Note:`), refuses HTML,
6
+ * and keeps inline text as the Markdown string `@texuli/blocks` paints. One
7
+ * pass; every loop advances at least one line.
8
+ */
9
+ import { LINK_HREF_PATTERN, MEDIA_SRC_PATTERN } from "../model/shared.js";
10
+ import { readAttributes } from "./attributes.js";
11
+ import { DECK_MARKDOWN_LIMITS } from "./limits.js";
12
+ import { trimBlankEnd } from "./text.js";
13
+ /** Directives whose body is one JSON value. */
14
+ export const JSON_DIRECTIVES = new Set(['component', 'block', 'custom', 'props', 'slide', 'chart']);
15
+ /** Directives whose body is Markdown (layout regions). */
16
+ export const REGION_DIRECTIVES = new Set(['left', 'right', 'card', 'video']);
17
+ /*
18
+ * Linear by construction: the patterns below run on lines with no line
19
+ * terminator (see `normalizeLines`), and the ones that ended in `[ \t]*$`
20
+ * after a lazy or greedy capture now run on `trimBlankEnd(text)` and end in
21
+ * `$`, so no two quantifiers compete for the same spaces. Image lines and
22
+ * table alignment rows are scanned by index (`matchImageLine`, `isAlignRow`).
23
+ */
24
+ const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/;
25
+ const RULE = /^ {0,3}(?:\*[ \t]*){3,}$|^ {0,3}(?:_[ \t]*){3,}$/;
26
+ const BULLET = /^( {0,3})([-*+])[ \t]+(.*)$/;
27
+ const ORDERED = /^( {0,3})(\d{1,9})([.)])[ \t]+(.*)$/;
28
+ const QUOTE = /^ {0,3}>[ ]?(.*)$/;
29
+ const FENCE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
30
+ const MATH_OPEN = /^ {0,3}\$\$[ \t]*$/;
31
+ const MATH_LINE = /^ {0,3}\$\$(.+)\$\$$/;
32
+ const DIRECTIVE = /^ {0,3}:::[ \t]*([A-Za-z][A-Za-z0-9-]*)?(.*)$/;
33
+ const DIRECTIVE_CLOSE = /^ {0,3}:::[ \t]*$/;
34
+ const NOTE = /^(?:Notes?|notes?):(?:[ \t]?(.*))?$/;
35
+ /**
36
+ * Notes lines the writer escapes with a leading `\`: separators and fence
37
+ * openers, which the slide splitter would otherwise read.
38
+ */
39
+ export const NOTE_ESCAPED = /^\\(?=-{2,}[ \t]*$| {0,3}(?::::|`{3}|~{3}|\$\$))/;
40
+ const TABLE_ROW = /^ {0,3}\|/;
41
+ const HTML_LINE = /^ {0,3}<[A-Za-z/!?]/;
42
+ const AUTOLINK = /^ {0,3}<(?:https?:\/\/|mailto:)/i;
43
+ const LINE_SPEC = /^\d{1,4}(?:-\d{1,4})?(?:,\d{1,4}(?:-\d{1,4})?)*$/;
44
+ const LANG = /^[a-z][a-z0-9+-]{0,31}$/;
45
+ const LINK_HREF = new RegExp(LINK_HREF_PATTERN, 'u');
46
+ const MEDIA_SRC = new RegExp(MEDIA_SRC_PATTERN, 'u');
47
+ /** True for an HTML tag at the start of a line (comments and autolinks are not tags). */
48
+ function isHtmlLine(text) {
49
+ return HTML_LINE.test(text) && !text.trimStart().startsWith('<!--') && !AUTOLINK.test(text);
50
+ }
51
+ /** `\s` as the regex class reads it (spaces, tabs, Unicode spaces). */
52
+ function isSpace(char) {
53
+ return char !== undefined && /\s/.test(char);
54
+ }
55
+ /**
56
+ * An image alone on its line: `![alt](url "title")`, with `<url>` allowed.
57
+ * Scanned by index: the regex it replaces (`\(\s*<?([^\s<>]*)>?(?:\s+"…")?\s*\)`)
58
+ * backtracked over a long run of spaces between the parentheses.
59
+ */
60
+ export function matchImageLine(line) {
61
+ const text = trimBlankEnd(line);
62
+ let i = 0;
63
+ while (i < 3 && text[i] === ' ')
64
+ i++;
65
+ if (!text.startsWith('![', i))
66
+ return null;
67
+ const altEnd = text.indexOf(']', i + 2);
68
+ if (altEnd < 0 || text[altEnd + 1] !== '(' || !text.endsWith(')'))
69
+ return null;
70
+ const alt = text.slice(i + 2, altEnd);
71
+ const inner = text.slice(altEnd + 2, text.length - 1);
72
+ let at = 0;
73
+ while (isSpace(inner[at]))
74
+ at++;
75
+ if (inner[at] === '<')
76
+ at++;
77
+ const urlStart = at;
78
+ while (at < inner.length && !isSpace(inner[at]) && inner[at] !== '<' && inner[at] !== '>')
79
+ at++;
80
+ const url = inner.slice(urlStart, at);
81
+ if (inner[at] === '>')
82
+ at++;
83
+ const gap = at;
84
+ while (isSpace(inner[at]))
85
+ at++;
86
+ if (at === inner.length)
87
+ return { alt, url };
88
+ // A title needs a space before it and only spaces after it.
89
+ if (at === gap || inner[at] !== '"')
90
+ return null;
91
+ const close = inner.indexOf('"', at + 1);
92
+ if (close < 0)
93
+ return null;
94
+ let end = close + 1;
95
+ while (isSpace(inner[end]))
96
+ end++;
97
+ if (end !== inner.length)
98
+ return null;
99
+ return { alt, url, title: inner.slice(at + 1, close) };
100
+ }
101
+ /** `[ \t]` trimmed off both ends, by index. */
102
+ function trimBlank(text) {
103
+ let start = 0;
104
+ while (text[start] === ' ' || text[start] === '\t')
105
+ start++;
106
+ return trimBlankEnd(text.slice(start));
107
+ }
108
+ const ALIGN_CELL = /^:?-+:?$/;
109
+ /**
110
+ * A table alignment row (`| :--- | ---: |`): split on `|`, every cell
111
+ * `:?-+:?` between spaces or tabs; outer pipes optional. Scanned by
112
+ * splitting: the single regex it replaces backtracked across cells.
113
+ */
114
+ export function isAlignRow(line) {
115
+ let text = line;
116
+ let i = 0;
117
+ while (i < 3 && text[i] === ' ')
118
+ i++;
119
+ text = text.slice(text[i] === '|' ? i + 1 : i);
120
+ text = trimBlankEnd(text);
121
+ if (text.endsWith('|'))
122
+ text = text.slice(0, -1);
123
+ const cells = text.split('|');
124
+ for (const cell of cells)
125
+ if (!ALIGN_CELL.test(trimBlank(cell)))
126
+ return false;
127
+ return true;
128
+ }
129
+ /** Split a trailing `<!-- .element: … -->` off a line. */
130
+ function trailingElement(text) {
131
+ const at = text.lastIndexOf('<!--');
132
+ if (at <= 0)
133
+ return null;
134
+ const tail = /^<!--[ \t]*\.element:(.*)-->$/.exec(trimBlankEnd(text.slice(at)));
135
+ if (!tail || (tail[1] ?? '').includes('-->'))
136
+ return null;
137
+ return {
138
+ text: trimBlankEnd(text.slice(0, at)),
139
+ attrs: tail[1] ?? '',
140
+ column: at + 1 + text.slice(at).indexOf(':') + 1,
141
+ };
142
+ }
143
+ class Reader {
144
+ lines;
145
+ sink;
146
+ i = 0;
147
+ slideAttrs = [];
148
+ notes;
149
+ constructor(lines, sink) {
150
+ this.lines = lines;
151
+ this.sink = sink;
152
+ }
153
+ src(line, column = 1) {
154
+ return { line: line.n, column };
155
+ }
156
+ /** Read element attributes from comment text at `line`/`column`. */
157
+ elementAttrs(text, line, column) {
158
+ return { list: readAttributes(text, line, column, this.sink), src: { line, column } };
159
+ }
160
+ /** Read nodes until the end, or until a bare `:::` when `inRegion`. */
161
+ nodes(depth, inRegion) {
162
+ const out = [];
163
+ while (this.i < this.lines.length) {
164
+ const line = this.lines[this.i];
165
+ const text = line.text;
166
+ if (text.trim() === '') {
167
+ this.i++;
168
+ continue;
169
+ }
170
+ if (DIRECTIVE_CLOSE.test(text)) {
171
+ this.i++;
172
+ if (inRegion)
173
+ return { nodes: out, closed: true };
174
+ this.sink.report('md-region-unknown', 'A `:::` closes nothing.', this.src(line));
175
+ continue;
176
+ }
177
+ if (!inRegion) {
178
+ const note = NOTE.exec(text);
179
+ if (note) {
180
+ this.readNotes(note[1] ?? '', line);
181
+ return { nodes: out, closed: false };
182
+ }
183
+ }
184
+ const trimmed = text.trimStart();
185
+ if (trimmed.startsWith('<!--')) {
186
+ this.comment(line, out, inRegion);
187
+ continue;
188
+ }
189
+ if (isHtmlLine(text)) {
190
+ const iframe = /^<\/?iframe\b/i.test(trimmed);
191
+ this.sink.report(iframe ? 'md-iframe-refused' : 'md-html-refused', iframe ? 'iframes are refused.' : 'Raw HTML is refused; this line renders nothing.', this.src(line, text.length - trimmed.length + 1), [], iframe
192
+ ? 'Place a catalog component with `::: component <id>` instead.'
193
+ : 'Use Markdown, a `layout` with `:::` regions, or `::: component <id>`.');
194
+ this.i++;
195
+ continue;
196
+ }
197
+ const node = this.block(depth);
198
+ if (node)
199
+ out.push(node);
200
+ }
201
+ if (inRegion)
202
+ return { nodes: out, closed: false };
203
+ return { nodes: out, closed: true };
204
+ }
205
+ readNotes(first, line) {
206
+ const parts = [first];
207
+ for (this.i++; this.i < this.lines.length; this.i++) {
208
+ parts.push(this.lines[this.i].text);
209
+ }
210
+ const text = parts
211
+ .map((part) => part.replace(NOTE_ESCAPED, ''))
212
+ .join('\n')
213
+ .trimEnd();
214
+ this.notes = { text, src: this.src(line) };
215
+ }
216
+ comment(line, out, inRegion) {
217
+ const text = line.text.trim();
218
+ const kind = /^<!--[ \t]*\.(slide|element):/.exec(text);
219
+ if (kind) {
220
+ const close = text.lastIndexOf('-->');
221
+ const head = kind[0].length;
222
+ const column = line.text.indexOf('<!--') + 1;
223
+ if (close < head || !text.endsWith('-->')) {
224
+ this.sink.report('md-attr-value', `An \`.${kind[1]}\` comment must open and close on one line.`, this.src(line, column));
225
+ this.i++;
226
+ return;
227
+ }
228
+ const body = text.slice(head, close);
229
+ const bodyColumn = column + head;
230
+ if (kind[1] === 'slide') {
231
+ if (inRegion) {
232
+ this.sink.report('md-attr-value', '`.slide` attributes belong at the top level of a slide, not inside a region.', this.src(line, column));
233
+ }
234
+ else
235
+ this.slideAttrs.push(...readAttributes(body, line.n, bodyColumn, this.sink));
236
+ }
237
+ else {
238
+ const target = out[out.length - 1];
239
+ if (!target) {
240
+ this.sink.report('md-element-orphan', 'This `.element` comment has no element before it.', this.src(line, column), [], 'Put it at the end of the element’s line, or on the line right after the element.');
241
+ }
242
+ else
243
+ this.attach(target, this.elementAttrs(body, line.n, bodyColumn));
244
+ }
245
+ this.i++;
246
+ return;
247
+ }
248
+ // A plain comment: dropped, possibly over several lines.
249
+ for (let j = this.i; j < this.lines.length; j++) {
250
+ const candidate = this.lines[j].text;
251
+ const from = j === this.i ? candidate.indexOf('<!--') + 4 : 0;
252
+ if (candidate.indexOf('-->', from) !== -1) {
253
+ this.i = j + 1;
254
+ return;
255
+ }
256
+ }
257
+ this.sink.report('md-comment-unclosed', 'This comment never closes; the rest of the slide was dropped.', this.src(line, line.text.indexOf('<!--') + 1), [], 'Close the comment with `-->`.');
258
+ this.i = this.lines.length;
259
+ }
260
+ attach(target, attrs) {
261
+ if (target.attrs) {
262
+ target.attrs.list.push(...attrs.list);
263
+ return;
264
+ }
265
+ target.attrs = attrs;
266
+ }
267
+ /** Take a trailing element comment off the current line's text. */
268
+ stripTrailing(line) {
269
+ const tail = trailingElement(line.text);
270
+ if (!tail)
271
+ return { text: line.text };
272
+ return { text: tail.text, attrs: this.elementAttrs(tail.attrs, line.n, tail.column) };
273
+ }
274
+ block(depth) {
275
+ const line = this.lines[this.i];
276
+ const text = line.text;
277
+ const fence = FENCE.exec(text);
278
+ if (fence && !((fence[2] ?? '').startsWith('`') && (fence[3] ?? '').includes('`'))) {
279
+ return this.code(line, fence[1]?.length ?? 0, fence[2] ?? '```', fence[3] ?? '');
280
+ }
281
+ const mathLine = MATH_LINE.exec(trimBlankEnd(text));
282
+ if (mathLine) {
283
+ this.i++;
284
+ return { kind: 'math', tex: (mathLine[1] ?? '').trim(), src: this.src(line) };
285
+ }
286
+ if (MATH_OPEN.test(text))
287
+ return this.math(line);
288
+ const directive = DIRECTIVE.exec(text);
289
+ if (directive && /^ {0,3}:::/.test(text)) {
290
+ return this.directive(line, directive[1] ?? '', directive[2] ?? '', depth);
291
+ }
292
+ const { text: content, attrs } = this.stripTrailing(line);
293
+ const heading = HEADING.exec(trimBlankEnd(content));
294
+ if (heading) {
295
+ this.i++;
296
+ const value = heading[2] ?? '';
297
+ const node = {
298
+ kind: 'heading',
299
+ level: (heading[1] ?? '#').length,
300
+ text: this.inline(value, line, content.indexOf(value) + 1),
301
+ src: this.src(line),
302
+ };
303
+ if (/(?<!\\)(?:\*|_|`|\[)/.test(value)) {
304
+ this.sink.report('md-heading-inline', 'Headings are plain text: Markdown inside a heading shows literally.', this.src(line), [], 'Remove the emphasis, or escape it with a backslash.');
305
+ }
306
+ if (attrs)
307
+ node.attrs = attrs;
308
+ return node;
309
+ }
310
+ if (RULE.test(content)) {
311
+ this.i++;
312
+ const node = { kind: 'rule', src: this.src(line) };
313
+ if (attrs)
314
+ node.attrs = attrs;
315
+ return node;
316
+ }
317
+ const image = matchImageLine(content);
318
+ if (image) {
319
+ this.i++;
320
+ const url = image.url;
321
+ this.checkUrl(url, true, line, content.indexOf('](') + 3);
322
+ const node = { kind: 'image', alt: image.alt, url, src: this.src(line) };
323
+ if (image.title !== undefined)
324
+ node.title = image.title;
325
+ if (attrs)
326
+ node.attrs = attrs;
327
+ return node;
328
+ }
329
+ if (QUOTE.test(content))
330
+ return this.quote();
331
+ if (BULLET.test(content) || ORDERED.test(content))
332
+ return this.list();
333
+ if (TABLE_ROW.test(content) && isAlignRow(this.lines[this.i + 1]?.text ?? '')) {
334
+ return this.table();
335
+ }
336
+ return this.paragraph();
337
+ }
338
+ /** True when `text` starts a block other than a paragraph continuation. */
339
+ startsBlock(text) {
340
+ return (text.trim() === '' ||
341
+ FENCE.test(text) ||
342
+ MATH_OPEN.test(text) ||
343
+ MATH_LINE.test(trimBlankEnd(text)) ||
344
+ /^ {0,3}:::/.test(text) ||
345
+ HEADING.test(trimBlankEnd(text)) ||
346
+ RULE.test(text) ||
347
+ QUOTE.test(text) ||
348
+ BULLET.test(text) ||
349
+ ORDERED.test(text) ||
350
+ TABLE_ROW.test(text) ||
351
+ text.trimStart().startsWith('<!--') ||
352
+ isHtmlLine(text) ||
353
+ NOTE.test(text) ||
354
+ matchImageLine(text) !== null);
355
+ }
356
+ paragraph() {
357
+ const first = this.lines[this.i];
358
+ const parts = [];
359
+ let attrs;
360
+ while (this.i < this.lines.length) {
361
+ const line = this.lines[this.i];
362
+ if (parts.length > 0 && this.startsBlock(line.text))
363
+ break;
364
+ const stripped = this.stripTrailing(line);
365
+ if (stripped.attrs)
366
+ attrs = stripped.attrs;
367
+ parts.push(this.inline(stripped.text.trim(), line, line.text.length - line.text.trimStart().length + 1));
368
+ this.i++;
369
+ if (stripped.attrs)
370
+ break;
371
+ }
372
+ const node = { kind: 'paragraph', text: parts.join('\n'), src: this.src(first) };
373
+ if (attrs)
374
+ node.attrs = attrs;
375
+ return node;
376
+ }
377
+ quote() {
378
+ const first = this.lines[this.i];
379
+ const parts = [];
380
+ let attrs;
381
+ while (this.i < this.lines.length) {
382
+ const line = this.lines[this.i];
383
+ const stripped = this.stripTrailing(line);
384
+ const match = QUOTE.exec(stripped.text);
385
+ if (!match)
386
+ break;
387
+ if (stripped.attrs)
388
+ attrs = stripped.attrs;
389
+ parts.push(this.inline(match[1] ?? '', line, stripped.text.indexOf('>') + 2));
390
+ this.i++;
391
+ }
392
+ let cite;
393
+ const last = parts[parts.length - 1] ?? '';
394
+ const citeMatch = /^(?:—|--|-)[ \t]+(.+)$/.exec(last);
395
+ if (citeMatch && parts.length > 1) {
396
+ cite = citeMatch[1];
397
+ parts.pop();
398
+ while (parts.length > 0 && (parts[parts.length - 1] ?? '').trim() === '')
399
+ parts.pop();
400
+ }
401
+ const node = { kind: 'quote', text: parts.join('\n'), src: this.src(first) };
402
+ if (cite !== undefined)
403
+ node.cite = cite;
404
+ if (attrs)
405
+ node.attrs = attrs;
406
+ return node;
407
+ }
408
+ list() {
409
+ const first = this.lines[this.i];
410
+ const ordered = ORDERED.test(this.stripTrailing(first).text);
411
+ const items = [];
412
+ while (this.i < this.lines.length) {
413
+ const line = this.lines[this.i];
414
+ const stripped = this.stripTrailing(line);
415
+ const bullet = BULLET.exec(stripped.text);
416
+ const numbered = ORDERED.exec(stripped.text);
417
+ const match = ordered ? numbered : bullet;
418
+ if (!match || RULE.test(stripped.text))
419
+ break;
420
+ const content = (ordered ? match[4] : match[3]) ?? '';
421
+ const markerWidth = match[0].length - content.length;
422
+ const indent = markerWidth;
423
+ const body = [this.inline(content, line, markerWidth + 1)];
424
+ this.i++;
425
+ // Continuation: indented lines (nested lists, wrapped text), and lazy
426
+ // lines that start no other block.
427
+ while (this.i < this.lines.length) {
428
+ const next = this.lines[this.i].text;
429
+ if (next.trim() === '') {
430
+ const after = this.lines[this.i + 1]?.text ?? '';
431
+ if (/^ {2,}\S/.test(after)) {
432
+ body.push('');
433
+ this.i++;
434
+ continue;
435
+ }
436
+ break;
437
+ }
438
+ if (/^ {2,}\S/.test(next) && !DIRECTIVE_CLOSE.test(next)) {
439
+ const spaces = next.length - next.trimStart().length;
440
+ const cut = Math.min(spaces, markerWidth);
441
+ body.push(this.inline(next.slice(cut), this.lines[this.i], cut + 1));
442
+ this.i++;
443
+ continue;
444
+ }
445
+ if (this.startsBlock(next))
446
+ break;
447
+ body.push(this.inline(next.trim(), this.lines[this.i], 1));
448
+ this.i++;
449
+ }
450
+ const item = { text: body.join('\n'), src: this.src(line, indent + 1) };
451
+ if (stripped.attrs)
452
+ item.attrs = stripped.attrs;
453
+ items.push(item);
454
+ }
455
+ return { kind: 'list', ordered, items, src: this.src(first) };
456
+ }
457
+ table() {
458
+ const first = this.lines[this.i];
459
+ const header = splitCells(this.stripTrailing(first).text).map((cell) => this.inline(cell, first, 1));
460
+ this.i += 2;
461
+ const rows = [];
462
+ while (this.i < this.lines.length) {
463
+ const line = this.lines[this.i];
464
+ const stripped = this.stripTrailing(line);
465
+ if (!TABLE_ROW.test(stripped.text))
466
+ break;
467
+ const row = {
468
+ cells: splitCells(stripped.text).map((cell) => this.inline(cell, line, 1)),
469
+ src: this.src(line),
470
+ };
471
+ if (stripped.attrs)
472
+ row.attrs = stripped.attrs;
473
+ rows.push(row);
474
+ this.i++;
475
+ }
476
+ return { kind: 'table', header, rows, src: this.src(first) };
477
+ }
478
+ code(line, indent, marker, info) {
479
+ this.i++;
480
+ const char = marker[0] ?? '`';
481
+ const body = [];
482
+ let closed = false;
483
+ while (this.i < this.lines.length) {
484
+ const text = this.lines[this.i].text;
485
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(text);
486
+ if (close?.[1] !== undefined && close[1][0] === char && close[1].length >= marker.length) {
487
+ this.i++;
488
+ closed = true;
489
+ break;
490
+ }
491
+ const spaces = text.length - text.trimStart().length;
492
+ body.push(text.slice(Math.min(spaces, indent)));
493
+ this.i++;
494
+ }
495
+ if (!closed) {
496
+ this.sink.report('md-code-unclosed', 'This code fence never closes; it runs to the end of the slide.', this.src(line, indent + 1), [], `Close it with a ${marker} line.`);
497
+ }
498
+ const node = { kind: 'code', code: body.join('\n'), src: this.src(line, indent + 1) };
499
+ this.codeInfo(node, info, line, indent + marker.length + 1);
500
+ return node;
501
+ }
502
+ codeInfo(node, info, line, column) {
503
+ let rest = info;
504
+ let at = column;
505
+ const skip = () => {
506
+ const trimmed = rest.replace(/^[ \t]+/, '');
507
+ at += rest.length - trimmed.length;
508
+ rest = trimmed;
509
+ };
510
+ skip();
511
+ const lang = /^[^\s[{]+/.exec(rest)?.[0];
512
+ if (lang !== undefined) {
513
+ if (LANG.test(lang))
514
+ node.lang = lang;
515
+ else {
516
+ this.sink.report('md-code-info', `"${lang.slice(0, 32)}" is not a language id; it was dropped.`, { line: line.n, column: at }, [], 'Use a lowercase id such as ts, js, py or sql.');
517
+ }
518
+ rest = rest.slice(lang.length);
519
+ at += lang.length;
520
+ skip();
521
+ }
522
+ if (rest.startsWith('[')) {
523
+ const end = rest.indexOf(']');
524
+ if (end === -1) {
525
+ this.sink.report('md-code-steps', 'The `[` of the highlight steps never closes.', {
526
+ line: line.n,
527
+ column: at,
528
+ });
529
+ rest = '';
530
+ }
531
+ else {
532
+ this.codeSteps(node, rest.slice(1, end), line.n, at);
533
+ rest = rest.slice(end + 1);
534
+ at += end + 1;
535
+ skip();
536
+ }
537
+ }
538
+ if (rest.startsWith('{') && rest.trimEnd().endsWith('}')) {
539
+ const inner = rest.trimEnd().slice(1, -1);
540
+ node.brace = {
541
+ list: readAttributes(inner, line.n, at + 1, this.sink, true),
542
+ src: { line: line.n, column: at },
543
+ };
544
+ rest = '';
545
+ }
546
+ if (rest.trim() !== '') {
547
+ this.sink.report('md-code-info', 'Unread text after the code fence language; it was dropped.', { line: line.n, column: at }, [], 'Write ```lang [1-2|4-6] {filename="x.ts"}.');
548
+ }
549
+ }
550
+ codeSteps(node, raw, line, column) {
551
+ let steps = raw;
552
+ const offset = /^\s*\d+\s*:/.exec(steps);
553
+ if (offset) {
554
+ this.sink.report('md-code-offset-unsupported', 'A `[n: …]` line offset is not supported; it was dropped.', { line, column });
555
+ steps = steps.slice(offset[0].length);
556
+ }
557
+ const parts = steps.split('|').map((part) => part.replace(/\s+/g, ''));
558
+ if (parts.length > DECK_MARKDOWN_LIMITS.codeSteps) {
559
+ this.sink.report('md-code-steps', `A code fence has at most ${DECK_MARKDOWN_LIMITS.codeSteps} highlight steps.`, { line, column });
560
+ return;
561
+ }
562
+ for (const part of parts) {
563
+ if (!LINE_SPEC.test(part)) {
564
+ this.sink.report('md-code-steps', `"${part.slice(0, 40)}" is not a line range list such as 1-2,4.`, { line, column }, [], 'Write steps as [1-2|4-6|8].');
565
+ return;
566
+ }
567
+ }
568
+ if (parts.length > 0 && !(parts.length === 1 && parts[0] === ''))
569
+ node.highlights = parts;
570
+ }
571
+ math(line) {
572
+ this.i++;
573
+ const body = [];
574
+ let closed = false;
575
+ while (this.i < this.lines.length) {
576
+ const text = this.lines[this.i].text;
577
+ this.i++;
578
+ if (MATH_OPEN.test(text)) {
579
+ closed = true;
580
+ break;
581
+ }
582
+ body.push(text);
583
+ }
584
+ if (!closed) {
585
+ this.sink.report('md-math-unclosed', 'This `$$` block never closes; it runs to the end of the slide.', this.src(line), [], 'Close it with a `$$` line.');
586
+ }
587
+ return { kind: 'math', tex: body.join('\n').trim(), src: this.src(line) };
588
+ }
589
+ directive(line, name, rest, depth) {
590
+ const column = line.text.indexOf(':::') + 1;
591
+ const src = this.src(line, column);
592
+ this.i++;
593
+ let tail = rest.trim();
594
+ let brace;
595
+ const braceAt = tail.indexOf('{');
596
+ if (braceAt !== -1 && tail.endsWith('}')) {
597
+ const inner = tail.slice(braceAt + 1, -1);
598
+ const innerColumn = line.text.indexOf('{', column) + 2;
599
+ brace = {
600
+ list: readAttributes(inner, line.n, innerColumn, this.sink, true),
601
+ src: { line: line.n, column: innerColumn - 1 },
602
+ };
603
+ tail = tail.slice(0, braceAt).trim();
604
+ }
605
+ const node = { kind: 'directive', name, src };
606
+ if (tail !== '')
607
+ node.arg = tail;
608
+ if (brace)
609
+ node.brace = brace;
610
+ if (JSON_DIRECTIVES.has(name)) {
611
+ const body = [];
612
+ let closed = false;
613
+ const bodyLine = this.lines[this.i]?.n ?? line.n + 1;
614
+ while (this.i < this.lines.length) {
615
+ const text = this.lines[this.i].text;
616
+ this.i++;
617
+ if (DIRECTIVE_CLOSE.test(text)) {
618
+ closed = true;
619
+ break;
620
+ }
621
+ body.push(text);
622
+ }
623
+ if (!closed)
624
+ this.unclosed(name, src);
625
+ node.body = body.join('\n');
626
+ node.bodyLine = bodyLine;
627
+ return node;
628
+ }
629
+ if (REGION_DIRECTIVES.has(name)) {
630
+ if (depth + 1 >= DECK_MARKDOWN_LIMITS.directiveDepth) {
631
+ this.sink.report('md-directive-depth', 'A region cannot hold another region.', src, [], 'Close the outer region first; regions only hold Markdown and JSON fences.');
632
+ }
633
+ const inner = this.nodes(depth + 1, true);
634
+ if (!inner.closed)
635
+ this.unclosed(name, src);
636
+ node.children = inner.nodes;
637
+ return node;
638
+ }
639
+ this.sink.report('md-region-unknown', name === ''
640
+ ? 'A `:::` directive needs a name.'
641
+ : `Unknown directive "::: ${name.slice(0, 32)}".`, src, [], 'Use a region (left, right, card, video) or a fence (component, block, chart, custom, props, slide).');
642
+ // Skip to its closer so its body is not read as slide content.
643
+ while (this.i < this.lines.length) {
644
+ const text = this.lines[this.i].text;
645
+ this.i++;
646
+ if (DIRECTIVE_CLOSE.test(text))
647
+ break;
648
+ }
649
+ return node;
650
+ }
651
+ unclosed(name, src) {
652
+ this.sink.report('md-region-unclosed', `"::: ${name}" never closes before the end of the slide.`, src, [], 'Close it with a `:::` line.');
653
+ }
654
+ /**
655
+ * Check inline Markdown: raw HTML tags (outside code spans) are reported and
656
+ * escaped so they stay text; link and image URLs get the syntax check;
657
+ * images inside text are flagged (they render as alt text).
658
+ */
659
+ inline(text, line, column) {
660
+ if (!text.includes('<') && !text.includes(']('))
661
+ return text;
662
+ let out = '';
663
+ let i = 0;
664
+ let start = 0;
665
+ // A run length with no closer, or a missing `)`, stays missing further on:
666
+ // remembering it keeps the scan linear on hostile input.
667
+ const unmatched = new Set();
668
+ let lastBracket = -1;
669
+ while (i < text.length) {
670
+ const c = text[i];
671
+ if (c === '`') {
672
+ let run = 1;
673
+ while (text[i + run] === '`')
674
+ run++;
675
+ const close = unmatched.has(run) ? -1 : text.indexOf('`'.repeat(run), i + run);
676
+ if (close === -1)
677
+ unmatched.add(run);
678
+ i = close === -1 ? i + run : close + run;
679
+ continue;
680
+ }
681
+ if (c === '\\') {
682
+ i += 2;
683
+ continue;
684
+ }
685
+ if (c === '<' && /[A-Za-z/!?]/.test(text[i + 1] ?? '')) {
686
+ const auto = /^<(?:https?:\/\/|mailto:)[^\s<>]*>/i.exec(text.slice(i, i + 2100));
687
+ if (auto) {
688
+ this.checkUrl(auto[0].slice(1, -1), false, line, column + i + 1);
689
+ i += auto[0].length;
690
+ continue;
691
+ }
692
+ const iframe = /^<\/?iframe\b/i.test(text.slice(i, i + 8));
693
+ this.sink.report(iframe ? 'md-iframe-refused' : 'md-html-refused', iframe ? 'iframes are refused.' : 'Inline HTML is refused; the tag stays literal text.', { line: line.n, column: column + i }, [], 'Use Markdown emphasis, links and code instead of tags.');
694
+ out += `${text.slice(start, i)}\\<`;
695
+ i++;
696
+ start = i;
697
+ continue;
698
+ }
699
+ if (c === ']' && text[i + 1] === '(') {
700
+ // The destination: up to whitespace, `)` or `>` (each character read once).
701
+ let j = i + 2;
702
+ while (text[j] === ' ' || text[j] === '<')
703
+ j++;
704
+ const from = j;
705
+ while (j < text.length && j - from < 2100 && !/[\s)<>]/.test(text[j] ?? ''))
706
+ j++;
707
+ const url = text.slice(from, j);
708
+ const isImage = lastBracket > 0 && text[lastBracket - 1] === '!';
709
+ if (isImage) {
710
+ this.sink.report('md-inline-image', 'An image inside text renders as its alt text only.', { line: line.n, column: column + lastBracket - 1 }, [], 'Put the image on its own line.');
711
+ }
712
+ this.checkUrl(url, isImage, line, column + from);
713
+ i = Math.max(j, i + 2);
714
+ continue;
715
+ }
716
+ if (c === '[')
717
+ lastBracket = i;
718
+ i++;
719
+ }
720
+ return start === 0 ? text : out + text.slice(start);
721
+ }
722
+ checkUrl(url, media, line, column) {
723
+ if (url === '')
724
+ return;
725
+ const ok = media ? MEDIA_SRC.test(url) : LINK_HREF.test(url);
726
+ if (ok)
727
+ return;
728
+ this.sink.report('md-url-refused', media
729
+ ? 'Media must be an https: URL, a relative path (no .. or .svg), a base64 data: raster or video, or an avatar: id.'
730
+ : 'Links must be https:, mailto:, a #fragment or a relative path.', { line: line.n, column }, [], 'Use an allowed URL; javascript:, data:text/html and protocol-relative URLs are refused.');
731
+ }
732
+ }
733
+ /** Split a pipe-table row into trimmed cells (`\|` stays inside a cell). */
734
+ export function splitCells(row) {
735
+ let text = row.trim();
736
+ if (text.startsWith('|'))
737
+ text = text.slice(1);
738
+ if (text.endsWith('|') && !text.endsWith('\\|'))
739
+ text = text.slice(0, -1);
740
+ const cells = [];
741
+ let current = '';
742
+ for (let i = 0; i < text.length; i++) {
743
+ const c = text[i];
744
+ if (c === '\\' && text[i + 1] === '|') {
745
+ current += '\\|';
746
+ i++;
747
+ }
748
+ else if (c === '|') {
749
+ cells.push(current.trim());
750
+ current = '';
751
+ }
752
+ else
753
+ current += c;
754
+ }
755
+ cells.push(current.trim());
756
+ return cells;
757
+ }
758
+ /** Read one slide's lines into nodes, slide attributes and notes. */
759
+ export function readSlide(lines, sink) {
760
+ const reader = new Reader(lines, sink);
761
+ const { nodes } = reader.nodes(0, false);
762
+ const out = { nodes, slideAttrs: reader.slideAttrs };
763
+ if (reader.notes)
764
+ out.notes = reader.notes;
765
+ return out;
766
+ }