@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.
- package/docs/reference/sdoc-authoring.sdoc +113 -3
- package/lexica/specification.sdoc +164 -6
- package/package.json +1 -1
- package/src/sdoc.js +525 -17
|
@@ -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
|
-
|
|
286
|
-
|
|
287
|
-
Founder
|
|
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 |
|
|
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
|
-
|
|
|
991
|
-
|
|
|
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.
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|