@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.
- package/docs/reference/sdoc-authoring.sdoc +47 -0
- package/lexica/specification.sdoc +93 -5
- package/package.json +1 -1
- package/src/sdoc.js +376 -5
|
@@ -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 |
|
|
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
|
-
|
|
|
991
|
-
|
|
|
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.
|
|
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
|
-
|
|
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,
|