@entropicwarrior/sdoc 0.1.17 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
1045
1024
 
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 };
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
+ }
1040
+
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) {
@@ -1429,13 +1428,18 @@ function renderInlineNodes(nodes) {
1429
1428
  }
1430
1429
 
1431
1430
  function renderScope(scope, depth, isTitleScope = false) {
1431
+ // :comment scopes are not rendered
1432
+ if (scope.scopeType === "comment") return "";
1433
+
1432
1434
  const level = Math.min(6, Math.max(1, depth));
1433
1435
  const children = scope.children.map((child) => renderNode(child, depth + 1)).join("\n");
1434
1436
  const rootClass = isTitleScope ? " sdoc-root" : "";
1435
1437
  const dl = dataLineAttrs(scope);
1438
+ const typeAttr = scope.scopeType ? ` data-scope-type="${escapeAttr(scope.scopeType)}"` : "";
1439
+ const typeClass = scope.scopeType ? ` sdoc-scope-type-${scope.scopeType}` : "";
1436
1440
 
1437
1441
  if (scope.hasHeading === false) {
1438
- return `<section class="sdoc-scope sdoc-scope-noheading${rootClass}"${dl}>${children}</section>`;
1442
+ return `<section class="sdoc-scope sdoc-scope-noheading${rootClass}${typeClass}"${typeAttr}${dl}>${children}</section>`;
1439
1443
  }
1440
1444
 
1441
1445
  const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
@@ -1443,7 +1447,7 @@ function renderScope(scope, depth, isTitleScope = false) {
1443
1447
  const toggle = hasChildren ? `<span class="sdoc-toggle"></span>` : "";
1444
1448
  const heading = `<h${level}${idAttr} class="sdoc-heading sdoc-depth-${level}"${dl}>${toggle}${renderInline(scope.title)}</h${level}>`;
1445
1449
  const childrenHtml = children ? `\n<div class="sdoc-scope-children">${children}</div>` : "";
1446
- return `<section class="sdoc-scope${rootClass}">${heading}${childrenHtml}</section>`;
1450
+ return `<section class="sdoc-scope${rootClass}${typeClass}"${typeAttr}>${heading}${childrenHtml}</section>`;
1447
1451
  }
1448
1452
 
1449
1453
  function renderList(list, depth) {
@@ -1536,7 +1540,16 @@ function renderTable(table) {
1536
1540
  .join("\n");
1537
1541
  const tbody = bodyRows ? `<tbody class="sdoc-table-body">${bodyRows}</tbody>` : "";
1538
1542
 
1539
- return `<table class="${classAttr}"${dl}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
1543
+ const styleParts = [];
1544
+ if (opts.width) {
1545
+ styleParts.push(`width:${opts.width}`);
1546
+ if (opts.width !== "auto") styleParts.push("table-layout:fixed");
1547
+ }
1548
+ if (opts.align === "center") styleParts.push("margin-left:auto", "margin-right:auto");
1549
+ else if (opts.align === "right") styleParts.push("margin-left:auto", "margin-right:0");
1550
+ const styleAttr = styleParts.length ? ` style="${styleParts.join(";")}"` : "";
1551
+
1552
+ return `<table class="${classAttr}"${dl}${styleAttr}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
1540
1553
  }
1541
1554
 
1542
1555
  function renderNode(node, depth) {
@@ -1568,7 +1581,8 @@ function renderNode(node, depth) {
1568
1581
  return `<div class="sdoc-math sdoc-math-block"${dl}>${renderKatex(node.text, true)}</div>`;
1569
1582
  }
1570
1583
  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>`;
1584
+ const dataLabel = node.dataFlag ? `<span class="sdoc-data-label">data</span>` : "";
1585
+ 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
1586
  }
1573
1587
  default:
1574
1588
  return "";
@@ -2009,6 +2023,22 @@ const DEFAULT_STYLE = `
2009
2023
  padding-left: 1.2rem;
2010
2024
  }
2011
2025
 
2026
+ .sdoc-data-label {
2027
+ position: absolute;
2028
+ top: 6px;
2029
+ left: 8px;
2030
+ z-index: 1;
2031
+ font-size: 0.65rem;
2032
+ font-weight: 600;
2033
+ text-transform: uppercase;
2034
+ letter-spacing: 0.05em;
2035
+ padding: 1px 5px;
2036
+ border-radius: 3px;
2037
+ background: rgba(59, 130, 195, 0.15);
2038
+ color: #3b82c3;
2039
+ pointer-events: none;
2040
+ }
2041
+
2012
2042
  `;
2013
2043
 
2014
2044
  const PRINT_STYLE = `
@@ -2314,17 +2344,35 @@ function listSections(nodes) {
2314
2344
  id: node.id || null,
2315
2345
  derivedId: slugify(node.title),
2316
2346
  title: node.title,
2347
+ scopeType: node.scopeType || null,
2317
2348
  preview: firstParagraphPreview(node.children || [], 100)
2318
2349
  }));
2319
2350
  }
2320
2351
 
2352
+ function collectDataBlocks(children) {
2353
+ const data = [];
2354
+ for (const child of children) {
2355
+ if (child.type === "code" && child.dataFlag && child.data !== undefined) {
2356
+ data.push(child.data);
2357
+ }
2358
+ }
2359
+ return data;
2360
+ }
2361
+
2321
2362
  function extractSection(nodes, sectionId) {
2322
2363
  const scopes = getContentScopes(nodes);
2323
2364
 
2365
+ function buildResult(node) {
2366
+ const data = collectDataBlocks(node.children || []);
2367
+ const result = { title: node.title, content: collectPlainText(node.children || []) };
2368
+ if (data.length) result.data = data;
2369
+ return result;
2370
+ }
2371
+
2324
2372
  // First pass: match explicit @id (case-sensitive)
2325
2373
  for (const node of scopes) {
2326
2374
  if (node.id && node.id === sectionId) {
2327
- return { title: node.title, content: collectPlainText(node.children || []) };
2375
+ return buildResult(node);
2328
2376
  }
2329
2377
  }
2330
2378
 
@@ -2332,13 +2380,41 @@ function extractSection(nodes, sectionId) {
2332
2380
  const lowerTarget = sectionId.toLowerCase();
2333
2381
  for (const node of scopes) {
2334
2382
  if (slugify(node.title).toLowerCase() === lowerTarget) {
2335
- return { title: node.title, content: collectPlainText(node.children || []) };
2383
+ return buildResult(node);
2336
2384
  }
2337
2385
  }
2338
2386
 
2339
2387
  return null;
2340
2388
  }
2341
2389
 
2390
+ function extractDataBlocks(nodes) {
2391
+ const results = [];
2392
+ function walk(nodeList, scopeId, scopeType, scopeTitle) {
2393
+ for (const node of nodeList) {
2394
+ if (node.type === "code" && node.dataFlag && node.data !== undefined) {
2395
+ results.push({
2396
+ scopeId: scopeId || null,
2397
+ scopeType: scopeType || null,
2398
+ scopeTitle: scopeTitle || null,
2399
+ data: node.data
2400
+ });
2401
+ }
2402
+ if (node.type === "scope") {
2403
+ walk(node.children || [], node.id, node.scopeType, node.title);
2404
+ }
2405
+ if (node.type === "list" && node.items) {
2406
+ for (const item of node.items) {
2407
+ if (item.type === "scope") {
2408
+ walk(item.children || [], item.id, item.scopeType, item.title);
2409
+ }
2410
+ }
2411
+ }
2412
+ }
2413
+ }
2414
+ walk(nodes, null, null, null);
2415
+ return results;
2416
+ }
2417
+
2342
2418
  function extractAbout(nodes) {
2343
2419
  const doc = getDocumentScope(nodes);
2344
2420
  const children = doc ? doc.children : nodes;
@@ -2364,6 +2440,12 @@ async function resolveIncludes(nodes, resolverFn) {
2364
2440
  text = allLines.slice(node.lines.start - 1, node.lines.end).join("\n");
2365
2441
  }
2366
2442
  node.text = text;
2443
+ // Re-parse JSON for :data blocks after include resolution
2444
+ if (node.dataFlag && node.lang === "json") {
2445
+ try {
2446
+ node.data = JSON.parse(node.text);
2447
+ } catch { /* leave node.data undefined — caller can check */ }
2448
+ }
2367
2449
  } catch (err) {
2368
2450
  node.text = `// Error: Could not read ${node.src} — ${err.message}`;
2369
2451
  }
@@ -2392,6 +2474,8 @@ module.exports = {
2392
2474
  listSections,
2393
2475
  extractSection,
2394
2476
  extractAbout,
2477
+ extractDataBlocks,
2478
+ KNOWN_SCOPE_TYPES,
2395
2479
  // Low-level helpers for custom renderers (e.g. slide-renderer)
2396
2480
  parseInline,
2397
2481
  renderKatex,
@@ -130,15 +130,26 @@ function renderTable(table) {
130
130
  .join("\n");
131
131
  const tbody = bodyRows ? `<tbody>\n${bodyRows}\n</tbody>` : "";
132
132
 
133
- return `<table${classAttr}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
133
+ const styleParts = [];
134
+ if (opts.width) {
135
+ styleParts.push(`width:${opts.width}`);
136
+ if (opts.width !== "auto") styleParts.push("table-layout:fixed");
137
+ }
138
+ if (opts.align === "center") styleParts.push("margin-left:auto", "margin-right:auto");
139
+ else if (opts.align === "right") styleParts.push("margin-left:auto", "margin-right:0");
140
+ const styleAttr = styleParts.length ? ` style="${styleParts.join(";")}"` : "";
141
+
142
+ return `<table${classAttr}${styleAttr}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
134
143
  }
135
144
 
136
145
  function renderNestedScope(scope) {
146
+ if (scope.scopeType === "comment") return "";
137
147
  const heading = scope.hasHeading !== false && scope.title
138
148
  ? `<h3>${renderInline(scope.title)}</h3>`
139
149
  : "";
150
+ const typeAttr = scope.scopeType ? ` data-scope-type="${escapeAttr(scope.scopeType)}"` : "";
140
151
  const children = scope.children.map((child) => renderNode(child)).join("\n");
141
- return `<section>${heading}\n${children}</section>`;
152
+ return `<section${typeAttr}>${heading}\n${children}</section>`;
142
153
  }
143
154
 
144
155
  function renderChildren(nodes) {
@@ -179,6 +190,10 @@ function extractNotes(children) {
179
190
  const notes = [];
180
191
  const rest = [];
181
192
  for (const child of children) {
193
+ if (child.type === "scope" && child.scopeType === "comment") {
194
+ // :comment scopes are excluded from both notes and content
195
+ continue;
196
+ }
182
197
  if (child.type === "scope" && child.id && child.id.toLowerCase() === "notes") {
183
198
  notes.push(child);
184
199
  } else {
@@ -208,8 +223,8 @@ function renderSlide(scope, slideIndex, overlayHtml) {
208
223
  let bodyHtml;
209
224
  if (config.layout === "two-column") {
210
225
  // In two-column layout, child scopes become columns
211
- const columns = contentNodes.filter((n) => n.type === "scope");
212
- const nonColumns = contentNodes.filter((n) => n.type !== "scope");
226
+ const columns = contentNodes.filter((n) => n.type === "scope" && n.scopeType !== "comment");
227
+ const nonColumns = contentNodes.filter((n) => n.type !== "scope" || n.scopeType === "comment");
213
228
  const preamble = nonColumns.length ? renderChildren(nonColumns) : "";
214
229
  const columnsHtml = columns
215
230
  .map((col) => {
@@ -256,8 +271,8 @@ function renderSlides(nodes, options = {}) {
256
271
  slideScopes = nodes;
257
272
  }
258
273
 
259
- // Filter to scope nodes only (skip stray paragraphs at top level)
260
- const slides = slideScopes.filter((n) => n.type === "scope");
274
+ // Filter to scope nodes only (skip stray paragraphs and :comment scopes)
275
+ const slides = slideScopes.filter((n) => n.type === "scope" && n.scopeType !== "comment");
261
276
 
262
277
  // Build per-slide footer: < CONFIDENTIAL ---gap--- Company >
263
278
  const footerParts = [];