@entropicwarrior/sdoc 0.2.11 → 0.2.13

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.
@@ -275,6 +275,66 @@ Content of Section B.
275
275
  }
276
276
  ```
277
277
 
278
+ # Column Directives @column-directives
279
+ {
280
+ An optional directive row after the header controls per-column alignment and number formatting. It is consumed by the parser and not rendered as data.
281
+
282
+ # Column Alignment
283
+ {
284
+ Use \`<\` (left), \`>\` (right), or \`=\` (center):
285
+
286
+ ```
287
+ {[table]
288
+ Programme | Amount | Status
289
+ < | > | =
290
+ Alpha | $1,000,000 | Active
291
+ Beta | $2,500,000 | Pending
292
+ }
293
+ ```
294
+
295
+ If absent, all columns default to left. Empty cells in the directive row keep the default. Alignment applies to both header and data cells.
296
+
297
+ For headerless tables, the directive row is the first row — it is consumed but no header is rendered:
298
+
299
+ ```
300
+ {[table headerless]
301
+ < | >
302
+ Alice | 100
303
+ Bob | 200
304
+ }
305
+ ```
306
+ }
307
+
308
+ # Column Formatting
309
+ {
310
+ A format token in the directive row formats numeric cells and formula results in that column. Text cells (like bold labels) pass through unchanged. The format is display-only — formulas always use raw numeric values.
311
+
312
+ {[.]
313
+ - \`$\` — currency, no decimals: \`$1,000,000\`
314
+ - \`$.N\` — currency with N decimals: \`$.2\` → \`$1,234.50\`
315
+ - \`,\` — thousands separator: \`1,000,000\`
316
+ - \`,.N\` — thousands with N decimals: \`,.2\` → \`1,234,567.89\`
317
+ - \`.N\` — fixed N decimals: \`.2\` → \`3.14\`
318
+ - \`%\` — percentage (value × 100): \`0.452\` → \`45.2%\`
319
+ - \`%.N\` — percentage with N decimals: \`%.1\` → \`45.7%\`
320
+ }
321
+
322
+ Alignment and format combine in a single directive cell:
323
+
324
+ ```
325
+ {[table]
326
+ Item | Amount
327
+ < | > $
328
+ Widget | 1000000
329
+ Gadget | 2500000
330
+ **Total** | =SUM(B1:B2)
331
+ }
332
+ ```
333
+
334
+ The \`Amount\` column is right-aligned and formatted as currency. The formula result and data cells all display as \`$1,000,000\` style. The \`**Total**\` label renders as bold text.
335
+ }
336
+ }
337
+
278
338
  # Table Formulas @table-formulas
279
339
  {
280
340
  Cells starting with \`=\` are evaluated as formulas. Hover a computed cell in preview to see the original formula.
@@ -282,9 +342,10 @@ Content of Section B.
282
342
  ```
283
343
  {[table]
284
344
  Investor | Shares | Ownership
285
- Seed Fund | 500,000 | 25%
286
- Founder A | 1,000,000 | 50%
287
- Founder B | 500,000 | 25%
345
+ < | > , | > %
346
+ Seed Fund | 500000 | 0.25
347
+ Founder A | 1000000 | 0.50
348
+ Founder B | 500000 | 0.25
288
349
  **Total** | =SUM(B1:B3) | =SUM(C1:C3)
289
350
  }
290
351
  ```
@@ -301,6 +362,8 @@ Content of Section B.
301
362
  }
302
363
 
303
364
  Errors display in red italic: \`#DIV/0!\` (division by zero), \`#VALUE!\` (bad reference), \`#REF!\` (invalid syntax), \`#NAME!\` (unknown function), \`#CIRCULAR!\` (circular dependency).
365
+
366
+ **Tip:** Use numeric values with a column format directive (e.g. \`> $\`) instead of pre-formatted strings like \`$1,000,000\`. Formatted strings are text — formulas cannot reference them.
304
367
  }
305
368
  }
306
369
 
@@ -365,6 +428,39 @@ Content of Section B.
365
428
  Do not write \`@setup\` when the target scope is in a different file — it will be flagged as a broken reference.
366
429
  }
367
430
 
431
+ # Citations @citations
432
+ {
433
+ 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.
434
+
435
+ ```
436
+ # Findings {
437
+ Edge AI is growing rapidly [@mm-edge-ai]. The chip market
438
+ is expected to double by 2031 [@mordor-chips].
439
+
440
+ {[citations]
441
+ - @mm-edge-ai MarketsandMarkets, "Edge AI Hardware
442
+ Market," Report SE 7017, June 2025.
443
+ - @mordor-chips Mordor Intelligence, "Edge AI Chips
444
+ Market Size & Share Analysis," 2026.
445
+ }
446
+ }
447
+ ```
448
+
449
+ {[.]
450
+ - \`[@key]\` renders as a clickable superscript number (e.g. \`[1]\`) linking to the citation entry
451
+ - \`[@key1, @key2]\` renders as \`[1, 3]\` with each number individually linked
452
+ - The same \`[@key]\` used multiple times always renders the same number
453
+ - Keys follow \`@slug\` conventions: lowercase kebab-case
454
+ - Citation text is free-form SDOC inline content — links, bold, italics all work
455
+ - Each citation entry includes a back-link (\`\u21A9\`) to the first place it was cited
456
+ - Numbering is by order of first \`[@key]\` appearance, not by definition order
457
+ - Multiple \`{[citations]\` blocks are allowed — numbering is unified across all blocks
458
+ - A \`[@key]\` with no matching definition is flagged as a broken citation
459
+ - A defined citation that is never referenced produces a warning
460
+ - Citation keys are document-local, like \`@slug\` references
461
+ }
462
+ }
463
+
368
464
  # Code Blocks @code-blocks
369
465
  {
370
466
  Fenced with triple backticks. Content inside is raw (no parsing):
@@ -766,5 +862,19 @@ Content of Section B.
766
862
 
767
863
  **Also right:** \`[See domain-model.sdoc § my-section](./domain-model.sdoc#my-section)\`
768
864
  }
865
+
866
+ # [@key] vs @key @citation-vs-ref
867
+ {
868
+ \`[@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.
869
+
870
+ Confusing them will produce errors:
871
+
872
+ {[.]
873
+ - Writing \`@smith2020\` when you mean \`[@smith2020]\` creates a broken section reference (there is no heading with ID \`smith2020\`)
874
+ - Writing \`[@setup]\` when you mean \`@setup\` creates a broken citation (there is no citation definition for \`setup\`)
875
+ }
876
+
877
+ Rule of thumb: square brackets mean citation, bare \`@\` means section.
878
+ }
769
879
  }
770
880
  }
@@ -380,6 +380,69 @@ Content of Section B.
380
380
  ```
381
381
  }
382
382
 
383
+ # Column Directives @column-directives
384
+ {
385
+ An optional directive row immediately after the header row (or as the first row in a headerless table) controls per-column alignment and number formatting. The directive row is consumed by the parser and never rendered as data.
386
+
387
+ # Column Alignment @column-alignment
388
+ {
389
+ Alignment characters in the directive row set the text-align for all cells in that column, including the header:
390
+
391
+ {[.]
392
+ - `<` — left (default)
393
+ - `>` — right (most useful for numbers and currency)
394
+ - `=` — center
395
+ }
396
+
397
+ ```
398
+ {[table]
399
+ Programme | Amount | Status
400
+ < | > | =
401
+ Alpha | $1,000,000 | Active
402
+ Beta | $2,500,000 | Pending
403
+ }
404
+ ```
405
+
406
+ If the directive row is absent, all columns default to left alignment. Empty cells in the directive row inherit the default (left).
407
+ }
408
+
409
+ # Column Formatting @column-formatting
410
+ {
411
+ A format token in the directive row causes numeric cells and formula results in that column to be formatted for display. Text cells are unaffected. The format is display-only — formulas always operate on raw numeric values.
412
+
413
+ {[.]
414
+ - `$` — currency, no decimals: `1000000` → `$1,000,000`
415
+ - `$.N` — currency with N decimals: `$.2` → `$1,234.50`
416
+ - `,` — thousands separator, no decimals: `1000000` → `1,000,000`
417
+ - `,.N` — thousands with N decimals: `,.2` → `1,234,567.89`
418
+ - `.N` — fixed N decimals: `.2` → `3.14`
419
+ - `%` — percentage (value × 100, auto decimals): `0.452` → `45.2%`
420
+ - `%.N` — percentage with N decimals: `%.1` → `45.7%`
421
+ }
422
+
423
+ A directive cell may combine alignment and format, separated by whitespace:
424
+
425
+ ```
426
+ {[table]
427
+ Item | Amount
428
+ < | > $
429
+ Widget | 1000000
430
+ Gadget | 2500000
431
+ **Total** | =SUM(B1:B2)
432
+ }
433
+ ```
434
+
435
+ All numeric values and formula results in the `Amount` column display as currency (`$3,500,000`). The `**Total**` label renders normally as bold text.
436
+ }
437
+
438
+ # Directive Row Detection @directive-detection
439
+ {
440
+ A row is identified as a directive row when every non-empty cell contains only an alignment character (`<`, `>`, `=`), a format token, or both. It must be the first row after the header (or the first row in a headerless table), and at least one cell must contain a directive.
441
+
442
+ For headerless tables, the directive row functions as a virtual header — it is consumed for its directives but no `<thead>` is rendered.
443
+ }
444
+ }
445
+
383
446
  # Table Formulas @table-formulas
384
447
  {
385
448
  Table cells whose text begins with `=` (but not `==`) are evaluated as formulas. The result replaces the cell text in rendered output; the original formula is preserved as a tooltip.
@@ -474,6 +537,84 @@ Content of Section B.
474
537
  }
475
538
  }
476
539
 
540
+ # Citations @citations
541
+ {
542
+ Citations provide stable, auto-numbered source references. An inline citation reference uses `[@key]`; citation definitions go in a `{[citations]` block.
543
+
544
+ # Inline Citation References @citation-refs
545
+ {
546
+ ```
547
+ The market is growing rapidly [@mm-report].
548
+ Multiple sources agree [@mm-report, @mordor-analysis].
549
+ ```
550
+
551
+ {[.]
552
+ - `[@key]` inserts a citation reference that renders as a clickable superscript number (e.g. `[1]`) linking to the corresponding entry in the `{[citations]` block
553
+ - `[@key1, @key2]` renders as `[1, 3]` (or whatever the assigned numbers are), with each number individually linked
554
+ - Whitespace around commas is optional and ignored
555
+ - Keys follow the existing `@slug` convention: lowercase kebab-case
556
+ - `[@key]` is visually and semantically distinct from `@slug` — the former is a citation, the latter is a section reference
557
+ - The same `[@key]` used multiple times always renders the same number
558
+ - Citation keys are document-local, like section references
559
+ }
560
+ }
561
+
562
+ # Citation Definition Block @citation-block
563
+ {
564
+ ```
565
+ {[citations]
566
+ - @mm-report MarketsandMarkets, "Edge AI Hardware Market,"
567
+ Report SE 7017, June 2025.
568
+ - @mordor-analysis Mordor Intelligence, "Edge AI Chips Market
569
+ Size & Share Analysis," 2026.
570
+ }
571
+ ```
572
+
573
+ {[.]
574
+ - `{[citations]` opens a citation definition block (no closing brace on the same line)
575
+ - Each citation is a list item with a `- @key` prefix followed by free-form inline content
576
+ - Citation text supports full SDOC inline formatting: links, bold, italics, inline code
577
+ - There are no structured bibliographic fields — the author formats the citation however they want
578
+ - Item text can span multiple continuation lines (same rules as list item continuation)
579
+ - The closing `}` must be on its own line
580
+ - Works with K&R brace style: `# References {[citations]`
581
+ }
582
+ }
583
+
584
+ # Numbering @citation-numbering
585
+ {
586
+ {[.]
587
+ - Numbers are assigned by order of first appearance of `[@key]` in the document text, starting at 1
588
+ - The `{[citations]` block renders entries sorted by assigned number, regardless of definition order
589
+ - Numbers always increase monotonically as the reader moves through the document
590
+ - Defined but unreferenced citations are appended after all referenced entries
591
+ }
592
+ }
593
+
594
+ # Multiple Blocks @citation-multiple-blocks
595
+ {
596
+ 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.
597
+ }
598
+
599
+ # Rendering @citation-rendering
600
+ {
601
+ {[.]
602
+ - In-text: `[@key]` renders as a superscript bracketed number linking to the entry (e.g. `[1]`)
603
+ - 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
604
+ - Only the first occurrence of each `[@key]` in the text receives an anchor ID for the back-link
605
+ }
606
+ }
607
+
608
+ # Errors and Warnings @citation-errors
609
+ {
610
+ {[.]
611
+ - `[@key]` with no matching definition in any `{[citations]` block: **warning** (renders with a broken-reference indicator, same as a broken `@slug`)
612
+ - Citation defined but never referenced: **warning** (entry is still rendered, but the author is told it is unused)
613
+ - Invalid entry inside `{[citations]` (not matching `- @key text`): **parse error**
614
+ }
615
+ }
616
+ }
617
+
477
618
  # Links @links
478
619
  {
479
620
  Markdown-style links with absolute URLs or relative file paths:
@@ -578,9 +719,13 @@ Content of Section B.
578
719
  - Warning marker: `{!text!}` (orange highlight)
579
720
  - Negative marker: `{-text-}` (red highlight)
580
721
  - Highlight: `{~text~}` (yellow highlight)
722
+ - Citation reference: `[@key]` (numbered superscript link to citation entry)
723
+ - Multiple citation references: `[@key1, @key2]` (each individually linked)
581
724
  }
582
725
 
583
726
  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 `$`.
727
+
728
+ 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
729
  }
585
730
 
586
731
  # Escaping @escaping
@@ -949,6 +1094,7 @@ Content of Section A.
949
1094
  - `}` scope close
950
1095
  - `{[.]` / `{[#]` list open
951
1096
  - `{[table]` / `{[table <flags>]` table open
1097
+ - `{[citations]` citations block open
952
1098
  - `>` blockquote line
953
1099
  - `---` / `***` / `___` horizontal rule
954
1100
  - `` ``` `` code fence
@@ -977,18 +1123,19 @@ Content of Section A.
977
1123
  ((ws id)? (ws scope_type)? | (ws scope_type)? (ws id)?) ws block_opener ;
978
1124
  id = "@" ident ;
979
1125
  scope_type = ":" ident ; (* id and scope_type may appear in either order *)
980
- block_opener = "{" | "{[.]" | "{[#]" | table_open ;
1126
+ block_opener = "{" | "{[.]" | "{[#]" | table_open | citations_open ;
981
1127
  block = "{" ws? block_body "}" ;
982
1128
  braceless_body = { paragraph | code_block | data_block | blockquote
983
1129
  | implicit_list | horizontal_rule | headingless_scope
984
- | list_scope | table_scope | bare_directive
985
- | line_comment | blank } ;
1130
+ | list_scope | table_scope | citations_scope
1131
+ | bare_directive | line_comment | blank } ;
986
1132
 
987
1133
  bare_directive = "@" ("meta" | "about") (ws block | braceless_body) ;
988
1134
  block_body = { blank | line_comment | paragraph | scope
989
1135
  | headingless_scope | list_scope | table_scope
990
- | implicit_list | blockquote | horizontal_rule
991
- | code_block | data_block | bare_directive | comma_sep } ;
1136
+ | citations_scope | implicit_list | blockquote
1137
+ | horizontal_rule | code_block | data_block
1138
+ | bare_directive | comma_sep } ;
992
1139
  headingless_scope = "{" ws? block_body "}" ;
993
1140
  list_scope = list_open ws? list_body "}" ;
994
1141
  list_open = "{[.]" | "{[#]" ;
@@ -999,8 +1146,19 @@ Content of Section A.
999
1146
  | "left" | "center" | "right" ;
1000
1147
  percentage = digit { digit } [ "." digit { digit } ] "%" ;
1001
1148
  pixels = digit { digit } "px" ;
1002
- table_body = table_row { table_row } ;
1149
+ table_body = [ directive_row ] table_row { table_row } ;
1150
+ directive_row = directive_cell { "|" directive_cell } ;
1151
+ directive_cell = [ align_char ] [ ws format_spec ] ;
1152
+ align_char = "<" | ">" | "=" ;
1153
+ format_spec = "$" [ "." digits ]
1154
+ | "," [ "." digits ]
1155
+ | "." digits
1156
+ | "%" [ "." digits ] ;
1003
1157
  table_row = cell { "|" cell } ;
1158
+ citations_scope = citations_open ws? citations_body "}" ;
1159
+ citations_open = "{[citations]" ;
1160
+ citations_body = { blank | citation_entry } ;
1161
+ citation_entry = "-" ws "@" ident ws text_line { continuation_line } ;
1004
1162
  list_body = { blank | comma_sep | scope | list_item_shorthand
1005
1163
  | anonymous_item } ;
1006
1164
  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.13",
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 {};
@@ -58,6 +63,105 @@ function parseTableOptions(text) {
58
63
  return options;
59
64
  }
60
65
 
66
+ // ── Column directive row (alignment + format) ──────────────────────────
67
+
68
+ function isDirectiveRow(cells) {
69
+ if (cells.length === 0) return false;
70
+ const pattern = /^([<>=])?\s*(\$(?:\.\d+)?|,(?:\.\d+)?|\.\d+|%(?:\.\d+)?)?$/;
71
+ let hasDirective = false;
72
+ for (const cell of cells) {
73
+ const trimmed = cell.trim();
74
+ if (trimmed === "") continue;
75
+ if (!pattern.test(trimmed)) return false;
76
+ hasDirective = true;
77
+ }
78
+ return hasDirective;
79
+ }
80
+
81
+ function parseFormatSpec(spec) {
82
+ if (!spec) return null;
83
+ if (spec.startsWith("$")) {
84
+ const decimals = spec.includes(".") ? parseInt(spec.slice(spec.indexOf(".") + 1), 10) : 0;
85
+ return { prefix: "$", thousands: true, decimals, percent: false };
86
+ }
87
+ if (spec.startsWith("%")) {
88
+ const decimals = spec.includes(".") ? parseInt(spec.slice(spec.indexOf(".") + 1), 10) : -1;
89
+ return { prefix: "", thousands: false, decimals, percent: true };
90
+ }
91
+ if (spec.startsWith(",")) {
92
+ const decimals = spec.includes(".") ? parseInt(spec.slice(spec.indexOf(".") + 1), 10) : 0;
93
+ return { prefix: "", thousands: true, decimals, percent: false };
94
+ }
95
+ if (spec.startsWith(".")) {
96
+ const decimals = parseInt(spec.slice(1), 10);
97
+ return { prefix: "", thousands: false, decimals, percent: false };
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function parseDirectiveRow(cells) {
103
+ const align = [];
104
+ const format = [];
105
+ let hasAlign = false;
106
+ let hasFormat = false;
107
+ const fmtPattern = /(\$(?:\.\d+)?|,(?:\.\d+)?|\.\d+|%(?:\.\d+)?)$/;
108
+
109
+ for (const cell of cells) {
110
+ const trimmed = cell.trim();
111
+ const alignMatch = trimmed.match(/^([<>=])/);
112
+ const fmtMatch = trimmed.match(fmtPattern);
113
+
114
+ let a = null;
115
+ if (alignMatch) {
116
+ a = alignMatch[1] === "<" ? "left" : alignMatch[1] === ">" ? "right" : "center";
117
+ hasAlign = true;
118
+ }
119
+ align.push(a);
120
+
121
+ let f = null;
122
+ if (fmtMatch) {
123
+ f = parseFormatSpec(fmtMatch[1]);
124
+ hasFormat = true;
125
+ }
126
+ format.push(f);
127
+ }
128
+
129
+ return {
130
+ align: hasAlign ? align : null,
131
+ format: hasFormat ? format : null,
132
+ };
133
+ }
134
+
135
+ function formatNumber(value, spec) {
136
+ if (spec.percent) {
137
+ const pct = value * 100;
138
+ if (spec.decimals < 0) {
139
+ return (Number.isInteger(pct) ? pct.toString() : pct.toFixed(2).replace(/\.?0+$/, "")) + "%";
140
+ }
141
+ return pct.toFixed(spec.decimals) + "%";
142
+ }
143
+
144
+ const negative = value < 0;
145
+ const absVal = Math.abs(value);
146
+ let result;
147
+
148
+ if (spec.decimals > 0) {
149
+ result = absVal.toFixed(spec.decimals);
150
+ } else {
151
+ result = Math.round(absVal).toString();
152
+ }
153
+
154
+ if (spec.thousands) {
155
+ const parts = result.split(".");
156
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
157
+ result = parts.join(".");
158
+ }
159
+
160
+ return (negative ? "-" : "") + spec.prefix + result;
161
+ }
162
+
163
+ // ── End column directive row ────────────────────────────────────────────
164
+
61
165
  class LineCursor {
62
166
  constructor(lines) {
63
167
  this.lines = lines;
@@ -299,6 +403,12 @@ function parseBlock(cursor, kind) {
299
403
  continue;
300
404
  }
301
405
 
406
+ if (isCitationsCommand(trimmed)) {
407
+ flushParagraph();
408
+ nodes.push(parseCitationsBlock(cursor));
409
+ continue;
410
+ }
411
+
302
412
  if (trimmed === COMMAND_SCOPE_OPEN) {
303
413
  flushParagraph();
304
414
  const scopeStartLine = cursor.index + 1;
@@ -340,6 +450,13 @@ function extractTrailingOpener(text) {
340
450
  return { text: trimmed.slice(0, pos).trimEnd(), opener: tableMatch[0] };
341
451
  }
342
452
  }
453
+ // Check for citations command: {[citations]
454
+ if (trimmed.endsWith(COMMAND_CITATIONS)) {
455
+ const pos = trimmed.length - COMMAND_CITATIONS.length;
456
+ if (!(pos > 0 && trimmed[pos - 1] === "\\")) {
457
+ return { text: trimmed.slice(0, pos).trimEnd(), opener: COMMAND_CITATIONS };
458
+ }
459
+ }
343
460
  // Check other openers
344
461
  const openers = [COMMAND_LIST_NUMBER, COMMAND_LIST_BULLET, COMMAND_SCOPE_OPEN];
345
462
  for (const opener of openers) {
@@ -388,6 +505,8 @@ function parseScope(cursor) {
388
505
  } else if (isTableCommand(trailing.opener)) {
389
506
  const tableOpts = parseTableOptions(trailing.opener);
390
507
  children = [parseTableBody(cursor, scopeStartLine, tableOpts)];
508
+ } else if (isCitationsCommand(trailing.opener)) {
509
+ children = [parseCitationsBody(cursor, scopeStartLine)];
391
510
  } else {
392
511
  children = parseBlock(cursor, "normal");
393
512
  }
@@ -545,6 +664,12 @@ function parseBracelessBlock(cursor) {
545
664
  continue;
546
665
  }
547
666
 
667
+ if (isCitationsCommand(trimmed)) {
668
+ flushParagraph();
669
+ nodes.push(parseCitationsBlock(cursor));
670
+ continue;
671
+ }
672
+
548
673
  if (!paragraphLines.length) {
549
674
  paragraphStartLine = cursor.index + 1;
550
675
  }
@@ -594,6 +719,10 @@ function parseScopeBlock(cursor) {
594
719
  return { blockType: "normal", children: [parseTableBlock(cursor)] };
595
720
  }
596
721
 
722
+ if (isCitationsCommand(trimmed)) {
723
+ return { blockType: "normal", children: [parseCitationsBlock(cursor)] };
724
+ }
725
+
597
726
  // No block opener found — braceless scope
598
727
  return { blockType: "braceless" };
599
728
  }
@@ -689,6 +818,7 @@ function isListContinuationLine(trimmedLeft) {
689
818
  if (trimmed === COMMAND_LIST_BULLET) return false;
690
819
  if (trimmed === COMMAND_LIST_NUMBER) return false;
691
820
  if (isTableCommand(trimmed)) return false;
821
+ if (isCitationsCommand(trimmed)) return false;
692
822
  if (trimmed === ",") return false;
693
823
  if (isHeadingLine(trimmedLeft)) return false;
694
824
  if (isBlockquoteLine(trimmedLeft)) return false;
@@ -720,6 +850,8 @@ function parseListItemLine(cursor, info, allowContinuation = false) {
720
850
  } else if (isTableCommand(trailing.opener)) {
721
851
  const tableOpts = parseTableOptions(trailing.opener);
722
852
  children = [parseTableBody(cursor, itemStartLine, tableOpts)];
853
+ } else if (isCitationsCommand(trailing.opener)) {
854
+ children = [parseCitationsBody(cursor, itemStartLine)];
723
855
  } else {
724
856
  children = parseBlock(cursor, "normal");
725
857
  }
@@ -802,21 +934,91 @@ function parseTableBody(cursor, tableStartLine, options) {
802
934
  cursor.next();
803
935
  }
804
936
 
937
+ // Detect column directive row (alignment / formatting)
938
+ // For headerless tables: check rows[0]; for normal tables: check rows[1] (after header)
939
+ const directiveIndex = options.headerless ? 0 : 1;
940
+ let columnAlign = null;
941
+ let columnFormat = null;
942
+ if (rows.length > directiveIndex && isDirectiveRow(rows[directiveIndex])) {
943
+ const directives = parseDirectiveRow(rows[directiveIndex]);
944
+ columnAlign = directives.align;
945
+ columnFormat = directives.format;
946
+ rows.splice(directiveIndex, 1);
947
+ }
948
+
805
949
  const hasOptions = options.borderless || options.headerless || options.width || options.align;
806
950
 
951
+ let tableNode;
807
952
  if (options.headerless) {
808
- const tableNode = { type: "table", headers: [], rows, lineStart: tableStartLine, lineEnd: cursor.index };
809
- if (hasOptions) tableNode.options = options;
810
- return tableNode;
953
+ tableNode = { type: "table", headers: [], rows, lineStart: tableStartLine, lineEnd: cursor.index };
954
+ } else {
955
+ const headers = rows.length > 0 ? rows[0] : [];
956
+ const body = rows.slice(1);
957
+ tableNode = { type: "table", headers, rows: body, lineStart: tableStartLine, lineEnd: cursor.index };
811
958
  }
812
-
813
- const headers = rows.length > 0 ? rows[0] : [];
814
- const body = rows.slice(1);
815
- const tableNode = { type: "table", headers, rows: body, lineStart: tableStartLine, lineEnd: cursor.index };
816
959
  if (hasOptions) tableNode.options = options;
960
+ if (columnAlign) tableNode.columnAlign = columnAlign;
961
+ if (columnFormat) tableNode.columnFormat = columnFormat;
817
962
  return tableNode;
818
963
  }
819
964
 
965
+ function parseCitationsBlock(cursor) {
966
+ const startLine = cursor.index + 1;
967
+ cursor.next();
968
+ return parseCitationsBody(cursor, startLine);
969
+ }
970
+
971
+ function parseCitationsBody(cursor, startLine) {
972
+ const entries = [];
973
+
974
+ while (!cursor.eof()) {
975
+ const line = cursor.current();
976
+ const trimmedLeft = line.replace(/^\s+/, "");
977
+ const trimmed = trimmedLeft.trim();
978
+
979
+ if (trimmed === "") {
980
+ cursor.next();
981
+ continue;
982
+ }
983
+
984
+ if (trimmed === COMMAND_SCOPE_CLOSE) {
985
+ cursor.next();
986
+ break;
987
+ }
988
+
989
+ // Citation items: - @key free-form text
990
+ const citationMatch = trimmed.match(/^-\s+@([A-Za-z_][A-Za-z0-9_-]*)\s+([\s\S]*)$/);
991
+ if (citationMatch) {
992
+ const entryStartLine = cursor.index + 1;
993
+ const key = citationMatch[1];
994
+ let text = citationMatch[2].trim();
995
+ cursor.next();
996
+
997
+ // Collect continuation lines (indented text not starting with - @key or })
998
+ while (!cursor.eof()) {
999
+ const nextLine = cursor.current();
1000
+ const nextTrimmedLeft = nextLine.replace(/^\s+/, "");
1001
+ const nextTrimmed = nextTrimmedLeft.trim();
1002
+
1003
+ if (nextTrimmed === "") break;
1004
+ if (nextTrimmed === COMMAND_SCOPE_CLOSE) break;
1005
+ if (/^-\s+@[A-Za-z_]/.test(nextTrimmed)) break;
1006
+
1007
+ text += " " + nextTrimmed;
1008
+ cursor.next();
1009
+ }
1010
+
1011
+ entries.push({ key, text, lineStart: entryStartLine, lineEnd: cursor.index });
1012
+ continue;
1013
+ }
1014
+
1015
+ cursor.error("Invalid citation entry (expected '- @key text').");
1016
+ cursor.next();
1017
+ }
1018
+
1019
+ return { type: "citations", entries, lineStart: startLine, lineEnd: cursor.index };
1020
+ }
1021
+
820
1022
  function parseOptionalBlock(cursor) {
821
1023
  while (!cursor.eof()) {
822
1024
  const line = cursor.current();
@@ -1123,6 +1325,18 @@ function parseImageWidth(raw) {
1123
1325
  return { src: raw };
1124
1326
  }
1125
1327
 
1328
+ function parseCitationKeys(inner) {
1329
+ // Parse "@key1, @key2, ..." — returns array of keys or null if invalid
1330
+ const parts = inner.split(",");
1331
+ const keys = [];
1332
+ for (const part of parts) {
1333
+ const match = part.trim().match(/^@([A-Za-z_][A-Za-z0-9_-]*)$/);
1334
+ if (!match) return null;
1335
+ keys.push(match[1]);
1336
+ }
1337
+ return keys.length > 0 ? keys : null;
1338
+ }
1339
+
1126
1340
  function parseInline(text) {
1127
1341
  const nodes = [];
1128
1342
  let buffer = "";
@@ -1252,6 +1466,21 @@ function parseInline(text) {
1252
1466
  }
1253
1467
  }
1254
1468
 
1469
+ // Citation references: [@key] or [@key1, @key2]
1470
+ if (ch === "[" && text[i + 1] === "@") {
1471
+ const endBracket = findUnescaped(text, i + 1, "]");
1472
+ if (endBracket !== -1) {
1473
+ const inner = text.slice(i + 1, endBracket).trim();
1474
+ const keys = parseCitationKeys(inner);
1475
+ if (keys) {
1476
+ flush();
1477
+ nodes.push({ type: "citation_ref", keys });
1478
+ i = endBracket + 1;
1479
+ continue;
1480
+ }
1481
+ }
1482
+ }
1483
+
1255
1484
  if (ch === "[") {
1256
1485
  const endLabel = findUnescaped(text, i + 1, "]");
1257
1486
  if (endLabel !== -1 && text[endLabel + 1] === "(") {
@@ -1371,6 +1600,81 @@ function findUnescaped(text, start, token) {
1371
1600
 
1372
1601
  let _renderOptions = {};
1373
1602
 
1603
+ // --- Citation numbering ---
1604
+ // Built before rendering; maps citation key → { number, anchorId }
1605
+ // anchorId is the id of the first inline citation_ref for back-linking
1606
+ let _citationNumbering = new Map();
1607
+ let _citationDefinitions = new Map(); // key → { text, lineStart, lineEnd }
1608
+
1609
+ function buildCitationNumbering(nodes) {
1610
+ const numbering = new Map();
1611
+ const definitions = new Map();
1612
+ let counter = 0;
1613
+
1614
+ // First pass: collect all citation_ref keys in document order to assign numbers
1615
+ function walkInlineNodes(inlineNodes) {
1616
+ for (const node of inlineNodes) {
1617
+ if (node.type === "citation_ref") {
1618
+ for (const key of node.keys) {
1619
+ if (!numbering.has(key)) {
1620
+ counter += 1;
1621
+ numbering.set(key, { number: counter, anchorId: `citeref-${key}` });
1622
+ }
1623
+ }
1624
+ }
1625
+ if (node.children) walkInlineNodes(node.children);
1626
+ }
1627
+ }
1628
+
1629
+ function walkInlineText(text) {
1630
+ walkInlineNodes(parseInline(text));
1631
+ }
1632
+
1633
+ function walk(nodeList) {
1634
+ for (const node of nodeList) {
1635
+ if (node.type === "paragraph" && node.text) {
1636
+ walkInlineText(node.text);
1637
+ } else if (node.type === "blockquote" && node.paragraphs) {
1638
+ for (const para of node.paragraphs) {
1639
+ walkInlineText(para);
1640
+ }
1641
+ } else if (node.type === "scope") {
1642
+ if (node.title) walkInlineText(node.title);
1643
+ if (node.children) walk(node.children);
1644
+ } else if (node.type === "list" && node.items) {
1645
+ walk(node.items);
1646
+ } else if (node.type === "table") {
1647
+ if (node.headers) {
1648
+ for (const cell of node.headers) walkInlineText(cell);
1649
+ }
1650
+ if (node.rows) {
1651
+ for (const row of node.rows) {
1652
+ for (const cell of row) walkInlineText(cell);
1653
+ }
1654
+ }
1655
+ } else if (node.type === "citations") {
1656
+ // Collect definitions
1657
+ for (const entry of node.entries) {
1658
+ if (!definitions.has(entry.key)) {
1659
+ definitions.set(entry.key, { text: entry.text, lineStart: entry.lineStart, lineEnd: entry.lineEnd });
1660
+ }
1661
+ }
1662
+ }
1663
+ }
1664
+ }
1665
+ walk(nodes);
1666
+
1667
+ // Assign numbers to defined-but-unreferenced citations (appended after referenced ones)
1668
+ for (const [key, def] of definitions) {
1669
+ if (!numbering.has(key)) {
1670
+ counter += 1;
1671
+ numbering.set(key, { number: counter, anchorId: null });
1672
+ }
1673
+ }
1674
+
1675
+ return { numbering, definitions };
1676
+ }
1677
+
1374
1678
  function dataLineAttrs(node) {
1375
1679
  if (node.lineStart == null) {
1376
1680
  return "";
@@ -1441,6 +1745,24 @@ function renderInlineNodes(nodes) {
1441
1745
  }
1442
1746
  return `<a class="sdoc-ref" href="${href}">@${escapeHtml(node.id)}</a>`;
1443
1747
  }
1748
+ case "citation_ref": {
1749
+ if (!_renderOptions._citationRefSeen) _renderOptions._citationRefSeen = new Set();
1750
+ const parts = node.keys.map((key) => {
1751
+ const info = _citationNumbering.get(key);
1752
+ const isBroken = !_citationDefinitions.has(key);
1753
+ if (isBroken) {
1754
+ // Undefined citation — render with warning
1755
+ const num = info ? info.number : escapeHtml(key);
1756
+ return `<a class="sdoc-citation-ref sdoc-broken-ref" href="#cite-${escapeAttr(key)}"><span class="sdoc-broken-icon">\u26A0</span>${num}</a>`;
1757
+ }
1758
+ // Only the first occurrence of each key gets the back-link anchor id
1759
+ const isFirst = !_renderOptions._citationRefSeen.has(key);
1760
+ if (isFirst) _renderOptions._citationRefSeen.add(key);
1761
+ const idAttr = isFirst ? ` id="citeref-${escapeAttr(key)}"` : "";
1762
+ return `<a class="sdoc-citation-ref"${idAttr} href="#cite-${escapeAttr(key)}">${info.number}</a>`;
1763
+ });
1764
+ return `<sup class="sdoc-citation-group">[${parts.join(", ")}]</sup>`;
1765
+ }
1444
1766
  case "code":
1445
1767
  return `<code class="sdoc-inline-code">${escapeHtml(node.value)}</code>`;
1446
1768
  case "em":
@@ -1517,6 +1839,33 @@ function renderScope(scope, depth, isTitleScope = false) {
1517
1839
  return `<section class="sdoc-scope${rootClass}${typeClass}"${typeAttr}>${heading}${childrenHtml}</section>`;
1518
1840
  }
1519
1841
 
1842
+ function renderCitations(node) {
1843
+ const dl = dataLineAttrs(node);
1844
+
1845
+ // Build entries sorted by assigned citation number
1846
+ const sorted = [];
1847
+ for (const entry of node.entries) {
1848
+ const info = _citationNumbering.get(entry.key);
1849
+ if (info) {
1850
+ sorted.push({ key: entry.key, text: entry.text, number: info.number, anchorId: info.anchorId });
1851
+ } else {
1852
+ // Defined but no number (shouldn't happen if buildCitationNumbering ran, but be safe)
1853
+ sorted.push({ key: entry.key, text: entry.text, number: Infinity, anchorId: null });
1854
+ }
1855
+ }
1856
+ sorted.sort((a, b) => a.number - b.number);
1857
+
1858
+ const items = sorted.map((entry) => {
1859
+ const backLink = entry.anchorId
1860
+ ? ` <a class="sdoc-citation-backlink" href="#${escapeAttr(entry.anchorId)}" title="Back to text">\u21A9</a>`
1861
+ : "";
1862
+ const unreferenced = entry.anchorId === null ? " sdoc-citation-unreferenced" : "";
1863
+ 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>`;
1864
+ }).join("\n");
1865
+
1866
+ return `<ol class="sdoc-citations"${dl}>${items}</ol>`;
1867
+ }
1868
+
1520
1869
  function renderList(list, depth) {
1521
1870
  return renderListFromItems(list.listType, list.items, depth, list);
1522
1871
  }
@@ -1885,15 +2234,23 @@ function formatFormulaResult(cell) {
1885
2234
  function renderTable(table) {
1886
2235
  const dl = dataLineAttrs(table);
1887
2236
  const opts = table.options || {};
2237
+ const colAlign = table.columnAlign || [];
2238
+ const colFormat = table.columnFormat || [];
1888
2239
  const classes = ["sdoc-table"];
1889
2240
  if (opts.borderless) classes.push("sdoc-table-borderless");
1890
2241
  if (opts.headerless) classes.push("sdoc-table-headerless");
1891
2242
  const classAttr = classes.join(" ");
1892
2243
 
2244
+ function cellStyle(colIndex) {
2245
+ const align = colAlign[colIndex];
2246
+ if (!align || align === "left") return "";
2247
+ return ` style="text-align:${align}"`;
2248
+ }
2249
+
1893
2250
  let thead = "";
1894
2251
  if (table.headers.length > 0) {
1895
2252
  const headerCells = table.headers
1896
- .map((cell) => `<th class="sdoc-table-th">${renderInline(cell)}</th>`)
2253
+ .map((cell, c) => `<th class="sdoc-table-th"${cellStyle(c)}>${renderInline(cell)}</th>`)
1897
2254
  .join("");
1898
2255
  thead = `<thead class="sdoc-table-head"><tr>${headerCells}</tr></thead>`;
1899
2256
  }
@@ -1905,16 +2262,33 @@ function renderTable(table) {
1905
2262
  .map((row, r) => {
1906
2263
  const cells = row
1907
2264
  .map((cell, c) => {
2265
+ const style = cellStyle(c);
2266
+ const fmt = colFormat[c] || null;
2267
+
1908
2268
  if (isFormulaCell(cell)) {
1909
2269
  const result = grid[r][c];
1910
- const display = escapeHtml(formatFormulaResult(result));
2270
+ let display;
2271
+ if (!result.error && fmt) {
2272
+ display = escapeHtml(formatNumber(result.value, fmt));
2273
+ } else {
2274
+ display = escapeHtml(formatFormulaResult(result));
2275
+ }
1911
2276
  const formula = escapeAttr(cell.trim());
1912
2277
  if (result.error) {
1913
- return `<td class="sdoc-table-td sdoc-formula-error" title="${formula}">${display}</td>`;
2278
+ return `<td class="sdoc-table-td sdoc-formula-error"${style} title="${formula}">${display}</td>`;
2279
+ }
2280
+ return `<td class="sdoc-table-td sdoc-formula-cell"${style} title="${formula}">${display}</td>`;
2281
+ }
2282
+
2283
+ // Apply column format to numeric data cells
2284
+ if (fmt) {
2285
+ const parsed = parseCellValue(cell);
2286
+ if (!isNaN(parsed.value)) {
2287
+ return `<td class="sdoc-table-td"${style}>${escapeHtml(formatNumber(parsed.value, fmt))}</td>`;
1914
2288
  }
1915
- return `<td class="sdoc-table-td sdoc-formula-cell" title="${formula}">${display}</td>`;
1916
2289
  }
1917
- return `<td class="sdoc-table-td">${renderInline(cell)}</td>`;
2290
+
2291
+ return `<td class="sdoc-table-td"${style}>${renderInline(cell)}</td>`;
1918
2292
  })
1919
2293
  .join("");
1920
2294
  return `<tr>${cells}</tr>`;
@@ -1955,6 +2329,8 @@ function renderNode(node, depth) {
1955
2329
  const editable = _renderOptions.editable ? ` contenteditable="true"` : "";
1956
2330
  return `<p class="sdoc-paragraph"${dl}${editable}>${renderInline(node.text)}</p>`;
1957
2331
  }
2332
+ case "citations":
2333
+ return renderCitations(node);
1958
2334
  case "code": {
1959
2335
  if (node.lang === "mermaid") {
1960
2336
  return `<pre class="mermaid"${dl}>${escapeHtml(node.text)}</pre>`;
@@ -2392,6 +2768,62 @@ const DEFAULT_STYLE = `
2392
2768
  margin-right: 0.15em;
2393
2769
  }
2394
2770
 
2771
+ .sdoc-citation-group {
2772
+ font-size: 0.8em;
2773
+ line-height: 1;
2774
+ vertical-align: super;
2775
+ }
2776
+
2777
+ .sdoc-citation-ref {
2778
+ color: var(--sdoc-accent);
2779
+ text-decoration: none;
2780
+ cursor: pointer;
2781
+ }
2782
+
2783
+ .sdoc-citation-ref:hover {
2784
+ text-decoration: underline;
2785
+ }
2786
+
2787
+ .sdoc-citations {
2788
+ margin: 1.5rem 0;
2789
+ padding: 1rem 0 0.5rem 0;
2790
+ border-top: 1px solid var(--sdoc-border);
2791
+ list-style: none;
2792
+ counter-reset: none;
2793
+ }
2794
+
2795
+ .sdoc-citation-entry {
2796
+ margin: 0.4rem 0;
2797
+ padding-left: 2.5rem;
2798
+ position: relative;
2799
+ line-height: 1.6;
2800
+ font-size: 0.92em;
2801
+ }
2802
+
2803
+ .sdoc-citation-entry::before {
2804
+ content: "[" attr(value) "]";
2805
+ position: absolute;
2806
+ left: 0;
2807
+ color: var(--sdoc-muted);
2808
+ font-weight: 600;
2809
+ font-size: 0.9em;
2810
+ }
2811
+
2812
+ .sdoc-citation-unreferenced {
2813
+ opacity: 0.6;
2814
+ }
2815
+
2816
+ .sdoc-citation-backlink {
2817
+ color: var(--sdoc-accent);
2818
+ text-decoration: none;
2819
+ margin-left: 0.3em;
2820
+ font-size: 0.85em;
2821
+ }
2822
+
2823
+ .sdoc-citation-backlink:hover {
2824
+ text-decoration: underline;
2825
+ }
2826
+
2395
2827
  .sdoc-image {
2396
2828
  display: inline-block;
2397
2829
  max-width: 100%;
@@ -2592,7 +3024,7 @@ hljs.COMMENT('^\\\\s*//','$'),
2592
3024
  {className:'link',begin:'\\\\(',end:'\\\\)'}
2593
3025
  ]},
2594
3026
  {className:'keyword',begin:'\\\\{[!?+\\\\-=~^]',end:'[!?+\\\\-=~^]\\\\}'},
2595
- {className:'keyword',begin:'\\\\{\\\\[(?:\\\\.|#|\\\\d+|table)\\\\]'},
3027
+ {className:'keyword',begin:'\\\\{\\\\[(?:\\\\.|#|\\\\d+|table|citations)\\\\]'},
2596
3028
  {className:'strong',begin:'\\\\*\\\\*',end:'\\\\*\\\\*'},
2597
3029
  {className:'emphasis',begin:'(?<!\\\\*)\\\\*(?!\\\\*)',end:'\\\\*(?!\\\\*)'},
2598
3030
  {className:'deletion',begin:'~~',end:'~~'},
@@ -2648,12 +3080,26 @@ function renderBodyNodes(nodes) {
2648
3080
  function renderHtmlBody(text) {
2649
3081
  const parsed = parseSdoc(text);
2650
3082
  const metaResult = extractMeta(parsed.nodes);
2651
- return renderBodyNodes(metaResult.nodes);
3083
+ const savedOptions = _renderOptions;
3084
+ _renderOptions = {};
3085
+ const citationData = buildCitationNumbering(metaResult.nodes);
3086
+ _citationNumbering = citationData.numbering;
3087
+ _citationDefinitions = citationData.definitions;
3088
+ const result = renderBodyNodes(metaResult.nodes);
3089
+ _citationNumbering = new Map();
3090
+ _citationDefinitions = new Map();
3091
+ _renderOptions = savedOptions;
3092
+ return result;
2652
3093
  }
2653
3094
 
2654
3095
  function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
2655
3096
  _renderOptions = options.renderOptions ?? {};
3097
+ const citationData = buildCitationNumbering(parsed.nodes);
3098
+ _citationNumbering = citationData.numbering;
3099
+ _citationDefinitions = citationData.definitions;
2656
3100
  const body = renderBodyNodes(parsed.nodes);
3101
+ _citationNumbering = new Map();
3102
+ _citationDefinitions = new Map();
2657
3103
  _renderOptions = {};
2658
3104
  const errorHtml = renderErrors(parsed.errors);
2659
3105
 
@@ -2782,11 +3228,12 @@ function formatSdoc(text, indentStr = " ") {
2782
3228
  continue;
2783
3229
  }
2784
3230
 
2785
- // Standalone opener: {, {[.], {[#], {[table]
3231
+ // Standalone opener: {, {[.], {[#], {[table], {[citations]
2786
3232
  if (trimmed === COMMAND_SCOPE_OPEN ||
2787
3233
  trimmed === COMMAND_LIST_BULLET ||
2788
3234
  trimmed === COMMAND_LIST_NUMBER ||
2789
- isTableCommand(trimmed)) {
3235
+ isTableCommand(trimmed) ||
3236
+ isCitationsCommand(trimmed)) {
2790
3237
  result.push(indentStr.repeat(depth) + trimmed);
2791
3238
  depth++;
2792
3239
  continue;
@@ -3017,6 +3464,7 @@ function collectAllIds(nodes) {
3017
3464
  function collectInlineRefs(nodes) {
3018
3465
  const refs = [];
3019
3466
  const links = [];
3467
+ const citationRefs = [];
3020
3468
 
3021
3469
  function walkInlineNodes(inlineNodes, lineStart, lineEnd) {
3022
3470
  for (const node of inlineNodes) {
@@ -3024,6 +3472,10 @@ function collectInlineRefs(nodes) {
3024
3472
  refs.push({ id: node.id, lineStart, lineEnd });
3025
3473
  } else if (node.type === "link") {
3026
3474
  links.push({ href: node.href, lineStart, lineEnd });
3475
+ } else if (node.type === "citation_ref") {
3476
+ for (const key of node.keys) {
3477
+ citationRefs.push({ key, lineStart, lineEnd });
3478
+ }
3027
3479
  }
3028
3480
  if (node.children) {
3029
3481
  walkInlineNodes(node.children, lineStart, lineEnd);
@@ -3073,7 +3525,24 @@ function collectInlineRefs(nodes) {
3073
3525
  }
3074
3526
  }
3075
3527
  walk(nodes);
3076
- return { refs, links };
3528
+ return { refs, links, citationRefs };
3529
+ }
3530
+
3531
+ function collectCitationDefinitions(nodes) {
3532
+ const defs = [];
3533
+ function walk(nodeList) {
3534
+ for (const node of nodeList) {
3535
+ if (node.type === "citations") {
3536
+ for (const entry of node.entries) {
3537
+ defs.push({ key: entry.key, lineStart: entry.lineStart, lineEnd: entry.lineEnd });
3538
+ }
3539
+ }
3540
+ if (node.children) walk(node.children);
3541
+ if (node.type === "list" && node.items) walk(node.items);
3542
+ }
3543
+ }
3544
+ walk(nodes);
3545
+ return defs;
3077
3546
  }
3078
3547
 
3079
3548
  function validateRefs(nodes, options = {}) {
@@ -3117,6 +3586,43 @@ function validateRefs(nodes, options = {}) {
3117
3586
  return warnings;
3118
3587
  }
3119
3588
 
3589
+ function validateCitations(nodes) {
3590
+ const { citationRefs } = collectInlineRefs(nodes);
3591
+ const defs = collectCitationDefinitions(nodes);
3592
+ const warnings = [];
3593
+
3594
+ const definedKeys = new Set(defs.map((d) => d.key));
3595
+ const referencedKeys = new Set(citationRefs.map((r) => r.key));
3596
+
3597
+ // Citation referenced but not defined
3598
+ for (const ref of citationRefs) {
3599
+ if (!definedKeys.has(ref.key)) {
3600
+ warnings.push({
3601
+ type: "broken-citation",
3602
+ key: ref.key,
3603
+ message: `Broken citation: [@${ref.key}] is not defined in any {[citations] block`,
3604
+ lineStart: ref.lineStart,
3605
+ lineEnd: ref.lineEnd
3606
+ });
3607
+ }
3608
+ }
3609
+
3610
+ // Citation defined but never referenced
3611
+ for (const def of defs) {
3612
+ if (!referencedKeys.has(def.key)) {
3613
+ warnings.push({
3614
+ type: "unused-citation",
3615
+ key: def.key,
3616
+ message: `Unused citation: @${def.key} is defined but never referenced with [@${def.key}]`,
3617
+ lineStart: def.lineStart,
3618
+ lineEnd: def.lineEnd
3619
+ });
3620
+ }
3621
+ }
3622
+
3623
+ return warnings;
3624
+ }
3625
+
3120
3626
  async function resolveIncludes(nodes, resolverFn) {
3121
3627
  for (const node of nodes) {
3122
3628
  if (node.type === "code" && node.src) {
@@ -3167,7 +3673,9 @@ module.exports = {
3167
3673
  // Validation
3168
3674
  collectAllIds,
3169
3675
  collectInlineRefs,
3676
+ collectCitationDefinitions,
3170
3677
  validateRefs,
3678
+ validateCitations,
3171
3679
  // Low-level helpers for custom renderers (e.g. slide-renderer)
3172
3680
  parseInline,
3173
3681
  renderKatex,