@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 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 — read the `@about` scope first (~50 tokens), then scan headings, then load only the section you need.
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
  ![A](a.png =48%) ![B](b.png =48%)
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** — \`@setup\` does not exist in this file:
724
+ **Wrong** — \`@extension\` looks for a local section, not one in \`foundations.sdoc\`:
697
725
 
698
726
  ```
699
- See `getting-started.sdoc` @setup for installation steps.
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 [Setup](./getting-started.sdoc#setup) for installation steps.
733
+ See [Extension](./foundations.sdoc#extension) for the design rationale.
706
734
  ```
707
735
 
708
- The fragment (\`#setup\`) matches the target scope's \`@id\`. Use \`\\@\` if you need a literal \`@\` in text without triggering reference resolution.
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
@@ -4,7 +4,7 @@
4
4
  {
5
5
  type: skill
6
6
 
7
- sdoc-version: 0.1
7
+ sdoc-version: 0.2
8
8
  }
9
9
 
10
10
  # About @about
@@ -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",
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) => `<td class="sdoc-table-td">${renderInline(cell)}</td>`)
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 getContentScopes(nodes).map((node) => ({
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 = getContentScopes(nodes);
2828
+ const scopes = getAllTaggedScopes(nodes);
2478
2829
 
2479
2830
  function buildResult(node) {
2480
2831
  const data = collectDataBlocks(node.children || []);