@entropicwarrior/sdoc 0.1.18 → 0.2.1

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 CHANGED
@@ -1,4 +1,9 @@
1
- const SDOC_FORMAT_VERSION = "0.1";
1
+ const SDOC_FORMAT_VERSION = "0.2";
2
+
3
+ const KNOWN_SCOPE_TYPES = [
4
+ "schema", "example", "requirement", "specification", "definition",
5
+ "note", "warning", "test", "task", "api", "config", "deprecated", "comment"
6
+ ];
2
7
 
3
8
  const COMMAND_HEADING = "#";
4
9
  const COMMAND_SCOPE_OPEN = "{";
@@ -45,6 +50,10 @@ function parseTableOptions(text) {
45
50
  for (const flag of flags) {
46
51
  if (flag === "borderless") options.borderless = true;
47
52
  else if (flag === "headerless") options.headerless = true;
53
+ else if (flag === "auto") options.width = "auto";
54
+ else if (/^\d+(?:\.\d+)?%$/.test(flag)) options.width = flag;
55
+ else if (/^\d+px$/.test(flag)) options.width = flag;
56
+ else if (flag === "center" || flag === "left" || flag === "right") options.align = flag;
48
57
  }
49
58
  return options;
50
59
  }
@@ -84,15 +93,7 @@ function parseSdoc(text) {
84
93
  const parsedHeading = parseHeading(cursor.current());
85
94
  cursor.next();
86
95
  const children = parseBlock(cursor, "normal");
87
- const rootNode = {
88
- type: "scope",
89
- title: parsedHeading.title,
90
- id: parsedHeading.id,
91
- children,
92
- hasHeading: true,
93
- lineStart: scopeStartLine,
94
- lineEnd: cursor.index
95
- };
96
+ const rootNode = makeScopeNode(parsedHeading, children, true, scopeStartLine, cursor.index);
96
97
  return { nodes: [rootNode], errors: cursor.errors };
97
98
  }
98
99
 
@@ -228,6 +229,12 @@ function parseBlock(cursor, kind) {
228
229
  continue;
229
230
  }
230
231
 
232
+ // Line comments — skip, don't flush paragraph (invisible to AST)
233
+ if (trimmedLeft.startsWith("//")) {
234
+ cursor.next();
235
+ continue;
236
+ }
237
+
231
238
  if (trimmed === COMMAND_SCOPE_CLOSE) {
232
239
  flushParagraph();
233
240
  cursor.next();
@@ -348,6 +355,21 @@ function extractTrailingOpener(text) {
348
355
  return null;
349
356
  }
350
357
 
358
+ function makeScopeNode(parsedHeading, children, hasHeading, lineStart, lineEnd, extra) {
359
+ const node = {
360
+ type: "scope",
361
+ title: parsedHeading.title,
362
+ id: parsedHeading.id,
363
+ children,
364
+ hasHeading,
365
+ lineStart,
366
+ lineEnd
367
+ };
368
+ if (parsedHeading.scopeType) node.scopeType = parsedHeading.scopeType;
369
+ if (extra) Object.assign(node, extra);
370
+ return node;
371
+ }
372
+
351
373
  function parseScope(cursor) {
352
374
  const scopeStartLine = cursor.index + 1;
353
375
  const headingLine = cursor.current();
@@ -369,15 +391,7 @@ function parseScope(cursor) {
369
391
  } else {
370
392
  children = parseBlock(cursor, "normal");
371
393
  }
372
- return {
373
- type: "scope",
374
- title: parsedHeading.title,
375
- id: parsedHeading.id,
376
- children,
377
- hasHeading: true,
378
- lineStart: scopeStartLine,
379
- lineEnd: cursor.index
380
- };
394
+ return makeScopeNode(parsedHeading, children, true, scopeStartLine, cursor.index);
381
395
  }
382
396
 
383
397
  const parsedHeading = parseHeading(headingLine);
@@ -385,38 +399,14 @@ function parseScope(cursor) {
385
399
 
386
400
  if (blockResult.blockType === "braceless") {
387
401
  const children = parseBracelessBlock(cursor);
388
- return {
389
- type: "scope",
390
- title: parsedHeading.title,
391
- id: parsedHeading.id,
392
- children,
393
- hasHeading: true,
394
- lineStart: scopeStartLine,
395
- lineEnd: cursor.index
396
- };
402
+ return makeScopeNode(parsedHeading, children, true, scopeStartLine, cursor.index);
397
403
  }
398
404
 
399
405
  if (blockResult.blockType === "list") {
400
- return {
401
- type: "scope",
402
- title: parsedHeading.title,
403
- id: parsedHeading.id,
404
- children: [blockResult.children],
405
- hasHeading: true,
406
- lineStart: scopeStartLine,
407
- lineEnd: cursor.index
408
- };
409
- }
410
-
411
- return {
412
- type: "scope",
413
- title: parsedHeading.title,
414
- id: parsedHeading.id,
415
- children: blockResult.children,
416
- hasHeading: true,
417
- lineStart: scopeStartLine,
418
- lineEnd: cursor.index
419
- };
406
+ return makeScopeNode(parsedHeading, [blockResult.children], true, scopeStartLine, cursor.index);
407
+ }
408
+
409
+ return makeScopeNode(parsedHeading, blockResult.children, true, scopeStartLine, cursor.index);
420
410
  }
421
411
 
422
412
  function tryParseInlineBlock(trimmed) {
@@ -494,6 +484,12 @@ function parseBracelessBlock(cursor) {
494
484
  break;
495
485
  }
496
486
 
487
+ // Line comments — skip, don't flush paragraph
488
+ if (trimmedLeft.startsWith("//")) {
489
+ cursor.next();
490
+ continue;
491
+ }
492
+
497
493
  if (trimmed === ",") {
498
494
  flushParagraph();
499
495
  cursor.next();
@@ -710,6 +706,9 @@ function parseListItemLine(cursor, info, allowContinuation = false) {
710
706
  const textForOpener = task ? task.text : raw;
711
707
  const trailing = extractTrailingOpener(textForOpener);
712
708
 
709
+ const listExtra = { shorthand: true };
710
+ if (task) listExtra.task = { checked: task.checked };
711
+
713
712
  if (trailing) {
714
713
  const parsed = parseHeadingText(trailing.text);
715
714
  cursor.next();
@@ -725,17 +724,7 @@ function parseListItemLine(cursor, info, allowContinuation = false) {
725
724
  children = parseBlock(cursor, "normal");
726
725
  }
727
726
 
728
- return {
729
- type: "scope",
730
- title: parsed.title,
731
- id: parsed.id,
732
- children,
733
- hasHeading: true,
734
- shorthand: true,
735
- task: task ? { checked: task.checked } : undefined,
736
- lineStart: itemStartLine,
737
- lineEnd: cursor.index
738
- };
727
+ return makeScopeNode(parsed, children, true, itemStartLine, cursor.index, listExtra);
739
728
  }
740
729
 
741
730
  cursor.next();
@@ -756,44 +745,14 @@ function parseListItemLine(cursor, info, allowContinuation = false) {
756
745
 
757
746
  const block = parseOptionalBlock(cursor);
758
747
  if (!block) {
759
- return {
760
- type: "scope",
761
- title: parsed.title,
762
- id: parsed.id,
763
- children: [],
764
- hasHeading: true,
765
- shorthand: true,
766
- task: task ? { checked: task.checked } : undefined,
767
- lineStart: itemStartLine,
768
- lineEnd: cursor.index
769
- };
748
+ return makeScopeNode(parsed, [], true, itemStartLine, cursor.index, listExtra);
770
749
  }
771
750
 
772
751
  if (block.blockType === "list") {
773
- return {
774
- type: "scope",
775
- title: parsed.title,
776
- id: parsed.id,
777
- children: [block.children],
778
- hasHeading: true,
779
- shorthand: true,
780
- task: task ? { checked: task.checked } : undefined,
781
- lineStart: itemStartLine,
782
- lineEnd: cursor.index
783
- };
784
- }
785
-
786
- return {
787
- type: "scope",
788
- title: parsed.title,
789
- id: parsed.id,
790
- children: block.children,
791
- hasHeading: true,
792
- shorthand: true,
793
- task: task ? { checked: task.checked } : undefined,
794
- lineStart: itemStartLine,
795
- lineEnd: cursor.index
796
- };
752
+ return makeScopeNode(parsed, [block.children], true, itemStartLine, cursor.index, listExtra);
753
+ }
754
+
755
+ return makeScopeNode(parsed, block.children, true, itemStartLine, cursor.index, listExtra);
797
756
  }
798
757
 
799
758
  function parseImplicitListBlock(cursor, listType) {
@@ -843,16 +802,18 @@ function parseTableBody(cursor, tableStartLine, options) {
843
802
  cursor.next();
844
803
  }
845
804
 
805
+ const hasOptions = options.borderless || options.headerless || options.width || options.align;
806
+
846
807
  if (options.headerless) {
847
808
  const tableNode = { type: "table", headers: [], rows, lineStart: tableStartLine, lineEnd: cursor.index };
848
- if (options.borderless || options.headerless) tableNode.options = options;
809
+ if (hasOptions) tableNode.options = options;
849
810
  return tableNode;
850
811
  }
851
812
 
852
813
  const headers = rows.length > 0 ? rows[0] : [];
853
814
  const body = rows.slice(1);
854
815
  const tableNode = { type: "table", headers, rows: body, lineStart: tableStartLine, lineEnd: cursor.index };
855
- if (options.borderless) tableNode.options = options;
816
+ if (hasOptions) tableNode.options = options;
856
817
  return tableNode;
857
818
  }
858
819
 
@@ -911,7 +872,7 @@ function parseTaskPrefix(raw) {
911
872
  function parseFenceMetadata(meta) {
912
873
  if (!meta) return {};
913
874
  const tokens = meta.split(/\s+/).filter(Boolean);
914
- let lang, src, lines;
875
+ let lang, src, lines, data = false;
915
876
  for (const token of tokens) {
916
877
  if (token.startsWith("src:")) {
917
878
  src = token.slice(4);
@@ -921,11 +882,13 @@ function parseFenceMetadata(meta) {
921
882
  if (match) {
922
883
  lines = { start: parseInt(match[1], 10), end: parseInt(match[2], 10) };
923
884
  }
885
+ } else if (token === ":data") {
886
+ data = true;
924
887
  } else if (!lang) {
925
888
  lang = token;
926
889
  }
927
890
  }
928
- return { lang, src, lines };
891
+ return { lang, src, lines, data };
929
892
  }
930
893
 
931
894
  function parseCodeBlock(cursor) {
@@ -943,26 +906,37 @@ function parseCodeBlock(cursor) {
943
906
 
944
907
  const contentLines = [];
945
908
 
909
+ function buildCodeNode() {
910
+ const node = { type: "code", lang, text: stripIndent(contentLines, fenceIndent), lineStart: codeStartLine, lineEnd: cursor.index };
911
+ if (fenceMeta.src) node.src = fenceMeta.src;
912
+ if (fenceMeta.lines) node.lines = fenceMeta.lines;
913
+ if (fenceMeta.data) {
914
+ node.dataFlag = true;
915
+ if (lang === "json" && !node.src) {
916
+ try {
917
+ node.data = JSON.parse(node.text);
918
+ } catch {
919
+ cursor.error("Invalid JSON in :data code block.");
920
+ }
921
+ }
922
+ }
923
+ return node;
924
+ }
925
+
946
926
  while (!cursor.eof()) {
947
927
  const nextLine = cursor.current();
948
928
  const nextTrimmed = nextLine.replace(/^\s+/, "");
949
929
  const closeMatch = nextTrimmed.match(/^(`{3,})\s*$/);
950
930
  if (closeMatch && closeMatch[1].length >= fenceLen) {
951
931
  cursor.next();
952
- const node = { type: "code", lang, text: stripIndent(contentLines, fenceIndent), lineStart: codeStartLine, lineEnd: cursor.index };
953
- if (fenceMeta.src) node.src = fenceMeta.src;
954
- if (fenceMeta.lines) node.lines = fenceMeta.lines;
955
- return node;
932
+ return buildCodeNode();
956
933
  }
957
934
  contentLines.push(nextLine);
958
935
  cursor.next();
959
936
  }
960
937
 
961
938
  cursor.error("Unterminated code fence.");
962
- const node = { type: "code", lang, text: stripIndent(contentLines, fenceIndent), lineStart: codeStartLine, lineEnd: cursor.index };
963
- if (fenceMeta.src) node.src = fenceMeta.src;
964
- if (fenceMeta.lines) node.lines = fenceMeta.lines;
965
- return node;
939
+ return buildCodeNode();
966
940
  }
967
941
 
968
942
  /**
@@ -1016,12 +990,15 @@ function parseBlockquote(cursor) {
1016
990
  function parseHeading(line) {
1017
991
  const trimmedLeft = line.replace(/^\s+/, "");
1018
992
  const raw = stripHeadingToken(trimmedLeft);
1019
- return parseHeadingText(raw);
993
+ const result = parseHeadingText(raw);
994
+ return result;
1020
995
  }
1021
996
 
1022
997
  function parseHeadingText(raw) {
1023
998
  const split = splitTrailingId(raw);
1024
- return { title: split.text.trimEnd(), id: split.id ? split.id.slice(1) : undefined };
999
+ const result = { title: split.text.trimEnd(), id: split.id ? split.id.slice(1) : undefined };
1000
+ if (split.scopeType) result.scopeType = split.scopeType;
1001
+ return result;
1025
1002
  }
1026
1003
 
1027
1004
  function stripHeadingToken(line) {
@@ -1033,27 +1010,49 @@ function stripHeadingToken(line) {
1033
1010
  }
1034
1011
 
1035
1012
  function splitTrailingId(raw) {
1036
- let i = raw.length - 1;
1037
- while (i >= 0 && /\s/.test(raw[i])) {
1038
- i -= 1;
1039
- }
1013
+ let id = undefined;
1014
+ let scopeType = undefined;
1015
+ let remaining = raw;
1040
1016
 
1041
- const end = i;
1042
- while (i >= 0 && isIdentChar(raw[i])) {
1043
- i -= 1;
1044
- }
1017
+ // Make up to two passes to extract trailing @id and :type in any order
1018
+ for (let pass = 0; pass < 2; pass++) {
1019
+ let i = remaining.length - 1;
1020
+ while (i >= 0 && /\s/.test(remaining[i])) {
1021
+ i -= 1;
1022
+ }
1023
+ if (i < 0) break;
1024
+
1025
+ const end = i;
1026
+ while (i >= 0 && isIdentChar(remaining[i])) {
1027
+ i -= 1;
1028
+ }
1029
+ if (i < 0 || end === i) break;
1030
+
1031
+ if (remaining[i] === "@" && !id && isIdentStart(remaining[i + 1])) {
1032
+ if (!isEscaped(remaining, i)) {
1033
+ if (i === 0 || /\s/.test(remaining[i - 1])) {
1034
+ id = remaining.slice(i, end + 1);
1035
+ remaining = remaining.slice(0, i).trimEnd();
1036
+ continue;
1037
+ }
1038
+ }
1039
+ }
1045
1040
 
1046
- if (i >= 0 && raw[i] === "@" && end > i && isIdentStart(raw[i + 1])) {
1047
- if (!isEscaped(raw, i)) {
1048
- if (i === 0 || /\s/.test(raw[i - 1])) {
1049
- const id = raw.slice(i, end + 1);
1050
- const text = raw.slice(0, i).trimEnd();
1051
- return { text, id };
1041
+ if (remaining[i] === ":" && !scopeType && isIdentStart(remaining[i + 1])) {
1042
+ if (i === 0 || /\s/.test(remaining[i - 1])) {
1043
+ scopeType = remaining.slice(i + 1, end + 1);
1044
+ remaining = remaining.slice(0, i).trimEnd();
1045
+ continue;
1052
1046
  }
1053
1047
  }
1048
+
1049
+ break;
1054
1050
  }
1055
1051
 
1056
- return { text: raw };
1052
+ const result = { text: remaining };
1053
+ if (id) result.id = id;
1054
+ if (scopeType) result.scopeType = scopeType;
1055
+ return result;
1057
1056
  }
1058
1057
 
1059
1058
  function isHeadingLine(line) {
@@ -1380,6 +1379,9 @@ function renderInlineNodes(nodes) {
1380
1379
  return escapeHtml(node.value);
1381
1380
  case "ref": {
1382
1381
  const href = `#${escapeAttr(node.id)}`;
1382
+ if (_renderOptions.brokenRefIds && _renderOptions.brokenRefIds.has(node.id)) {
1383
+ return `<a class="sdoc-ref sdoc-broken-ref" href="${href}"><span class="sdoc-broken-icon">\u26A0</span>@${escapeHtml(node.id)}</a>`;
1384
+ }
1383
1385
  return `<a class="sdoc-ref" href="${href}">@${escapeHtml(node.id)}</a>`;
1384
1386
  }
1385
1387
  case "code":
@@ -1405,6 +1407,11 @@ function renderInlineNodes(nodes) {
1405
1407
  case "mark_highlight":
1406
1408
  return `<mark class="sdoc-mark sdoc-mark-highlight">${renderInlineNodes(node.children)}</mark>`;
1407
1409
  case "link":
1410
+ if (_renderOptions.brokenLinkHrefs && _renderOptions.brokenLinkHrefs.has(node.href)) {
1411
+ return `<a class="sdoc-link sdoc-broken-link" href="${escapeAttr(node.href)}" target="_blank" rel="noopener noreferrer"><span class="sdoc-broken-icon">\u26A0</span>${renderInlineNodes(
1412
+ node.children
1413
+ )}</a>`;
1414
+ }
1408
1415
  return `<a class="sdoc-link" href="${escapeAttr(node.href)}" target="_blank" rel="noopener noreferrer">${renderInlineNodes(
1409
1416
  node.children
1410
1417
  )}</a>`;
@@ -1429,13 +1436,18 @@ function renderInlineNodes(nodes) {
1429
1436
  }
1430
1437
 
1431
1438
  function renderScope(scope, depth, isTitleScope = false) {
1439
+ // :comment scopes are not rendered
1440
+ if (scope.scopeType === "comment") return "";
1441
+
1432
1442
  const level = Math.min(6, Math.max(1, depth));
1433
1443
  const children = scope.children.map((child) => renderNode(child, depth + 1)).join("\n");
1434
1444
  const rootClass = isTitleScope ? " sdoc-root" : "";
1435
1445
  const dl = dataLineAttrs(scope);
1446
+ const typeAttr = scope.scopeType ? ` data-scope-type="${escapeAttr(scope.scopeType)}"` : "";
1447
+ const typeClass = scope.scopeType ? ` sdoc-scope-type-${scope.scopeType}` : "";
1436
1448
 
1437
1449
  if (scope.hasHeading === false) {
1438
- return `<section class="sdoc-scope sdoc-scope-noheading${rootClass}"${dl}>${children}</section>`;
1450
+ return `<section class="sdoc-scope sdoc-scope-noheading${rootClass}${typeClass}"${typeAttr}${dl}>${children}</section>`;
1439
1451
  }
1440
1452
 
1441
1453
  const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
@@ -1443,7 +1455,7 @@ function renderScope(scope, depth, isTitleScope = false) {
1443
1455
  const toggle = hasChildren ? `<span class="sdoc-toggle"></span>` : "";
1444
1456
  const heading = `<h${level}${idAttr} class="sdoc-heading sdoc-depth-${level}"${dl}>${toggle}${renderInline(scope.title)}</h${level}>`;
1445
1457
  const childrenHtml = children ? `\n<div class="sdoc-scope-children">${children}</div>` : "";
1446
- return `<section class="sdoc-scope${rootClass}">${heading}${childrenHtml}</section>`;
1458
+ return `<section class="sdoc-scope${rootClass}${typeClass}"${typeAttr}>${heading}${childrenHtml}</section>`;
1447
1459
  }
1448
1460
 
1449
1461
  function renderList(list, depth) {
@@ -1536,7 +1548,16 @@ function renderTable(table) {
1536
1548
  .join("\n");
1537
1549
  const tbody = bodyRows ? `<tbody class="sdoc-table-body">${bodyRows}</tbody>` : "";
1538
1550
 
1539
- return `<table class="${classAttr}"${dl}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
1551
+ const styleParts = [];
1552
+ if (opts.width) {
1553
+ styleParts.push(`width:${opts.width}`);
1554
+ if (opts.width !== "auto") styleParts.push("table-layout:fixed");
1555
+ }
1556
+ if (opts.align === "center") styleParts.push("margin-left:auto", "margin-right:auto");
1557
+ else if (opts.align === "right") styleParts.push("margin-left:auto", "margin-right:0");
1558
+ const styleAttr = styleParts.length ? ` style="${styleParts.join(";")}"` : "";
1559
+
1560
+ return `<table class="${classAttr}"${dl}${styleAttr}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
1540
1561
  }
1541
1562
 
1542
1563
  function renderNode(node, depth) {
@@ -1568,7 +1589,8 @@ function renderNode(node, depth) {
1568
1589
  return `<div class="sdoc-math sdoc-math-block"${dl}>${renderKatex(node.text, true)}</div>`;
1569
1590
  }
1570
1591
  const langClass = node.lang ? ` class="language-${escapeAttr(node.lang)}"` : "";
1571
- return `<div class="sdoc-code-wrap"${dl}><pre class="sdoc-code"><code${langClass}>${escapeHtml(node.text)}</code></pre><button class="sdoc-copy-btn" title="Copy code">\u29C9</button></div>`;
1592
+ const dataLabel = node.dataFlag ? `<span class="sdoc-data-label">data</span>` : "";
1593
+ return `<div class="sdoc-code-wrap"${dl}>${dataLabel}<pre class="sdoc-code"><code${langClass}>${escapeHtml(node.text)}</code></pre><button class="sdoc-copy-btn" title="Copy code">\u29C9</button></div>`;
1572
1594
  }
1573
1595
  default:
1574
1596
  return "";
@@ -1967,6 +1989,19 @@ const DEFAULT_STYLE = `
1967
1989
  .sdoc-mark-negative { background-color: rgba(210, 25, 25, 0.18); color: #a81414; }
1968
1990
  .sdoc-mark-highlight { background-color: rgba(255, 255, 0, 0.75); }
1969
1991
 
1992
+ .sdoc-broken-ref, .sdoc-link.sdoc-broken-link {
1993
+ color: #c33;
1994
+ text-decoration: wavy underline #c33;
1995
+ text-underline-offset: 2px;
1996
+ background: rgba(204, 51, 51, 0.08);
1997
+ border-radius: 2px;
1998
+ padding: 0 0.15em;
1999
+ }
2000
+ .sdoc-broken-icon {
2001
+ font-size: 0.75em;
2002
+ margin-right: 0.15em;
2003
+ }
2004
+
1970
2005
  .sdoc-image {
1971
2006
  display: inline-block;
1972
2007
  max-width: 100%;
@@ -2009,6 +2044,22 @@ const DEFAULT_STYLE = `
2009
2044
  padding-left: 1.2rem;
2010
2045
  }
2011
2046
 
2047
+ .sdoc-data-label {
2048
+ position: absolute;
2049
+ top: 6px;
2050
+ left: 8px;
2051
+ z-index: 1;
2052
+ font-size: 0.65rem;
2053
+ font-weight: 600;
2054
+ text-transform: uppercase;
2055
+ letter-spacing: 0.05em;
2056
+ padding: 1px 5px;
2057
+ border-radius: 3px;
2058
+ background: rgba(59, 130, 195, 0.15);
2059
+ color: #3b82c3;
2060
+ pointer-events: none;
2061
+ }
2062
+
2012
2063
  `;
2013
2064
 
2014
2065
  const PRINT_STYLE = `
@@ -2063,6 +2114,9 @@ const PRINT_STYLE = `
2063
2114
  word-wrap: break-word;
2064
2115
  }
2065
2116
  .sdoc-mark { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
2117
+ .sdoc-table { break-inside: avoid; }
2118
+ .sdoc-code { break-inside: avoid; }
2119
+ .sdoc-blockquote { break-inside: avoid; }
2066
2120
  }
2067
2121
  `;
2068
2122
 
@@ -2082,9 +2136,8 @@ function hasMermaidBlocks(nodes) {
2082
2136
  return false;
2083
2137
  }
2084
2138
 
2085
- function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
2086
- _renderOptions = options.renderOptions ?? {};
2087
- const body = parsed.nodes
2139
+ function renderBodyNodes(nodes) {
2140
+ return nodes
2088
2141
  .map((node, index) => {
2089
2142
  if (node.type === "scope" && index === 0) {
2090
2143
  return renderScope(node, 1, true);
@@ -2092,6 +2145,17 @@ function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
2092
2145
  return renderNode(node, 1);
2093
2146
  })
2094
2147
  .join("\n");
2148
+ }
2149
+
2150
+ function renderHtmlBody(text) {
2151
+ const parsed = parseSdoc(text);
2152
+ const metaResult = extractMeta(parsed.nodes);
2153
+ return renderBodyNodes(metaResult.nodes);
2154
+ }
2155
+
2156
+ function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
2157
+ _renderOptions = options.renderOptions ?? {};
2158
+ const body = renderBodyNodes(parsed.nodes);
2095
2159
  _renderOptions = {};
2096
2160
  const errorHtml = renderErrors(parsed.errors);
2097
2161
 
@@ -2314,17 +2378,35 @@ function listSections(nodes) {
2314
2378
  id: node.id || null,
2315
2379
  derivedId: slugify(node.title),
2316
2380
  title: node.title,
2381
+ scopeType: node.scopeType || null,
2317
2382
  preview: firstParagraphPreview(node.children || [], 100)
2318
2383
  }));
2319
2384
  }
2320
2385
 
2386
+ function collectDataBlocks(children) {
2387
+ const data = [];
2388
+ for (const child of children) {
2389
+ if (child.type === "code" && child.dataFlag && child.data !== undefined) {
2390
+ data.push(child.data);
2391
+ }
2392
+ }
2393
+ return data;
2394
+ }
2395
+
2321
2396
  function extractSection(nodes, sectionId) {
2322
2397
  const scopes = getContentScopes(nodes);
2323
2398
 
2399
+ function buildResult(node) {
2400
+ const data = collectDataBlocks(node.children || []);
2401
+ const result = { title: node.title, content: collectPlainText(node.children || []) };
2402
+ if (data.length) result.data = data;
2403
+ return result;
2404
+ }
2405
+
2324
2406
  // First pass: match explicit @id (case-sensitive)
2325
2407
  for (const node of scopes) {
2326
2408
  if (node.id && node.id === sectionId) {
2327
- return { title: node.title, content: collectPlainText(node.children || []) };
2409
+ return buildResult(node);
2328
2410
  }
2329
2411
  }
2330
2412
 
@@ -2332,13 +2414,41 @@ function extractSection(nodes, sectionId) {
2332
2414
  const lowerTarget = sectionId.toLowerCase();
2333
2415
  for (const node of scopes) {
2334
2416
  if (slugify(node.title).toLowerCase() === lowerTarget) {
2335
- return { title: node.title, content: collectPlainText(node.children || []) };
2417
+ return buildResult(node);
2336
2418
  }
2337
2419
  }
2338
2420
 
2339
2421
  return null;
2340
2422
  }
2341
2423
 
2424
+ function extractDataBlocks(nodes) {
2425
+ const results = [];
2426
+ function walk(nodeList, scopeId, scopeType, scopeTitle) {
2427
+ for (const node of nodeList) {
2428
+ if (node.type === "code" && node.dataFlag && node.data !== undefined) {
2429
+ results.push({
2430
+ scopeId: scopeId || null,
2431
+ scopeType: scopeType || null,
2432
+ scopeTitle: scopeTitle || null,
2433
+ data: node.data
2434
+ });
2435
+ }
2436
+ if (node.type === "scope") {
2437
+ walk(node.children || [], node.id, node.scopeType, node.title);
2438
+ }
2439
+ if (node.type === "list" && node.items) {
2440
+ for (const item of node.items) {
2441
+ if (item.type === "scope") {
2442
+ walk(item.children || [], item.id, item.scopeType, item.title);
2443
+ }
2444
+ }
2445
+ }
2446
+ }
2447
+ }
2448
+ walk(nodes, null, null, null);
2449
+ return results;
2450
+ }
2451
+
2342
2452
  function extractAbout(nodes) {
2343
2453
  const doc = getDocumentScope(nodes);
2344
2454
  const children = doc ? doc.children : nodes;
@@ -2354,6 +2464,127 @@ function extractAbout(nodes) {
2354
2464
  return null;
2355
2465
  }
2356
2466
 
2467
+ function collectAllIds(nodes) {
2468
+ const ids = new Set();
2469
+ function walk(nodeList) {
2470
+ for (const node of nodeList) {
2471
+ if (node.type === "scope") {
2472
+ if (node.id) ids.add(node.id);
2473
+ if (node.title) ids.add(slugify(node.title));
2474
+ }
2475
+ if (node.children) walk(node.children);
2476
+ if (node.type === "list" && node.items) {
2477
+ walk(node.items);
2478
+ }
2479
+ }
2480
+ }
2481
+ walk(nodes);
2482
+ return ids;
2483
+ }
2484
+
2485
+ function collectInlineRefs(nodes) {
2486
+ const refs = [];
2487
+ const links = [];
2488
+
2489
+ function walkInlineNodes(inlineNodes, lineStart, lineEnd) {
2490
+ for (const node of inlineNodes) {
2491
+ if (node.type === "ref") {
2492
+ refs.push({ id: node.id, lineStart, lineEnd });
2493
+ } else if (node.type === "link") {
2494
+ links.push({ href: node.href, lineStart, lineEnd });
2495
+ }
2496
+ if (node.children) {
2497
+ walkInlineNodes(node.children, lineStart, lineEnd);
2498
+ }
2499
+ }
2500
+ }
2501
+
2502
+ function processText(text, lineStart, lineEnd) {
2503
+ const inlineNodes = parseInline(text);
2504
+ walkInlineNodes(inlineNodes, lineStart, lineEnd);
2505
+ }
2506
+
2507
+ function walk(nodeList) {
2508
+ for (const node of nodeList) {
2509
+ if (node.type === "paragraph" && node.text) {
2510
+ processText(node.text, node.lineStart, node.lineEnd);
2511
+ } else if (node.type === "blockquote" && node.paragraphs) {
2512
+ for (const para of node.paragraphs) {
2513
+ processText(para, node.lineStart, node.lineEnd);
2514
+ }
2515
+ } else if (node.type === "scope") {
2516
+ if (node.title) {
2517
+ processText(node.title, node.lineStart, node.lineStart);
2518
+ }
2519
+ if (node.children) walk(node.children);
2520
+ } else if (node.type === "list" && node.items) {
2521
+ walk(node.items);
2522
+ } else if (node.type === "table") {
2523
+ if (node.headers) {
2524
+ for (const cell of node.headers) {
2525
+ processText(cell, node.lineStart, node.lineEnd);
2526
+ }
2527
+ }
2528
+ if (node.rows) {
2529
+ for (const row of node.rows) {
2530
+ for (const cell of row) {
2531
+ processText(cell, node.lineStart, node.lineEnd);
2532
+ }
2533
+ }
2534
+ }
2535
+ }
2536
+ // Handle list items (no type field, but have title/children)
2537
+ if (!node.type) {
2538
+ if (node.title) processText(node.title, node.lineStart || 0, node.lineEnd || node.lineStart || 0);
2539
+ if (node.children) walk(node.children);
2540
+ }
2541
+ }
2542
+ }
2543
+ walk(nodes);
2544
+ return { refs, links };
2545
+ }
2546
+
2547
+ function validateRefs(nodes, options = {}) {
2548
+ const ids = collectAllIds(nodes);
2549
+ const externalIds = options.externalIds || new Set();
2550
+ const { refs, links } = collectInlineRefs(nodes);
2551
+ const warnings = [];
2552
+
2553
+ for (const ref of refs) {
2554
+ if (!ids.has(ref.id) && !externalIds.has(ref.id)) {
2555
+ warnings.push({
2556
+ type: "broken-ref",
2557
+ id: ref.id,
2558
+ message: `Broken reference: @${ref.id} does not match any scope ID or title`,
2559
+ lineStart: ref.lineStart,
2560
+ lineEnd: ref.lineEnd
2561
+ });
2562
+ }
2563
+ }
2564
+
2565
+ for (const link of links) {
2566
+ const href = link.href;
2567
+ if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href) || href.startsWith("#") || href.startsWith("data:")) {
2568
+ continue;
2569
+ }
2570
+ if (options.resolveFilePath) {
2571
+ const filePath = href.split("#")[0].split("?")[0];
2572
+ if (!filePath) continue;
2573
+ if (!options.resolveFilePath(filePath)) {
2574
+ warnings.push({
2575
+ type: "broken-link",
2576
+ href,
2577
+ message: `Broken link: file not found — ${href}`,
2578
+ lineStart: link.lineStart,
2579
+ lineEnd: link.lineEnd
2580
+ });
2581
+ }
2582
+ }
2583
+ }
2584
+
2585
+ return warnings;
2586
+ }
2587
+
2357
2588
  async function resolveIncludes(nodes, resolverFn) {
2358
2589
  for (const node of nodes) {
2359
2590
  if (node.type === "code" && node.src) {
@@ -2364,6 +2595,12 @@ async function resolveIncludes(nodes, resolverFn) {
2364
2595
  text = allLines.slice(node.lines.start - 1, node.lines.end).join("\n");
2365
2596
  }
2366
2597
  node.text = text;
2598
+ // Re-parse JSON for :data blocks after include resolution
2599
+ if (node.dataFlag && node.lang === "json") {
2600
+ try {
2601
+ node.data = JSON.parse(node.text);
2602
+ } catch { /* leave node.data undefined — caller can check */ }
2603
+ }
2367
2604
  } catch (err) {
2368
2605
  node.text = `// Error: Could not read ${node.src} — ${err.message}`;
2369
2606
  }
@@ -2384,6 +2621,7 @@ module.exports = {
2384
2621
  resolveIncludes,
2385
2622
  renderFragment,
2386
2623
  renderTextParagraphs,
2624
+ renderHtmlBody,
2387
2625
  renderHtmlDocumentFromParsed,
2388
2626
  renderHtmlDocument,
2389
2627
  formatSdoc,
@@ -2392,6 +2630,12 @@ module.exports = {
2392
2630
  listSections,
2393
2631
  extractSection,
2394
2632
  extractAbout,
2633
+ extractDataBlocks,
2634
+ KNOWN_SCOPE_TYPES,
2635
+ // Validation
2636
+ collectAllIds,
2637
+ collectInlineRefs,
2638
+ validateRefs,
2395
2639
  // Low-level helpers for custom renderers (e.g. slide-renderer)
2396
2640
  parseInline,
2397
2641
  renderKatex,