@entropicwarrior/sdoc 0.2.5 → 0.2.7
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/README.md +12 -2
- package/docs/reference/sdoc-authoring.sdoc +34 -6
- package/docs/reference/slide-authoring.sdoc +1 -1
- package/lexica/specification.sdoc +74 -0
- package/package.json +1 -1
- package/src/sdoc.js +355 -4
package/README.md
CHANGED
|
@@ -74,7 +74,17 @@ Key resources for agents:
|
|
|
74
74
|
| [`docs/reference/sdoc-authoring.sdoc`](https://raw.githubusercontent.com/entropicwarrior/sdoc/main/docs/reference/sdoc-authoring.sdoc) | Skill document — drop into context to read/write SDOC immediately |
|
|
75
75
|
| [`lexica/specification.sdoc`](https://raw.githubusercontent.com/entropicwarrior/sdoc/main/lexica/specification.sdoc) | Formal spec with EBNF grammar |
|
|
76
76
|
|
|
77
|
-
All `.sdoc` files are designed for progressive disclosure
|
|
77
|
+
All `.sdoc` files are designed for progressive disclosure. The JavaScript API provides three functions that let agents navigate without loading entire files:
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
const { extractAbout, listSections, extractSection } = require("@entropicwarrior/sdoc");
|
|
81
|
+
|
|
82
|
+
extractAbout(text); // ~50 tokens — what is this file about?
|
|
83
|
+
listSections(text); // ~50-100 tokens — what sections does it have?
|
|
84
|
+
extractSection(text, "error-handling"); // ~200-1000 tokens — give me just this section
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Total cost for a precise answer: ~750 tokens. The same lookup in Markdown requires loading the full file (5,000-50,000 tokens).
|
|
78
88
|
|
|
79
89
|
## Format at a Glance
|
|
80
90
|
|
|
@@ -159,7 +169,7 @@ Markdown-style images with optional width and alignment:
|
|
|
159
169
|
|
|
160
170
|
### Tables
|
|
161
171
|
|
|
162
|
-
Pipe-delimited tables with optional flags for appearance (`borderless`, `headerless`), width (`auto`, `60%`, `400px`), and alignment (`left`, `center`, `right`). All flags compose freely.
|
|
172
|
+
Pipe-delimited tables with optional flags for appearance (`borderless`, `headerless`), width (`auto`, `60%`, `400px`), and alignment (`left`, `center`, `right`). All flags compose freely. Cells starting with `=` are evaluated as formulas (`=SUM`, `=AVG`, `=COUNT`, arithmetic with A1 cell references).
|
|
163
173
|
|
|
164
174
|
### Lists
|
|
165
175
|
|
|
@@ -274,6 +274,34 @@ Content of Section B.
|
|
|
274
274
|
\|x\| + \|y\| | 8
|
|
275
275
|
}
|
|
276
276
|
```
|
|
277
|
+
|
|
278
|
+
# Table Formulas @table-formulas
|
|
279
|
+
{
|
|
280
|
+
Cells starting with \`=\` are evaluated as formulas. Hover a computed cell in preview to see the original formula.
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
{[table]
|
|
284
|
+
Investor | Shares | Ownership
|
|
285
|
+
Seed Fund | 500,000 | 25%
|
|
286
|
+
Founder A | 1,000,000 | 50%
|
|
287
|
+
Founder B | 500,000 | 25%
|
|
288
|
+
**Total** | =SUM(B1:B3) | =SUM(C1:C3)
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Cell references use A1 notation (column letter + row number). Headers are excluded from row numbering, so the first data row is row 1.
|
|
293
|
+
|
|
294
|
+
{[.]
|
|
295
|
+
- \`=SUM(range)\` — sum of values
|
|
296
|
+
- \`=AVG(range)\` — arithmetic mean
|
|
297
|
+
- \`=COUNT(range)\` — number of cells
|
|
298
|
+
- Arithmetic: \`=B1+B2\`, \`=B1*3\`, \`=(A1+A2)/2\`, \`=-B1\`
|
|
299
|
+
- Function names are uppercase only
|
|
300
|
+
- Escape with \`\\=\` for a literal equals sign at the start of a cell
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
Errors display in red italic: \`#DIV/0!\` (division by zero), \`#VALUE!\` (bad reference), \`#REF!\` (invalid syntax), \`#NAME!\` (unknown function), \`#CIRCULAR!\` (circular dependency).
|
|
304
|
+
}
|
|
277
305
|
}
|
|
278
306
|
|
|
279
307
|
# Inline Formatting @inline-formatting
|
|
@@ -309,7 +337,7 @@ Content of Section B.
|
|
|
309
337
|
 
|
|
310
338
|
```
|
|
311
339
|
|
|
312
|
-
Autolinks: \`\<https://example.com\>\` — angle brackets are optional; bare URLs starting with \`http://\`, \`https://\`, or \`mailto:\` are also auto-linked.
|
|
340
|
+
Autolinks: \`\<https://example.com\>\` — angle brackets are optional; bare URLs starting with \`http://\`, \`https://\`, or \`mailto:\` are also auto-linked. Bare email addresses (e.g. \`hello@example.com\`) are automatically rendered as \`mailto:\` links.
|
|
313
341
|
|
|
314
342
|
Math: Use \`\$...\$\` for inline math and \`\$\$...\$\$\` for display math. Use \`\\\`\\\`\\\`math\` code fences for multi-line equations. A plain \`\$\` followed by a digit (e.g. \`\$100\`) does not trigger math mode.
|
|
315
343
|
}
|
|
@@ -691,21 +719,21 @@ Content of Section B.
|
|
|
691
719
|
|
|
692
720
|
# Cross-Document @slug References @cross-doc-refs
|
|
693
721
|
{
|
|
694
|
-
\`@slug\` references are document-local. Using \`@slug\` to refer to a section in another file produces a broken reference error
|
|
722
|
+
\`@slug\` references are document-local. Using \`@slug\` to refer to a section in another file produces a broken reference error. This is the most common reference mistake — especially in audits, reviews, and documents that discuss other documents, where writing \`file.sdoc @section\` reads naturally but is wrong.
|
|
695
723
|
|
|
696
|
-
**Wrong** — \`@
|
|
724
|
+
**Wrong** — \`@extension\` looks for a local section, not one in \`foundations.sdoc\`:
|
|
697
725
|
|
|
698
726
|
```
|
|
699
|
-
See `
|
|
727
|
+
See `foundations.sdoc` @extension for the design rationale.
|
|
700
728
|
```
|
|
701
729
|
|
|
702
730
|
**Right** — use a link with a fragment:
|
|
703
731
|
|
|
704
732
|
```
|
|
705
|
-
See [
|
|
733
|
+
See [Extension](./foundations.sdoc#extension) for the design rationale.
|
|
706
734
|
```
|
|
707
735
|
|
|
708
|
-
The fragment (\`#
|
|
736
|
+
The fragment (\`#extension\`) matches the target scope's \`@id\`. Use \`\\@\` if you need a literal \`@\` in text without triggering reference resolution.
|
|
709
737
|
}
|
|
710
738
|
|
|
711
739
|
# @References Inside Link Labels @refs-in-link-labels
|
|
@@ -378,6 +378,78 @@ Content of Section B.
|
|
|
378
378
|
}
|
|
379
379
|
```
|
|
380
380
|
}
|
|
381
|
+
|
|
382
|
+
# Table Formulas @table-formulas
|
|
383
|
+
{
|
|
384
|
+
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.
|
|
385
|
+
|
|
386
|
+
```
|
|
387
|
+
{[table]
|
|
388
|
+
Item | Qty | Price
|
|
389
|
+
Widget | 10 | 5.00
|
|
390
|
+
Gadget | 5 | 12.50
|
|
391
|
+
**Total** | =SUM(B1:B2) | =SUM(C1:C2)
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
# Cell References
|
|
396
|
+
{
|
|
397
|
+
{[.]
|
|
398
|
+
- References use spreadsheet-style A1 notation: column letter (A-Z) followed by row number (1-based)
|
|
399
|
+
- Row numbering starts at 1 for the first data row; headers are excluded from numbering
|
|
400
|
+
- A range is two references separated by a colon: `A1:B3`
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
# Supported Functions
|
|
405
|
+
{
|
|
406
|
+
{[.]
|
|
407
|
+
- `=SUM(range)` — sum of all values in the range
|
|
408
|
+
- `=AVG(range)` — arithmetic mean of the range
|
|
409
|
+
- `=COUNT(range)` — number of cells in the range
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
Function names are case-sensitive (uppercase only). Multiple ranges can be separated by commas: `=SUM(A1:A3,B1:B3)`.
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
# Arithmetic Expressions
|
|
416
|
+
{
|
|
417
|
+
Cells may also contain arithmetic expressions using `+`, `-`, `*`, `/`, parentheses, and cell references:
|
|
418
|
+
|
|
419
|
+
```
|
|
420
|
+
=B1+B2
|
|
421
|
+
=B1*3
|
|
422
|
+
=(A1+A2)/2
|
|
423
|
+
=-B1
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Standard operator precedence applies (multiplication and division before addition and subtraction). Unary minus is supported.
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
# Percentage Values
|
|
430
|
+
{
|
|
431
|
+
Cells ending in `%` are stored internally as decimals (e.g. `25%` = 0.25). When all values in a `SUM` or `AVG` are percentages, the result displays as a percentage. Arithmetic between percentages and plain numbers produces a plain number.
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
# Error Values
|
|
435
|
+
{
|
|
436
|
+
{[.]
|
|
437
|
+
- `#DIV/0!` — division by zero
|
|
438
|
+
- `#VALUE!` — unresolvable reference (e.g. reference to a text cell or out-of-bounds)
|
|
439
|
+
- `#REF!` — invalid cell reference syntax
|
|
440
|
+
- `#NAME!` — unknown function name
|
|
441
|
+
- `#SYNTAX!` — malformed formula expression
|
|
442
|
+
- `#CIRCULAR!` — circular dependency between formula cells
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
Error cells are styled distinctly (red italic) and display the original formula as a tooltip.
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
# Escaping
|
|
449
|
+
{
|
|
450
|
+
To display a literal `=` at the start of a cell, escape it: `\=SUM(...)` renders as plain text `=SUM(...)`.
|
|
451
|
+
}
|
|
452
|
+
}
|
|
381
453
|
}
|
|
382
454
|
|
|
383
455
|
# Paragraphs @paragraphs
|
|
@@ -431,6 +503,8 @@ Content of Section B.
|
|
|
431
503
|
Only `http`, `https`, and `mailto` schemes are recognised.
|
|
432
504
|
|
|
433
505
|
Bare URLs starting with `http://`, `https://`, or `mailto:` are also auto-linked without angle brackets.
|
|
506
|
+
|
|
507
|
+
Bare email addresses (e.g. `hello@example.com`) are automatically detected and rendered as `mailto:` links. The detection requires at least one character before and after the `@`, with a domain containing a dot. Email auto-detection does not apply inside code spans, links, or angle-bracket autolinks.
|
|
434
508
|
}
|
|
435
509
|
|
|
436
510
|
# Images @images
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@entropicwarrior/sdoc",
|
|
3
3
|
"displayName": "SDOC",
|
|
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.7",
|
|
6
6
|
"publisher": "entropicwarrior",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
package/src/sdoc.js
CHANGED
|
@@ -1550,6 +1550,307 @@ function renderListItem(scope, listType, depth) {
|
|
|
1550
1550
|
return `<section class="${scopeClass}">${heading}${bodyWrapper}</section>`;
|
|
1551
1551
|
}
|
|
1552
1552
|
|
|
1553
|
+
// ── Table formula engine ──────────────────────────────────────────────
|
|
1554
|
+
|
|
1555
|
+
function isFormulaCell(text) {
|
|
1556
|
+
const t = text.trim();
|
|
1557
|
+
return t.length > 1 && t[0] === "=" && t[1] !== "=" && !t.startsWith("\\=");
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function parseCellRef(ref) {
|
|
1561
|
+
const m = ref.match(/^([A-Z])(\d+)$/);
|
|
1562
|
+
if (!m) return null;
|
|
1563
|
+
return { col: m[1].charCodeAt(0) - 65, row: parseInt(m[2], 10) - 1 };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function expandRange(rangeStr) {
|
|
1567
|
+
const parts = rangeStr.split(":");
|
|
1568
|
+
if (parts.length !== 2) return null;
|
|
1569
|
+
const start = parseCellRef(parts[0]);
|
|
1570
|
+
const end = parseCellRef(parts[1]);
|
|
1571
|
+
if (!start || !end) return null;
|
|
1572
|
+
const refs = [];
|
|
1573
|
+
for (let r = start.row; r <= end.row; r++) {
|
|
1574
|
+
for (let c = start.col; c <= end.col; c++) {
|
|
1575
|
+
refs.push({ col: c, row: r });
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return refs;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function parseCellValue(text) {
|
|
1582
|
+
const t = text.trim();
|
|
1583
|
+
if (/^-?\d+(\.\d+)?%$/.test(t)) {
|
|
1584
|
+
return { value: parseFloat(t) / 100, isPercent: true };
|
|
1585
|
+
}
|
|
1586
|
+
// Strip commas for numbers like 1,000,000
|
|
1587
|
+
const stripped = t.replace(/,/g, "");
|
|
1588
|
+
if (/^-?\d+(\.\d+)?$/.test(stripped)) {
|
|
1589
|
+
return { value: parseFloat(stripped), isPercent: false };
|
|
1590
|
+
}
|
|
1591
|
+
return { value: NaN, isPercent: false };
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function buildCellGrid(rows) {
|
|
1595
|
+
return rows.map((row) => row.map((cell) => {
|
|
1596
|
+
if (isFormulaCell(cell)) return { value: NaN, isPercent: false, formula: cell.trim().slice(1) };
|
|
1597
|
+
return parseCellValue(cell);
|
|
1598
|
+
}));
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function resolveRef(grid, ref) {
|
|
1602
|
+
if (ref.row < 0 || ref.row >= grid.length) return null;
|
|
1603
|
+
if (ref.col < 0 || ref.col >= grid[ref.row].length) return null;
|
|
1604
|
+
return grid[ref.row][ref.col];
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function resolveRefs(grid, refs) {
|
|
1608
|
+
const values = [];
|
|
1609
|
+
for (const ref of refs) {
|
|
1610
|
+
const cell = resolveRef(grid, ref);
|
|
1611
|
+
if (!cell || isNaN(cell.value)) return null;
|
|
1612
|
+
values.push(cell);
|
|
1613
|
+
}
|
|
1614
|
+
return values;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
function tokenizeFormula(expr) {
|
|
1618
|
+
const tokens = [];
|
|
1619
|
+
let i = 0;
|
|
1620
|
+
while (i < expr.length) {
|
|
1621
|
+
if (/\s/.test(expr[i])) { i++; continue; }
|
|
1622
|
+
// Function name
|
|
1623
|
+
if (/[A-Z]/.test(expr[i]) && i + 1 < expr.length && /[A-Z]/.test(expr[i + 1])) {
|
|
1624
|
+
let j = i;
|
|
1625
|
+
while (j < expr.length && /[A-Z]/.test(expr[j])) j++;
|
|
1626
|
+
tokens.push({ type: "func", value: expr.slice(i, j) });
|
|
1627
|
+
i = j;
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
// Cell ref or range (e.g. A1, A1:B3)
|
|
1631
|
+
if (/[A-Z]/.test(expr[i]) && i + 1 < expr.length && /\d/.test(expr[i + 1])) {
|
|
1632
|
+
let j = i;
|
|
1633
|
+
while (j < expr.length && /[A-Z0-9:]/.test(expr[j])) j++;
|
|
1634
|
+
tokens.push({ type: "ref", value: expr.slice(i, j) });
|
|
1635
|
+
i = j;
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
// Number
|
|
1639
|
+
if (/[\d.]/.test(expr[i])) {
|
|
1640
|
+
let j = i;
|
|
1641
|
+
while (j < expr.length && /[\d.]/.test(expr[j])) j++;
|
|
1642
|
+
const numStr = expr.slice(i, j);
|
|
1643
|
+
if ((numStr.match(/\./g) || []).length > 1) return { error: "#SYNTAX!" };
|
|
1644
|
+
if (j < expr.length && expr[j] === "%") {
|
|
1645
|
+
tokens.push({ type: "num", value: parseFloat(numStr) / 100, isPercent: true });
|
|
1646
|
+
j++;
|
|
1647
|
+
} else {
|
|
1648
|
+
tokens.push({ type: "num", value: parseFloat(numStr), isPercent: false });
|
|
1649
|
+
}
|
|
1650
|
+
i = j;
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
if ("+-*/(),".includes(expr[i])) {
|
|
1654
|
+
tokens.push({ type: "op", value: expr[i] });
|
|
1655
|
+
i++;
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
return { error: "#SYNTAX!" };
|
|
1659
|
+
}
|
|
1660
|
+
return { tokens };
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function evaluateFormula(formula, grid) {
|
|
1664
|
+
const { tokens, error } = tokenizeFormula(formula);
|
|
1665
|
+
if (error) return { value: NaN, isPercent: false, error };
|
|
1666
|
+
|
|
1667
|
+
let pos = 0;
|
|
1668
|
+
const peek = () => pos < tokens.length ? tokens[pos] : null;
|
|
1669
|
+
const consume = () => tokens[pos++];
|
|
1670
|
+
|
|
1671
|
+
function resolveArg() {
|
|
1672
|
+
const tok = peek();
|
|
1673
|
+
if (!tok) return null;
|
|
1674
|
+
if (tok.type === "ref") {
|
|
1675
|
+
consume();
|
|
1676
|
+
if (tok.value.includes(":")) {
|
|
1677
|
+
const refs = expandRange(tok.value);
|
|
1678
|
+
if (!refs) return { error: "#REF!" };
|
|
1679
|
+
const cells = resolveRefs(grid, refs);
|
|
1680
|
+
if (!cells) return { error: "#VALUE!" };
|
|
1681
|
+
return { cells };
|
|
1682
|
+
} else {
|
|
1683
|
+
const ref = parseCellRef(tok.value);
|
|
1684
|
+
if (!ref) return { error: "#REF!" };
|
|
1685
|
+
const cell = resolveRef(grid, ref);
|
|
1686
|
+
if (!cell || isNaN(cell.value)) return { error: "#VALUE!" };
|
|
1687
|
+
return { cells: [cell] };
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function parseFuncArgs() {
|
|
1694
|
+
const allCells = [];
|
|
1695
|
+
if (!peek() || peek().value !== "(") return { error: "#SYNTAX!" };
|
|
1696
|
+
consume(); // (
|
|
1697
|
+
while (peek() && peek().value !== ")") {
|
|
1698
|
+
const arg = resolveArg();
|
|
1699
|
+
if (!arg) return { error: "#SYNTAX!" };
|
|
1700
|
+
if (arg.error) return arg;
|
|
1701
|
+
allCells.push(...arg.cells);
|
|
1702
|
+
if (peek() && peek().value === ",") consume();
|
|
1703
|
+
}
|
|
1704
|
+
if (!peek() || peek().value !== ")") return { error: "#SYNTAX!" };
|
|
1705
|
+
consume(); // )
|
|
1706
|
+
return { cells: allCells };
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function parseAtom() {
|
|
1710
|
+
const tok = peek();
|
|
1711
|
+
if (!tok) return { error: "#SYNTAX!" };
|
|
1712
|
+
|
|
1713
|
+
if (tok.type === "func") {
|
|
1714
|
+
const fname = consume().value;
|
|
1715
|
+
const args = parseFuncArgs();
|
|
1716
|
+
if (args.error) return args;
|
|
1717
|
+
const allPercent = args.cells.every((c) => c.isPercent);
|
|
1718
|
+
const vals = args.cells.map((c) => c.value);
|
|
1719
|
+
if (fname === "SUM") {
|
|
1720
|
+
return { value: vals.reduce((a, b) => a + b, 0), isPercent: allPercent };
|
|
1721
|
+
} else if (fname === "AVG") {
|
|
1722
|
+
if (vals.length === 0) return { error: "#DIV/0!" };
|
|
1723
|
+
return { value: vals.reduce((a, b) => a + b, 0) / vals.length, isPercent: allPercent };
|
|
1724
|
+
} else if (fname === "COUNT") {
|
|
1725
|
+
return { value: vals.length, isPercent: false };
|
|
1726
|
+
}
|
|
1727
|
+
return { error: "#NAME!" };
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
if (tok.type === "num") {
|
|
1731
|
+
consume();
|
|
1732
|
+
return { value: tok.value, isPercent: tok.isPercent };
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if (tok.type === "ref") {
|
|
1736
|
+
consume();
|
|
1737
|
+
const ref = parseCellRef(tok.value);
|
|
1738
|
+
if (!ref) return { error: "#REF!" };
|
|
1739
|
+
const cell = resolveRef(grid, ref);
|
|
1740
|
+
if (!cell || isNaN(cell.value)) return { error: "#VALUE!" };
|
|
1741
|
+
return { value: cell.value, isPercent: cell.isPercent };
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
if (tok.type === "op" && tok.value === "(") {
|
|
1745
|
+
consume();
|
|
1746
|
+
const result = parseExpr();
|
|
1747
|
+
if (result.error) return result;
|
|
1748
|
+
if (!peek() || peek().value !== ")") return { error: "#SYNTAX!" };
|
|
1749
|
+
consume();
|
|
1750
|
+
return result;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// Unary minus
|
|
1754
|
+
if (tok.type === "op" && tok.value === "-") {
|
|
1755
|
+
consume();
|
|
1756
|
+
const operand = parseAtom();
|
|
1757
|
+
if (operand.error) return operand;
|
|
1758
|
+
return { value: -operand.value, isPercent: operand.isPercent };
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
return { error: "#SYNTAX!" };
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
function parseTerm() {
|
|
1765
|
+
let left = parseAtom();
|
|
1766
|
+
if (left.error) return left;
|
|
1767
|
+
while (peek() && (peek().value === "*" || peek().value === "/")) {
|
|
1768
|
+
const op = consume().value;
|
|
1769
|
+
const right = parseAtom();
|
|
1770
|
+
if (right.error) return right;
|
|
1771
|
+
if (op === "/") {
|
|
1772
|
+
if (right.value === 0) return { error: "#DIV/0!" };
|
|
1773
|
+
left = { value: left.value / right.value, isPercent: false };
|
|
1774
|
+
} else {
|
|
1775
|
+
left = { value: left.value * right.value, isPercent: false };
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
return left;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function parseExpr() {
|
|
1782
|
+
let left = parseTerm();
|
|
1783
|
+
if (left.error) return left;
|
|
1784
|
+
while (peek() && (peek().value === "+" || peek().value === "-")) {
|
|
1785
|
+
const op = consume().value;
|
|
1786
|
+
const right = parseTerm();
|
|
1787
|
+
if (right.error) return right;
|
|
1788
|
+
const bothPercent = left.isPercent && right.isPercent;
|
|
1789
|
+
left = {
|
|
1790
|
+
value: op === "+" ? left.value + right.value : left.value - right.value,
|
|
1791
|
+
isPercent: bothPercent,
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
return left;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
const result = parseExpr();
|
|
1798
|
+
if (result.error) return { value: NaN, isPercent: false, error: result.error };
|
|
1799
|
+
if (peek()) return { value: NaN, isPercent: false, error: "#SYNTAX!" };
|
|
1800
|
+
return { value: result.value, isPercent: result.isPercent, error: null };
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function evaluateGrid(grid) {
|
|
1804
|
+
// Track which cells started as formulas for circular reference detection
|
|
1805
|
+
const formulaCells = [];
|
|
1806
|
+
for (let r = 0; r < grid.length; r++) {
|
|
1807
|
+
for (let c = 0; c < grid[r].length; c++) {
|
|
1808
|
+
if (grid[r][c].formula) formulaCells.push([r, c]);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
// Topological evaluation: resolve formulas that depend on other formulas
|
|
1812
|
+
const maxPasses = formulaCells.length + 1;
|
|
1813
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
1814
|
+
let pending = false;
|
|
1815
|
+
for (let r = 0; r < grid.length; r++) {
|
|
1816
|
+
for (let c = 0; c < grid[r].length; c++) {
|
|
1817
|
+
const cell = grid[r][c];
|
|
1818
|
+
if (!cell.formula) continue;
|
|
1819
|
+
const result = evaluateFormula(cell.formula, grid);
|
|
1820
|
+
if (result.error === "#VALUE!" && pass < maxPasses - 1) {
|
|
1821
|
+
// Might resolve on a later pass when dependencies are computed
|
|
1822
|
+
pending = true;
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1825
|
+
cell.value = result.value;
|
|
1826
|
+
cell.isPercent = result.isPercent;
|
|
1827
|
+
cell.error = result.error;
|
|
1828
|
+
delete cell.formula;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
if (!pending) break;
|
|
1832
|
+
}
|
|
1833
|
+
// Any formula cells still showing #VALUE! after all passes are circular
|
|
1834
|
+
for (const [r, c] of formulaCells) {
|
|
1835
|
+
if (grid[r][c].error === "#VALUE!") {
|
|
1836
|
+
grid[r][c].error = "#CIRCULAR!";
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
return grid;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
function formatFormulaResult(cell) {
|
|
1843
|
+
if (cell.error) return cell.error;
|
|
1844
|
+
if (cell.isPercent) {
|
|
1845
|
+
const pct = cell.value * 100;
|
|
1846
|
+
return (Number.isInteger(pct) ? pct.toString() : pct.toFixed(2).replace(/\.?0+$/, "")) + "%";
|
|
1847
|
+
}
|
|
1848
|
+
if (Number.isInteger(cell.value)) return cell.value.toLocaleString();
|
|
1849
|
+
return cell.value.toFixed(2).replace(/\.?0+$/, "");
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// ── End formula engine ───────────────────────────────────────────────
|
|
1853
|
+
|
|
1553
1854
|
function renderTable(table) {
|
|
1554
1855
|
const dl = dataLineAttrs(table);
|
|
1555
1856
|
const opts = table.options || {};
|
|
@@ -1566,10 +1867,24 @@ function renderTable(table) {
|
|
|
1566
1867
|
thead = `<thead class="sdoc-table-head"><tr>${headerCells}</tr></thead>`;
|
|
1567
1868
|
}
|
|
1568
1869
|
|
|
1870
|
+
// Formula evaluation
|
|
1871
|
+
const grid = evaluateGrid(buildCellGrid(table.rows));
|
|
1872
|
+
|
|
1569
1873
|
const bodyRows = table.rows
|
|
1570
|
-
.map((row) => {
|
|
1874
|
+
.map((row, r) => {
|
|
1571
1875
|
const cells = row
|
|
1572
|
-
.map((cell) =>
|
|
1876
|
+
.map((cell, c) => {
|
|
1877
|
+
if (isFormulaCell(cell)) {
|
|
1878
|
+
const result = grid[r][c];
|
|
1879
|
+
const display = escapeHtml(formatFormulaResult(result));
|
|
1880
|
+
const formula = escapeAttr(cell.trim());
|
|
1881
|
+
if (result.error) {
|
|
1882
|
+
return `<td class="sdoc-table-td sdoc-formula-error" title="${formula}">${display}</td>`;
|
|
1883
|
+
}
|
|
1884
|
+
return `<td class="sdoc-table-td sdoc-formula-cell" title="${formula}">${display}</td>`;
|
|
1885
|
+
}
|
|
1886
|
+
return `<td class="sdoc-table-td">${renderInline(cell)}</td>`;
|
|
1887
|
+
})
|
|
1573
1888
|
.join("");
|
|
1574
1889
|
return `<tr>${cells}</tr>`;
|
|
1575
1890
|
})
|
|
@@ -1899,6 +2214,19 @@ const DEFAULT_STYLE = `
|
|
|
1899
2214
|
border-bottom: none;
|
|
1900
2215
|
}
|
|
1901
2216
|
|
|
2217
|
+
td.sdoc-formula-cell {
|
|
2218
|
+
font-variant-numeric: tabular-nums;
|
|
2219
|
+
font-weight: 600;
|
|
2220
|
+
color: #2a7a8a !important;
|
|
2221
|
+
cursor: help;
|
|
2222
|
+
border-bottom: 1px dotted #2a7a8a40;
|
|
2223
|
+
}
|
|
2224
|
+
td.sdoc-formula-error {
|
|
2225
|
+
color: #c33 !important;
|
|
2226
|
+
font-style: italic;
|
|
2227
|
+
cursor: help;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
1902
2230
|
.sdoc-table-borderless,
|
|
1903
2231
|
.sdoc-table-borderless th,
|
|
1904
2232
|
.sdoc-table-borderless td {
|
|
@@ -2453,8 +2781,31 @@ function firstParagraphPreview(nodes, maxLen) {
|
|
|
2453
2781
|
return "";
|
|
2454
2782
|
}
|
|
2455
2783
|
|
|
2784
|
+
/**
|
|
2785
|
+
* Recursively collect all tagged (has @id) scope nodes from the content tree.
|
|
2786
|
+
* Skips @meta and @about. Used for deep section discovery — lets MCP clients
|
|
2787
|
+
* find sections nested inside top-level scopes (e.g. @pass-terminology inside
|
|
2788
|
+
* @pedantic-review inside @writing).
|
|
2789
|
+
*/
|
|
2790
|
+
function getAllTaggedScopes(nodes) {
|
|
2791
|
+
const result = [];
|
|
2792
|
+
function walk(nodeList) {
|
|
2793
|
+
for (const node of nodeList) {
|
|
2794
|
+
if (node.type === "scope") {
|
|
2795
|
+
if (node.id && node.id.toLowerCase() !== "meta" && node.id.toLowerCase() !== "about") {
|
|
2796
|
+
result.push(node);
|
|
2797
|
+
}
|
|
2798
|
+
if (node.children) walk(node.children);
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
const doc = getDocumentScope(nodes);
|
|
2803
|
+
walk(doc ? doc.children : nodes);
|
|
2804
|
+
return result;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2456
2807
|
function listSections(nodes) {
|
|
2457
|
-
return
|
|
2808
|
+
return getAllTaggedScopes(nodes).map((node) => ({
|
|
2458
2809
|
id: node.id || null,
|
|
2459
2810
|
derivedId: slugify(node.title),
|
|
2460
2811
|
title: node.title,
|
|
@@ -2474,7 +2825,7 @@ function collectDataBlocks(children) {
|
|
|
2474
2825
|
}
|
|
2475
2826
|
|
|
2476
2827
|
function extractSection(nodes, sectionId) {
|
|
2477
|
-
const scopes =
|
|
2828
|
+
const scopes = getAllTaggedScopes(nodes);
|
|
2478
2829
|
|
|
2479
2830
|
function buildResult(node) {
|
|
2480
2831
|
const data = collectDataBlocks(node.children || []);
|