orz-slides 0.1.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/README.md +192 -0
- package/assets/app.js +683 -0
- package/assets/themes/base.css +777 -0
- package/assets/themes/theme-architect.css +64 -0
- package/assets/themes/theme-chalk.css +111 -0
- package/assets/themes/theme-executive.css +65 -0
- package/assets/themes/theme-neon.css +107 -0
- package/assets/themes/theme-paper.css +50 -0
- package/assets/themes/theme-poppy.css +58 -0
- package/assets/themes/theme-sage.css +50 -0
- package/dist/browser-entry.js +615 -0
- package/dist/cli.js +187 -0
- package/dist/layout.js +42 -0
- package/dist/orz-slides.browser.js +388 -0
- package/dist/render-slide.js +149 -0
- package/dist/slide-parser.js +622 -0
- package/dist/template.js +275 -0
- package/dist/types.js +12 -0
- package/orz-slides-skills/SKILL.md +386 -0
- package/package.json +50 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/* ── Marker regexes ──────────────────────────────────────────────────────── */
|
|
2
|
+
const DECK_BLOCK = /^\s*<!--\s*deck\b([\s\S]*?)-->/;
|
|
3
|
+
// A `<!-- slide … -->` marker on its own (we split the source on these).
|
|
4
|
+
const SLIDE_MARKER = /<!--\s*slide\b([^>]*?)-->/g;
|
|
5
|
+
// A region marker `<!-- @name … -->` at the start of a line.
|
|
6
|
+
const REGION_MARKER = /^[ \t]*<!--\s*@([A-Za-z0-9_-]+)\b([^>]*?)-->[ \t]*$/;
|
|
7
|
+
/* ── Public entry point ──────────────────────────────────────────────────── */
|
|
8
|
+
export function parseDeck(source) {
|
|
9
|
+
const src = source.replace(/\r\n?/g, '\n');
|
|
10
|
+
const { config, rest } = parseDeckBlock(src);
|
|
11
|
+
const slides = splitSlides(rest).map((raw, index) => parseSlide(raw, index));
|
|
12
|
+
return { config, slides };
|
|
13
|
+
}
|
|
14
|
+
/* ── Deck config ─────────────────────────────────────────────────────────── */
|
|
15
|
+
function parseDeckBlock(src) {
|
|
16
|
+
const m = src.match(DECK_BLOCK);
|
|
17
|
+
if (!m)
|
|
18
|
+
return { config: {}, rest: src };
|
|
19
|
+
const config = {};
|
|
20
|
+
for (const line of m[1].split('\n')) {
|
|
21
|
+
const kv = line.match(/^\s*([A-Za-z_][\w-]*)\s*:\s*(.*?)\s*$/);
|
|
22
|
+
if (!kv)
|
|
23
|
+
continue;
|
|
24
|
+
const key = kv[1].toLowerCase();
|
|
25
|
+
const value = kv[2];
|
|
26
|
+
if (value === '')
|
|
27
|
+
continue;
|
|
28
|
+
switch (key) {
|
|
29
|
+
case 'title':
|
|
30
|
+
config.title = value;
|
|
31
|
+
break;
|
|
32
|
+
case 'theme':
|
|
33
|
+
config.theme = value;
|
|
34
|
+
break;
|
|
35
|
+
case 'ratio':
|
|
36
|
+
config.ratio = value;
|
|
37
|
+
break;
|
|
38
|
+
case 'author':
|
|
39
|
+
config.author = value;
|
|
40
|
+
break;
|
|
41
|
+
case 'footer':
|
|
42
|
+
config.footer = value;
|
|
43
|
+
break;
|
|
44
|
+
case 'transition':
|
|
45
|
+
config.transition = value;
|
|
46
|
+
break;
|
|
47
|
+
// Unknown keys are ignored (forward-compatible).
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Everything after the deck block is slide content.
|
|
51
|
+
const rest = src.slice((m.index ?? 0) + m[0].length);
|
|
52
|
+
return { config, rest };
|
|
53
|
+
}
|
|
54
|
+
/* ── Slide splitting ─────────────────────────────────────────────────────── */
|
|
55
|
+
/**
|
|
56
|
+
* Split the post-deck source into per-slide raw chunks. Every slide *begins*
|
|
57
|
+
* with a `<!-- slide … -->` marker (the marker is the separator), so each chunk
|
|
58
|
+
* runs from one marker up to (but not including) the next.
|
|
59
|
+
*/
|
|
60
|
+
function splitSlides(src) {
|
|
61
|
+
SLIDE_MARKER.lastIndex = 0;
|
|
62
|
+
const starts = [];
|
|
63
|
+
let m;
|
|
64
|
+
while ((m = SLIDE_MARKER.exec(src)) !== null)
|
|
65
|
+
starts.push(m.index);
|
|
66
|
+
if (starts.length === 0)
|
|
67
|
+
return [];
|
|
68
|
+
const slides = [];
|
|
69
|
+
for (let i = 0; i < starts.length; i++) {
|
|
70
|
+
const begin = starts[i];
|
|
71
|
+
const end = i + 1 < starts.length ? starts[i + 1] : src.length;
|
|
72
|
+
const chunk = src.slice(begin, end).replace(/\s+$/, '');
|
|
73
|
+
slides.push(chunk);
|
|
74
|
+
}
|
|
75
|
+
return slides;
|
|
76
|
+
}
|
|
77
|
+
/* ── Per-slide parse ─────────────────────────────────────────────────────── */
|
|
78
|
+
function parseSlide(raw, index) {
|
|
79
|
+
const lint = [];
|
|
80
|
+
// The slide marker is the first thing in the chunk.
|
|
81
|
+
const mm = raw.match(/^[ \t]*<!--\s*slide\b([^>]*?)-->[ \t]*\n?/);
|
|
82
|
+
const markerArgs = mm ? mm[1].trim() : '';
|
|
83
|
+
const body = mm ? raw.slice(mm[0].length) : raw;
|
|
84
|
+
const { options, layoutSpec, template, templateVariant } = parseMarker(markerArgs);
|
|
85
|
+
// Template slides: own layout + fixed semantics; no preset/split expansion.
|
|
86
|
+
if (template !== undefined) {
|
|
87
|
+
const { regions, floats, footer, notes } = parseRegions(body,
|
|
88
|
+
// Template primary region is conventionally 'body'.
|
|
89
|
+
'body');
|
|
90
|
+
return {
|
|
91
|
+
index,
|
|
92
|
+
kind: 'template',
|
|
93
|
+
template,
|
|
94
|
+
templateVariant,
|
|
95
|
+
layout: regionLeaf('body'),
|
|
96
|
+
regions,
|
|
97
|
+
floats,
|
|
98
|
+
footer,
|
|
99
|
+
notes,
|
|
100
|
+
options,
|
|
101
|
+
lint,
|
|
102
|
+
raw,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Normal slide: expand the layout, find the primary region, parse regions.
|
|
106
|
+
const layout = layoutSpec
|
|
107
|
+
? parseLayoutSpec(layoutSpec, lint)
|
|
108
|
+
: regionLeaf('body');
|
|
109
|
+
const primary = firstLeafName(layout);
|
|
110
|
+
const titled = extractTitle(body, lint);
|
|
111
|
+
const { regions, floats, footer, notes } = parseRegions(titled.body, primary);
|
|
112
|
+
return {
|
|
113
|
+
index,
|
|
114
|
+
kind: 'normal',
|
|
115
|
+
title: titled.title,
|
|
116
|
+
layout,
|
|
117
|
+
regions,
|
|
118
|
+
floats,
|
|
119
|
+
footer,
|
|
120
|
+
notes,
|
|
121
|
+
options,
|
|
122
|
+
lint,
|
|
123
|
+
raw,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function parseMarker(args) {
|
|
127
|
+
const options = {};
|
|
128
|
+
let template;
|
|
129
|
+
let templateVariant;
|
|
130
|
+
// Pull out `key=value` options first (these never contain spaces in value
|
|
131
|
+
// except for quoted values). We support quotes for bg/class.
|
|
132
|
+
// Strategy: tokenize, treating `{ … }` braces as opaque (they belong to the
|
|
133
|
+
// layout split expression, not options).
|
|
134
|
+
const optionKeys = new Set([
|
|
135
|
+
'bg',
|
|
136
|
+
't',
|
|
137
|
+
'fit',
|
|
138
|
+
'class',
|
|
139
|
+
'id',
|
|
140
|
+
'template',
|
|
141
|
+
'v',
|
|
142
|
+
]);
|
|
143
|
+
const leftover = [];
|
|
144
|
+
const tokens = tokenizeMarker(args);
|
|
145
|
+
for (const tok of tokens) {
|
|
146
|
+
const eq = tok.indexOf('=');
|
|
147
|
+
if (eq > 0 && !tok.startsWith('{')) {
|
|
148
|
+
const key = tok.slice(0, eq).toLowerCase();
|
|
149
|
+
let value = tok.slice(eq + 1);
|
|
150
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
151
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
152
|
+
value = value.slice(1, -1);
|
|
153
|
+
}
|
|
154
|
+
if (optionKeys.has(key)) {
|
|
155
|
+
switch (key) {
|
|
156
|
+
case 'bg':
|
|
157
|
+
options.bg = value;
|
|
158
|
+
break;
|
|
159
|
+
case 't':
|
|
160
|
+
options.transition = value;
|
|
161
|
+
break;
|
|
162
|
+
case 'fit':
|
|
163
|
+
if (value === 'fit' || value === 'scroll' || value === 'off')
|
|
164
|
+
options.fit = value;
|
|
165
|
+
break;
|
|
166
|
+
case 'class':
|
|
167
|
+
options.class = value;
|
|
168
|
+
break;
|
|
169
|
+
case 'id':
|
|
170
|
+
options.id = value;
|
|
171
|
+
break;
|
|
172
|
+
case 'template':
|
|
173
|
+
template = value;
|
|
174
|
+
break;
|
|
175
|
+
case 'v':
|
|
176
|
+
templateVariant = Number(value);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (tok === 'step') {
|
|
183
|
+
options.step = true;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
leftover.push(tok);
|
|
187
|
+
}
|
|
188
|
+
const layoutSpec = leftover.join(' ').trim();
|
|
189
|
+
return { options, layoutSpec, template, templateVariant };
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Split marker args on whitespace, but keep `{ … }` brace groups (the split
|
|
193
|
+
* body) intact so a layout like `col 3/2 { main; side }` survives.
|
|
194
|
+
*/
|
|
195
|
+
function tokenizeMarker(args) {
|
|
196
|
+
const out = [];
|
|
197
|
+
let buf = '';
|
|
198
|
+
let depth = 0;
|
|
199
|
+
let quote = '';
|
|
200
|
+
for (let i = 0; i < args.length; i++) {
|
|
201
|
+
const c = args[i];
|
|
202
|
+
if (quote) {
|
|
203
|
+
buf += c;
|
|
204
|
+
if (c === quote)
|
|
205
|
+
quote = '';
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (c === '"' || c === "'") {
|
|
209
|
+
quote = c;
|
|
210
|
+
buf += c;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (c === '{')
|
|
214
|
+
depth++;
|
|
215
|
+
if (c === '}')
|
|
216
|
+
depth = Math.max(0, depth - 1);
|
|
217
|
+
if (/\s/.test(c) && depth === 0) {
|
|
218
|
+
if (buf)
|
|
219
|
+
out.push(buf);
|
|
220
|
+
buf = '';
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
buf += c;
|
|
224
|
+
}
|
|
225
|
+
if (buf)
|
|
226
|
+
out.push(buf);
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
/* ── Layout spec → LayoutNode ────────────────────────────────────────────── */
|
|
230
|
+
/**
|
|
231
|
+
* A layout spec is either a *preset alias* (optionally with a track ratio,
|
|
232
|
+
* e.g. `2col 3/2`, `main-side`, `quad`) or a *raw split expression*
|
|
233
|
+
* (`col 3/2 { main; side }`). Presets are expanded here.
|
|
234
|
+
*/
|
|
235
|
+
function parseLayoutSpec(spec, lint) {
|
|
236
|
+
const trimmed = spec.trim();
|
|
237
|
+
if (trimmed === '')
|
|
238
|
+
return regionLeaf('body');
|
|
239
|
+
// Raw split expression: starts with row/col and contains braces.
|
|
240
|
+
const head = trimmed.split(/\s+/, 1)[0];
|
|
241
|
+
if ((head === 'row' || head === 'col') && trimmed.includes('{')) {
|
|
242
|
+
try {
|
|
243
|
+
return parseSplit(trimmed);
|
|
244
|
+
}
|
|
245
|
+
catch (e) {
|
|
246
|
+
lint.push({
|
|
247
|
+
level: 'error',
|
|
248
|
+
message: `Invalid layout split: ${e.message}`,
|
|
249
|
+
});
|
|
250
|
+
return regionLeaf('body');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Otherwise: a preset alias, possibly followed by a ratio token.
|
|
254
|
+
const parts = trimmed.split(/\s+/);
|
|
255
|
+
const name = parts[0];
|
|
256
|
+
const ratio = parts[1];
|
|
257
|
+
const node = expandPreset(name, ratio);
|
|
258
|
+
if (node)
|
|
259
|
+
return node;
|
|
260
|
+
lint.push({ level: 'error', message: `Unknown layout preset: ${name}` });
|
|
261
|
+
return regionLeaf('body');
|
|
262
|
+
}
|
|
263
|
+
/** Expand a preset alias (+ optional ratio like '3/2') to a layout tree. */
|
|
264
|
+
function expandPreset(name, ratio) {
|
|
265
|
+
const r = (def) => parseTracks(ratio ?? def);
|
|
266
|
+
switch (name) {
|
|
267
|
+
case '2col':
|
|
268
|
+
return split('col', r('1/1'), [regionLeaf('left'), regionLeaf('right')]);
|
|
269
|
+
case '3col':
|
|
270
|
+
return split('col', r('1/1/1'), [
|
|
271
|
+
regionLeaf('left'),
|
|
272
|
+
regionLeaf('mid'),
|
|
273
|
+
regionLeaf('right'),
|
|
274
|
+
]);
|
|
275
|
+
case '2row':
|
|
276
|
+
return split('row', r('1/1'), [regionLeaf('top'), regionLeaf('bottom')]);
|
|
277
|
+
case 'main-side':
|
|
278
|
+
return split('col', r('2/1'), [regionLeaf('main'), regionLeaf('side')]);
|
|
279
|
+
case 'quad':
|
|
280
|
+
return split('row', parseTracks('1/1'), [
|
|
281
|
+
split('col', parseTracks('1/1'), [
|
|
282
|
+
regionLeaf('tl'),
|
|
283
|
+
regionLeaf('tr'),
|
|
284
|
+
]),
|
|
285
|
+
split('col', parseTracks('1/1'), [
|
|
286
|
+
regionLeaf('bl'),
|
|
287
|
+
regionLeaf('br'),
|
|
288
|
+
]),
|
|
289
|
+
]);
|
|
290
|
+
default:
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/* ── Raw split grammar parser ────────────────────────────────────────────── */
|
|
295
|
+
//
|
|
296
|
+
// split := ("row" | "col") tracks "{" item (";" item)* "}"
|
|
297
|
+
// item := region-name | split
|
|
298
|
+
// tracks := token ("/" token)*
|
|
299
|
+
//
|
|
300
|
+
// Implemented as a small recursive-descent parser over a character cursor.
|
|
301
|
+
function parseSplit(input) {
|
|
302
|
+
const p = new SplitCursor(input);
|
|
303
|
+
const node = p.parseSplitNode();
|
|
304
|
+
if (!p.atEnd())
|
|
305
|
+
throw new Error(`unexpected trailing input near "${p.rest()}"`);
|
|
306
|
+
return node;
|
|
307
|
+
}
|
|
308
|
+
class SplitCursor {
|
|
309
|
+
s;
|
|
310
|
+
i = 0;
|
|
311
|
+
constructor(s) {
|
|
312
|
+
this.s = s;
|
|
313
|
+
}
|
|
314
|
+
parseSplitNode() {
|
|
315
|
+
this.skipWs();
|
|
316
|
+
const dir = this.readWord();
|
|
317
|
+
if (dir !== 'row' && dir !== 'col')
|
|
318
|
+
throw new Error(`expected "row" or "col", got "${dir}"`);
|
|
319
|
+
this.skipWs();
|
|
320
|
+
const tracksStr = this.readUntil('{').trim();
|
|
321
|
+
if (tracksStr === '')
|
|
322
|
+
throw new Error('missing tracks before "{"');
|
|
323
|
+
const tracks = parseTracks(tracksStr);
|
|
324
|
+
this.expect('{');
|
|
325
|
+
const children = [];
|
|
326
|
+
do {
|
|
327
|
+
this.skipWs();
|
|
328
|
+
children.push(this.parseItem());
|
|
329
|
+
this.skipWs();
|
|
330
|
+
} while (this.consumeIf(';'));
|
|
331
|
+
this.skipWs();
|
|
332
|
+
this.expect('}');
|
|
333
|
+
if (children.length !== tracks.length)
|
|
334
|
+
throw new Error(`track count (${tracks.length}) != child count (${children.length})`);
|
|
335
|
+
return { kind: 'split', dir, tracks, children };
|
|
336
|
+
}
|
|
337
|
+
parseItem() {
|
|
338
|
+
this.skipWs();
|
|
339
|
+
// Lookahead: a nested split starts with "row" or "col" followed (after
|
|
340
|
+
// tracks) by "{". A region name is a bare identifier.
|
|
341
|
+
const save = this.i;
|
|
342
|
+
const word = this.peekWord();
|
|
343
|
+
if (word === 'row' || word === 'col') {
|
|
344
|
+
// Confirm there's a "{" before the next ";" or "}" — otherwise treat the
|
|
345
|
+
// word as a (oddly-named) region. In practice row/col are reserved, but
|
|
346
|
+
// being defensive keeps single-name items safe.
|
|
347
|
+
if (this.hasBraceBeforeDelimiter()) {
|
|
348
|
+
return this.parseSplitNode();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
this.i = save;
|
|
352
|
+
const name = this.readName();
|
|
353
|
+
if (name === '')
|
|
354
|
+
throw new Error('expected region name');
|
|
355
|
+
return regionLeaf(name);
|
|
356
|
+
}
|
|
357
|
+
/* ── primitives ── */
|
|
358
|
+
skipWs() {
|
|
359
|
+
while (this.i < this.s.length && /\s/.test(this.s[this.i]))
|
|
360
|
+
this.i++;
|
|
361
|
+
}
|
|
362
|
+
readWord() {
|
|
363
|
+
this.skipWs();
|
|
364
|
+
let w = '';
|
|
365
|
+
while (this.i < this.s.length && /[A-Za-z]/.test(this.s[this.i]))
|
|
366
|
+
w += this.s[this.i++];
|
|
367
|
+
return w;
|
|
368
|
+
}
|
|
369
|
+
peekWord() {
|
|
370
|
+
const save = this.i;
|
|
371
|
+
const w = this.readWord();
|
|
372
|
+
this.i = save;
|
|
373
|
+
return w;
|
|
374
|
+
}
|
|
375
|
+
readName() {
|
|
376
|
+
let n = '';
|
|
377
|
+
while (this.i < this.s.length && /[A-Za-z0-9_-]/.test(this.s[this.i]))
|
|
378
|
+
n += this.s[this.i++];
|
|
379
|
+
return n;
|
|
380
|
+
}
|
|
381
|
+
readUntil(stop) {
|
|
382
|
+
let out = '';
|
|
383
|
+
while (this.i < this.s.length && this.s[this.i] !== stop)
|
|
384
|
+
out += this.s[this.i++];
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
expect(ch) {
|
|
388
|
+
this.skipWs();
|
|
389
|
+
if (this.s[this.i] !== ch)
|
|
390
|
+
throw new Error(`expected "${ch}" at "${this.rest()}"`);
|
|
391
|
+
this.i++;
|
|
392
|
+
}
|
|
393
|
+
consumeIf(ch) {
|
|
394
|
+
this.skipWs();
|
|
395
|
+
if (this.s[this.i] === ch) {
|
|
396
|
+
this.i++;
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
hasBraceBeforeDelimiter() {
|
|
402
|
+
let depth = 0;
|
|
403
|
+
for (let j = this.i; j < this.s.length; j++) {
|
|
404
|
+
const c = this.s[j];
|
|
405
|
+
if (c === '{')
|
|
406
|
+
return true;
|
|
407
|
+
if ((c === ';' || c === '}') && depth === 0)
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
atEnd() {
|
|
413
|
+
this.skipWs();
|
|
414
|
+
return this.i >= this.s.length;
|
|
415
|
+
}
|
|
416
|
+
rest() {
|
|
417
|
+
return this.s.slice(this.i, this.i + 20);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** Parse a `/`-separated track list: `2/1`, `auto/1fr`, `30%/1fr`, `200px/1`. */
|
|
421
|
+
function parseTracks(str) {
|
|
422
|
+
return str
|
|
423
|
+
.trim()
|
|
424
|
+
.split('/')
|
|
425
|
+
.map((t) => t.trim())
|
|
426
|
+
.filter((t) => t !== '');
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Split the slide body into region chunks at `<!-- @name -->` markers. Content
|
|
430
|
+
* before the first marker fills `primaryName` (the layout's primary region).
|
|
431
|
+
* Reserved names (`notes`, `footer`, `float`) are routed to dedicated fields.
|
|
432
|
+
*/
|
|
433
|
+
function parseRegions(body, primaryName) {
|
|
434
|
+
const lines = body.split('\n');
|
|
435
|
+
const regions = [];
|
|
436
|
+
const floats = [];
|
|
437
|
+
let footer;
|
|
438
|
+
let notes;
|
|
439
|
+
// Current accumulator.
|
|
440
|
+
let curName = null; // null = leading (primary) region
|
|
441
|
+
let curAttrs = '';
|
|
442
|
+
let buf = [];
|
|
443
|
+
const flush = () => {
|
|
444
|
+
const markdown = trimBlock(buf.join('\n'));
|
|
445
|
+
if (curName === null) {
|
|
446
|
+
// Leading content → primary region (only if non-empty).
|
|
447
|
+
if (markdown !== '')
|
|
448
|
+
regions.push({ name: primaryName, markdown });
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
const lname = curName.toLowerCase();
|
|
452
|
+
if (lname === 'notes') {
|
|
453
|
+
notes = markdown;
|
|
454
|
+
}
|
|
455
|
+
else if (lname === 'footer') {
|
|
456
|
+
footer = markdown;
|
|
457
|
+
}
|
|
458
|
+
else if (lname === 'float') {
|
|
459
|
+
floats.push({ geom: parseFloatGeom(curAttrs), markdown });
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
regions.push({ name: curName, markdown });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
buf = [];
|
|
466
|
+
};
|
|
467
|
+
for (const line of lines) {
|
|
468
|
+
const m = line.match(REGION_MARKER);
|
|
469
|
+
if (m) {
|
|
470
|
+
flush();
|
|
471
|
+
curName = m[1];
|
|
472
|
+
curAttrs = (m[2] || '').trim();
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
buf.push(line);
|
|
476
|
+
}
|
|
477
|
+
flush();
|
|
478
|
+
return { regions, floats, footer, notes };
|
|
479
|
+
}
|
|
480
|
+
/** Parse `<!-- @float left=58% top=10% w=36% h=44% z=3 -->` attributes. */
|
|
481
|
+
function parseFloatGeom(attrs) {
|
|
482
|
+
const geom = {};
|
|
483
|
+
const re = /([A-Za-z]+)\s*=\s*("[^"]*"|'[^']*'|\S+)/g;
|
|
484
|
+
let m;
|
|
485
|
+
while ((m = re.exec(attrs)) !== null) {
|
|
486
|
+
const key = m[1].toLowerCase();
|
|
487
|
+
let val = m[2];
|
|
488
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
489
|
+
(val.startsWith("'") && val.endsWith("'")))
|
|
490
|
+
val = val.slice(1, -1);
|
|
491
|
+
switch (key) {
|
|
492
|
+
case 'left':
|
|
493
|
+
geom.left = val;
|
|
494
|
+
break;
|
|
495
|
+
case 'right':
|
|
496
|
+
geom.right = val;
|
|
497
|
+
break;
|
|
498
|
+
case 'top':
|
|
499
|
+
geom.top = val;
|
|
500
|
+
break;
|
|
501
|
+
case 'bottom':
|
|
502
|
+
geom.bottom = val;
|
|
503
|
+
break;
|
|
504
|
+
case 'w':
|
|
505
|
+
case 'width':
|
|
506
|
+
geom.w = val;
|
|
507
|
+
break;
|
|
508
|
+
case 'h':
|
|
509
|
+
case 'height':
|
|
510
|
+
geom.h = val;
|
|
511
|
+
break;
|
|
512
|
+
case 'z':
|
|
513
|
+
geom.z = Number(val);
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return geom;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* On a normal slide the single leading h2 is lifted into the title band. Lint:
|
|
521
|
+
* - the first content must be exactly one h2;
|
|
522
|
+
* - a second h2, or any h1, anywhere on a normal slide is an error.
|
|
523
|
+
* Headings inside fenced code blocks are ignored.
|
|
524
|
+
*/
|
|
525
|
+
function extractTitle(body, lint) {
|
|
526
|
+
const lines = body.split('\n');
|
|
527
|
+
// Locate the first non-blank, non-region-marker content line.
|
|
528
|
+
let firstIdx = -1;
|
|
529
|
+
let inFence = false;
|
|
530
|
+
let fenceTok = '';
|
|
531
|
+
const headings = [];
|
|
532
|
+
for (let i = 0; i < lines.length; i++) {
|
|
533
|
+
const line = lines[i];
|
|
534
|
+
const fence = line.match(/^\s*(```+|~~~+)/);
|
|
535
|
+
if (fence) {
|
|
536
|
+
if (!inFence) {
|
|
537
|
+
inFence = true;
|
|
538
|
+
fenceTok = fence[1][0];
|
|
539
|
+
}
|
|
540
|
+
else if (line.trimStart().startsWith(fenceTok)) {
|
|
541
|
+
inFence = false;
|
|
542
|
+
}
|
|
543
|
+
if (firstIdx === -1 && line.trim() !== '')
|
|
544
|
+
firstIdx = i;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (inFence)
|
|
548
|
+
continue;
|
|
549
|
+
if (REGION_MARKER.test(line))
|
|
550
|
+
continue;
|
|
551
|
+
if (line.trim() === '')
|
|
552
|
+
continue;
|
|
553
|
+
if (firstIdx === -1)
|
|
554
|
+
firstIdx = i;
|
|
555
|
+
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
556
|
+
if (h)
|
|
557
|
+
headings.push({ level: h[1].length, idx: i, text: h[2].trim() });
|
|
558
|
+
}
|
|
559
|
+
let title;
|
|
560
|
+
let removeIdx = -1;
|
|
561
|
+
const firstHeading = headings[0];
|
|
562
|
+
const firstIsLeadingH2 = firstHeading && firstHeading.idx === firstIdx && firstHeading.level === 2;
|
|
563
|
+
if (firstIsLeadingH2) {
|
|
564
|
+
title = firstHeading.text;
|
|
565
|
+
removeIdx = firstHeading.idx;
|
|
566
|
+
}
|
|
567
|
+
else if (firstIdx !== -1) {
|
|
568
|
+
// First content is not a leading h2.
|
|
569
|
+
if (firstHeading && firstHeading.idx === firstIdx && firstHeading.level === 1) {
|
|
570
|
+
lint.push({
|
|
571
|
+
level: 'error',
|
|
572
|
+
message: 'h1 is not allowed on a normal slide (use template=title).',
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
lint.push({
|
|
577
|
+
level: 'error',
|
|
578
|
+
message: 'A normal slide must begin with exactly one h2 title.',
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// Lint the remaining headings: any h1, or a second h2, is an error.
|
|
583
|
+
for (const h of headings) {
|
|
584
|
+
if (h.idx === removeIdx)
|
|
585
|
+
continue;
|
|
586
|
+
if (h.level === 1) {
|
|
587
|
+
lint.push({
|
|
588
|
+
level: 'error',
|
|
589
|
+
message: 'h1 is not allowed on a normal slide (use template=title).',
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
else if (h.level === 2) {
|
|
593
|
+
lint.push({
|
|
594
|
+
level: 'error',
|
|
595
|
+
message: 'A normal slide may have only one h2 (the title).',
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
let outLines = lines;
|
|
600
|
+
if (removeIdx !== -1) {
|
|
601
|
+
outLines = lines.slice();
|
|
602
|
+
outLines.splice(removeIdx, 1);
|
|
603
|
+
}
|
|
604
|
+
return { title, body: outLines.join('\n') };
|
|
605
|
+
}
|
|
606
|
+
/* ── Small helpers ───────────────────────────────────────────────────────── */
|
|
607
|
+
function split(dir, tracks, children) {
|
|
608
|
+
return { kind: 'split', dir, tracks, children };
|
|
609
|
+
}
|
|
610
|
+
function regionLeaf(name) {
|
|
611
|
+
return { kind: 'region', name };
|
|
612
|
+
}
|
|
613
|
+
/** Name of the first leaf in document order (the primary region). */
|
|
614
|
+
function firstLeafName(node) {
|
|
615
|
+
if (node.kind === 'region')
|
|
616
|
+
return node.name;
|
|
617
|
+
return firstLeafName(node.children[0]);
|
|
618
|
+
}
|
|
619
|
+
/** Trim leading/trailing blank lines but keep internal structure. */
|
|
620
|
+
function trimBlock(s) {
|
|
621
|
+
return s.replace(/^\n+/, '').replace(/\n+$/, '').replace(/^[ \t]+$/gm, '').trim();
|
|
622
|
+
}
|