@entropicwarrior/sdoc 0.2.11 → 0.2.12

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.
@@ -365,6 +365,39 @@ Content of Section B.
365
365
  Do not write \`@setup\` when the target scope is in a different file — it will be flagged as a broken reference.
366
366
  }
367
367
 
368
+ # Citations @citations
369
+ {
370
+ Use \`[@key]\` in text to cite a source. Define citations in a \`{[citations]\` block. The renderer assigns numbers by order of first appearance and links everything together.
371
+
372
+ ```
373
+ # Findings {
374
+ Edge AI is growing rapidly [@mm-edge-ai]. The chip market
375
+ is expected to double by 2031 [@mordor-chips].
376
+
377
+ {[citations]
378
+ - @mm-edge-ai MarketsandMarkets, "Edge AI Hardware
379
+ Market," Report SE 7017, June 2025.
380
+ - @mordor-chips Mordor Intelligence, "Edge AI Chips
381
+ Market Size & Share Analysis," 2026.
382
+ }
383
+ }
384
+ ```
385
+
386
+ {[.]
387
+ - \`[@key]\` renders as a clickable superscript number (e.g. \`[1]\`) linking to the citation entry
388
+ - \`[@key1, @key2]\` renders as \`[1, 3]\` with each number individually linked
389
+ - The same \`[@key]\` used multiple times always renders the same number
390
+ - Keys follow \`@slug\` conventions: lowercase kebab-case
391
+ - Citation text is free-form SDOC inline content — links, bold, italics all work
392
+ - Each citation entry includes a back-link (\`\u21A9\`) to the first place it was cited
393
+ - Numbering is by order of first \`[@key]\` appearance, not by definition order
394
+ - Multiple \`{[citations]\` blocks are allowed — numbering is unified across all blocks
395
+ - A \`[@key]\` with no matching definition is flagged as a broken citation
396
+ - A defined citation that is never referenced produces a warning
397
+ - Citation keys are document-local, like \`@slug\` references
398
+ }
399
+ }
400
+
368
401
  # Code Blocks @code-blocks
369
402
  {
370
403
  Fenced with triple backticks. Content inside is raw (no parsing):
@@ -766,5 +799,19 @@ Content of Section B.
766
799
 
767
800
  **Also right:** \`[See domain-model.sdoc § my-section](./domain-model.sdoc#my-section)\`
768
801
  }
802
+
803
+ # [@key] vs @key @citation-vs-ref
804
+ {
805
+ \`[@key]\` is a **citation reference** — it renders as a numbered superscript linking to a citation entry. \`@key\` is a **section reference** — it renders as a clickable link to a heading with that ID.
806
+
807
+ Confusing them will produce errors:
808
+
809
+ {[.]
810
+ - Writing \`@smith2020\` when you mean \`[@smith2020]\` creates a broken section reference (there is no heading with ID \`smith2020\`)
811
+ - Writing \`[@setup]\` when you mean \`@setup\` creates a broken citation (there is no citation definition for \`setup\`)
812
+ }
813
+
814
+ Rule of thumb: square brackets mean citation, bare \`@\` means section.
815
+ }
769
816
  }
770
817
  }
@@ -474,6 +474,84 @@ Content of Section B.
474
474
  }
475
475
  }
476
476
 
477
+ # Citations @citations
478
+ {
479
+ Citations provide stable, auto-numbered source references. An inline citation reference uses `[@key]`; citation definitions go in a `{[citations]` block.
480
+
481
+ # Inline Citation References @citation-refs
482
+ {
483
+ ```
484
+ The market is growing rapidly [@mm-report].
485
+ Multiple sources agree [@mm-report, @mordor-analysis].
486
+ ```
487
+
488
+ {[.]
489
+ - `[@key]` inserts a citation reference that renders as a clickable superscript number (e.g. `[1]`) linking to the corresponding entry in the `{[citations]` block
490
+ - `[@key1, @key2]` renders as `[1, 3]` (or whatever the assigned numbers are), with each number individually linked
491
+ - Whitespace around commas is optional and ignored
492
+ - Keys follow the existing `@slug` convention: lowercase kebab-case
493
+ - `[@key]` is visually and semantically distinct from `@slug` — the former is a citation, the latter is a section reference
494
+ - The same `[@key]` used multiple times always renders the same number
495
+ - Citation keys are document-local, like section references
496
+ }
497
+ }
498
+
499
+ # Citation Definition Block @citation-block
500
+ {
501
+ ```
502
+ {[citations]
503
+ - @mm-report MarketsandMarkets, "Edge AI Hardware Market,"
504
+ Report SE 7017, June 2025.
505
+ - @mordor-analysis Mordor Intelligence, "Edge AI Chips Market
506
+ Size & Share Analysis," 2026.
507
+ }
508
+ ```
509
+
510
+ {[.]
511
+ - `{[citations]` opens a citation definition block (no closing brace on the same line)
512
+ - Each citation is a list item with a `- @key` prefix followed by free-form inline content
513
+ - Citation text supports full SDOC inline formatting: links, bold, italics, inline code
514
+ - There are no structured bibliographic fields — the author formats the citation however they want
515
+ - Item text can span multiple continuation lines (same rules as list item continuation)
516
+ - The closing `}` must be on its own line
517
+ - Works with K&R brace style: `# References {[citations]`
518
+ }
519
+ }
520
+
521
+ # Numbering @citation-numbering
522
+ {
523
+ {[.]
524
+ - Numbers are assigned by order of first appearance of `[@key]` in the document text, starting at 1
525
+ - The `{[citations]` block renders entries sorted by assigned number, regardless of definition order
526
+ - Numbers always increase monotonically as the reader moves through the document
527
+ - Defined but unreferenced citations are appended after all referenced entries
528
+ }
529
+ }
530
+
531
+ # Multiple Blocks @citation-multiple-blocks
532
+ {
533
+ Multiple `{[citations]` blocks are allowed in a single document. All blocks share a unified numbering sequence. Each block renders at its own position in the document. This lets authors place citation definitions near the content that references them.
534
+ }
535
+
536
+ # Rendering @citation-rendering
537
+ {
538
+ {[.]
539
+ - In-text: `[@key]` renders as a superscript bracketed number linking to the entry (e.g. `[1]`)
540
+ - Citation list: each entry renders with its assigned number, the citation text, and a back-link (`\u21A9`) to the first location where it was cited
541
+ - Only the first occurrence of each `[@key]` in the text receives an anchor ID for the back-link
542
+ }
543
+ }
544
+
545
+ # Errors and Warnings @citation-errors
546
+ {
547
+ {[.]
548
+ - `[@key]` with no matching definition in any `{[citations]` block: **warning** (renders with a broken-reference indicator, same as a broken `@slug`)
549
+ - Citation defined but never referenced: **warning** (entry is still rendered, but the author is told it is unused)
550
+ - Invalid entry inside `{[citations]` (not matching `- @key text`): **parse error**
551
+ }
552
+ }
553
+ }
554
+
477
555
  # Links @links
478
556
  {
479
557
  Markdown-style links with absolute URLs or relative file paths:
@@ -578,9 +656,13 @@ Content of Section B.
578
656
  - Warning marker: `{!text!}` (orange highlight)
579
657
  - Negative marker: `{-text-}` (red highlight)
580
658
  - Highlight: `{~text~}` (yellow highlight)
659
+ - Citation reference: `[@key]` (numbered superscript link to citation entry)
660
+ - Multiple citation references: `[@key1, @key2]` (each individually linked)
581
661
  }
582
662
 
583
663
  Inline math requires non-whitespace immediately after the opening `$` and before the closing `$`. A plain `$` followed by a digit (e.g. `$100`) does not trigger math mode because there is no closing `$`.
664
+
665
+ Citation references (`[@key]`) are parsed before link syntax. `[@key]` is always a citation reference even if followed by `(url)`. Use `\[@key]` for a literal `[@key]` in text.
584
666
  }
585
667
 
586
668
  # Escaping @escaping
@@ -949,6 +1031,7 @@ Content of Section A.
949
1031
  - `}` scope close
950
1032
  - `{[.]` / `{[#]` list open
951
1033
  - `{[table]` / `{[table <flags>]` table open
1034
+ - `{[citations]` citations block open
952
1035
  - `>` blockquote line
953
1036
  - `---` / `***` / `___` horizontal rule
954
1037
  - `` ``` `` code fence
@@ -977,18 +1060,19 @@ Content of Section A.
977
1060
  ((ws id)? (ws scope_type)? | (ws scope_type)? (ws id)?) ws block_opener ;
978
1061
  id = "@" ident ;
979
1062
  scope_type = ":" ident ; (* id and scope_type may appear in either order *)
980
- block_opener = "{" | "{[.]" | "{[#]" | table_open ;
1063
+ block_opener = "{" | "{[.]" | "{[#]" | table_open | citations_open ;
981
1064
  block = "{" ws? block_body "}" ;
982
1065
  braceless_body = { paragraph | code_block | data_block | blockquote
983
1066
  | implicit_list | horizontal_rule | headingless_scope
984
- | list_scope | table_scope | bare_directive
985
- | line_comment | blank } ;
1067
+ | list_scope | table_scope | citations_scope
1068
+ | bare_directive | line_comment | blank } ;
986
1069
 
987
1070
  bare_directive = "@" ("meta" | "about") (ws block | braceless_body) ;
988
1071
  block_body = { blank | line_comment | paragraph | scope
989
1072
  | headingless_scope | list_scope | table_scope
990
- | implicit_list | blockquote | horizontal_rule
991
- | code_block | data_block | bare_directive | comma_sep } ;
1073
+ | citations_scope | implicit_list | blockquote
1074
+ | horizontal_rule | code_block | data_block
1075
+ | bare_directive | comma_sep } ;
992
1076
  headingless_scope = "{" ws? block_body "}" ;
993
1077
  list_scope = list_open ws? list_body "}" ;
994
1078
  list_open = "{[.]" | "{[#]" ;
@@ -1001,6 +1085,10 @@ Content of Section A.
1001
1085
  pixels = digit { digit } "px" ;
1002
1086
  table_body = table_row { table_row } ;
1003
1087
  table_row = cell { "|" cell } ;
1088
+ citations_scope = citations_open ws? citations_body "}" ;
1089
+ citations_open = "{[citations]" ;
1090
+ citations_body = { blank | citation_entry } ;
1091
+ citation_entry = "-" ws "@" ident ws text_line { continuation_line } ;
1004
1092
  list_body = { blank | comma_sep | scope | list_item_shorthand
1005
1093
  | anonymous_item } ;
1006
1094
  implicit_list = list_item_shorthand { list_item_shorthand } ;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@entropicwarrior/sdoc",
3
3
  "displayName": "SDOC - Docs for Human/Agent Teams",
4
4
  "description": "A plain-text documentation format with explicit brace scoping — deterministic parsing, AI-agent efficiency, and 10-50x token savings vs Markdown.",
5
- "version": "0.2.11",
5
+ "version": "0.2.12",
6
6
  "publisher": "entropicwarrior-msenfin",
7
7
  "license": "MIT",
8
8
  "repository": {
package/src/sdoc.js CHANGED
@@ -11,6 +11,7 @@ const COMMAND_SCOPE_CLOSE = "}";
11
11
  const COMMAND_LIST_BULLET = "{[.]";
12
12
  const COMMAND_LIST_NUMBER = "{[#]";
13
13
  const COMMAND_TABLE = "{[table]";
14
+ const COMMAND_CITATIONS = "{[citations]";
14
15
  const COMMAND_CODE_FENCE = "```";
15
16
 
16
17
  const ESCAPABLE = new Set(["\\", "{", "}", "@", "[", "]", "(", ")", "*", "`", "#", "!", "~", "<", ">", "$", "+", "=", "-", "^", "?", "|"]);
@@ -41,6 +42,10 @@ function isTableCommand(text) {
41
42
  return /^\{\[table(?:\s+[^\]]*?)?\]$/.test(text);
42
43
  }
43
44
 
45
+ function isCitationsCommand(text) {
46
+ return text === COMMAND_CITATIONS;
47
+ }
48
+
44
49
  function parseTableOptions(text) {
45
50
  const match = text.match(/^\{\[table(?:\s+(.*))?\]$/);
46
51
  if (!match) return {};
@@ -299,6 +304,12 @@ function parseBlock(cursor, kind) {
299
304
  continue;
300
305
  }
301
306
 
307
+ if (isCitationsCommand(trimmed)) {
308
+ flushParagraph();
309
+ nodes.push(parseCitationsBlock(cursor));
310
+ continue;
311
+ }
312
+
302
313
  if (trimmed === COMMAND_SCOPE_OPEN) {
303
314
  flushParagraph();
304
315
  const scopeStartLine = cursor.index + 1;
@@ -340,6 +351,13 @@ function extractTrailingOpener(text) {
340
351
  return { text: trimmed.slice(0, pos).trimEnd(), opener: tableMatch[0] };
341
352
  }
342
353
  }
354
+ // Check for citations command: {[citations]
355
+ if (trimmed.endsWith(COMMAND_CITATIONS)) {
356
+ const pos = trimmed.length - COMMAND_CITATIONS.length;
357
+ if (!(pos > 0 && trimmed[pos - 1] === "\\")) {
358
+ return { text: trimmed.slice(0, pos).trimEnd(), opener: COMMAND_CITATIONS };
359
+ }
360
+ }
343
361
  // Check other openers
344
362
  const openers = [COMMAND_LIST_NUMBER, COMMAND_LIST_BULLET, COMMAND_SCOPE_OPEN];
345
363
  for (const opener of openers) {
@@ -388,6 +406,8 @@ function parseScope(cursor) {
388
406
  } else if (isTableCommand(trailing.opener)) {
389
407
  const tableOpts = parseTableOptions(trailing.opener);
390
408
  children = [parseTableBody(cursor, scopeStartLine, tableOpts)];
409
+ } else if (isCitationsCommand(trailing.opener)) {
410
+ children = [parseCitationsBody(cursor, scopeStartLine)];
391
411
  } else {
392
412
  children = parseBlock(cursor, "normal");
393
413
  }
@@ -545,6 +565,12 @@ function parseBracelessBlock(cursor) {
545
565
  continue;
546
566
  }
547
567
 
568
+ if (isCitationsCommand(trimmed)) {
569
+ flushParagraph();
570
+ nodes.push(parseCitationsBlock(cursor));
571
+ continue;
572
+ }
573
+
548
574
  if (!paragraphLines.length) {
549
575
  paragraphStartLine = cursor.index + 1;
550
576
  }
@@ -594,6 +620,10 @@ function parseScopeBlock(cursor) {
594
620
  return { blockType: "normal", children: [parseTableBlock(cursor)] };
595
621
  }
596
622
 
623
+ if (isCitationsCommand(trimmed)) {
624
+ return { blockType: "normal", children: [parseCitationsBlock(cursor)] };
625
+ }
626
+
597
627
  // No block opener found — braceless scope
598
628
  return { blockType: "braceless" };
599
629
  }
@@ -689,6 +719,7 @@ function isListContinuationLine(trimmedLeft) {
689
719
  if (trimmed === COMMAND_LIST_BULLET) return false;
690
720
  if (trimmed === COMMAND_LIST_NUMBER) return false;
691
721
  if (isTableCommand(trimmed)) return false;
722
+ if (isCitationsCommand(trimmed)) return false;
692
723
  if (trimmed === ",") return false;
693
724
  if (isHeadingLine(trimmedLeft)) return false;
694
725
  if (isBlockquoteLine(trimmedLeft)) return false;
@@ -720,6 +751,8 @@ function parseListItemLine(cursor, info, allowContinuation = false) {
720
751
  } else if (isTableCommand(trailing.opener)) {
721
752
  const tableOpts = parseTableOptions(trailing.opener);
722
753
  children = [parseTableBody(cursor, itemStartLine, tableOpts)];
754
+ } else if (isCitationsCommand(trailing.opener)) {
755
+ children = [parseCitationsBody(cursor, itemStartLine)];
723
756
  } else {
724
757
  children = parseBlock(cursor, "normal");
725
758
  }
@@ -817,6 +850,63 @@ function parseTableBody(cursor, tableStartLine, options) {
817
850
  return tableNode;
818
851
  }
819
852
 
853
+ function parseCitationsBlock(cursor) {
854
+ const startLine = cursor.index + 1;
855
+ cursor.next();
856
+ return parseCitationsBody(cursor, startLine);
857
+ }
858
+
859
+ function parseCitationsBody(cursor, startLine) {
860
+ const entries = [];
861
+
862
+ while (!cursor.eof()) {
863
+ const line = cursor.current();
864
+ const trimmedLeft = line.replace(/^\s+/, "");
865
+ const trimmed = trimmedLeft.trim();
866
+
867
+ if (trimmed === "") {
868
+ cursor.next();
869
+ continue;
870
+ }
871
+
872
+ if (trimmed === COMMAND_SCOPE_CLOSE) {
873
+ cursor.next();
874
+ break;
875
+ }
876
+
877
+ // Citation items: - @key free-form text
878
+ const citationMatch = trimmed.match(/^-\s+@([A-Za-z_][A-Za-z0-9_-]*)\s+([\s\S]*)$/);
879
+ if (citationMatch) {
880
+ const entryStartLine = cursor.index + 1;
881
+ const key = citationMatch[1];
882
+ let text = citationMatch[2].trim();
883
+ cursor.next();
884
+
885
+ // Collect continuation lines (indented text not starting with - @key or })
886
+ while (!cursor.eof()) {
887
+ const nextLine = cursor.current();
888
+ const nextTrimmedLeft = nextLine.replace(/^\s+/, "");
889
+ const nextTrimmed = nextTrimmedLeft.trim();
890
+
891
+ if (nextTrimmed === "") break;
892
+ if (nextTrimmed === COMMAND_SCOPE_CLOSE) break;
893
+ if (/^-\s+@[A-Za-z_]/.test(nextTrimmed)) break;
894
+
895
+ text += " " + nextTrimmed;
896
+ cursor.next();
897
+ }
898
+
899
+ entries.push({ key, text, lineStart: entryStartLine, lineEnd: cursor.index });
900
+ continue;
901
+ }
902
+
903
+ cursor.error("Invalid citation entry (expected '- @key text').");
904
+ cursor.next();
905
+ }
906
+
907
+ return { type: "citations", entries, lineStart: startLine, lineEnd: cursor.index };
908
+ }
909
+
820
910
  function parseOptionalBlock(cursor) {
821
911
  while (!cursor.eof()) {
822
912
  const line = cursor.current();
@@ -1123,6 +1213,18 @@ function parseImageWidth(raw) {
1123
1213
  return { src: raw };
1124
1214
  }
1125
1215
 
1216
+ function parseCitationKeys(inner) {
1217
+ // Parse "@key1, @key2, ..." — returns array of keys or null if invalid
1218
+ const parts = inner.split(",");
1219
+ const keys = [];
1220
+ for (const part of parts) {
1221
+ const match = part.trim().match(/^@([A-Za-z_][A-Za-z0-9_-]*)$/);
1222
+ if (!match) return null;
1223
+ keys.push(match[1]);
1224
+ }
1225
+ return keys.length > 0 ? keys : null;
1226
+ }
1227
+
1126
1228
  function parseInline(text) {
1127
1229
  const nodes = [];
1128
1230
  let buffer = "";
@@ -1252,6 +1354,21 @@ function parseInline(text) {
1252
1354
  }
1253
1355
  }
1254
1356
 
1357
+ // Citation references: [@key] or [@key1, @key2]
1358
+ if (ch === "[" && text[i + 1] === "@") {
1359
+ const endBracket = findUnescaped(text, i + 1, "]");
1360
+ if (endBracket !== -1) {
1361
+ const inner = text.slice(i + 1, endBracket).trim();
1362
+ const keys = parseCitationKeys(inner);
1363
+ if (keys) {
1364
+ flush();
1365
+ nodes.push({ type: "citation_ref", keys });
1366
+ i = endBracket + 1;
1367
+ continue;
1368
+ }
1369
+ }
1370
+ }
1371
+
1255
1372
  if (ch === "[") {
1256
1373
  const endLabel = findUnescaped(text, i + 1, "]");
1257
1374
  if (endLabel !== -1 && text[endLabel + 1] === "(") {
@@ -1371,6 +1488,81 @@ function findUnescaped(text, start, token) {
1371
1488
 
1372
1489
  let _renderOptions = {};
1373
1490
 
1491
+ // --- Citation numbering ---
1492
+ // Built before rendering; maps citation key → { number, anchorId }
1493
+ // anchorId is the id of the first inline citation_ref for back-linking
1494
+ let _citationNumbering = new Map();
1495
+ let _citationDefinitions = new Map(); // key → { text, lineStart, lineEnd }
1496
+
1497
+ function buildCitationNumbering(nodes) {
1498
+ const numbering = new Map();
1499
+ const definitions = new Map();
1500
+ let counter = 0;
1501
+
1502
+ // First pass: collect all citation_ref keys in document order to assign numbers
1503
+ function walkInlineNodes(inlineNodes) {
1504
+ for (const node of inlineNodes) {
1505
+ if (node.type === "citation_ref") {
1506
+ for (const key of node.keys) {
1507
+ if (!numbering.has(key)) {
1508
+ counter += 1;
1509
+ numbering.set(key, { number: counter, anchorId: `citeref-${key}` });
1510
+ }
1511
+ }
1512
+ }
1513
+ if (node.children) walkInlineNodes(node.children);
1514
+ }
1515
+ }
1516
+
1517
+ function walkInlineText(text) {
1518
+ walkInlineNodes(parseInline(text));
1519
+ }
1520
+
1521
+ function walk(nodeList) {
1522
+ for (const node of nodeList) {
1523
+ if (node.type === "paragraph" && node.text) {
1524
+ walkInlineText(node.text);
1525
+ } else if (node.type === "blockquote" && node.paragraphs) {
1526
+ for (const para of node.paragraphs) {
1527
+ walkInlineText(para);
1528
+ }
1529
+ } else if (node.type === "scope") {
1530
+ if (node.title) walkInlineText(node.title);
1531
+ if (node.children) walk(node.children);
1532
+ } else if (node.type === "list" && node.items) {
1533
+ walk(node.items);
1534
+ } else if (node.type === "table") {
1535
+ if (node.headers) {
1536
+ for (const cell of node.headers) walkInlineText(cell);
1537
+ }
1538
+ if (node.rows) {
1539
+ for (const row of node.rows) {
1540
+ for (const cell of row) walkInlineText(cell);
1541
+ }
1542
+ }
1543
+ } else if (node.type === "citations") {
1544
+ // Collect definitions
1545
+ for (const entry of node.entries) {
1546
+ if (!definitions.has(entry.key)) {
1547
+ definitions.set(entry.key, { text: entry.text, lineStart: entry.lineStart, lineEnd: entry.lineEnd });
1548
+ }
1549
+ }
1550
+ }
1551
+ }
1552
+ }
1553
+ walk(nodes);
1554
+
1555
+ // Assign numbers to defined-but-unreferenced citations (appended after referenced ones)
1556
+ for (const [key, def] of definitions) {
1557
+ if (!numbering.has(key)) {
1558
+ counter += 1;
1559
+ numbering.set(key, { number: counter, anchorId: null });
1560
+ }
1561
+ }
1562
+
1563
+ return { numbering, definitions };
1564
+ }
1565
+
1374
1566
  function dataLineAttrs(node) {
1375
1567
  if (node.lineStart == null) {
1376
1568
  return "";
@@ -1441,6 +1633,24 @@ function renderInlineNodes(nodes) {
1441
1633
  }
1442
1634
  return `<a class="sdoc-ref" href="${href}">@${escapeHtml(node.id)}</a>`;
1443
1635
  }
1636
+ case "citation_ref": {
1637
+ if (!_renderOptions._citationRefSeen) _renderOptions._citationRefSeen = new Set();
1638
+ const parts = node.keys.map((key) => {
1639
+ const info = _citationNumbering.get(key);
1640
+ const isBroken = !_citationDefinitions.has(key);
1641
+ if (isBroken) {
1642
+ // Undefined citation — render with warning
1643
+ const num = info ? info.number : escapeHtml(key);
1644
+ return `<a class="sdoc-citation-ref sdoc-broken-ref" href="#cite-${escapeAttr(key)}"><span class="sdoc-broken-icon">\u26A0</span>${num}</a>`;
1645
+ }
1646
+ // Only the first occurrence of each key gets the back-link anchor id
1647
+ const isFirst = !_renderOptions._citationRefSeen.has(key);
1648
+ if (isFirst) _renderOptions._citationRefSeen.add(key);
1649
+ const idAttr = isFirst ? ` id="citeref-${escapeAttr(key)}"` : "";
1650
+ return `<a class="sdoc-citation-ref"${idAttr} href="#cite-${escapeAttr(key)}">${info.number}</a>`;
1651
+ });
1652
+ return `<sup class="sdoc-citation-group">[${parts.join(", ")}]</sup>`;
1653
+ }
1444
1654
  case "code":
1445
1655
  return `<code class="sdoc-inline-code">${escapeHtml(node.value)}</code>`;
1446
1656
  case "em":
@@ -1517,6 +1727,33 @@ function renderScope(scope, depth, isTitleScope = false) {
1517
1727
  return `<section class="sdoc-scope${rootClass}${typeClass}"${typeAttr}>${heading}${childrenHtml}</section>`;
1518
1728
  }
1519
1729
 
1730
+ function renderCitations(node) {
1731
+ const dl = dataLineAttrs(node);
1732
+
1733
+ // Build entries sorted by assigned citation number
1734
+ const sorted = [];
1735
+ for (const entry of node.entries) {
1736
+ const info = _citationNumbering.get(entry.key);
1737
+ if (info) {
1738
+ sorted.push({ key: entry.key, text: entry.text, number: info.number, anchorId: info.anchorId });
1739
+ } else {
1740
+ // Defined but no number (shouldn't happen if buildCitationNumbering ran, but be safe)
1741
+ sorted.push({ key: entry.key, text: entry.text, number: Infinity, anchorId: null });
1742
+ }
1743
+ }
1744
+ sorted.sort((a, b) => a.number - b.number);
1745
+
1746
+ const items = sorted.map((entry) => {
1747
+ const backLink = entry.anchorId
1748
+ ? ` <a class="sdoc-citation-backlink" href="#${escapeAttr(entry.anchorId)}" title="Back to text">\u21A9</a>`
1749
+ : "";
1750
+ const unreferenced = entry.anchorId === null ? " sdoc-citation-unreferenced" : "";
1751
+ return `<li id="cite-${escapeAttr(entry.key)}" class="sdoc-citation-entry${unreferenced}" value="${entry.number}"><span class="sdoc-citation-text">${renderInline(entry.text)}</span>${backLink}</li>`;
1752
+ }).join("\n");
1753
+
1754
+ return `<ol class="sdoc-citations"${dl}>${items}</ol>`;
1755
+ }
1756
+
1520
1757
  function renderList(list, depth) {
1521
1758
  return renderListFromItems(list.listType, list.items, depth, list);
1522
1759
  }
@@ -1955,6 +2192,8 @@ function renderNode(node, depth) {
1955
2192
  const editable = _renderOptions.editable ? ` contenteditable="true"` : "";
1956
2193
  return `<p class="sdoc-paragraph"${dl}${editable}>${renderInline(node.text)}</p>`;
1957
2194
  }
2195
+ case "citations":
2196
+ return renderCitations(node);
1958
2197
  case "code": {
1959
2198
  if (node.lang === "mermaid") {
1960
2199
  return `<pre class="mermaid"${dl}>${escapeHtml(node.text)}</pre>`;
@@ -2392,6 +2631,62 @@ const DEFAULT_STYLE = `
2392
2631
  margin-right: 0.15em;
2393
2632
  }
2394
2633
 
2634
+ .sdoc-citation-group {
2635
+ font-size: 0.8em;
2636
+ line-height: 1;
2637
+ vertical-align: super;
2638
+ }
2639
+
2640
+ .sdoc-citation-ref {
2641
+ color: var(--sdoc-accent);
2642
+ text-decoration: none;
2643
+ cursor: pointer;
2644
+ }
2645
+
2646
+ .sdoc-citation-ref:hover {
2647
+ text-decoration: underline;
2648
+ }
2649
+
2650
+ .sdoc-citations {
2651
+ margin: 1.5rem 0;
2652
+ padding: 1rem 0 0.5rem 0;
2653
+ border-top: 1px solid var(--sdoc-border);
2654
+ list-style: none;
2655
+ counter-reset: none;
2656
+ }
2657
+
2658
+ .sdoc-citation-entry {
2659
+ margin: 0.4rem 0;
2660
+ padding-left: 2.5rem;
2661
+ position: relative;
2662
+ line-height: 1.6;
2663
+ font-size: 0.92em;
2664
+ }
2665
+
2666
+ .sdoc-citation-entry::before {
2667
+ content: "[" attr(value) "]";
2668
+ position: absolute;
2669
+ left: 0;
2670
+ color: var(--sdoc-muted);
2671
+ font-weight: 600;
2672
+ font-size: 0.9em;
2673
+ }
2674
+
2675
+ .sdoc-citation-unreferenced {
2676
+ opacity: 0.6;
2677
+ }
2678
+
2679
+ .sdoc-citation-backlink {
2680
+ color: var(--sdoc-accent);
2681
+ text-decoration: none;
2682
+ margin-left: 0.3em;
2683
+ font-size: 0.85em;
2684
+ }
2685
+
2686
+ .sdoc-citation-backlink:hover {
2687
+ text-decoration: underline;
2688
+ }
2689
+
2395
2690
  .sdoc-image {
2396
2691
  display: inline-block;
2397
2692
  max-width: 100%;
@@ -2592,7 +2887,7 @@ hljs.COMMENT('^\\\\s*//','$'),
2592
2887
  {className:'link',begin:'\\\\(',end:'\\\\)'}
2593
2888
  ]},
2594
2889
  {className:'keyword',begin:'\\\\{[!?+\\\\-=~^]',end:'[!?+\\\\-=~^]\\\\}'},
2595
- {className:'keyword',begin:'\\\\{\\\\[(?:\\\\.|#|\\\\d+|table)\\\\]'},
2890
+ {className:'keyword',begin:'\\\\{\\\\[(?:\\\\.|#|\\\\d+|table|citations)\\\\]'},
2596
2891
  {className:'strong',begin:'\\\\*\\\\*',end:'\\\\*\\\\*'},
2597
2892
  {className:'emphasis',begin:'(?<!\\\\*)\\\\*(?!\\\\*)',end:'\\\\*(?!\\\\*)'},
2598
2893
  {className:'deletion',begin:'~~',end:'~~'},
@@ -2648,12 +2943,26 @@ function renderBodyNodes(nodes) {
2648
2943
  function renderHtmlBody(text) {
2649
2944
  const parsed = parseSdoc(text);
2650
2945
  const metaResult = extractMeta(parsed.nodes);
2651
- return renderBodyNodes(metaResult.nodes);
2946
+ const savedOptions = _renderOptions;
2947
+ _renderOptions = {};
2948
+ const citationData = buildCitationNumbering(metaResult.nodes);
2949
+ _citationNumbering = citationData.numbering;
2950
+ _citationDefinitions = citationData.definitions;
2951
+ const result = renderBodyNodes(metaResult.nodes);
2952
+ _citationNumbering = new Map();
2953
+ _citationDefinitions = new Map();
2954
+ _renderOptions = savedOptions;
2955
+ return result;
2652
2956
  }
2653
2957
 
2654
2958
  function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
2655
2959
  _renderOptions = options.renderOptions ?? {};
2960
+ const citationData = buildCitationNumbering(parsed.nodes);
2961
+ _citationNumbering = citationData.numbering;
2962
+ _citationDefinitions = citationData.definitions;
2656
2963
  const body = renderBodyNodes(parsed.nodes);
2964
+ _citationNumbering = new Map();
2965
+ _citationDefinitions = new Map();
2657
2966
  _renderOptions = {};
2658
2967
  const errorHtml = renderErrors(parsed.errors);
2659
2968
 
@@ -2782,11 +3091,12 @@ function formatSdoc(text, indentStr = " ") {
2782
3091
  continue;
2783
3092
  }
2784
3093
 
2785
- // Standalone opener: {, {[.], {[#], {[table]
3094
+ // Standalone opener: {, {[.], {[#], {[table], {[citations]
2786
3095
  if (trimmed === COMMAND_SCOPE_OPEN ||
2787
3096
  trimmed === COMMAND_LIST_BULLET ||
2788
3097
  trimmed === COMMAND_LIST_NUMBER ||
2789
- isTableCommand(trimmed)) {
3098
+ isTableCommand(trimmed) ||
3099
+ isCitationsCommand(trimmed)) {
2790
3100
  result.push(indentStr.repeat(depth) + trimmed);
2791
3101
  depth++;
2792
3102
  continue;
@@ -3017,6 +3327,7 @@ function collectAllIds(nodes) {
3017
3327
  function collectInlineRefs(nodes) {
3018
3328
  const refs = [];
3019
3329
  const links = [];
3330
+ const citationRefs = [];
3020
3331
 
3021
3332
  function walkInlineNodes(inlineNodes, lineStart, lineEnd) {
3022
3333
  for (const node of inlineNodes) {
@@ -3024,6 +3335,10 @@ function collectInlineRefs(nodes) {
3024
3335
  refs.push({ id: node.id, lineStart, lineEnd });
3025
3336
  } else if (node.type === "link") {
3026
3337
  links.push({ href: node.href, lineStart, lineEnd });
3338
+ } else if (node.type === "citation_ref") {
3339
+ for (const key of node.keys) {
3340
+ citationRefs.push({ key, lineStart, lineEnd });
3341
+ }
3027
3342
  }
3028
3343
  if (node.children) {
3029
3344
  walkInlineNodes(node.children, lineStart, lineEnd);
@@ -3073,7 +3388,24 @@ function collectInlineRefs(nodes) {
3073
3388
  }
3074
3389
  }
3075
3390
  walk(nodes);
3076
- return { refs, links };
3391
+ return { refs, links, citationRefs };
3392
+ }
3393
+
3394
+ function collectCitationDefinitions(nodes) {
3395
+ const defs = [];
3396
+ function walk(nodeList) {
3397
+ for (const node of nodeList) {
3398
+ if (node.type === "citations") {
3399
+ for (const entry of node.entries) {
3400
+ defs.push({ key: entry.key, lineStart: entry.lineStart, lineEnd: entry.lineEnd });
3401
+ }
3402
+ }
3403
+ if (node.children) walk(node.children);
3404
+ if (node.type === "list" && node.items) walk(node.items);
3405
+ }
3406
+ }
3407
+ walk(nodes);
3408
+ return defs;
3077
3409
  }
3078
3410
 
3079
3411
  function validateRefs(nodes, options = {}) {
@@ -3117,6 +3449,43 @@ function validateRefs(nodes, options = {}) {
3117
3449
  return warnings;
3118
3450
  }
3119
3451
 
3452
+ function validateCitations(nodes) {
3453
+ const { citationRefs } = collectInlineRefs(nodes);
3454
+ const defs = collectCitationDefinitions(nodes);
3455
+ const warnings = [];
3456
+
3457
+ const definedKeys = new Set(defs.map((d) => d.key));
3458
+ const referencedKeys = new Set(citationRefs.map((r) => r.key));
3459
+
3460
+ // Citation referenced but not defined
3461
+ for (const ref of citationRefs) {
3462
+ if (!definedKeys.has(ref.key)) {
3463
+ warnings.push({
3464
+ type: "broken-citation",
3465
+ key: ref.key,
3466
+ message: `Broken citation: [@${ref.key}] is not defined in any {[citations] block`,
3467
+ lineStart: ref.lineStart,
3468
+ lineEnd: ref.lineEnd
3469
+ });
3470
+ }
3471
+ }
3472
+
3473
+ // Citation defined but never referenced
3474
+ for (const def of defs) {
3475
+ if (!referencedKeys.has(def.key)) {
3476
+ warnings.push({
3477
+ type: "unused-citation",
3478
+ key: def.key,
3479
+ message: `Unused citation: @${def.key} is defined but never referenced with [@${def.key}]`,
3480
+ lineStart: def.lineStart,
3481
+ lineEnd: def.lineEnd
3482
+ });
3483
+ }
3484
+ }
3485
+
3486
+ return warnings;
3487
+ }
3488
+
3120
3489
  async function resolveIncludes(nodes, resolverFn) {
3121
3490
  for (const node of nodes) {
3122
3491
  if (node.type === "code" && node.src) {
@@ -3167,7 +3536,9 @@ module.exports = {
3167
3536
  // Validation
3168
3537
  collectAllIds,
3169
3538
  collectInlineRefs,
3539
+ collectCitationDefinitions,
3170
3540
  validateRefs,
3541
+ validateCitations,
3171
3542
  // Low-level helpers for custom renderers (e.g. slide-renderer)
3172
3543
  parseInline,
3173
3544
  renderKatex,