@entropicwarrior/sdoc 0.1.3

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/src/sdoc.js ADDED
@@ -0,0 +1,2223 @@
1
+ const COMMAND_HEADING = "#";
2
+ const COMMAND_SCOPE_OPEN = "{";
3
+ const COMMAND_SCOPE_CLOSE = "}";
4
+ const COMMAND_LIST_BULLET = "{[.]";
5
+ const COMMAND_LIST_NUMBER = "{[#]";
6
+ const COMMAND_TABLE = "{[table]";
7
+ const COMMAND_CODE_FENCE = "```";
8
+
9
+ const ESCAPABLE = new Set(["\\", "{", "}", "@", "[", "]", "(", ")", "*", "`", "#", "!", "~", "<", ">"]);
10
+
11
+ function isTableCommand(text) {
12
+ return /^\{\[table(?:\s+[^\]]*?)?\]$/.test(text);
13
+ }
14
+
15
+ function parseTableOptions(text) {
16
+ const match = text.match(/^\{\[table(?:\s+(.*))?\]$/);
17
+ if (!match) return {};
18
+ const flagStr = (match[1] || "").trim();
19
+ const flags = flagStr ? flagStr.split(/\s+/) : [];
20
+ const options = { borderless: false, headerless: false };
21
+ for (const flag of flags) {
22
+ if (flag === "borderless") options.borderless = true;
23
+ else if (flag === "headerless") options.headerless = true;
24
+ }
25
+ return options;
26
+ }
27
+
28
+ class LineCursor {
29
+ constructor(lines) {
30
+ this.lines = lines;
31
+ this.index = 0;
32
+ this.errors = [];
33
+ }
34
+
35
+ eof() {
36
+ return this.index >= this.lines.length;
37
+ }
38
+
39
+ current() {
40
+ return this.lines[this.index] ?? "";
41
+ }
42
+
43
+ next() {
44
+ this.index += 1;
45
+ }
46
+
47
+ error(message) {
48
+ this.errors.push({ message, line: this.index + 1 });
49
+ }
50
+ }
51
+
52
+ function parseSdoc(text) {
53
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
54
+ const cursor = new LineCursor(normalized.split("\n"));
55
+
56
+ // Check for implicit root: first non-blank line is a heading, next non-blank is NOT a block opener
57
+ const implicitRoot = detectImplicitRoot(cursor);
58
+ if (implicitRoot) {
59
+ const scopeStartLine = cursor.index + 1;
60
+ const parsedHeading = parseHeading(cursor.current());
61
+ cursor.next();
62
+ const children = parseBlock(cursor, "normal");
63
+ const rootNode = {
64
+ type: "scope",
65
+ title: parsedHeading.title,
66
+ id: parsedHeading.id,
67
+ children,
68
+ hasHeading: true,
69
+ lineStart: scopeStartLine,
70
+ lineEnd: cursor.index
71
+ };
72
+ return { nodes: [rootNode], errors: cursor.errors };
73
+ }
74
+
75
+ const nodes = parseBlock(cursor, "normal");
76
+ return { nodes, errors: cursor.errors };
77
+ }
78
+
79
+ function detectImplicitRoot(cursor) {
80
+ const saved = cursor.index;
81
+ // Find first non-blank line
82
+ while (!cursor.eof()) {
83
+ const trimmed = cursor.current().trim();
84
+ if (trimmed !== "") break;
85
+ cursor.next();
86
+ }
87
+ if (cursor.eof()) { cursor.index = saved; return false; }
88
+
89
+ const firstLine = cursor.current();
90
+ const trimmedLeft = firstLine.replace(/^\s+/, "");
91
+ if (!isHeadingLine(trimmedLeft)) { cursor.index = saved; return false; }
92
+
93
+ // Check if heading has trailing opener (K&R style) — if so, it's explicit
94
+ const stripped = stripHeadingToken(trimmedLeft);
95
+ const trailing = extractTrailingOpener(stripped);
96
+ if (trailing) { cursor.index = saved; return false; }
97
+
98
+ // Peek at the next non-blank line after the heading
99
+ const headingIndex = cursor.index;
100
+ cursor.index = headingIndex + 1;
101
+ while (!cursor.eof()) {
102
+ const trimmed = cursor.current().trim();
103
+ if (trimmed !== "") break;
104
+ cursor.next();
105
+ }
106
+
107
+ let isImplicit = false;
108
+ if (cursor.eof()) {
109
+ // Heading followed by nothing — implicit root with no content
110
+ isImplicit = true;
111
+ } else {
112
+ const nextTrimmed = cursor.current().replace(/^\s+/, "").trim();
113
+ // If next non-blank is a block opener, it's explicit
114
+ if (nextTrimmed === COMMAND_SCOPE_OPEN ||
115
+ nextTrimmed === COMMAND_LIST_BULLET ||
116
+ nextTrimmed === COMMAND_LIST_NUMBER ||
117
+ isTableCommand(nextTrimmed)) {
118
+ isImplicit = false;
119
+ } else if (tryParseInlineBlock(nextTrimmed) !== null) {
120
+ isImplicit = false;
121
+ } else {
122
+ isImplicit = true;
123
+ }
124
+ }
125
+
126
+ // Restore cursor to first non-blank (the heading line)
127
+ cursor.index = headingIndex;
128
+ if (!isImplicit) {
129
+ cursor.index = saved;
130
+ }
131
+ return isImplicit;
132
+ }
133
+
134
+ const BARE_DIRECTIVES = new Set(["meta", "about"]);
135
+
136
+ function parseBareDirective(trimmed) {
137
+ // Match @meta, @about, @meta {, @about {
138
+ if (!trimmed.startsWith("@")) return null;
139
+ const withoutAt = trimmed.slice(1);
140
+ // Check for "@directive {" (K&R style)
141
+ const spaceIdx = withoutAt.indexOf(" ");
142
+ if (spaceIdx === -1) {
143
+ // Bare "@directive" with no trailing brace — only valid if next line is "{"
144
+ return BARE_DIRECTIVES.has(withoutAt) ? { id: withoutAt, hasOpenBrace: false } : null;
145
+ }
146
+ const name = withoutAt.slice(0, spaceIdx);
147
+ const rest = withoutAt.slice(spaceIdx).trim();
148
+ if (!BARE_DIRECTIVES.has(name)) return null;
149
+ if (rest === COMMAND_SCOPE_OPEN) return { id: name, hasOpenBrace: true };
150
+ return null;
151
+ }
152
+
153
+ function parseBareScope(cursor, directive) {
154
+ const scopeStartLine = cursor.index + 1;
155
+ cursor.next();
156
+
157
+ if (directive.hasOpenBrace) {
158
+ // Brace was on the same line — parse contents until closing }
159
+ const children = parseBlock(cursor, "normal");
160
+ return { type: "scope", title: "", id: directive.id, children, hasHeading: false, lineStart: scopeStartLine, lineEnd: cursor.index };
161
+ }
162
+
163
+ // Brace should be on the next non-blank line
164
+ const saved = cursor.index;
165
+ while (!cursor.eof() && cursor.current().trim() === "") {
166
+ cursor.next();
167
+ }
168
+ if (!cursor.eof() && cursor.current().trim() === COMMAND_SCOPE_OPEN) {
169
+ cursor.next();
170
+ const children = parseBlock(cursor, "normal");
171
+ return { type: "scope", title: "", id: directive.id, children, hasHeading: false, lineStart: scopeStartLine, lineEnd: cursor.index };
172
+ }
173
+
174
+ // No opening brace found — treat as braceless scope (content until next heading, }, or EOF)
175
+ cursor.index = saved;
176
+ const children = parseBracelessBlock(cursor);
177
+ return { type: "scope", title: "", id: directive.id, children, hasHeading: false, lineStart: scopeStartLine, lineEnd: cursor.index };
178
+ }
179
+
180
+ function parseBlock(cursor, kind) {
181
+ const nodes = [];
182
+ let paragraphLines = [];
183
+ let paragraphStartLine = 0;
184
+
185
+ const flushParagraph = () => {
186
+ if (!paragraphLines.length) {
187
+ return;
188
+ }
189
+ const text = paragraphLines.join(" ").trim();
190
+ if (text) {
191
+ nodes.push({ type: "paragraph", text, lineStart: paragraphStartLine, lineEnd: cursor.index });
192
+ }
193
+ paragraphLines = [];
194
+ };
195
+
196
+ while (!cursor.eof()) {
197
+ const line = cursor.current();
198
+ const trimmedLeft = line.replace(/^\s+/, "");
199
+ const trimmed = trimmedLeft.trim();
200
+
201
+ if (trimmed === "") {
202
+ flushParagraph();
203
+ cursor.next();
204
+ continue;
205
+ }
206
+
207
+ if (trimmed === COMMAND_SCOPE_CLOSE) {
208
+ flushParagraph();
209
+ cursor.next();
210
+ break;
211
+ }
212
+
213
+ if (trimmed === ",") {
214
+ flushParagraph();
215
+ cursor.next();
216
+ continue;
217
+ }
218
+
219
+ if (isHorizontalRule(trimmed)) {
220
+ flushParagraph();
221
+ const hrLine = cursor.index + 1;
222
+ nodes.push({ type: "hr", lineStart: hrLine, lineEnd: hrLine });
223
+ cursor.next();
224
+ continue;
225
+ }
226
+
227
+ if (isBlockquoteLine(trimmedLeft)) {
228
+ flushParagraph();
229
+ nodes.push(parseBlockquote(cursor));
230
+ continue;
231
+ }
232
+
233
+ if (isFenceStart(trimmedLeft)) {
234
+ flushParagraph();
235
+ nodes.push(parseCodeBlock(cursor));
236
+ continue;
237
+ }
238
+
239
+ const implicitListInfo = getListItemInfo(trimmedLeft);
240
+ if (implicitListInfo && kind === "normal") {
241
+ flushParagraph();
242
+ nodes.push(parseImplicitListBlock(cursor, implicitListInfo.type));
243
+ continue;
244
+ }
245
+
246
+ const bareDirective = parseBareDirective(trimmed);
247
+ if (bareDirective) {
248
+ flushParagraph();
249
+ nodes.push(parseBareScope(cursor, bareDirective));
250
+ continue;
251
+ }
252
+
253
+ if (isHeadingLine(trimmedLeft)) {
254
+ flushParagraph();
255
+ nodes.push(parseScope(cursor));
256
+ continue;
257
+ }
258
+
259
+ if (trimmed === COMMAND_LIST_BULLET || trimmed === COMMAND_LIST_NUMBER) {
260
+ flushParagraph();
261
+ nodes.push(parseListBlock(cursor, trimmed === COMMAND_LIST_BULLET ? "bullet" : "number"));
262
+ continue;
263
+ }
264
+
265
+ if (isTableCommand(trimmed)) {
266
+ flushParagraph();
267
+ nodes.push(parseTableBlock(cursor));
268
+ continue;
269
+ }
270
+
271
+ if (trimmed === COMMAND_SCOPE_OPEN) {
272
+ flushParagraph();
273
+ const scopeStartLine = cursor.index + 1;
274
+ cursor.next();
275
+ const children = parseBlock(cursor, "normal");
276
+ nodes.push({ type: "scope", title: "", id: undefined, children, hasHeading: false, lineStart: scopeStartLine, lineEnd: cursor.index });
277
+ continue;
278
+ }
279
+
280
+ if (kind === "list") {
281
+ flushParagraph();
282
+ cursor.error("Only scoped list items are allowed inside list blocks.");
283
+ cursor.next();
284
+ continue;
285
+ }
286
+
287
+ if (!paragraphLines.length) {
288
+ paragraphStartLine = cursor.index + 1;
289
+ }
290
+ paragraphLines.push(trimmedLeft.trim());
291
+ cursor.next();
292
+ }
293
+
294
+ flushParagraph();
295
+ return nodes;
296
+ }
297
+
298
+ function extractTrailingOpener(text) {
299
+ const trimmed = text.trimEnd();
300
+ // Don't match if the line also ends with } (inline block like "{ content }")
301
+ if (trimmed.endsWith(COMMAND_SCOPE_CLOSE)) {
302
+ return null;
303
+ }
304
+ // Check for table command with optional flags: {[table ...]}
305
+ const tableMatch = trimmed.match(/\{\[table(?:\s+[^\]]*?)?\]$/);
306
+ if (tableMatch) {
307
+ const pos = tableMatch.index;
308
+ if (!(pos > 0 && trimmed[pos - 1] === "\\")) {
309
+ return { text: trimmed.slice(0, pos).trimEnd(), opener: tableMatch[0] };
310
+ }
311
+ }
312
+ // Check other openers
313
+ const openers = [COMMAND_LIST_NUMBER, COMMAND_LIST_BULLET, COMMAND_SCOPE_OPEN];
314
+ for (const opener of openers) {
315
+ if (trimmed.endsWith(opener)) {
316
+ const pos = trimmed.length - opener.length;
317
+ // Don't match escaped braces
318
+ if (pos > 0 && trimmed[pos - 1] === "\\") {
319
+ continue;
320
+ }
321
+ return { text: trimmed.slice(0, pos).trimEnd(), opener };
322
+ }
323
+ }
324
+ return null;
325
+ }
326
+
327
+ function parseScope(cursor) {
328
+ const scopeStartLine = cursor.index + 1;
329
+ const headingLine = cursor.current();
330
+ cursor.next();
331
+
332
+ const trimmedLeft = headingLine.replace(/^\s+/, "");
333
+ const stripped = stripHeadingToken(trimmedLeft);
334
+ const trailing = extractTrailingOpener(stripped);
335
+
336
+ if (trailing) {
337
+ const parsedHeading = parseHeadingText(trailing.text);
338
+ let children;
339
+ if (trailing.opener === COMMAND_LIST_BULLET || trailing.opener === COMMAND_LIST_NUMBER) {
340
+ const listBody = parseListBody(cursor, trailing.opener === COMMAND_LIST_BULLET ? "bullet" : "number");
341
+ children = [listBody];
342
+ } else if (isTableCommand(trailing.opener)) {
343
+ const tableOpts = parseTableOptions(trailing.opener);
344
+ children = [parseTableBody(cursor, scopeStartLine, tableOpts)];
345
+ } else {
346
+ children = parseBlock(cursor, "normal");
347
+ }
348
+ return {
349
+ type: "scope",
350
+ title: parsedHeading.title,
351
+ id: parsedHeading.id,
352
+ children,
353
+ hasHeading: true,
354
+ lineStart: scopeStartLine,
355
+ lineEnd: cursor.index
356
+ };
357
+ }
358
+
359
+ const parsedHeading = parseHeading(headingLine);
360
+ const blockResult = parseScopeBlock(cursor);
361
+
362
+ if (blockResult.blockType === "braceless") {
363
+ const children = parseBracelessBlock(cursor);
364
+ return {
365
+ type: "scope",
366
+ title: parsedHeading.title,
367
+ id: parsedHeading.id,
368
+ children,
369
+ hasHeading: true,
370
+ lineStart: scopeStartLine,
371
+ lineEnd: cursor.index
372
+ };
373
+ }
374
+
375
+ if (blockResult.blockType === "list") {
376
+ return {
377
+ type: "scope",
378
+ title: parsedHeading.title,
379
+ id: parsedHeading.id,
380
+ children: [blockResult.children],
381
+ hasHeading: true,
382
+ lineStart: scopeStartLine,
383
+ lineEnd: cursor.index
384
+ };
385
+ }
386
+
387
+ return {
388
+ type: "scope",
389
+ title: parsedHeading.title,
390
+ id: parsedHeading.id,
391
+ children: blockResult.children,
392
+ hasHeading: true,
393
+ lineStart: scopeStartLine,
394
+ lineEnd: cursor.index
395
+ };
396
+ }
397
+
398
+ function tryParseInlineBlock(trimmed) {
399
+ // Check if line matches pattern { ... }
400
+ if (!trimmed.startsWith(COMMAND_SCOPE_OPEN) || !trimmed.endsWith(COMMAND_SCOPE_CLOSE)) {
401
+ return null;
402
+ }
403
+
404
+ // Extract content between { and }
405
+ const content = trimmed.slice(1, -1).trim();
406
+
407
+ // Check for nested braces - if there are any unescaped { or }, this isn't a simple inline block
408
+ let depth = 0;
409
+ let i = 0;
410
+ while (i < content.length) {
411
+ if (content[i] === "\\" && i + 1 < content.length && ESCAPABLE.has(content[i + 1])) {
412
+ i += 2;
413
+ continue;
414
+ }
415
+ if (content[i] === "{" || content[i] === "}") {
416
+ depth++;
417
+ }
418
+ i++;
419
+ }
420
+
421
+ // If we have nested braces, this isn't a simple inline block
422
+ if (depth > 0) {
423
+ return null;
424
+ }
425
+
426
+ // Return the content as text
427
+ return content;
428
+ }
429
+
430
+ function parseBracelessBlock(cursor) {
431
+ const nodes = [];
432
+ let paragraphLines = [];
433
+ let paragraphStartLine = 0;
434
+
435
+ const flushParagraph = () => {
436
+ if (!paragraphLines.length) return;
437
+ const text = paragraphLines.join(" ").trim();
438
+ if (text) {
439
+ nodes.push({ type: "paragraph", text, lineStart: paragraphStartLine, lineEnd: cursor.index });
440
+ }
441
+ paragraphLines = [];
442
+ };
443
+
444
+ while (!cursor.eof()) {
445
+ const line = cursor.current();
446
+ const trimmedLeft = line.replace(/^\s+/, "");
447
+ const trimmed = trimmedLeft.trim();
448
+
449
+ if (trimmed === "") {
450
+ flushParagraph();
451
+ cursor.next();
452
+ continue;
453
+ }
454
+
455
+ // Stop on } — parent owns it, don't advance
456
+ if (trimmed === COMMAND_SCOPE_CLOSE) {
457
+ flushParagraph();
458
+ break;
459
+ }
460
+
461
+ // Stop on # heading — becomes sibling, don't advance
462
+ if (isHeadingLine(trimmedLeft)) {
463
+ flushParagraph();
464
+ break;
465
+ }
466
+
467
+ // Stop on bare @meta / @about — becomes sibling, don't advance
468
+ if (parseBareDirective(trimmed)) {
469
+ flushParagraph();
470
+ break;
471
+ }
472
+
473
+ if (trimmed === ",") {
474
+ flushParagraph();
475
+ cursor.next();
476
+ continue;
477
+ }
478
+
479
+ if (isHorizontalRule(trimmed)) {
480
+ flushParagraph();
481
+ const hrLine = cursor.index + 1;
482
+ nodes.push({ type: "hr", lineStart: hrLine, lineEnd: hrLine });
483
+ cursor.next();
484
+ continue;
485
+ }
486
+
487
+ if (isBlockquoteLine(trimmedLeft)) {
488
+ flushParagraph();
489
+ nodes.push(parseBlockquote(cursor));
490
+ continue;
491
+ }
492
+
493
+ if (isFenceStart(trimmedLeft)) {
494
+ flushParagraph();
495
+ nodes.push(parseCodeBlock(cursor));
496
+ continue;
497
+ }
498
+
499
+ const implicitListInfo = getListItemInfo(trimmedLeft);
500
+ if (implicitListInfo) {
501
+ flushParagraph();
502
+ nodes.push(parseImplicitListBlock(cursor, implicitListInfo.type));
503
+ continue;
504
+ }
505
+
506
+ // Headingless scopes { ... }
507
+ if (trimmed === COMMAND_SCOPE_OPEN) {
508
+ flushParagraph();
509
+ const scopeStartLine = cursor.index + 1;
510
+ cursor.next();
511
+ const children = parseBlock(cursor, "normal");
512
+ nodes.push({ type: "scope", title: "", id: undefined, children, hasHeading: false, lineStart: scopeStartLine, lineEnd: cursor.index });
513
+ continue;
514
+ }
515
+
516
+ if (trimmed === COMMAND_LIST_BULLET || trimmed === COMMAND_LIST_NUMBER) {
517
+ flushParagraph();
518
+ nodes.push(parseListBlock(cursor, trimmed === COMMAND_LIST_BULLET ? "bullet" : "number"));
519
+ continue;
520
+ }
521
+
522
+ if (isTableCommand(trimmed)) {
523
+ flushParagraph();
524
+ nodes.push(parseTableBlock(cursor));
525
+ continue;
526
+ }
527
+
528
+ if (!paragraphLines.length) {
529
+ paragraphStartLine = cursor.index + 1;
530
+ }
531
+ paragraphLines.push(trimmedLeft.trim());
532
+ cursor.next();
533
+ }
534
+
535
+ flushParagraph();
536
+ return nodes;
537
+ }
538
+
539
+ function parseScopeBlock(cursor) {
540
+ while (!cursor.eof()) {
541
+ const line = cursor.current();
542
+ const trimmed = line.replace(/^\s+/, "").trim();
543
+
544
+ if (trimmed === "") {
545
+ cursor.next();
546
+ continue;
547
+ }
548
+
549
+ // Try to parse as inline block: { content }
550
+ const inlineContent = tryParseInlineBlock(trimmed);
551
+ if (inlineContent !== null) {
552
+ const inlineLine = cursor.index + 1;
553
+ cursor.next();
554
+ if (inlineContent === "") {
555
+ return { blockType: "normal", children: [] };
556
+ }
557
+ return { blockType: "normal", children: [{ type: "paragraph", text: inlineContent, lineStart: inlineLine, lineEnd: inlineLine }] };
558
+ }
559
+
560
+ if (trimmed === COMMAND_SCOPE_OPEN) {
561
+ cursor.next();
562
+ return { blockType: "normal", children: parseBlock(cursor, "normal") };
563
+ }
564
+
565
+ if (trimmed === COMMAND_LIST_BULLET || trimmed === COMMAND_LIST_NUMBER) {
566
+ cursor.next();
567
+ return {
568
+ blockType: "list",
569
+ children: parseListBody(cursor, trimmed === COMMAND_LIST_BULLET ? "bullet" : "number")
570
+ };
571
+ }
572
+
573
+ if (isTableCommand(trimmed)) {
574
+ return { blockType: "normal", children: [parseTableBlock(cursor)] };
575
+ }
576
+
577
+ // No block opener found — braceless scope
578
+ return { blockType: "braceless" };
579
+ }
580
+
581
+ // EOF after heading — braceless scope with no content
582
+ return { blockType: "braceless" };
583
+ }
584
+
585
+ function parseListBlock(cursor, listType) {
586
+ cursor.next();
587
+ return parseListBody(cursor, listType);
588
+ }
589
+
590
+ function parseListBody(cursor, listType) {
591
+ const listStartLine = cursor.index + 1;
592
+ const items = [];
593
+
594
+ while (!cursor.eof()) {
595
+ const line = cursor.current();
596
+ const trimmedLeft = line.replace(/^\s+/, "");
597
+ const trimmed = trimmedLeft.trim();
598
+
599
+ if (trimmed === "") {
600
+ cursor.next();
601
+ continue;
602
+ }
603
+
604
+ if (trimmed === COMMAND_SCOPE_CLOSE) {
605
+ cursor.next();
606
+ break;
607
+ }
608
+
609
+ if (trimmed === ",") {
610
+ cursor.next();
611
+ continue;
612
+ }
613
+
614
+ if (isHeadingLine(trimmedLeft)) {
615
+ items.push(parseScope(cursor));
616
+ continue;
617
+ }
618
+
619
+ const itemInfo = getListItemInfo(trimmedLeft);
620
+ if (itemInfo) {
621
+ items.push(parseListItemLine(cursor, itemInfo, true));
622
+ continue;
623
+ }
624
+
625
+ if (trimmed === COMMAND_SCOPE_OPEN) {
626
+ items.push(parseAnonymousListItem(cursor));
627
+ continue;
628
+ }
629
+
630
+ cursor.error("Orphaned text in list block (no preceding list item).");
631
+ cursor.next();
632
+ }
633
+
634
+ return { type: "list", listType, items, lineStart: listStartLine, lineEnd: cursor.index };
635
+ }
636
+
637
+ function parseAnonymousListItem(cursor) {
638
+ const itemStartLine = cursor.index + 1;
639
+ const line = cursor.current();
640
+ const trimmed = line.replace(/^\s+/, "").trim();
641
+ if (trimmed !== COMMAND_SCOPE_OPEN) {
642
+ cursor.error("Expected '{' to start an anonymous list item.");
643
+ cursor.next();
644
+ return { type: "scope", title: "", id: undefined, children: [], hasHeading: false, lineStart: itemStartLine, lineEnd: cursor.index };
645
+ }
646
+
647
+ cursor.next();
648
+ const children = parseBlock(cursor, "normal");
649
+ return { type: "scope", title: "", id: undefined, children, hasHeading: false, lineStart: itemStartLine, lineEnd: cursor.index };
650
+ }
651
+
652
+ function getListItemInfo(line) {
653
+ const trimmedLeft = line.replace(/^\s+/, "");
654
+ if (trimmedLeft.startsWith("- ")) {
655
+ return { type: "bullet", text: trimmedLeft.slice(1).trim() };
656
+ }
657
+ const numberedMatch = trimmedLeft.match(/^(\d+)[.)]\s+(.*)$/);
658
+ if (numberedMatch) {
659
+ return { type: "number", text: numberedMatch[2] };
660
+ }
661
+ return null;
662
+ }
663
+
664
+ function isListContinuationLine(trimmedLeft) {
665
+ const trimmed = trimmedLeft.trim();
666
+ if (trimmed === "") return false;
667
+ if (trimmed === COMMAND_SCOPE_CLOSE) return false;
668
+ if (trimmed === COMMAND_SCOPE_OPEN) return false;
669
+ if (trimmed === COMMAND_LIST_BULLET) return false;
670
+ if (trimmed === COMMAND_LIST_NUMBER) return false;
671
+ if (isTableCommand(trimmed)) return false;
672
+ if (trimmed === ",") return false;
673
+ if (isHeadingLine(trimmedLeft)) return false;
674
+ if (isBlockquoteLine(trimmedLeft)) return false;
675
+ if (isFenceStart(trimmedLeft)) return false;
676
+ if (isHorizontalRule(trimmed)) return false;
677
+ if (getListItemInfo(trimmedLeft)) return false;
678
+ if (tryParseInlineBlock(trimmed) !== null) return false;
679
+ return true;
680
+ }
681
+
682
+ function parseListItemLine(cursor, info, allowContinuation = false) {
683
+ const itemStartLine = cursor.index + 1;
684
+ const raw = info.text;
685
+ const task = parseTaskPrefix(raw);
686
+ const textForOpener = task ? task.text : raw;
687
+ const trailing = extractTrailingOpener(textForOpener);
688
+
689
+ if (trailing) {
690
+ const parsed = parseHeadingText(trailing.text);
691
+ cursor.next();
692
+
693
+ let children;
694
+ if (trailing.opener === COMMAND_LIST_BULLET || trailing.opener === COMMAND_LIST_NUMBER) {
695
+ const listBody = parseListBody(cursor, trailing.opener === COMMAND_LIST_BULLET ? "bullet" : "number");
696
+ children = [listBody];
697
+ } else if (isTableCommand(trailing.opener)) {
698
+ const tableOpts = parseTableOptions(trailing.opener);
699
+ children = [parseTableBody(cursor, itemStartLine, tableOpts)];
700
+ } else {
701
+ children = parseBlock(cursor, "normal");
702
+ }
703
+
704
+ return {
705
+ type: "scope",
706
+ title: parsed.title,
707
+ id: parsed.id,
708
+ children,
709
+ hasHeading: true,
710
+ shorthand: true,
711
+ task: task ? { checked: task.checked } : undefined,
712
+ lineStart: itemStartLine,
713
+ lineEnd: cursor.index
714
+ };
715
+ }
716
+
717
+ cursor.next();
718
+
719
+ // Collect continuation lines in explicit list blocks
720
+ let fullText = task ? task.text : raw;
721
+ if (allowContinuation) {
722
+ while (!cursor.eof()) {
723
+ const nextLine = cursor.current();
724
+ const nextTrimmedLeft = nextLine.replace(/^\s+/, "");
725
+ if (!isListContinuationLine(nextTrimmedLeft)) break;
726
+ fullText += " " + nextTrimmedLeft.trim();
727
+ cursor.next();
728
+ }
729
+ }
730
+
731
+ const parsed = parseHeadingText(fullText);
732
+
733
+ const block = parseOptionalBlock(cursor);
734
+ if (!block) {
735
+ return {
736
+ type: "scope",
737
+ title: parsed.title,
738
+ id: parsed.id,
739
+ children: [],
740
+ hasHeading: true,
741
+ shorthand: true,
742
+ task: task ? { checked: task.checked } : undefined,
743
+ lineStart: itemStartLine,
744
+ lineEnd: cursor.index
745
+ };
746
+ }
747
+
748
+ if (block.blockType === "list") {
749
+ return {
750
+ type: "scope",
751
+ title: parsed.title,
752
+ id: parsed.id,
753
+ children: [block.children],
754
+ hasHeading: true,
755
+ shorthand: true,
756
+ task: task ? { checked: task.checked } : undefined,
757
+ lineStart: itemStartLine,
758
+ lineEnd: cursor.index
759
+ };
760
+ }
761
+
762
+ return {
763
+ type: "scope",
764
+ title: parsed.title,
765
+ id: parsed.id,
766
+ children: block.children,
767
+ hasHeading: true,
768
+ shorthand: true,
769
+ task: task ? { checked: task.checked } : undefined,
770
+ lineStart: itemStartLine,
771
+ lineEnd: cursor.index
772
+ };
773
+ }
774
+
775
+ function parseImplicitListBlock(cursor, listType) {
776
+ const listStartLine = cursor.index + 1;
777
+ const items = [];
778
+ while (!cursor.eof()) {
779
+ const line = cursor.current();
780
+ const info = getListItemInfo(line);
781
+ if (!info) {
782
+ break;
783
+ }
784
+ if (info.type !== listType) {
785
+ break;
786
+ }
787
+ items.push(parseListItemLine(cursor, info));
788
+ }
789
+ return { type: "list", listType, items, lineStart: listStartLine, lineEnd: cursor.index };
790
+ }
791
+
792
+ function parseTableBlock(cursor) {
793
+ const tableStartLine = cursor.index + 1;
794
+ const options = parseTableOptions(cursor.current().trim());
795
+ cursor.next();
796
+ return parseTableBody(cursor, tableStartLine, options);
797
+ }
798
+
799
+ function parseTableBody(cursor, tableStartLine, options) {
800
+ options = options || {};
801
+ const rows = [];
802
+
803
+ while (!cursor.eof()) {
804
+ const line = cursor.current();
805
+ const trimmed = line.replace(/^\s+/, "").trim();
806
+
807
+ if (trimmed === "") {
808
+ cursor.next();
809
+ continue;
810
+ }
811
+
812
+ if (trimmed === COMMAND_SCOPE_CLOSE) {
813
+ cursor.next();
814
+ break;
815
+ }
816
+
817
+ const cells = trimmed.split("|").map((cell) => cell.trim());
818
+ rows.push(cells);
819
+ cursor.next();
820
+ }
821
+
822
+ if (options.headerless) {
823
+ const tableNode = { type: "table", headers: [], rows, lineStart: tableStartLine, lineEnd: cursor.index };
824
+ if (options.borderless || options.headerless) tableNode.options = options;
825
+ return tableNode;
826
+ }
827
+
828
+ const headers = rows.length > 0 ? rows[0] : [];
829
+ const body = rows.slice(1);
830
+ const tableNode = { type: "table", headers, rows: body, lineStart: tableStartLine, lineEnd: cursor.index };
831
+ if (options.borderless) tableNode.options = options;
832
+ return tableNode;
833
+ }
834
+
835
+ function parseOptionalBlock(cursor) {
836
+ while (!cursor.eof()) {
837
+ const line = cursor.current();
838
+ const trimmed = line.replace(/^\s+/, "").trim();
839
+
840
+ if (trimmed === "") {
841
+ cursor.next();
842
+ continue;
843
+ }
844
+
845
+ // Try to parse as inline block: { content }
846
+ const inlineContent = tryParseInlineBlock(trimmed);
847
+ if (inlineContent !== null) {
848
+ const inlineLine = cursor.index + 1;
849
+ cursor.next();
850
+ if (inlineContent === "") {
851
+ return { blockType: "normal", children: [] };
852
+ }
853
+ return { blockType: "normal", children: [{ type: "paragraph", text: inlineContent, lineStart: inlineLine, lineEnd: inlineLine }] };
854
+ }
855
+
856
+ if (trimmed === COMMAND_SCOPE_OPEN) {
857
+ cursor.next();
858
+ return { blockType: "normal", children: parseBlock(cursor, "normal") };
859
+ }
860
+
861
+ if (trimmed === COMMAND_LIST_BULLET || trimmed === COMMAND_LIST_NUMBER) {
862
+ cursor.next();
863
+ return {
864
+ blockType: "list",
865
+ children: parseListBody(cursor, trimmed === COMMAND_LIST_BULLET ? "bullet" : "number")
866
+ };
867
+ }
868
+
869
+ if (isTableCommand(trimmed)) {
870
+ return { blockType: "normal", children: [parseTableBlock(cursor)] };
871
+ }
872
+
873
+ return null;
874
+ }
875
+
876
+ return null;
877
+ }
878
+
879
+ function parseTaskPrefix(raw) {
880
+ const match = raw.match(/^\[( |x|X)\]\s*(.*)$/);
881
+ if (!match) {
882
+ return null;
883
+ }
884
+ return { checked: match[1].toLowerCase() === "x", text: match[2] };
885
+ }
886
+
887
+ function parseFenceMetadata(meta) {
888
+ if (!meta) return {};
889
+ const tokens = meta.split(/\s+/).filter(Boolean);
890
+ let lang, src, lines;
891
+ for (const token of tokens) {
892
+ if (token.startsWith("src:")) {
893
+ src = token.slice(4);
894
+ } else if (token.startsWith("lines:")) {
895
+ const range = token.slice(6);
896
+ const match = range.match(/^(\d+)-(\d+)$/);
897
+ if (match) {
898
+ lines = { start: parseInt(match[1], 10), end: parseInt(match[2], 10) };
899
+ }
900
+ } else if (!lang) {
901
+ lang = token;
902
+ }
903
+ }
904
+ return { lang, src, lines };
905
+ }
906
+
907
+ function parseCodeBlock(cursor) {
908
+ const codeStartLine = cursor.index + 1;
909
+ const line = cursor.current();
910
+ const trimmedLeft = line.replace(/^\s+/, "");
911
+ const fenceMatch = trimmedLeft.match(/^(`{3,})/);
912
+ const fenceLen = fenceMatch ? fenceMatch[1].length : 3;
913
+ const metaStr = trimmedLeft.slice(fenceLen).trim();
914
+ const fenceMeta = parseFenceMetadata(metaStr);
915
+ const lang = fenceMeta.lang || undefined;
916
+ // Indentation of the opening fence — strip this much from content lines.
917
+ const fenceIndent = line.length - trimmedLeft.length;
918
+ cursor.next();
919
+
920
+ const contentLines = [];
921
+
922
+ while (!cursor.eof()) {
923
+ const nextLine = cursor.current();
924
+ const nextTrimmed = nextLine.replace(/^\s+/, "");
925
+ const closeMatch = nextTrimmed.match(/^(`{3,})\s*$/);
926
+ if (closeMatch && closeMatch[1].length >= fenceLen) {
927
+ cursor.next();
928
+ const node = { type: "code", lang, text: stripIndent(contentLines, fenceIndent), lineStart: codeStartLine, lineEnd: cursor.index };
929
+ if (fenceMeta.src) node.src = fenceMeta.src;
930
+ if (fenceMeta.lines) node.lines = fenceMeta.lines;
931
+ return node;
932
+ }
933
+ contentLines.push(nextLine);
934
+ cursor.next();
935
+ }
936
+
937
+ cursor.error("Unterminated code fence.");
938
+ const node = { type: "code", lang, text: stripIndent(contentLines, fenceIndent), lineStart: codeStartLine, lineEnd: cursor.index };
939
+ if (fenceMeta.src) node.src = fenceMeta.src;
940
+ if (fenceMeta.lines) node.lines = fenceMeta.lines;
941
+ return node;
942
+ }
943
+
944
+ /**
945
+ * Strip up to `indent` leading whitespace characters from each line.
946
+ * Preserves any extra indentation beyond the baseline.
947
+ */
948
+ function stripIndent(lines, indent) {
949
+ const re = new RegExp(`^\\s{0,${indent}}`);
950
+ return lines.map((l) => l.replace(re, "")).join("\n");
951
+ }
952
+
953
+ function parseBlockquote(cursor) {
954
+ const bqStartLine = cursor.index + 1;
955
+ const paragraphs = [];
956
+ let paragraphLines = [];
957
+
958
+ const flushParagraph = () => {
959
+ if (!paragraphLines.length) {
960
+ return;
961
+ }
962
+ const text = paragraphLines.join(" ").trim();
963
+ if (text) {
964
+ paragraphs.push(text);
965
+ }
966
+ paragraphLines = [];
967
+ };
968
+
969
+ while (!cursor.eof()) {
970
+ const line = cursor.current();
971
+ const trimmedLeft = line.replace(/^\s+/, "");
972
+ if (!isBlockquoteLine(trimmedLeft)) {
973
+ break;
974
+ }
975
+ let content = trimmedLeft.slice(1);
976
+ if (content.startsWith(" ")) {
977
+ content = content.slice(1);
978
+ }
979
+ if (content.trim() === "") {
980
+ flushParagraph();
981
+ cursor.next();
982
+ continue;
983
+ }
984
+ paragraphLines.push(content.trim());
985
+ cursor.next();
986
+ }
987
+
988
+ flushParagraph();
989
+ return { type: "blockquote", paragraphs, lineStart: bqStartLine, lineEnd: cursor.index };
990
+ }
991
+
992
+ function parseHeading(line) {
993
+ const trimmedLeft = line.replace(/^\s+/, "");
994
+ const raw = stripHeadingToken(trimmedLeft);
995
+ return parseHeadingText(raw);
996
+ }
997
+
998
+ function parseHeadingText(raw) {
999
+ const split = splitTrailingId(raw);
1000
+ return { title: split.text.trimEnd(), id: split.id ? split.id.slice(1) : undefined };
1001
+ }
1002
+
1003
+ function stripHeadingToken(line) {
1004
+ let i = 0;
1005
+ while (i < line.length && line[i] === "#") {
1006
+ i += 1;
1007
+ }
1008
+ return line.slice(i).trim();
1009
+ }
1010
+
1011
+ function splitTrailingId(raw) {
1012
+ let i = raw.length - 1;
1013
+ while (i >= 0 && /\s/.test(raw[i])) {
1014
+ i -= 1;
1015
+ }
1016
+
1017
+ const end = i;
1018
+ while (i >= 0 && isIdentChar(raw[i])) {
1019
+ i -= 1;
1020
+ }
1021
+
1022
+ if (i >= 0 && raw[i] === "@" && end > i && isIdentStart(raw[i + 1])) {
1023
+ if (!isEscaped(raw, i)) {
1024
+ if (i === 0 || /\s/.test(raw[i - 1])) {
1025
+ const id = raw.slice(i, end + 1);
1026
+ const text = raw.slice(0, i).trimEnd();
1027
+ return { text, id };
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ return { text: raw };
1033
+ }
1034
+
1035
+ function isHeadingLine(line) {
1036
+ const trimmedLeft = line.replace(/^\s+/, "");
1037
+ if (trimmedLeft.startsWith("\\#")) {
1038
+ return false;
1039
+ }
1040
+ return trimmedLeft.startsWith(COMMAND_HEADING);
1041
+ }
1042
+
1043
+ function isBlockquoteLine(line) {
1044
+ const trimmedLeft = line.replace(/^\s+/, "");
1045
+ if (trimmedLeft.startsWith("\\>")) {
1046
+ return false;
1047
+ }
1048
+ return trimmedLeft.startsWith(">");
1049
+ }
1050
+
1051
+ function isHorizontalRule(trimmed) {
1052
+ const compact = trimmed.replace(/\s+/g, "");
1053
+ if (compact.length < 3) {
1054
+ return false;
1055
+ }
1056
+ if (!/^[-*_]+$/.test(compact)) {
1057
+ return false;
1058
+ }
1059
+ const char = compact[0];
1060
+ for (let i = 1; i < compact.length; i += 1) {
1061
+ if (compact[i] !== char) {
1062
+ return false;
1063
+ }
1064
+ }
1065
+ return true;
1066
+ }
1067
+
1068
+ function isFenceStart(line) {
1069
+ const trimmedLeft = line.replace(/^\s+/, "");
1070
+ return trimmedLeft.startsWith(COMMAND_CODE_FENCE);
1071
+ }
1072
+
1073
+ function isIdentStart(ch) {
1074
+ return /[A-Za-z_]/.test(ch);
1075
+ }
1076
+
1077
+ function isIdentChar(ch) {
1078
+ return /[A-Za-z0-9_-]/.test(ch);
1079
+ }
1080
+
1081
+ function isEscaped(text, index) {
1082
+ let count = 0;
1083
+ for (let i = index - 1; i >= 0; i -= 1) {
1084
+ if (text[i] === "\\") {
1085
+ count += 1;
1086
+ } else {
1087
+ break;
1088
+ }
1089
+ }
1090
+ return count % 2 === 1;
1091
+ }
1092
+
1093
+ function parseImageWidth(raw) {
1094
+ const match = raw.match(/^(.*)\s+=(\d+(?:\.\d+)?(?:%|px))(?:\s+(center|left|right))?$/);
1095
+ if (match) {
1096
+ const result = { src: match[1].trim(), width: match[2] };
1097
+ if (match[3]) result.align = match[3];
1098
+ return result;
1099
+ }
1100
+ return { src: raw };
1101
+ }
1102
+
1103
+ function parseInline(text) {
1104
+ const nodes = [];
1105
+ let buffer = "";
1106
+ let i = 0;
1107
+
1108
+ const flush = () => {
1109
+ if (buffer) {
1110
+ nodes.push({ type: "text", value: buffer });
1111
+ buffer = "";
1112
+ }
1113
+ };
1114
+
1115
+ while (i < text.length) {
1116
+ const ch = text[i];
1117
+ const next = text[i + 1];
1118
+
1119
+ if (ch === "\\" && next && ESCAPABLE.has(next)) {
1120
+ buffer += next;
1121
+ i += 2;
1122
+ continue;
1123
+ }
1124
+
1125
+ if (ch === "`") {
1126
+ const end = findUnescaped(text, i + 1, "`");
1127
+ if (end !== -1) {
1128
+ flush();
1129
+ nodes.push({ type: "code", value: text.slice(i + 1, end) });
1130
+ i = end + 1;
1131
+ continue;
1132
+ }
1133
+ }
1134
+
1135
+ if (text.startsWith("**", i)) {
1136
+ const end = findUnescaped(text, i + 2, "**");
1137
+ if (end !== -1) {
1138
+ flush();
1139
+ const inner = parseInline(text.slice(i + 2, end));
1140
+ nodes.push({ type: "strong", children: inner });
1141
+ i = end + 2;
1142
+ continue;
1143
+ }
1144
+ }
1145
+
1146
+ if (text.startsWith("~~", i)) {
1147
+ const end = findUnescaped(text, i + 2, "~~");
1148
+ if (end !== -1) {
1149
+ flush();
1150
+ const inner = parseInline(text.slice(i + 2, end));
1151
+ nodes.push({ type: "strike", children: inner });
1152
+ i = end + 2;
1153
+ continue;
1154
+ }
1155
+ }
1156
+
1157
+ if (ch === "*") {
1158
+ const end = findUnescaped(text, i + 1, "*");
1159
+ if (end !== -1) {
1160
+ flush();
1161
+ const inner = parseInline(text.slice(i + 1, end));
1162
+ nodes.push({ type: "em", children: inner });
1163
+ i = end + 1;
1164
+ continue;
1165
+ }
1166
+ }
1167
+
1168
+ if (ch === "!" && next === "[") {
1169
+ const endLabel = findUnescaped(text, i + 2, "]");
1170
+ if (endLabel !== -1 && text[endLabel + 1] === "(") {
1171
+ const endUrl = findUnescaped(text, endLabel + 2, ")");
1172
+ if (endUrl !== -1) {
1173
+ const label = text.slice(i + 2, endLabel);
1174
+ const rawUrl = text.slice(endLabel + 2, endUrl).trim();
1175
+ if (rawUrl) {
1176
+ flush();
1177
+ const { src: imgSrc, width: imgWidth, align: imgAlign } = parseImageWidth(rawUrl);
1178
+ const imgNode = { type: "image", alt: label, src: imgSrc };
1179
+ if (imgWidth) imgNode.width = imgWidth;
1180
+ if (imgAlign) imgNode.align = imgAlign;
1181
+ nodes.push(imgNode);
1182
+ i = endUrl + 1;
1183
+ continue;
1184
+ }
1185
+ }
1186
+ }
1187
+ }
1188
+
1189
+ if (ch === "[") {
1190
+ const endLabel = findUnescaped(text, i + 1, "]");
1191
+ if (endLabel !== -1 && text[endLabel + 1] === "(") {
1192
+ const endUrl = findUnescaped(text, endLabel + 2, ")");
1193
+ if (endUrl !== -1) {
1194
+ const label = text.slice(i + 1, endLabel);
1195
+ const url = text.slice(endLabel + 2, endUrl).trim();
1196
+ if (url) {
1197
+ flush();
1198
+ nodes.push({ type: "link", href: url, children: parseInline(label) });
1199
+ i = endUrl + 1;
1200
+ continue;
1201
+ }
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ if (ch === "<") {
1207
+ const end = findUnescaped(text, i + 1, ">");
1208
+ if (end !== -1) {
1209
+ const url = text.slice(i + 1, end).trim();
1210
+ if (/^(https?:\/\/|mailto:)/i.test(url)) {
1211
+ flush();
1212
+ nodes.push({ type: "link", href: url, children: [{ type: "text", value: url }] });
1213
+ i = end + 1;
1214
+ continue;
1215
+ }
1216
+ }
1217
+ }
1218
+
1219
+ if (ch === "@" && next && isIdentStart(next)) {
1220
+ let j = i + 1;
1221
+ while (j < text.length && isIdentChar(text[j])) {
1222
+ j += 1;
1223
+ }
1224
+ const id = text.slice(i + 1, j);
1225
+ flush();
1226
+ nodes.push({ type: "ref", id });
1227
+ i = j;
1228
+ continue;
1229
+ }
1230
+
1231
+ buffer += ch;
1232
+ i += 1;
1233
+ }
1234
+
1235
+ flush();
1236
+ return nodes;
1237
+ }
1238
+
1239
+ function findUnescaped(text, start, token) {
1240
+ if (!token) {
1241
+ return -1;
1242
+ }
1243
+ for (let i = start; i <= text.length - token.length; i += 1) {
1244
+ if (text.startsWith(token, i) && !isEscaped(text, i)) {
1245
+ return i;
1246
+ }
1247
+ }
1248
+ return -1;
1249
+ }
1250
+
1251
+ let _renderOptions = {};
1252
+
1253
+ function dataLineAttrs(node) {
1254
+ if (node.lineStart == null) {
1255
+ return "";
1256
+ }
1257
+ let attrs = ` data-line="${node.lineStart}"`;
1258
+ if (node.lineEnd != null && node.lineEnd !== node.lineStart) {
1259
+ attrs += ` data-line-end="${node.lineEnd}"`;
1260
+ }
1261
+ return attrs;
1262
+ }
1263
+
1264
+ function escapeHtml(value) {
1265
+ return value
1266
+ .replace(/&/g, "&amp;")
1267
+ .replace(/</g, "&lt;")
1268
+ .replace(/>/g, "&gt;")
1269
+ .replace(/"/g, "&quot;");
1270
+ }
1271
+
1272
+ function escapeAttr(value) {
1273
+ return escapeHtml(value).replace(/'/g, "&#39;");
1274
+ }
1275
+
1276
+ function renderInline(text) {
1277
+ const nodes = parseInline(text);
1278
+ return renderInlineNodes(nodes);
1279
+ }
1280
+
1281
+ function renderInlineNodes(nodes) {
1282
+ return nodes
1283
+ .map((node) => {
1284
+ switch (node.type) {
1285
+ case "text":
1286
+ return escapeHtml(node.value);
1287
+ case "ref": {
1288
+ const href = `#${escapeAttr(node.id)}`;
1289
+ return `<a class="sdoc-ref" href="${href}">@${escapeHtml(node.id)}</a>`;
1290
+ }
1291
+ case "code":
1292
+ return `<code class="sdoc-inline-code">${escapeHtml(node.value)}</code>`;
1293
+ case "em":
1294
+ return `<em>${renderInlineNodes(node.children)}</em>`;
1295
+ case "strong":
1296
+ return `<strong>${renderInlineNodes(node.children)}</strong>`;
1297
+ case "strike":
1298
+ return `<del>${renderInlineNodes(node.children)}</del>`;
1299
+ case "link":
1300
+ return `<a class="sdoc-link" href="${escapeAttr(node.href)}" target="_blank" rel="noopener noreferrer">${renderInlineNodes(
1301
+ node.children
1302
+ )}</a>`;
1303
+ case "image": {
1304
+ const imgParts = [];
1305
+ if (node.width) imgParts.push(`width:${escapeAttr(node.width)}`);
1306
+ if (node.align === "center") imgParts.push("display:block", "margin-left:auto", "margin-right:auto");
1307
+ else if (node.align === "left") imgParts.push("display:block", "float:left", "margin-right:1rem");
1308
+ else if (node.align === "right") imgParts.push("display:block", "float:right", "margin-left:1rem");
1309
+ const imgStyle = imgParts.length ? ` style="${imgParts.join(";")}"` : "";
1310
+ return `<img class="sdoc-image" src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt)}"${imgStyle} />`;
1311
+ }
1312
+ default:
1313
+ return "";
1314
+ }
1315
+ })
1316
+ .join("");
1317
+ }
1318
+
1319
+ function renderScope(scope, depth, isTitleScope = false) {
1320
+ const level = Math.min(6, Math.max(1, depth));
1321
+ const children = scope.children.map((child) => renderNode(child, depth + 1)).join("\n");
1322
+ const rootClass = isTitleScope ? " sdoc-root" : "";
1323
+ const dl = dataLineAttrs(scope);
1324
+
1325
+ if (scope.hasHeading === false) {
1326
+ return `<section class="sdoc-scope sdoc-scope-noheading${rootClass}"${dl}>${children}</section>`;
1327
+ }
1328
+
1329
+ const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
1330
+ const hasChildren = scope.children.length > 0;
1331
+ const toggle = hasChildren ? `<span class="sdoc-toggle"></span>` : "";
1332
+ const heading = `<h${level}${idAttr} class="sdoc-heading sdoc-depth-${level}"${dl}>${toggle}${renderInline(scope.title)}</h${level}>`;
1333
+ const childrenHtml = children ? `\n<div class="sdoc-scope-children">${children}</div>` : "";
1334
+ return `<section class="sdoc-scope${rootClass}">${heading}${childrenHtml}</section>`;
1335
+ }
1336
+
1337
+ function renderList(list, depth) {
1338
+ return renderListFromItems(list.listType, list.items, depth, list);
1339
+ }
1340
+
1341
+ function renderListFromItems(listType, items, depth, list) {
1342
+ const tag = listType === "number" ? "ol" : "ul";
1343
+ const dl = list ? dataLineAttrs(list) : "";
1344
+ const renderedItems = items
1345
+ .map((item) => `<li class="sdoc-list-item">${renderListItem(item, listType, depth + 1)}</li>`)
1346
+ .join("\n");
1347
+ return `<${tag} class="sdoc-list sdoc-list-${listType}"${dl}>${renderedItems}</${tag}>`;
1348
+ }
1349
+
1350
+ function renderListItem(scope, listType, depth) {
1351
+ const level = Math.min(6, Math.max(1, depth));
1352
+ const task = scope.task ? scope.task : null;
1353
+ const hasHeading = scope.hasHeading !== false && (scope.title.trim() !== "" || task);
1354
+ const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
1355
+ const dl = dataLineAttrs(scope);
1356
+ let headingInner = renderInline(scope.title);
1357
+ if (task) {
1358
+ const checked = task.checked ? " checked" : "";
1359
+ headingInner = `<span class="sdoc-task"><input class="sdoc-task-box" type="checkbox"${checked} disabled /><span class="sdoc-task-label">${headingInner}</span></span>`;
1360
+ }
1361
+ const isShorthand = scope.shorthand === true;
1362
+ const hasChildren = scope.children.length > 0;
1363
+ let heading;
1364
+ if (!hasHeading) {
1365
+ heading = "";
1366
+ } else if (isShorthand || !hasChildren) {
1367
+ heading = `<span${idAttr} class="sdoc-list-item-text"${dl}>${headingInner}</span>`;
1368
+ } else {
1369
+ const toggle = `<span class="sdoc-toggle"></span>`;
1370
+ heading = `<h${level}${idAttr} class="sdoc-heading sdoc-depth-${level}"${dl}>${toggle}${headingInner}</h${level}>`;
1371
+ }
1372
+ const bodyParts = [];
1373
+ let pendingScopes = [];
1374
+
1375
+ const flushPending = () => {
1376
+ if (!pendingScopes.length) {
1377
+ return;
1378
+ }
1379
+ bodyParts.push(renderListFromItems(listType, pendingScopes, depth + 1));
1380
+ pendingScopes = [];
1381
+ };
1382
+
1383
+ for (const child of scope.children) {
1384
+ if (child.type === "scope") {
1385
+ pendingScopes.push(child);
1386
+ continue;
1387
+ }
1388
+
1389
+ flushPending();
1390
+ bodyParts.push(renderNode(child, depth + 1));
1391
+ }
1392
+
1393
+ flushPending();
1394
+
1395
+ const body = bodyParts.join("\n");
1396
+ const bodyWrapper = body ? `\n<div class="sdoc-list-item-body sdoc-scope-children">${body}</div>` : "";
1397
+ const scopeClass = hasHeading ? "sdoc-scope" : "sdoc-scope sdoc-scope-noheading";
1398
+ return `<section class="${scopeClass}">${heading}${bodyWrapper}</section>`;
1399
+ }
1400
+
1401
+ function renderTable(table) {
1402
+ const dl = dataLineAttrs(table);
1403
+ const opts = table.options || {};
1404
+ const classes = ["sdoc-table"];
1405
+ if (opts.borderless) classes.push("sdoc-table-borderless");
1406
+ if (opts.headerless) classes.push("sdoc-table-headerless");
1407
+ const classAttr = classes.join(" ");
1408
+
1409
+ let thead = "";
1410
+ if (table.headers.length > 0) {
1411
+ const headerCells = table.headers
1412
+ .map((cell) => `<th class="sdoc-table-th">${renderInline(cell)}</th>`)
1413
+ .join("");
1414
+ thead = `<thead class="sdoc-table-head"><tr>${headerCells}</tr></thead>`;
1415
+ }
1416
+
1417
+ const bodyRows = table.rows
1418
+ .map((row) => {
1419
+ const cells = row
1420
+ .map((cell) => `<td class="sdoc-table-td">${renderInline(cell)}</td>`)
1421
+ .join("");
1422
+ return `<tr>${cells}</tr>`;
1423
+ })
1424
+ .join("\n");
1425
+ const tbody = bodyRows ? `<tbody class="sdoc-table-body">${bodyRows}</tbody>` : "";
1426
+
1427
+ return `<table class="${classAttr}"${dl}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
1428
+ }
1429
+
1430
+ function renderNode(node, depth) {
1431
+ const dl = dataLineAttrs(node);
1432
+ switch (node.type) {
1433
+ case "scope":
1434
+ return renderScope(node, depth);
1435
+ case "list":
1436
+ return renderList(node, depth);
1437
+ case "table":
1438
+ return renderTable(node);
1439
+ case "blockquote": {
1440
+ const paragraphs = node.paragraphs
1441
+ .map((text) => `<p class="sdoc-paragraph">${renderInline(text)}</p>`)
1442
+ .join("\n");
1443
+ return `<blockquote class="sdoc-blockquote"${dl}>${paragraphs}</blockquote>`;
1444
+ }
1445
+ case "hr":
1446
+ return `<hr class="sdoc-rule"${dl} />`;
1447
+ case "paragraph": {
1448
+ const editable = _renderOptions.editable ? ` contenteditable="true"` : "";
1449
+ return `<p class="sdoc-paragraph"${dl}${editable}>${renderInline(node.text)}</p>`;
1450
+ }
1451
+ case "code": {
1452
+ if (node.lang === "mermaid") {
1453
+ return `<pre class="mermaid"${dl}>${escapeHtml(node.text)}</pre>`;
1454
+ }
1455
+ const langClass = node.lang ? ` class="language-${escapeAttr(node.lang)}"` : "";
1456
+ return `<pre class="sdoc-code"${dl}><code${langClass}>${escapeHtml(node.text)}</code></pre>`;
1457
+ }
1458
+ default:
1459
+ return "";
1460
+ }
1461
+ }
1462
+
1463
+ function renderErrors(errors) {
1464
+ if (!errors.length) {
1465
+ return "";
1466
+ }
1467
+ const items = errors
1468
+ .map((error) => `<li>Line ${error.line}: ${escapeHtml(error.message)}</li>`)
1469
+ .join("\n");
1470
+ return `<aside class="sdoc-errors"><strong>SDOC parse warnings</strong><ul>${items}</ul></aside>`;
1471
+ }
1472
+
1473
+ function extractMeta(nodes) {
1474
+ const doc = getDocumentScope(nodes);
1475
+ const searchNodes = doc ? doc.children : nodes;
1476
+
1477
+ let metaIndex = -1;
1478
+ for (let i = 0; i < searchNodes.length; i += 1) {
1479
+ const node = searchNodes[i];
1480
+ if (node.type === "scope" && node.id && node.id.toLowerCase() === "meta") {
1481
+ metaIndex = i;
1482
+ break;
1483
+ }
1484
+ }
1485
+
1486
+ if (metaIndex === -1) {
1487
+ return { nodes, meta: {} };
1488
+ }
1489
+
1490
+ const metaNode = searchNodes[metaIndex];
1491
+ const meta = {
1492
+ stylePath: null,
1493
+ styleAppendPath: null,
1494
+ headerNodes: null,
1495
+ footerNodes: null,
1496
+ headerText: null,
1497
+ footerText: null,
1498
+ properties: {}
1499
+ };
1500
+
1501
+ // First pass: sub-scope syntax (takes precedence)
1502
+ for (const child of metaNode.children) {
1503
+ if (child.type !== "scope") {
1504
+ continue;
1505
+ }
1506
+ const key = child.title.trim().toLowerCase();
1507
+ if (key === "style") {
1508
+ meta.stylePath = collectParagraphText(child.children);
1509
+ } else if (key === "styleappend" || key === "style-append") {
1510
+ meta.styleAppendPath = collectParagraphText(child.children);
1511
+ } else if (key === "header") {
1512
+ meta.headerNodes = child.children;
1513
+ } else if (key === "footer") {
1514
+ meta.footerNodes = child.children;
1515
+ }
1516
+ }
1517
+
1518
+ // Second pass: key:value syntax from paragraph nodes (only if not already set by sub-scope)
1519
+ const kvPattern = /^([\w][\w-]*)\s*:\s+(.+)$/;
1520
+ for (const child of metaNode.children) {
1521
+ if (child.type !== "paragraph") continue;
1522
+ const match = child.text.match(kvPattern);
1523
+ if (!match) continue;
1524
+ const key = match[1].toLowerCase();
1525
+ const value = match[2].trim();
1526
+ if (key === "style") {
1527
+ if (!meta.stylePath) meta.stylePath = value;
1528
+ } else if (key === "styleappend" || key === "style-append") {
1529
+ if (!meta.styleAppendPath) meta.styleAppendPath = value;
1530
+ } else if (key === "header") {
1531
+ if (!meta.headerNodes && !meta.headerText) meta.headerText = value;
1532
+ } else if (key === "footer") {
1533
+ if (!meta.footerNodes && !meta.footerText) meta.footerText = value;
1534
+ } else {
1535
+ if (!(key in meta.properties)) meta.properties[key] = value;
1536
+ }
1537
+ }
1538
+
1539
+ // Promote well-known Lexica properties
1540
+ meta.uuid = meta.properties.uuid || null;
1541
+ meta.type = meta.properties.type || null;
1542
+ meta.tags = meta.properties.tags
1543
+ ? meta.properties.tags.split(",").map((t) => t.trim()).filter(Boolean)
1544
+ : [];
1545
+ meta.company = meta.properties.company || null;
1546
+ meta.confidential = meta.properties.confidential || null;
1547
+
1548
+ if (doc) {
1549
+ // @meta was inside the document scope — strip it from children
1550
+ const filteredChildren = doc.children.filter((_, index) => index !== metaIndex);
1551
+ const stripped = { ...doc, children: filteredChildren };
1552
+ return { nodes: [stripped], meta };
1553
+ }
1554
+ const bodyNodes = nodes.filter((_, index) => index !== metaIndex);
1555
+ return { nodes: bodyNodes, meta };
1556
+ }
1557
+
1558
+ function collectParagraphText(nodes) {
1559
+ return nodes
1560
+ .filter((node) => node.type === "paragraph")
1561
+ .map((node) => node.text)
1562
+ .join("\n")
1563
+ .trim();
1564
+ }
1565
+
1566
+ function renderTextParagraphs(text) {
1567
+ if (!text) {
1568
+ return "";
1569
+ }
1570
+ const paragraphs = text
1571
+ .split(/\n\s*\n/)
1572
+ .map((chunk) => chunk.trim())
1573
+ .filter(Boolean);
1574
+ return paragraphs.map((chunk) => `<p class="sdoc-paragraph">${renderInline(chunk)}</p>`).join("\n");
1575
+ }
1576
+
1577
+ function renderFragment(nodes, depth = 2) {
1578
+ return nodes.map((node) => renderNode(node, depth)).join("\n");
1579
+ }
1580
+
1581
+ function buildConfidentialHtml(meta) {
1582
+ if (!meta || !meta.confidential) return "";
1583
+ const val = meta.confidential.trim();
1584
+ if (!val) return "";
1585
+ const entity = val.toLowerCase() === "true" ? meta.company : val;
1586
+ const text = entity
1587
+ ? `CONFIDENTIAL \u2014 ${escapeHtml(entity)}`
1588
+ : "CONFIDENTIAL";
1589
+ return `<div class="sdoc-confidential-notice">${text}</div>`;
1590
+ }
1591
+
1592
+ const DEFAULT_STYLE = `
1593
+ :root {
1594
+ --sdoc-bg: #ffffff;
1595
+ --sdoc-fg: #2a2a2a;
1596
+ --sdoc-muted: #555555;
1597
+ --sdoc-accent: #c1662f;
1598
+ --sdoc-accent-soft: rgba(193, 102, 47, 0.12);
1599
+ --sdoc-border: rgba(127, 120, 112, 0.35);
1600
+ }
1601
+
1602
+ body {
1603
+ margin: 0;
1604
+ color: var(--sdoc-fg);
1605
+ font-family: "Source Sans 3", "Noto Sans", "Segoe UI", "Helvetica Neue", "Arial", sans-serif;
1606
+ background: var(--sdoc-bg);
1607
+ height: 100vh;
1608
+ overflow: hidden;
1609
+ }
1610
+
1611
+ .sdoc-shell {
1612
+ display: flex;
1613
+ flex-direction: column;
1614
+ height: 100%;
1615
+ }
1616
+
1617
+ .sdoc-page-header,
1618
+ .sdoc-page-footer {
1619
+ background: var(--sdoc-bg);
1620
+ border-bottom: 1px solid var(--sdoc-border);
1621
+ padding: 16px 24px;
1622
+ }
1623
+
1624
+ .sdoc-page-footer {
1625
+ border-top: 1px solid var(--sdoc-border);
1626
+ border-bottom: none;
1627
+ color: var(--sdoc-muted);
1628
+ }
1629
+
1630
+ .sdoc-confidential-notice {
1631
+ background: rgba(187, 68, 68, 0.08);
1632
+ border-bottom: 1px solid rgba(187, 68, 68, 0.2);
1633
+ color: rgba(160, 40, 40, 0.85);
1634
+ text-align: center;
1635
+ padding: 5px 24px;
1636
+ font-size: 0.72rem;
1637
+ font-weight: 600;
1638
+ letter-spacing: 0.12em;
1639
+ text-transform: uppercase;
1640
+ }
1641
+
1642
+ .sdoc-main {
1643
+ flex: 1;
1644
+ overflow: auto;
1645
+ }
1646
+
1647
+ main {
1648
+ width: 100%;
1649
+ max-width: none;
1650
+ margin: 0;
1651
+ padding: clamp(20px, 2.5vw, 36px) clamp(24px, 4vw, 72px) clamp(28px, 4vw, 56px);
1652
+ box-sizing: border-box;
1653
+ }
1654
+
1655
+ .sdoc-heading {
1656
+ margin: 1.6rem 0 0.5rem;
1657
+ font-weight: 700;
1658
+ letter-spacing: 0.01em;
1659
+ }
1660
+
1661
+ .sdoc-depth-1 { font-size: 2.2rem; border-bottom: 2px solid var(--sdoc-border); padding-bottom: 0.4rem; }
1662
+ .sdoc-depth-2 { font-size: 1.8rem; color: var(--sdoc-fg); }
1663
+ .sdoc-depth-3 { font-size: 1.5rem; color: var(--sdoc-fg); }
1664
+ .sdoc-depth-4 { font-size: 1.2rem; color: var(--sdoc-muted); letter-spacing: 0.04em; }
1665
+ .sdoc-depth-5, .sdoc-depth-6 { font-size: 1rem; color: var(--sdoc-muted); letter-spacing: 0.04em; }
1666
+
1667
+ .sdoc-paragraph {
1668
+ margin: 0.6rem 0;
1669
+ line-height: 1.6;
1670
+ }
1671
+
1672
+ .sdoc-scope-children > .sdoc-scope {
1673
+ padding-left: 1.5rem;
1674
+ }
1675
+
1676
+ .sdoc-list-item-body > .sdoc-paragraph:first-child {
1677
+ margin-top: 0.2rem;
1678
+ }
1679
+
1680
+ .sdoc-scope-noheading .sdoc-list-item-body > .sdoc-paragraph:first-child {
1681
+ margin-top: 0;
1682
+ }
1683
+
1684
+ .sdoc-blockquote {
1685
+ border-left: 3px solid var(--sdoc-border);
1686
+ padding: 0.4rem 1rem;
1687
+ margin: 1rem 0;
1688
+ color: var(--sdoc-muted);
1689
+ }
1690
+
1691
+ .sdoc-blockquote .sdoc-paragraph {
1692
+ margin: 0.4rem 0;
1693
+ }
1694
+
1695
+ .sdoc-rule {
1696
+ border: none;
1697
+ border-top: 1px solid var(--sdoc-border);
1698
+ margin: 1.4rem 0;
1699
+ }
1700
+
1701
+ .sdoc-table {
1702
+ border-collapse: separate;
1703
+ border-spacing: 0;
1704
+ width: 100%;
1705
+ margin: 1rem 0;
1706
+ border: 1px solid var(--sdoc-border);
1707
+ border-radius: 10px;
1708
+ overflow: hidden;
1709
+ }
1710
+
1711
+ .sdoc-table-th {
1712
+ background: rgba(0, 0, 0, 0.06);
1713
+ font-weight: 600;
1714
+ text-align: left;
1715
+ padding: 10px 14px;
1716
+ border-bottom: 1px solid var(--sdoc-border);
1717
+ }
1718
+
1719
+ .sdoc-table-td {
1720
+ padding: 8px 14px;
1721
+ border-bottom: 1px solid rgba(127, 120, 112, 0.15);
1722
+ }
1723
+
1724
+ .sdoc-table-body tr:nth-child(even) {
1725
+ background: rgba(0, 0, 0, 0.025);
1726
+ }
1727
+
1728
+ .sdoc-table-body tr:last-child .sdoc-table-td {
1729
+ border-bottom: none;
1730
+ }
1731
+
1732
+ .sdoc-table-borderless,
1733
+ .sdoc-table-borderless th,
1734
+ .sdoc-table-borderless td {
1735
+ border: none;
1736
+ }
1737
+
1738
+ .sdoc-table-borderless tr:nth-child(even) td {
1739
+ background: none;
1740
+ }
1741
+
1742
+ .sdoc-task {
1743
+ display: inline-flex;
1744
+ align-items: center;
1745
+ gap: 0.5rem;
1746
+ }
1747
+
1748
+ .sdoc-task-box {
1749
+ width: 1rem;
1750
+ height: 1rem;
1751
+ margin: 0;
1752
+ accent-color: var(--sdoc-accent);
1753
+ }
1754
+
1755
+ .sdoc-task-label {
1756
+ display: inline-block;
1757
+ }
1758
+
1759
+ .sdoc-list {
1760
+ margin: 0.8rem 0 0.8rem;
1761
+ }
1762
+
1763
+ .sdoc-list-bullet {
1764
+ padding-left: 1.4rem;
1765
+ list-style: disc;
1766
+ }
1767
+
1768
+ .sdoc-list-bullet .sdoc-list-bullet {
1769
+ list-style: circle;
1770
+ }
1771
+
1772
+ .sdoc-list-number {
1773
+ list-style: none;
1774
+ padding-left: 0;
1775
+ margin-left: 0;
1776
+ counter-reset: sdoc-item;
1777
+ }
1778
+
1779
+ .sdoc-list-number .sdoc-list-number {
1780
+ margin-left: 1.4rem;
1781
+ }
1782
+
1783
+ .sdoc-list-number > .sdoc-list-item {
1784
+ counter-increment: sdoc-item;
1785
+ position: relative;
1786
+ padding-left: 2rem;
1787
+ margin: 0.4rem 0 0.8rem;
1788
+ }
1789
+
1790
+ .sdoc-list-number > .sdoc-list-item::before {
1791
+ content: counters(sdoc-item, ".") ".";
1792
+ position: absolute;
1793
+ left: 0;
1794
+ top: 0.2rem;
1795
+ color: var(--sdoc-muted);
1796
+ font-weight: 600;
1797
+ }
1798
+
1799
+ .sdoc-list-item {
1800
+ margin: 0.4rem 0 0.8rem;
1801
+ }
1802
+
1803
+ .sdoc-list-item-text {
1804
+ line-height: 1.6;
1805
+ }
1806
+
1807
+ .sdoc-ref {
1808
+ color: var(--sdoc-accent);
1809
+ text-decoration: none;
1810
+ border-bottom: 1px solid var(--sdoc-accent-soft);
1811
+ }
1812
+
1813
+ .sdoc-ref:hover {
1814
+ border-bottom-color: var(--sdoc-accent);
1815
+ }
1816
+
1817
+ .sdoc-link {
1818
+ color: var(--sdoc-accent);
1819
+ text-decoration: underline;
1820
+ text-decoration-color: var(--sdoc-accent-soft);
1821
+ text-underline-offset: 2px;
1822
+ }
1823
+
1824
+ .sdoc-link:hover {
1825
+ text-decoration-color: var(--sdoc-accent);
1826
+ }
1827
+
1828
+ .sdoc-inline-code {
1829
+ font-family: "JetBrains Mono", "Fira Code", "Source Code Pro", monospace;
1830
+ font-size: 0.95em;
1831
+ background: rgba(0, 0, 0, 0.06);
1832
+ border: 1px solid var(--sdoc-border);
1833
+ border-radius: 4px;
1834
+ padding: 0 0.2em;
1835
+ }
1836
+
1837
+ .sdoc-image {
1838
+ display: inline-block;
1839
+ max-width: 100%;
1840
+ border-radius: 10px;
1841
+ border: 1px solid var(--sdoc-border);
1842
+ margin: 0.4rem 0;
1843
+ vertical-align: top;
1844
+ }
1845
+
1846
+ .sdoc-image + .sdoc-image {
1847
+ margin-left: 0.5%;
1848
+ }
1849
+
1850
+ .sdoc-code {
1851
+ background: rgba(22, 21, 19, 0.06);
1852
+ border: 1px solid var(--sdoc-border);
1853
+ border-radius: 10px;
1854
+ padding: 12px 14px;
1855
+ overflow-x: auto;
1856
+ font-family: "JetBrains Mono", "Fira Code", "Source Code Pro", monospace;
1857
+ font-size: 0.9rem;
1858
+ }
1859
+
1860
+ .sdoc-code code {
1861
+ background: none;
1862
+ border: none;
1863
+ padding: 0;
1864
+ }
1865
+
1866
+ .sdoc-errors {
1867
+ background: rgba(187, 112, 68, 0.12);
1868
+ border: 1px solid rgba(187, 112, 68, 0.4);
1869
+ padding: 12px 16px;
1870
+ border-radius: 10px;
1871
+ margin-bottom: 1.5rem;
1872
+ }
1873
+
1874
+ .sdoc-errors ul {
1875
+ margin: 0.6rem 0 0;
1876
+ padding-left: 1.2rem;
1877
+ }
1878
+
1879
+ @media print {
1880
+ body {
1881
+ height: auto;
1882
+ overflow: visible;
1883
+ }
1884
+ .sdoc-shell {
1885
+ height: auto;
1886
+ }
1887
+ .sdoc-main {
1888
+ overflow: visible;
1889
+ }
1890
+ .sdoc-code {
1891
+ white-space: pre-wrap;
1892
+ word-wrap: break-word;
1893
+ overflow-x: visible;
1894
+ }
1895
+ .sdoc-code code {
1896
+ white-space: pre-wrap;
1897
+ word-wrap: break-word;
1898
+ }
1899
+ }
1900
+ `;
1901
+
1902
+ const MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
1903
+
1904
+ function hasMermaidBlocks(nodes) {
1905
+ for (const node of nodes) {
1906
+ if (node.type === "code" && node.lang === "mermaid") return true;
1907
+ if (node.children && hasMermaidBlocks(node.children)) return true;
1908
+ if (node.items) {
1909
+ for (const item of node.items) {
1910
+ if (item.children && hasMermaidBlocks(item.children)) return true;
1911
+ }
1912
+ }
1913
+ }
1914
+ return false;
1915
+ }
1916
+
1917
+ function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
1918
+ _renderOptions = options.renderOptions ?? {};
1919
+ const body = parsed.nodes
1920
+ .map((node, index) => {
1921
+ if (node.type === "scope" && index === 0) {
1922
+ return renderScope(node, 1, true);
1923
+ }
1924
+ return renderNode(node, 1);
1925
+ })
1926
+ .join("\n");
1927
+ _renderOptions = {};
1928
+ const errorHtml = renderErrors(parsed.errors);
1929
+
1930
+ const meta = options.meta ?? {};
1931
+ const config = options.config ?? {};
1932
+ const confidentialHtml = buildConfidentialHtml(meta);
1933
+ const headerHtml = meta.headerNodes ? renderFragment(meta.headerNodes, 2)
1934
+ : meta.headerText ? renderTextParagraphs(meta.headerText)
1935
+ : renderTextParagraphs(config.header);
1936
+ const companyHtml = meta.company
1937
+ ? `<span class="sdoc-company-footer">${escapeHtml(meta.company)}</span>`
1938
+ : "";
1939
+ const footerHtml = meta.footerNodes ? renderFragment(meta.footerNodes, 2)
1940
+ : meta.footerText ? renderTextParagraphs(meta.footerText)
1941
+ : renderTextParagraphs(config.footer);
1942
+ const footerContent = [footerHtml, companyHtml].filter(Boolean).join("\n");
1943
+
1944
+ const cssBase = options.cssOverride ?? DEFAULT_STYLE;
1945
+ const cssAppend = options.cssAppend ? `\n${options.cssAppend}` : "";
1946
+ const scriptTag = options.script ? `\n<script>${options.script}</script>` : "";
1947
+ const mermaidScript = hasMermaidBlocks(parsed.nodes)
1948
+ ? `\n<script src="${MERMAID_CDN}"></script>\n<script>mermaid.initialize({startOnLoad:true,theme:"neutral",themeCSS:".node rect, .node polygon, .node circle { rx: 4; ry: 4; }"});</script>`
1949
+ : "";
1950
+
1951
+ return `<!DOCTYPE html>
1952
+ <html lang="en">
1953
+ <head>
1954
+ <meta charset="UTF-8" />
1955
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1956
+ <title>${escapeHtml(title)}</title>
1957
+ <style>
1958
+ ${cssBase}${cssAppend}
1959
+ </style>
1960
+ </head>
1961
+ <body>
1962
+ <div class="sdoc-shell">
1963
+ ${confidentialHtml}
1964
+ ${headerHtml ? `<header class="sdoc-page-header">${headerHtml}</header>` : ""}
1965
+ <div class="sdoc-main">
1966
+ <main>
1967
+ ${errorHtml}
1968
+ ${body}
1969
+ </main>
1970
+ </div>
1971
+ ${footerContent ? `<footer class="sdoc-page-footer">${footerContent}</footer>` : ""}
1972
+ </div>${scriptTag}${mermaidScript}
1973
+ </body>
1974
+ </html>`;
1975
+ }
1976
+
1977
+ function renderHtmlDocument(text, title, options = {}) {
1978
+ const parsed = parseSdoc(text);
1979
+ const metaResult = extractMeta(parsed.nodes);
1980
+ return renderHtmlDocumentFromParsed({ nodes: metaResult.nodes, errors: parsed.errors }, title, {
1981
+ ...options,
1982
+ meta: metaResult.meta
1983
+ });
1984
+ }
1985
+
1986
+ function formatSdoc(text, indentStr = " ") {
1987
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1988
+ const lines = normalized.split("\n");
1989
+ const result = [];
1990
+ let depth = 0;
1991
+ let inCodeBlock = false;
1992
+ let codeFenceLen = 0;
1993
+
1994
+ for (const line of lines) {
1995
+ const trimmed = line.trim();
1996
+
1997
+ // Blank lines — emit empty, no depth change
1998
+ if (trimmed === "") {
1999
+ result.push("");
2000
+ continue;
2001
+ }
2002
+
2003
+ // Inside code block — pass through raw
2004
+ if (inCodeBlock) {
2005
+ const closeMatch = trimmed.match(/^(`{3,})\s*$/);
2006
+ if (closeMatch && closeMatch[1].length >= codeFenceLen) {
2007
+ inCodeBlock = false;
2008
+ codeFenceLen = 0;
2009
+ result.push(indentStr.repeat(depth) + trimmed);
2010
+ } else {
2011
+ result.push(line);
2012
+ }
2013
+ continue;
2014
+ }
2015
+
2016
+ // Code fence opening
2017
+ if (isFenceStart(trimmed)) {
2018
+ const openMatch = trimmed.match(/^(`{3,})/);
2019
+ codeFenceLen = openMatch ? openMatch[1].length : 3;
2020
+ result.push(indentStr.repeat(depth) + trimmed);
2021
+ inCodeBlock = true;
2022
+ continue;
2023
+ }
2024
+
2025
+ // Closing brace — decrement first, then indent
2026
+ if (trimmed === COMMAND_SCOPE_CLOSE) {
2027
+ depth = Math.max(0, depth - 1);
2028
+ result.push(indentStr.repeat(depth) + trimmed);
2029
+ continue;
2030
+ }
2031
+
2032
+ // Inline block { content }
2033
+ if (tryParseInlineBlock(trimmed) !== null) {
2034
+ result.push(indentStr.repeat(depth) + trimmed);
2035
+ continue;
2036
+ }
2037
+
2038
+ // Standalone opener: {, {[.], {[#], {[table]
2039
+ if (trimmed === COMMAND_SCOPE_OPEN ||
2040
+ trimmed === COMMAND_LIST_BULLET ||
2041
+ trimmed === COMMAND_LIST_NUMBER ||
2042
+ isTableCommand(trimmed)) {
2043
+ result.push(indentStr.repeat(depth) + trimmed);
2044
+ depth++;
2045
+ continue;
2046
+ }
2047
+
2048
+ // K&R line — heading or list item ending with opener
2049
+ const trailing = extractTrailingOpener(trimmed);
2050
+ if (trailing) {
2051
+ result.push(indentStr.repeat(depth) + trimmed);
2052
+ depth++;
2053
+ continue;
2054
+ }
2055
+
2056
+ // Everything else — indent at current depth
2057
+ result.push(indentStr.repeat(depth) + trimmed);
2058
+ }
2059
+
2060
+ return result.join("\n");
2061
+ }
2062
+
2063
+ // --- Lexica utility functions ---
2064
+
2065
+ const KNOWN_TYPES = ["skill", "doc"];
2066
+
2067
+ function inferType(filename, meta) {
2068
+ if (meta && meta.type) return meta.type;
2069
+ const base = filename.replace(/\.sdoc$/i, "");
2070
+ const prefix = base.split("-")[0].toLowerCase();
2071
+ if (KNOWN_TYPES.includes(prefix)) return prefix;
2072
+ return null;
2073
+ }
2074
+
2075
+ function slugify(text) {
2076
+ return text
2077
+ .replace(/[*~`_]/g, "")
2078
+ .toLowerCase()
2079
+ .replace(/[^a-z0-9]+/g, "-")
2080
+ .replace(/^-+|-+$/g, "");
2081
+ }
2082
+
2083
+ function getDocumentScope(nodes) {
2084
+ if (nodes.length === 1 && nodes[0].type === "scope" && nodes[0].children) {
2085
+ return nodes[0];
2086
+ }
2087
+ return null;
2088
+ }
2089
+
2090
+ function getContentScopes(nodes) {
2091
+ const doc = getDocumentScope(nodes);
2092
+ const children = doc ? doc.children : nodes;
2093
+ return children.filter(
2094
+ (n) => n.type === "scope" && (!n.id || (n.id.toLowerCase() !== "meta" && n.id.toLowerCase() !== "about"))
2095
+ );
2096
+ }
2097
+
2098
+ function collectPlainText(nodes) {
2099
+ const parts = [];
2100
+ for (const node of nodes) {
2101
+ if (node.type === "paragraph") {
2102
+ parts.push(node.text);
2103
+ } else if (node.type === "list") {
2104
+ for (const item of node.items || []) {
2105
+ if (item.title) parts.push(item.title);
2106
+ if (item.children) parts.push(collectPlainText(item.children));
2107
+ }
2108
+ } else if (node.type === "scope" && node.children) {
2109
+ parts.push(collectPlainText(node.children));
2110
+ } else if (node.type === "code" && node.content) {
2111
+ parts.push(node.content);
2112
+ } else if (node.type === "blockquote" && node.text) {
2113
+ parts.push(node.text);
2114
+ } else if (node.type === "table") {
2115
+ if (node.headers) parts.push(node.headers.join(" | "));
2116
+ if (node.rows) {
2117
+ for (const row of node.rows) parts.push(row.join(" | "));
2118
+ }
2119
+ }
2120
+ }
2121
+ return parts.filter(Boolean).join("\n\n");
2122
+ }
2123
+
2124
+ function firstParagraphPreview(nodes, maxLen) {
2125
+ for (const node of nodes) {
2126
+ if (node.type === "paragraph" && node.text) {
2127
+ const text = node.text.trim();
2128
+ if (text.length <= maxLen) return text;
2129
+ const truncated = text.substring(0, maxLen);
2130
+ const lastSpace = truncated.lastIndexOf(" ");
2131
+ return (lastSpace > maxLen * 0.5 ? truncated.substring(0, lastSpace) : truncated) + "...";
2132
+ }
2133
+ }
2134
+ return "";
2135
+ }
2136
+
2137
+ function listSections(nodes) {
2138
+ return getContentScopes(nodes).map((node) => ({
2139
+ id: node.id || null,
2140
+ derivedId: slugify(node.title),
2141
+ title: node.title,
2142
+ preview: firstParagraphPreview(node.children || [], 100)
2143
+ }));
2144
+ }
2145
+
2146
+ function extractSection(nodes, sectionId) {
2147
+ const scopes = getContentScopes(nodes);
2148
+
2149
+ // First pass: match explicit @id (case-sensitive)
2150
+ for (const node of scopes) {
2151
+ if (node.id && node.id === sectionId) {
2152
+ return { title: node.title, content: collectPlainText(node.children || []) };
2153
+ }
2154
+ }
2155
+
2156
+ // Second pass: match derived slug (case-insensitive)
2157
+ const lowerTarget = sectionId.toLowerCase();
2158
+ for (const node of scopes) {
2159
+ if (slugify(node.title).toLowerCase() === lowerTarget) {
2160
+ return { title: node.title, content: collectPlainText(node.children || []) };
2161
+ }
2162
+ }
2163
+
2164
+ return null;
2165
+ }
2166
+
2167
+ function extractAbout(nodes) {
2168
+ const doc = getDocumentScope(nodes);
2169
+ const children = doc ? doc.children : nodes;
2170
+
2171
+ for (const node of children) {
2172
+ if (node.type === "scope" && node.id && node.id.toLowerCase() === "about") {
2173
+ const texts = (node.children || [])
2174
+ .filter((c) => c.type === "paragraph")
2175
+ .map((c) => c.text.trim());
2176
+ return texts.length ? texts.join(" ") : null;
2177
+ }
2178
+ }
2179
+ return null;
2180
+ }
2181
+
2182
+ async function resolveIncludes(nodes, resolverFn) {
2183
+ for (const node of nodes) {
2184
+ if (node.type === "code" && node.src) {
2185
+ try {
2186
+ let text = await resolverFn(node.src);
2187
+ if (node.lines) {
2188
+ const allLines = text.split("\n");
2189
+ text = allLines.slice(node.lines.start - 1, node.lines.end).join("\n");
2190
+ }
2191
+ node.text = text;
2192
+ } catch (err) {
2193
+ node.text = `// Error: Could not read ${node.src} — ${err.message}`;
2194
+ }
2195
+ }
2196
+ if (node.children) {
2197
+ await resolveIncludes(node.children, resolverFn);
2198
+ }
2199
+ if (node.items) {
2200
+ await resolveIncludes(node.items, resolverFn);
2201
+ }
2202
+ }
2203
+ }
2204
+
2205
+ module.exports = {
2206
+ parseSdoc,
2207
+ extractMeta,
2208
+ resolveIncludes,
2209
+ renderFragment,
2210
+ renderTextParagraphs,
2211
+ renderHtmlDocumentFromParsed,
2212
+ renderHtmlDocument,
2213
+ formatSdoc,
2214
+ slugify,
2215
+ inferType,
2216
+ listSections,
2217
+ extractSection,
2218
+ extractAbout,
2219
+ // Low-level helpers for custom renderers (e.g. slide-renderer)
2220
+ parseInline,
2221
+ escapeHtml,
2222
+ escapeAttr
2223
+ };