@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,854 @@
1
+ /**
2
+ * Slide builder: the nodes of one slide → a v2 slide object (not yet
3
+ * validated), with fragments numbered the reveal.js way and a source map
4
+ * from JSON paths to Markdown positions. Free slides get blocks; the 20
5
+ * catalog layouts map headings, paragraphs, lists, images, tables and `:::`
6
+ * regions onto their props; `component`, `custom` and `::: props` / `::: slide`
7
+ * fences carry bounded JSON.
8
+ */
9
+ import { texToMath } from "../math/index.js";
10
+ import { SLIDE_MASTERS } from "../model/deck.js";
11
+ import { DECK_CATALOG_LAYOUT_IDS } from "../model/layouts/catalog.js";
12
+ import { toJsonPath } from "../schema/index.js";
13
+ import {} from "./attributes.js";
14
+ import { readElement, readSlideAttrs, } from "./attrs-closed.js";
15
+ import { readJson } from "./json.js";
16
+ import { DECK_MARKDOWN_LIMITS } from "./limits.js";
17
+ import { unescapeInline } from "./text.js";
18
+ class SlideBuilder {
19
+ ctx;
20
+ base;
21
+ marks = [];
22
+ constructor(ctx, base) {
23
+ this.ctx = ctx;
24
+ this.base = base;
25
+ }
26
+ get sink() {
27
+ return this.ctx.sink;
28
+ }
29
+ record(path, src) {
30
+ const key = toJsonPath([...this.base, ...path]);
31
+ if (!this.ctx.map.has(key))
32
+ this.ctx.map.set(key, src);
33
+ }
34
+ /** Read element/brace attributes onto `target`: id, fragment marks and allowed extras. */
35
+ element(target, attrs, allowed = []) {
36
+ const read = readElement(attrs?.list ?? [], new Set(allowed), this.sink);
37
+ if (read.id !== undefined)
38
+ target.id = read.id;
39
+ if (read.mark) {
40
+ if (read.mark.fragment)
41
+ this.marks.push({ kind: 'one', target, spec: read.mark });
42
+ else if (read.mark.tone !== undefined)
43
+ target.tone = read.mark.tone;
44
+ }
45
+ return read;
46
+ }
47
+ extra(node, what) {
48
+ this.sink.report('md-layout-extra', `${what} has no place in this layout.`, node.src, this.base, 'Remove it, use a free slide, or write the props as a `::: props` JSON fence.');
49
+ }
50
+ missing(src, what, path) {
51
+ this.sink.report('md-layout-missing', `This layout needs ${what}.`, src, [...this.base, ...path], 'Add it, or check the layout sample in the layout catalog.');
52
+ }
53
+ /** Parse a JSON fence body; record every value's position. */
54
+ json(node, path) {
55
+ const body = node.body ?? '';
56
+ const result = readJson(body, { nodes: this.ctx.jsonBudget.nodes });
57
+ this.ctx.jsonBudget.nodes = Math.max(0, this.ctx.jsonBudget.nodes - result.nodes);
58
+ const where = (offset) => {
59
+ let line = node.bodyLine ?? node.src.line + 1;
60
+ let lineStart = 0;
61
+ for (let i = 0; i < offset && i < body.length; i++) {
62
+ if (body.charCodeAt(i) === 0x0a) {
63
+ line++;
64
+ lineStart = i + 1;
65
+ }
66
+ }
67
+ return { line, column: offset - lineStart + 1 };
68
+ };
69
+ if (!result.ok) {
70
+ this.sink.report(result.limit ? 'md-json-limit' : 'md-json', result.message, where(result.offset), [...this.base, ...path], 'Write one JSON object between the opening `::: …` line and a closing `:::` line.');
71
+ return undefined;
72
+ }
73
+ // Offsets → positions in one pass over the body.
74
+ const entries = [...result.offsets.entries()].sort((a, b) => a[1] - b[1]);
75
+ let line = node.bodyLine ?? node.src.line + 1;
76
+ let lineStart = 0;
77
+ let i = 0;
78
+ for (const [relative, offset] of entries) {
79
+ for (; i < offset && i < body.length; i++) {
80
+ if (body.charCodeAt(i) === 0x0a) {
81
+ line++;
82
+ lineStart = i + 1;
83
+ }
84
+ }
85
+ const suffix = relative.slice(1);
86
+ const key = `${toJsonPath([...this.base, ...path])}${suffix}`;
87
+ if (!this.ctx.map.has(key))
88
+ this.ctx.map.set(key, { line, column: offset - lineStart + 1 });
89
+ }
90
+ return result.value;
91
+ }
92
+ jsonObject(node, path, what) {
93
+ const value = this.json(node, path);
94
+ if (value === undefined)
95
+ return undefined;
96
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
97
+ this.sink.report('md-json', `${what} must be a JSON object.`, node.src, [
98
+ ...this.base,
99
+ ...path,
100
+ ]);
101
+ return undefined;
102
+ }
103
+ return value;
104
+ }
105
+ /** Number the marks: reveal.js order, explicit `step` values kept. */
106
+ numberMarks() {
107
+ let counter = 0;
108
+ const apply = (target, spec, step) => {
109
+ target.step = step;
110
+ if (spec.effect !== undefined)
111
+ target.fragment = spec.effect;
112
+ if (spec.reveal !== undefined)
113
+ target.reveal = spec.reveal;
114
+ if (spec.tone !== undefined)
115
+ target.tone = spec.tone;
116
+ };
117
+ const at = (mark) => mark.kind === 'one' ? [mark.spec.line, mark.spec.column] : [mark.src.line, mark.src.column];
118
+ const ordered = this.marks
119
+ .map((mark, index) => ({ mark, index }))
120
+ .sort((a, b) => {
121
+ const [la, ca] = at(a.mark);
122
+ const [lb, cb] = at(b.mark);
123
+ return la - lb || ca - cb || a.index - b.index;
124
+ })
125
+ .map(({ mark }) => mark);
126
+ for (const mark of ordered) {
127
+ if (mark.kind === 'one') {
128
+ const step = mark.spec.step ?? counter + 1;
129
+ counter = Math.max(counter, step);
130
+ if (Array.isArray(mark.target.items)) {
131
+ counter = Math.max(counter, step + mark.target.items.length - 1);
132
+ }
133
+ apply(mark.target, mark.spec, step);
134
+ continue;
135
+ }
136
+ const marked = mark.specs.filter((spec) => spec?.fragment === true);
137
+ const first = marked[0];
138
+ if (!first)
139
+ continue;
140
+ const n = mark.specs.length;
141
+ const same = marked.length === n &&
142
+ marked.every((spec, i) => spec.effect === first.effect &&
143
+ spec.tone === first.tone &&
144
+ spec.reveal === first.reveal &&
145
+ (i === 0 || spec.step === undefined));
146
+ if (!same) {
147
+ this.sink.report('md-fragment-approximated', 'List items reveal one after another with one effect; these item fragments differ, so the list steps as a whole from its first fragment.', mark.src, this.base, 'Mark every item with the same effect, or none.');
148
+ }
149
+ const step = first.step ?? counter + 1;
150
+ counter = Math.max(counter, step + n - 1);
151
+ apply(mark.target, first, step);
152
+ }
153
+ }
154
+ // -------------------------------------------------------------------------
155
+ // Free slides
156
+ freeBlocks(nodes) {
157
+ const blocks = [];
158
+ for (const node of nodes) {
159
+ const path = ['blocks', blocks.length];
160
+ const block = this.freeBlock(node, path);
161
+ if (block) {
162
+ this.record(path, node.src);
163
+ blocks.push(block);
164
+ }
165
+ }
166
+ return blocks;
167
+ }
168
+ freeBlock(node, path) {
169
+ switch (node.kind) {
170
+ case 'heading': {
171
+ const block = { type: 'heading', level: node.level, text: unescapeInline(node.text) };
172
+ this.element(block, node.attrs);
173
+ return block;
174
+ }
175
+ case 'paragraph': {
176
+ const block = { type: 'text', markdown: node.text };
177
+ this.element(block, node.attrs);
178
+ return block;
179
+ }
180
+ case 'list': {
181
+ const block = { type: 'list', items: node.items.map((item) => ({ text: item.text })) };
182
+ if (node.ordered)
183
+ block.ordered = true;
184
+ node.items.forEach((item, i) => {
185
+ this.record([...path, 'items', i], item.src);
186
+ });
187
+ this.listMarks(block, node.items, node.src);
188
+ this.element(block, node.attrs);
189
+ return block;
190
+ }
191
+ case 'quote': {
192
+ const block = { type: 'quote', quote: node.text };
193
+ if (node.cite !== undefined)
194
+ block.cite = unescapeInline(node.cite);
195
+ this.element(block, node.attrs);
196
+ return block;
197
+ }
198
+ case 'table': {
199
+ const columns = node.header.map((label, i) => ({
200
+ id: `c${i + 1}`,
201
+ label: unescapeInline(label),
202
+ }));
203
+ const rows = node.rows.map((row, r) => {
204
+ this.record([...path, 'rows', r], row.src);
205
+ if (row.attrs) {
206
+ this.sink.report('md-fragment-not-steppable', 'Table rows of a free slide do not reveal one by one; the attributes were dropped.', row.attrs.src, [...this.base, ...path, 'rows', r]);
207
+ }
208
+ const out = {};
209
+ columns.forEach((column, c) => {
210
+ const cell = row.cells[c];
211
+ out[column.id] = cell === undefined || cell === '' ? null : unescapeInline(cell);
212
+ });
213
+ return out;
214
+ });
215
+ const block = { type: 'table', columns, rows };
216
+ this.element(block, node.attrs);
217
+ return block;
218
+ }
219
+ case 'image': {
220
+ const block = { type: 'image', src: node.url, alt: unescapeInline(node.alt) };
221
+ const read = this.element(block, node.attrs, ['width', 'height', 'decorative']);
222
+ this.dimensions(block, read);
223
+ if (read.other.has('decorative')) {
224
+ block.decorative = true;
225
+ delete block.alt;
226
+ }
227
+ if (node.title !== undefined)
228
+ block.caption = node.title;
229
+ return block;
230
+ }
231
+ case 'rule': {
232
+ const block = { type: 'divider' };
233
+ this.element(block, node.attrs);
234
+ return block;
235
+ }
236
+ case 'code': {
237
+ const block = { type: 'code', code: node.code };
238
+ if (node.lang !== undefined)
239
+ block.lang = node.lang;
240
+ if (node.highlights !== undefined)
241
+ block.highlights = node.highlights;
242
+ const brace = this.element(block, node.brace, ['filename', 'caption', 'numbers']);
243
+ const filename = brace.other.get('filename');
244
+ if (filename && filename.value !== true)
245
+ block.filename = filename.value;
246
+ const caption = brace.other.get('caption');
247
+ if (caption && caption.value !== true)
248
+ block.caption = caption.value;
249
+ if (brace.other.has('numbers'))
250
+ block.lineNumbers = true;
251
+ this.element(block, node.attrs);
252
+ return block;
253
+ }
254
+ case 'math': {
255
+ const block = { type: 'math', tex: node.tex };
256
+ const math = texToMath(node.tex, { display: true });
257
+ for (const issue of math.issues) {
258
+ this.sink.report(issue.code === 'tex-limit' ? 'md-tex-limit' : 'md-tex-unsupported', issue.message, node.src, [...this.base, ...path, 'tex'], 'Use the supported TeX subset (see @texuli/deck/math); the formula shows as source text.');
259
+ }
260
+ this.element(block, node.attrs);
261
+ return block;
262
+ }
263
+ case 'directive':
264
+ return this.freeDirective(node, path);
265
+ }
266
+ }
267
+ dimensions(target, read) {
268
+ for (const name of ['width', 'height']) {
269
+ const attr = read.other.get(name);
270
+ if (!attr)
271
+ continue;
272
+ const value = attr.value === true ? Number.NaN : Number(attr.value);
273
+ if (!Number.isInteger(value) ||
274
+ value < 1 ||
275
+ value > 4096 ||
276
+ !/^\d+$/.test(String(attr.value))) {
277
+ this.sink.report('md-attr-value', `"${name}" is a whole number of pixels from 1 to 4096.`, {
278
+ line: attr.line,
279
+ column: attr.column,
280
+ });
281
+ continue;
282
+ }
283
+ target[name] = value;
284
+ }
285
+ }
286
+ listMarks(target, items, src) {
287
+ const specs = items.map((item) => {
288
+ if (!item.attrs)
289
+ return undefined;
290
+ const read = readElement(item.attrs.list, new Set(), this.sink);
291
+ if (read.id !== undefined) {
292
+ this.sink.report('md-attr-value', 'List items have no id; it was dropped.', item.attrs.src);
293
+ }
294
+ return read.mark;
295
+ });
296
+ if (specs.some((spec) => spec?.fragment === true)) {
297
+ this.marks.push({ kind: 'items', target, specs, src });
298
+ }
299
+ }
300
+ freeDirective(node, path) {
301
+ if (node.name === 'component') {
302
+ const id = node.arg ?? '';
303
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(id)) {
304
+ this.sink.report('md-attr-value', '`::: component` needs a catalog component id.', node.src, [...this.base, ...path], 'Write `::: component chart-bar` (a lowercase catalog id).');
305
+ return undefined;
306
+ }
307
+ const props = (node.body ?? '').trim() === ''
308
+ ? {}
309
+ : this.jsonObject(node, [...path, 'props'], 'Component props');
310
+ if (props === undefined)
311
+ return undefined;
312
+ const item = { type: 'component', component: id, props };
313
+ const read = this.element(item, node.brace, ['height']);
314
+ const height = read.other.get('height');
315
+ if (height && height.value !== true)
316
+ item.height = height.value;
317
+ this.element(item, node.attrs);
318
+ return item;
319
+ }
320
+ if (node.name === 'block') {
321
+ const body = this.jsonObject(node, path, 'A `::: block` body');
322
+ if (body === undefined)
323
+ return undefined;
324
+ if (node.arg !== undefined) {
325
+ if (body.type !== undefined && body.type !== node.arg) {
326
+ this.sink.report('md-attr-value', `The body's type "${String(body.type).slice(0, 40)}" differs from "${node.arg}".`, node.src, [...this.base, ...path]);
327
+ }
328
+ body.type ??= node.arg;
329
+ }
330
+ this.element(body, node.brace);
331
+ this.element(body, node.attrs);
332
+ return body;
333
+ }
334
+ this.sink.report('md-region-unknown', `"::: ${node.name}" is not allowed on a free slide.`, node.src, [...this.base, ...path], node.name === 'left' || node.name === 'right' || node.name === 'card'
335
+ ? 'Set a layout first: <!-- .slide: layout="two-column-split" -->.'
336
+ : 'Free slides take Markdown, `::: component <id>` and `::: block` fences.');
337
+ return undefined;
338
+ }
339
+ }
340
+ // ---------------------------------------------------------------------------
341
+ // Layouts
342
+ /** `*text*` as a whole paragraph (no other unescaped `*` inside): its inner text. */
343
+ function wrapped(raw) {
344
+ if (raw.length < 3 || !raw.startsWith('*') || raw.startsWith('**') || !raw.endsWith('*')) {
345
+ return undefined;
346
+ }
347
+ if (raw.endsWith('\\*'))
348
+ return undefined;
349
+ const inner = raw.slice(1, -1);
350
+ if (/(?<!\\)\*/.test(inner) || /^\s|\s$/.test(inner))
351
+ return undefined;
352
+ return inner;
353
+ }
354
+ /** `**lead** rest` → [lead, rest]. */
355
+ function leadSplit(raw) {
356
+ const match = /^\*\*((?:\\.|[^*\\])+)\*\*(?:[ \t]+([\s\S]+))?$/.exec(raw.trim());
357
+ if (!match)
358
+ return undefined;
359
+ const out = { lead: match[1] ?? '' };
360
+ if (match[2] !== undefined)
361
+ out.rest = match[2];
362
+ return out;
363
+ }
364
+ const plain = unescapeInline;
365
+ class Pool {
366
+ nodes;
367
+ constructor(nodes) {
368
+ this.nodes = [...nodes];
369
+ }
370
+ take(kind, test = () => true) {
371
+ const index = this.nodes.findIndex((node) => node.kind === kind && test(node));
372
+ if (index === -1)
373
+ return undefined;
374
+ return this.nodes.splice(index, 1)[0];
375
+ }
376
+ directive(name) {
377
+ return this.take('directive', (node) => node.name === name);
378
+ }
379
+ }
380
+ function heading(b, pool, props, field, max) {
381
+ const node = pool.take('heading', (h) => h.level <= max);
382
+ if (node) {
383
+ props[field] = plain(node.text);
384
+ b.record(['props', field], node.src);
385
+ }
386
+ }
387
+ function subheading(b, pool, target, path) {
388
+ const node = pool.take('heading', (h) => h.level >= 3);
389
+ if (node) {
390
+ target.heading = plain(node.text);
391
+ b.record([...path, 'heading'], node.src);
392
+ }
393
+ }
394
+ function italic(b, pool, target, field, path, prefix = '') {
395
+ const node = pool.take('paragraph', (p) => {
396
+ const inner = wrapped(p.text);
397
+ return (inner !== undefined &&
398
+ (prefix === '' ? !/^(?:Credit|Source): /.test(inner) : inner.startsWith(prefix)));
399
+ });
400
+ if (node) {
401
+ target[field] = plain((wrapped(node.text) ?? '').slice(prefix.length));
402
+ b.record([...path, field], node.src);
403
+ }
404
+ }
405
+ function body(b, pool, target, field, path) {
406
+ const node = pool.take('paragraph', (p) => wrapped(p.text) === undefined);
407
+ if (node) {
408
+ target[field] = plain(node.text);
409
+ b.record([...path, field], node.src);
410
+ b.element(target, node.attrs);
411
+ }
412
+ }
413
+ function imageProp(b, pool, props, src, overlay = false) {
414
+ const node = pool.take('image');
415
+ if (!node) {
416
+ b.missing(src, 'an image line: ![alt](src)', ['props', 'image']);
417
+ return;
418
+ }
419
+ const image = { src: node.url, alt: plain(node.alt) };
420
+ const read = b.element(image, node.attrs, overlay ? ['width', 'height', 'overlay'] : ['width', 'height']);
421
+ b.dimensions(image, read);
422
+ const value = read.other.get('overlay');
423
+ if (value && value.value !== true)
424
+ props.overlay = value.value;
425
+ props.image = image;
426
+ b.record(['props', 'image'], node.src);
427
+ }
428
+ function region(b, node, path, allowed = []) {
429
+ const target = {};
430
+ const read = b.element(target, node.brace, allowed);
431
+ b.element(target, node.attrs);
432
+ b.record(path, node.src);
433
+ return { target, pool: new Pool(node.children ?? []), read };
434
+ }
435
+ function leftovers(b, pool) {
436
+ for (const node of pool.nodes)
437
+ b.extra(node, node.kind === 'directive' ? `"::: ${node.name}"` : `This ${node.kind}`);
438
+ }
439
+ function videoProp(b, pool, props, src) {
440
+ const node = pool.directive('video');
441
+ if (!node) {
442
+ b.missing(src, 'a `::: video {src="…"}` region', ['props', 'video']);
443
+ return;
444
+ }
445
+ const { target, pool: inner, read, } = region(b, node, ['props', 'video'], ['src', 'label', 'width', 'height', 'autoplay', 'loop', 'muted']);
446
+ const srcAttr = read.other.get('src');
447
+ if (srcAttr && srcAttr.value !== true)
448
+ target.src = srcAttr.value;
449
+ else
450
+ b.missing(node.src, 'a video `src`', ['props', 'video', 'src']);
451
+ const label = read.other.get('label');
452
+ if (label && label.value !== true)
453
+ target.label = label.value;
454
+ b.dimensions(target, read);
455
+ for (const flag of ['autoplay', 'loop', 'muted']) {
456
+ const attr = read.other.get(flag);
457
+ if (attr)
458
+ target[flag] = attr.value === true || attr.value === 'true';
459
+ }
460
+ leftovers(b, inner);
461
+ props.video = target;
462
+ }
463
+ function listItems(b, pool, props, field, ordered, src, read) {
464
+ const node = pool.take('list', (l) => l.ordered === ordered);
465
+ if (!node) {
466
+ b.missing(src, ordered ? 'a numbered list' : 'a bullet list', ['props', field]);
467
+ return;
468
+ }
469
+ props[field] = node.items.map((item, i) => {
470
+ const out = {};
471
+ read(item, out);
472
+ b.element(out, item.attrs, field === 'people' ? ['avatar'] : []);
473
+ b.record(['props', field, i], item.src);
474
+ return out;
475
+ });
476
+ }
477
+ const LAYOUTS = {
478
+ 'title-hero'(b, pool, props, src) {
479
+ heading(b, pool, props, 'title', 6);
480
+ if (props.title === undefined)
481
+ b.missing(src, 'a `# Title` heading', ['props', 'title']);
482
+ body(b, pool, props, 'subtitle', ['props']);
483
+ italic(b, pool, props, 'meta', ['props']);
484
+ },
485
+ 'section-divider'(b, pool, props, src) {
486
+ italic(b, pool, props, 'eyebrow', ['props']);
487
+ heading(b, pool, props, 'title', 6);
488
+ if (props.title === undefined)
489
+ b.missing(src, 'a `# Title` heading', ['props', 'title']);
490
+ },
491
+ 'two-column-split'(b, pool, props, src) {
492
+ heading(b, pool, props, 'title', 2);
493
+ for (const side of ['left', 'right']) {
494
+ const node = pool.directive(side);
495
+ if (!node) {
496
+ b.missing(src, `a \`::: ${side}\` region`, ['props', side]);
497
+ continue;
498
+ }
499
+ const { target, pool: inner } = region(b, node, ['props', side]);
500
+ subheading(b, inner, target, ['props', side]);
501
+ body(b, inner, target, 'body', ['props', side]);
502
+ leftovers(b, inner);
503
+ props[side] = target;
504
+ }
505
+ },
506
+ 'three-column-cards'(b, pool, props, src) {
507
+ heading(b, pool, props, 'title', 2);
508
+ const cards = [];
509
+ for (let node = pool.directive('card'); node; node = pool.directive('card')) {
510
+ const path = ['props', 'cards', cards.length];
511
+ const { target, pool: inner, read } = region(b, node, path, ['icon']);
512
+ const icon = read.other.get('icon');
513
+ if (icon && icon.value !== true)
514
+ target.icon = icon.value;
515
+ subheading(b, inner, target, path);
516
+ body(b, inner, target, 'body', path);
517
+ leftovers(b, inner);
518
+ cards.push(target);
519
+ }
520
+ if (cards.length === 0)
521
+ b.missing(src, 'three `::: card` regions', ['props', 'cards']);
522
+ props.cards = cards;
523
+ },
524
+ 'image-left': imageSide,
525
+ 'image-right': imageSide,
526
+ 'image-centered'(b, pool, props, src) {
527
+ heading(b, pool, props, 'title', 2);
528
+ imageProp(b, pool, props, src);
529
+ italic(b, pool, props, 'caption', ['props']);
530
+ },
531
+ 'image-full-bleed'(b, pool, props, src) {
532
+ imageProp(b, pool, props, src, true);
533
+ italic(b, pool, props, 'caption', ['props']);
534
+ italic(b, pool, props, 'credit', ['props'], 'Credit: ');
535
+ },
536
+ quote(b, pool, props, src) {
537
+ const node = pool.take('quote');
538
+ if (!node) {
539
+ b.missing(src, 'a `> quote` block', ['props', 'quote']);
540
+ return;
541
+ }
542
+ props.quote = plain(node.text);
543
+ if (node.cite !== undefined)
544
+ props.cite = plain(node.cite);
545
+ b.record(['props', 'quote'], node.src);
546
+ b.element(props, node.attrs);
547
+ },
548
+ 'code-with-callout'(b, pool, props, src) {
549
+ heading(b, pool, props, 'title', 2);
550
+ const node = pool.take('code');
551
+ if (!node)
552
+ b.missing(src, 'a code fence', ['props', 'code']);
553
+ else {
554
+ const code = { lang: node.lang ?? '', source: node.code };
555
+ if (node.highlights !== undefined)
556
+ code.highlights = node.highlights;
557
+ b.element(code, node.brace);
558
+ b.element(code, node.attrs);
559
+ if (node.lang === undefined)
560
+ b.missing(node.src, 'a code language (```ts)', ['props', 'code', 'lang']);
561
+ props.code = code;
562
+ b.record(['props', 'code'], node.src);
563
+ }
564
+ subheading(b, pool, props, ['props']);
565
+ const list = pool.take('list');
566
+ if (!list)
567
+ b.missing(src, 'a bullet list', ['props', 'bullets']);
568
+ else {
569
+ props.bullets = list.items.map((item, i) => {
570
+ b.record(['props', 'bullets', i], item.src);
571
+ return plain(item.text);
572
+ });
573
+ }
574
+ },
575
+ 'big-stat'(b, pool, props, src) {
576
+ const node = pool.take('paragraph', (p) => leadSplit(p.text)?.rest !== undefined);
577
+ if (!node)
578
+ b.missing(src, 'a `**number** label` paragraph', ['props', 'number']);
579
+ else {
580
+ const split = leadSplit(node.text);
581
+ props.number = plain(split?.lead ?? '');
582
+ props.label = plain(split?.rest ?? '');
583
+ b.record(['props', 'number'], node.src);
584
+ b.element(props, node.attrs);
585
+ }
586
+ italic(b, pool, props, 'source', ['props'], 'Source: ');
587
+ const context = pool.take('paragraph', (p) => wrapped(p.text) === undefined);
588
+ if (context) {
589
+ props.context = plain(context.text);
590
+ b.record(['props', 'context'], context.src);
591
+ }
592
+ },
593
+ 'comparison-table'(b, pool, props, src) {
594
+ heading(b, pool, props, 'title', 2);
595
+ const node = pool.take('table');
596
+ if (!node) {
597
+ b.missing(src, 'a pipe table', ['props', 'rows']);
598
+ return;
599
+ }
600
+ props.columns = node.header.slice(1).map(plain);
601
+ props.rows = node.rows.map((row, i) => {
602
+ const out = {
603
+ label: plain(row.cells[0] ?? ''),
604
+ values: row.cells.slice(1).map((cell) => {
605
+ const text = plain(cell);
606
+ if (text === 'Yes')
607
+ return true;
608
+ if (text === 'No')
609
+ return false;
610
+ return text === '' ? null : text;
611
+ }),
612
+ };
613
+ b.element(out, row.attrs);
614
+ b.record(['props', 'rows', i], row.src);
615
+ return out;
616
+ });
617
+ b.record(['props', 'columns'], node.src);
618
+ },
619
+ 'process-steps'(b, pool, props, src) {
620
+ heading(b, pool, props, 'title', 2);
621
+ listItems(b, pool, props, 'steps', true, src, (item, out) => {
622
+ const split = leadSplit(item.text);
623
+ out.title = plain(split?.lead ?? item.text);
624
+ if (split?.rest !== undefined)
625
+ out.body = plain(split.rest);
626
+ });
627
+ },
628
+ timeline(b, pool, props, src) {
629
+ heading(b, pool, props, 'title', 2);
630
+ listItems(b, pool, props, 'events', false, src, (item, out) => {
631
+ const split = leadSplit(item.text);
632
+ out.time = plain(split?.lead ?? '');
633
+ out.text = plain(split?.rest ?? item.text);
634
+ });
635
+ },
636
+ 'team-avatars'(b, pool, props, src) {
637
+ heading(b, pool, props, 'title', 2);
638
+ const node = pool.take('list', (l) => !l.ordered);
639
+ if (!node) {
640
+ b.missing(src, 'a bullet list of people', ['props', 'people']);
641
+ return;
642
+ }
643
+ props.people = node.items.map((item, i) => {
644
+ const out = {};
645
+ const split = leadSplit(item.text);
646
+ out.name = plain(split?.lead ?? item.text);
647
+ const role = split?.rest?.replace(/^(?:—|--)[ \t]+/, '');
648
+ if (role !== undefined)
649
+ out.role = plain(role);
650
+ const read = b.element(out, item.attrs, ['avatar']);
651
+ const avatar = read.other.get('avatar');
652
+ if (avatar && avatar.value !== true)
653
+ out.avatar = avatar.value;
654
+ b.record(['props', 'people', i], item.src);
655
+ return out;
656
+ });
657
+ },
658
+ 'closing-cta'(b, pool, props, src) {
659
+ heading(b, pool, props, 'title', 6);
660
+ if (props.title === undefined)
661
+ b.missing(src, 'a `# Title` heading', ['props', 'title']);
662
+ const link = pool.take('paragraph', (p) => /^\[((?:\\.|[^\]\\])+)\]\(([^\s()]+)\)$/.test(p.text.trim()));
663
+ if (!link)
664
+ b.missing(src, 'a `[Label](https://…)` link paragraph', ['props', 'cta']);
665
+ else {
666
+ const match = /^\[((?:\\.|[^\]\\])+)\]\(([^\s()]+)\)$/.exec(link.text.trim());
667
+ const cta = { label: plain(match?.[1] ?? ''), href: match?.[2] ?? '' };
668
+ b.element(cta, link.attrs);
669
+ props.cta = cta;
670
+ b.record(['props', 'cta'], link.src);
671
+ }
672
+ italic(b, pool, props, 'footnote', ['props']);
673
+ body(b, pool, props, 'text', ['props']);
674
+ },
675
+ 'video-centered'(b, pool, props, src) {
676
+ heading(b, pool, props, 'title', 2);
677
+ videoProp(b, pool, props, src);
678
+ italic(b, pool, props, 'caption', ['props']);
679
+ },
680
+ 'video-left': videoSide,
681
+ 'video-right': videoSide,
682
+ chart(b, pool, props, src) {
683
+ heading(b, pool, props, 'title', 2);
684
+ const node = pool.directive('chart');
685
+ if (!node) {
686
+ b.missing(src, 'a `::: chart` JSON fence', ['props', 'spec']);
687
+ return;
688
+ }
689
+ const spec = b.jsonObject(node, ['props', 'spec'], 'The chart spec');
690
+ if (spec !== undefined)
691
+ props.spec = spec;
692
+ const read = readElement(node.brace?.list ?? [], new Set(['height', 'table']), b.sink);
693
+ const height = read.other.get('height');
694
+ if (height && height.value !== true)
695
+ props.height = height.value;
696
+ const table = read.other.get('table');
697
+ if (table)
698
+ props.showTable = table.value === true || table.value === 'true';
699
+ if (read.mark || read.id !== undefined) {
700
+ b.sink.report('md-fragment-not-steppable', 'Put chart step fields inside the chart JSON.', node.src, [...b.base, 'props', 'spec']);
701
+ }
702
+ },
703
+ };
704
+ function imageSide(b, pool, props, src) {
705
+ heading(b, pool, props, 'title', 2);
706
+ imageProp(b, pool, props, src);
707
+ subheading(b, pool, props, ['props']);
708
+ body(b, pool, props, 'body', ['props']);
709
+ }
710
+ function videoSide(b, pool, props, src) {
711
+ heading(b, pool, props, 'title', 2);
712
+ videoProp(b, pool, props, src);
713
+ subheading(b, pool, props, ['props']);
714
+ body(b, pool, props, 'body', ['props']);
715
+ }
716
+ // ---------------------------------------------------------------------------
717
+ function isCatalogLayout(value) {
718
+ return DECK_CATALOG_LAYOUT_IDS.includes(value);
719
+ }
720
+ /** Build one slide. `index` is its position in `slides`. */
721
+ export function buildSlide(source, index, src, ctx) {
722
+ const base = ['slides', index];
723
+ const b = new SlideBuilder(ctx, base);
724
+ const attrs = readSlideAttrs(source.slideAttrs, ctx.sink);
725
+ const at = (name) => {
726
+ const attr = attrs.at.get(name);
727
+ return attr ? { line: attr.line, column: attr.column } : src;
728
+ };
729
+ ctx.map.set(toJsonPath(base), src);
730
+ // `::: slide`: the whole slide as JSON.
731
+ const whole = source.nodes.find((node) => node.kind === 'directive' && node.name === 'slide');
732
+ if (whole) {
733
+ for (const node of source.nodes)
734
+ if (node !== whole)
735
+ b.extra(node, 'Content next to `::: slide`');
736
+ const slide = b.jsonObject(whole, [], 'A `::: slide` body') ?? {};
737
+ if (source.notes && slide.notes === undefined)
738
+ slide.notes = source.notes.text;
739
+ return { slide, attrs, src };
740
+ }
741
+ const slide = {};
742
+ const layout = attrs.layout ?? 'free';
743
+ slide.layout = layout;
744
+ ctx.map.set(toJsonPath([...base, 'layout']), at('layout'));
745
+ if (layout === 'free') {
746
+ if (attrs.master !== undefined) {
747
+ if (SLIDE_MASTERS.includes(attrs.master))
748
+ slide.master = attrs.master;
749
+ else {
750
+ ctx.sink.report('md-attr-value', `"master" is one of: ${SLIDE_MASTERS.join(', ')}.`, at('master'), base);
751
+ }
752
+ }
753
+ slide.blocks = b.freeBlocks(source.nodes);
754
+ }
755
+ else {
756
+ if (attrs.master !== undefined) {
757
+ ctx.sink.report('md-attr-conflict', '`master` applies to free slides only.', at('master'), base);
758
+ }
759
+ const pool = new Pool(source.nodes);
760
+ if (layout === 'component') {
761
+ const node = pool.directive('component');
762
+ if (!node)
763
+ b.missing(src, 'one `::: component <id>` fence', ['component']);
764
+ else {
765
+ const props = (node.body ?? '').trim() === ''
766
+ ? {}
767
+ : b.jsonObject(node, ['component', 'props'], 'Component props');
768
+ slide.component = { component: node.arg ?? '', props: props ?? {} };
769
+ ctx.map.set(toJsonPath([...base, 'component']), node.src);
770
+ if ((node.brace?.list.length ?? 0) > 0 || node.attrs) {
771
+ ctx.sink.report('md-fragment-not-steppable', 'A whole-slide component takes no attributes; use slide `steps`.', node.src, base);
772
+ }
773
+ }
774
+ }
775
+ else if (layout === 'custom') {
776
+ const node = pool.directive('custom');
777
+ if (!node)
778
+ b.missing(src, 'one `::: custom <component>` fence', ['props']);
779
+ else {
780
+ const props = { component: node.arg ?? '' };
781
+ if ((node.body ?? '').trim() !== '') {
782
+ const data = b.jsonObject(node, ['props', 'data'], 'Custom slide data');
783
+ if (data !== undefined)
784
+ props.data = data;
785
+ }
786
+ slide.props = props;
787
+ ctx.map.set(toJsonPath([...base, 'props']), node.src);
788
+ }
789
+ }
790
+ else if (isCatalogLayout(layout)) {
791
+ const json = pool.directive('props');
792
+ if (json) {
793
+ slide.props = b.jsonObject(json, ['props'], 'Layout props') ?? {};
794
+ ctx.map.set(toJsonPath([...base, 'props']), json.src);
795
+ }
796
+ else {
797
+ const props = {};
798
+ LAYOUTS[layout](b, pool, props, src);
799
+ slide.props = props;
800
+ }
801
+ }
802
+ else {
803
+ ctx.sink.report('md-layout-unknown', `Unknown layout "${layout.slice(0, 64)}".`, at('layout'), [...base, 'layout'], `Use one of: ${DECK_CATALOG_LAYOUT_IDS.join(', ')}, free, component, custom.`);
804
+ slide.props = {};
805
+ }
806
+ leftovers(b, pool);
807
+ }
808
+ b.numberMarks();
809
+ if (attrs.title !== undefined)
810
+ slide.title = attrs.title;
811
+ if (attrs.theme !== undefined)
812
+ slide.theme = attrs.theme;
813
+ if (attrs.transition !== undefined)
814
+ slide.transition = attrs.transition;
815
+ if (attrs.hidden !== undefined)
816
+ slide.hidden = attrs.hidden;
817
+ if (attrs.steps !== undefined)
818
+ slide.steps = attrs.steps;
819
+ if (attrs.background !== undefined)
820
+ slide.background = attrs.background;
821
+ for (const [field, attr] of [
822
+ ['title', 'title'],
823
+ ['theme', 'theme'],
824
+ ['transition', 'transition'],
825
+ ['hidden', 'hidden'],
826
+ ['steps', 'steps'],
827
+ ['background', 'background'],
828
+ ]) {
829
+ if (attrs.at.has(attr))
830
+ ctx.map.set(toJsonPath([...base, field]), at(attr));
831
+ }
832
+ if (source.notes) {
833
+ slide.notes = source.notes.text;
834
+ ctx.map.set(toJsonPath([...base, 'notes']), source.notes.src);
835
+ }
836
+ if (DECK_MARKDOWN_LIMITS.slides < index + 1) {
837
+ ctx.sink.report('md-too-many-slides', 'Too many slides.', src, base);
838
+ }
839
+ return { slide, attrs, src };
840
+ }
841
+ /** The text an id is derived from: the slide title, the props title, or a free slide's first heading. */
842
+ export function idCandidate(slide) {
843
+ if (typeof slide.title === 'string' && slide.title.trim())
844
+ return slide.title;
845
+ const props = slide.props;
846
+ if (props && typeof props.title === 'string' && slide.layout !== 'custom')
847
+ return props.title;
848
+ if (Array.isArray(slide.blocks)) {
849
+ const first = slide.blocks.find((block) => block?.type === 'heading');
850
+ if (first && typeof first.text === 'string')
851
+ return first.text;
852
+ }
853
+ return '';
854
+ }