@entropicwarrior/sdoc 0.2.12 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -275,6 +275,66 @@ Content of Section B.
275
275
  }
276
276
  ```
277
277
 
278
+ # Column Directives @column-directives
279
+ {
280
+ An optional directive row after the header controls per-column alignment and number formatting. It is consumed by the parser and not rendered as data.
281
+
282
+ # Column Alignment
283
+ {
284
+ Use \`<\` (left), \`>\` (right), or \`=\` (center):
285
+
286
+ ```
287
+ {[table]
288
+ Programme | Amount | Status
289
+ < | > | =
290
+ Alpha | $1,000,000 | Active
291
+ Beta | $2,500,000 | Pending
292
+ }
293
+ ```
294
+
295
+ If absent, all columns default to left. Empty cells in the directive row keep the default. Alignment applies to both header and data cells.
296
+
297
+ For headerless tables, the directive row is the first row — it is consumed but no header is rendered:
298
+
299
+ ```
300
+ {[table headerless]
301
+ < | >
302
+ Alice | 100
303
+ Bob | 200
304
+ }
305
+ ```
306
+ }
307
+
308
+ # Column Formatting
309
+ {
310
+ A format token in the directive row formats numeric cells and formula results in that column. Text cells (like bold labels) pass through unchanged. The format is display-only — formulas always use raw numeric values.
311
+
312
+ {[.]
313
+ - \`$\` — currency, no decimals: \`$1,000,000\`
314
+ - \`$.N\` — currency with N decimals: \`$.2\` → \`$1,234.50\`
315
+ - \`,\` — thousands separator: \`1,000,000\`
316
+ - \`,.N\` — thousands with N decimals: \`,.2\` → \`1,234,567.89\`
317
+ - \`.N\` — fixed N decimals: \`.2\` → \`3.14\`
318
+ - \`%\` — percentage (value × 100): \`0.452\` → \`45.2%\`
319
+ - \`%.N\` — percentage with N decimals: \`%.1\` → \`45.7%\`
320
+ }
321
+
322
+ Alignment and format combine in a single directive cell:
323
+
324
+ ```
325
+ {[table]
326
+ Item | Amount
327
+ < | > $
328
+ Widget | 1000000
329
+ Gadget | 2500000
330
+ **Total** | =SUM(B1:B2)
331
+ }
332
+ ```
333
+
334
+ The \`Amount\` column is right-aligned and formatted as currency. The formula result and data cells all display as \`$1,000,000\` style. The \`**Total**\` label renders as bold text.
335
+ }
336
+ }
337
+
278
338
  # Table Formulas @table-formulas
279
339
  {
280
340
  Cells starting with \`=\` are evaluated as formulas. Hover a computed cell in preview to see the original formula.
@@ -282,9 +342,10 @@ Content of Section B.
282
342
  ```
283
343
  {[table]
284
344
  Investor | Shares | Ownership
285
- Seed Fund | 500,000 | 25%
286
- Founder A | 1,000,000 | 50%
287
- Founder B | 500,000 | 25%
345
+ < | > , | > %
346
+ Seed Fund | 500000 | 0.25
347
+ Founder A | 1000000 | 0.50
348
+ Founder B | 500000 | 0.25
288
349
  **Total** | =SUM(B1:B3) | =SUM(C1:C3)
289
350
  }
290
351
  ```
@@ -301,6 +362,8 @@ Content of Section B.
301
362
  }
302
363
 
303
364
  Errors display in red italic: \`#DIV/0!\` (division by zero), \`#VALUE!\` (bad reference), \`#REF!\` (invalid syntax), \`#NAME!\` (unknown function), \`#CIRCULAR!\` (circular dependency).
365
+
366
+ **Tip:** Use numeric values with a column format directive (e.g. \`> $\`) instead of pre-formatted strings like \`$1,000,000\`. Formatted strings are text — formulas cannot reference them.
304
367
  }
305
368
  }
306
369
 
@@ -491,7 +554,7 @@ Content of Section B.
491
554
  {
492
555
  The reserved \`@meta\` scope configures per-file settings and is not rendered in the document body. Use the bare \`@meta\` form (preferred) or the heading form:
493
556
 
494
- ```
557
+ ```sdoc
495
558
  @meta {
496
559
  type: doc
497
560
 
@@ -541,15 +604,20 @@ Content of Section B.
541
604
 
542
605
  # About Scope @about-scope
543
606
  {
544
- The reserved \`@about\` scope provides a discovery summary for the document. It is used by \`list_knowledge\` to describe the file but is not rendered in the document body. Use the bare \`@about\` form (preferred) or the heading form:
607
+ The reserved \`@about\` scope provides a discovery summary for the document. It is used by \`list_knowledge\` to describe the file and is also rendered in the live preview with a distinct meta-section style so readers can tell it apart from regular body content. Use the bare \`@about\` form (preferred) or the heading form:
545
608
 
546
- ```
609
+ ```sdoc
547
610
  @about {
548
- How to write correct SDOC files. Covers document structure, inline formatting, block types, and common mistakes.
611
+ How to write correct SDOC files.
612
+ Covers document structure, inline formatting, block types, and common mistakes.
549
613
  }
550
614
  ```
551
615
 
552
- The heading form \`# About @about { }\` also works but the bare form is preferred.
616
+ The heading form \`# About @about { }\` also works but the bare form is preferred. With the bare form, the renderer synthesizes an \`About\` heading so the meta-section is always labelled. The heading form lets you override that label — \`# Document Discovery @about { }\` renders with `Document Discovery` as the title.
617
+
618
+ **Hidden by default in HTML / PDF exports.** Exported files are usually sent to a recipient who has already decided to read the document, so the \`@about\` discovery summary is redundant in that context. To keep it in an export, pass the opt-in flag — \`--include-about\` (see [CLI and Tools Reference](./cli.sdoc)).
619
+
620
+ An empty or whitespace-only \`@about\` scope is never rendered, regardless of the include flag.
553
621
  }
554
622
 
555
623
  # Escaping @escaping
@@ -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.
@@ -919,6 +982,29 @@ Content of Section B.
919
982
  }
920
983
  }
921
984
  }
985
+
986
+ # About Scope @about-scope
987
+ {
988
+ A reserved \`@about\` scope holds a short discovery summary for the document — what it covers, who it is for, and when to reach for it. The bare form is preferred:
989
+
990
+ ```sdoc
991
+ @about {
992
+ How to write correct SDOC files. Covers document structure,
993
+ inline formatting, block types, and common mistakes.
994
+ }
995
+ ```
996
+
997
+ The heading form `# Title @about { }` is also accepted; the title given there overrides the default `About` label when the section is rendered.
998
+
999
+ {[.]
1000
+ - The `@about` id is reserved and must not be used for normal references
1001
+ - The scope is consumed by discovery tooling (e.g. `list_knowledge` and related agent-facing helpers) to describe a document without loading the full body
1002
+ - In the VS Code live preview, `@about` renders as a meta-section — distinct background, dashed border, subdued typography — so readers can tell it apart from regular body content
1003
+ - **HTML and PDF export hide `@about` by default**, on the principle that an exported file is normally sent to a recipient who has already been asked to read it, so the "should I read this?" framing is redundant. Renderers expose an `includeAbout` opt-in (`--include-about` for the CLI; `{ includeAbout: true }` for the VS Code export commands)
1004
+ - When rendered, a bare `@about { ... }` (no heading line) is given a synthesized `About` heading so the meta-section is always labelled. The heading form preserves any custom title the author wrote
1005
+ - An empty or whitespace-only `@about` scope is never rendered, regardless of the `includeAbout` setting — the meta-section box is suppressed entirely
1006
+ }
1007
+ }
922
1008
  }
923
1009
 
924
1010
  # Interactive Preview @interactive-preview
@@ -1083,7 +1169,17 @@ Content of Section A.
1083
1169
  | "left" | "center" | "right" ;
1084
1170
  percentage = digit { digit } [ "." digit { digit } ] "%" ;
1085
1171
  pixels = digit { digit } "px" ;
1086
- table_body = table_row { table_row } ;
1172
+ table_body = table_row [ directive_row ] { table_row }
1173
+ | directive_row { table_row } ;
1174
+ (* normal tables: header row then optional directive row;
1175
+ headerless tables: optional directive row first *)
1176
+ directive_row = directive_cell { "|" directive_cell } ;
1177
+ directive_cell = [ align_char ] [ format_spec ] ;
1178
+ align_char = "<" | ">" | "=" ;
1179
+ format_spec = "$" [ "." digits ]
1180
+ | "," [ "." digits ]
1181
+ | "." digits
1182
+ | "%" [ "." digits ] ;
1087
1183
  table_row = cell { "|" cell } ;
1088
1184
  citations_scope = citations_open ws? citations_body "}" ;
1089
1185
  citations_open = "{[citations]" ;
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.12",
5
+ "version": "0.2.14",
6
6
  "publisher": "entropicwarrior-msenfin",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -7,7 +7,7 @@
7
7
  // const { nodes, meta } = extractMeta(parsed.nodes);
8
8
  // const blocks = renderNotionBlocks(nodes);
9
9
 
10
- const { parseInline } = require("./sdoc");
10
+ const { parseInline, isAboutEmpty } = require("./sdoc");
11
11
 
12
12
  // ---------------------------------------------------------------------------
13
13
  // Constants
@@ -293,6 +293,11 @@ function renderNode(node, depth, nestLevel) {
293
293
  function renderScope(scope, depth, nestLevel) {
294
294
  if (scope.scopeType === "comment") return [];
295
295
 
296
+ if (scope.id && scope.id.toLowerCase() === "about") {
297
+ if (isAboutEmpty(scope)) return [];
298
+ return renderAboutCallout(scope, depth, nestLevel);
299
+ }
300
+
296
301
  const level = Math.min(3, Math.max(1, depth));
297
302
 
298
303
  if (scope.hasHeading === false) {
@@ -310,6 +315,49 @@ function renderScope(scope, depth, nestLevel) {
310
315
  return [headingBlock(level, scope.title, null), ...childBlocks];
311
316
  }
312
317
 
318
+ // Render @about as a Notion callout so readers can tell it apart from body
319
+ // content. Paragraph children fold into the callout's rich_text (separated by
320
+ // newlines); anything else becomes a child block.
321
+ function renderAboutCallout(scope, depth, nestLevel) {
322
+ // Callout children sit one level deeper in the Notion block tree than the
323
+ // callout itself. Clamp to MAX_NEST so we never produce blocks past Notion's
324
+ // nesting limit.
325
+ const childDepth = depth + 1;
326
+ const childNestLevel = Math.min(nestLevel + 1, MAX_NEST);
327
+ const richTexts = [];
328
+ const childBlocks = [];
329
+
330
+ for (const child of scope.children || []) {
331
+ if (child.type === "paragraph") {
332
+ if (richTexts.length > 0) {
333
+ richTexts.push(richText("\n", null, defaultAnnotations()));
334
+ }
335
+ const { richText: rt, imageBlocks } = extractImagesFromInline(child.text);
336
+ richTexts.push(...rt);
337
+ childBlocks.push(...imageBlocks);
338
+ } else {
339
+ childBlocks.push(...renderNode(child, childDepth, childNestLevel));
340
+ }
341
+ }
342
+
343
+ if (richTexts.length === 0) {
344
+ richTexts.push(richText("", null, defaultAnnotations()));
345
+ }
346
+
347
+ const callout = {
348
+ type: "callout",
349
+ callout: {
350
+ rich_text: enforceContentLimit(richTexts, RICH_TEXT_LIMIT),
351
+ icon: { type: "emoji", emoji: "ℹ️" },
352
+ color: "gray_background"
353
+ }
354
+ };
355
+ if (childBlocks.length > 0) {
356
+ callout.callout.children = childBlocks;
357
+ }
358
+ return [callout];
359
+ }
360
+
313
361
  function renderParagraph(node) {
314
362
  const { richText: rt, imageBlocks } = extractImagesFromInline(node.text);
315
363
  const blocks = [];
package/src/sdoc.js CHANGED
@@ -63,6 +63,105 @@ function parseTableOptions(text) {
63
63
  return options;
64
64
  }
65
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
+
66
165
  class LineCursor {
67
166
  constructor(lines) {
68
167
  this.lines = lines;
@@ -144,7 +243,8 @@ function detectImplicitRoot(cursor) {
144
243
  if (nextTrimmed === COMMAND_SCOPE_OPEN ||
145
244
  nextTrimmed === COMMAND_LIST_BULLET ||
146
245
  nextTrimmed === COMMAND_LIST_NUMBER ||
147
- isTableCommand(nextTrimmed)) {
246
+ isTableCommand(nextTrimmed) ||
247
+ isCitationsCommand(nextTrimmed)) {
148
248
  isImplicit = false;
149
249
  } else if (tryParseInlineBlock(nextTrimmed) !== null) {
150
250
  isImplicit = false;
@@ -835,18 +935,31 @@ function parseTableBody(cursor, tableStartLine, options) {
835
935
  cursor.next();
836
936
  }
837
937
 
938
+ // Detect column directive row (alignment / formatting)
939
+ // For headerless tables: check rows[0]; for normal tables: check rows[1] (after header)
940
+ const directiveIndex = options.headerless ? 0 : 1;
941
+ let columnAlign = null;
942
+ let columnFormat = null;
943
+ if (rows.length > directiveIndex && isDirectiveRow(rows[directiveIndex])) {
944
+ const directives = parseDirectiveRow(rows[directiveIndex]);
945
+ columnAlign = directives.align;
946
+ columnFormat = directives.format;
947
+ rows.splice(directiveIndex, 1);
948
+ }
949
+
838
950
  const hasOptions = options.borderless || options.headerless || options.width || options.align;
839
951
 
952
+ let tableNode;
840
953
  if (options.headerless) {
841
- const tableNode = { type: "table", headers: [], rows, lineStart: tableStartLine, lineEnd: cursor.index };
842
- if (hasOptions) tableNode.options = options;
843
- return tableNode;
954
+ tableNode = { type: "table", headers: [], rows, lineStart: tableStartLine, lineEnd: cursor.index };
955
+ } else {
956
+ const headers = rows.length > 0 ? rows[0] : [];
957
+ const body = rows.slice(1);
958
+ tableNode = { type: "table", headers, rows: body, lineStart: tableStartLine, lineEnd: cursor.index };
844
959
  }
845
-
846
- const headers = rows.length > 0 ? rows[0] : [];
847
- const body = rows.slice(1);
848
- const tableNode = { type: "table", headers, rows: body, lineStart: tableStartLine, lineEnd: cursor.index };
849
960
  if (hasOptions) tableNode.options = options;
961
+ if (columnAlign) tableNode.columnAlign = columnAlign;
962
+ if (columnFormat) tableNode.columnFormat = columnFormat;
850
963
  return tableNode;
851
964
  }
852
965
 
@@ -1705,8 +1818,17 @@ function renderInlineNodes(nodes) {
1705
1818
  function renderScope(scope, depth, isTitleScope = false) {
1706
1819
  // :comment scopes are not rendered
1707
1820
  if (scope.scopeType === "comment") return "";
1708
- // @about is document metadata, not rendered in output
1709
- if (scope.id && scope.id.toLowerCase() === "about") return "";
1821
+
1822
+ const isAbout = scope.id && scope.id.toLowerCase() === "about";
1823
+ // Skip empty/whitespace-only @about — no point rendering an empty meta box.
1824
+ if (isAbout && isAboutEmpty(scope)) return "";
1825
+
1826
+ // Bare `@about { ... }` (no heading) and heading-form with empty title both
1827
+ // get a default heading of "About" so the meta-section is always labelled.
1828
+ // A custom title (e.g. `# Document Discovery @about`) is preserved.
1829
+ if (isAbout && (!scope.hasHeading || !scope.title || !scope.title.trim())) {
1830
+ scope = { ...scope, hasHeading: true, title: "About" };
1831
+ }
1710
1832
 
1711
1833
  const level = Math.min(6, Math.max(1, depth));
1712
1834
  const children = scope.children.map((child) => renderNode(child, depth + 1)).join("\n");
@@ -1714,9 +1836,10 @@ function renderScope(scope, depth, isTitleScope = false) {
1714
1836
  const dl = dataLineAttrs(scope);
1715
1837
  const typeAttr = scope.scopeType ? ` data-scope-type="${escapeAttr(scope.scopeType)}"` : "";
1716
1838
  const typeClass = scope.scopeType ? ` sdoc-scope-type-${scope.scopeType}` : "";
1839
+ const metaClass = isAbout ? " sdoc-meta-section" : "";
1717
1840
 
1718
1841
  if (scope.hasHeading === false) {
1719
- return `<section class="sdoc-scope sdoc-scope-noheading${rootClass}${typeClass}"${typeAttr}${dl}>${children}</section>`;
1842
+ return `<section class="sdoc-scope sdoc-scope-noheading${rootClass}${typeClass}${metaClass}"${typeAttr}${dl}>${children}</section>`;
1720
1843
  }
1721
1844
 
1722
1845
  const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
@@ -1724,7 +1847,7 @@ function renderScope(scope, depth, isTitleScope = false) {
1724
1847
  const toggle = hasChildren ? `<span class="sdoc-toggle"></span>` : "";
1725
1848
  const heading = `<h${level}${idAttr} class="sdoc-heading sdoc-depth-${level}"${dl}>${toggle}${renderInline(scope.title)}</h${level}>`;
1726
1849
  const childrenHtml = children ? `\n<div class="sdoc-scope-children">${children}</div>` : "";
1727
- return `<section class="sdoc-scope${rootClass}${typeClass}"${typeAttr}>${heading}${childrenHtml}</section>`;
1850
+ return `<section class="sdoc-scope${rootClass}${typeClass}${metaClass}"${typeAttr}>${heading}${childrenHtml}</section>`;
1728
1851
  }
1729
1852
 
1730
1853
  function renderCitations(node) {
@@ -2122,15 +2245,23 @@ function formatFormulaResult(cell) {
2122
2245
  function renderTable(table) {
2123
2246
  const dl = dataLineAttrs(table);
2124
2247
  const opts = table.options || {};
2248
+ const colAlign = table.columnAlign || [];
2249
+ const colFormat = table.columnFormat || [];
2125
2250
  const classes = ["sdoc-table"];
2126
2251
  if (opts.borderless) classes.push("sdoc-table-borderless");
2127
2252
  if (opts.headerless) classes.push("sdoc-table-headerless");
2128
2253
  const classAttr = classes.join(" ");
2129
2254
 
2255
+ function cellStyle(colIndex) {
2256
+ const align = colAlign[colIndex];
2257
+ if (!align || align === "left") return "";
2258
+ return ` style="text-align:${align}"`;
2259
+ }
2260
+
2130
2261
  let thead = "";
2131
2262
  if (table.headers.length > 0) {
2132
2263
  const headerCells = table.headers
2133
- .map((cell) => `<th class="sdoc-table-th">${renderInline(cell)}</th>`)
2264
+ .map((cell, c) => `<th class="sdoc-table-th"${cellStyle(c)}>${renderInline(cell)}</th>`)
2134
2265
  .join("");
2135
2266
  thead = `<thead class="sdoc-table-head"><tr>${headerCells}</tr></thead>`;
2136
2267
  }
@@ -2142,16 +2273,33 @@ function renderTable(table) {
2142
2273
  .map((row, r) => {
2143
2274
  const cells = row
2144
2275
  .map((cell, c) => {
2276
+ const style = cellStyle(c);
2277
+ const fmt = colFormat[c] || null;
2278
+
2145
2279
  if (isFormulaCell(cell)) {
2146
2280
  const result = grid[r][c];
2147
- const display = escapeHtml(formatFormulaResult(result));
2281
+ let display;
2282
+ if (!result.error && fmt) {
2283
+ display = escapeHtml(formatNumber(result.value, fmt));
2284
+ } else {
2285
+ display = escapeHtml(formatFormulaResult(result));
2286
+ }
2148
2287
  const formula = escapeAttr(cell.trim());
2149
2288
  if (result.error) {
2150
- return `<td class="sdoc-table-td sdoc-formula-error" title="${formula}">${display}</td>`;
2289
+ return `<td class="sdoc-table-td sdoc-formula-error"${style} title="${formula}">${display}</td>`;
2290
+ }
2291
+ return `<td class="sdoc-table-td sdoc-formula-cell"${style} title="${formula}">${display}</td>`;
2292
+ }
2293
+
2294
+ // Apply column format to numeric data cells
2295
+ if (fmt) {
2296
+ const parsed = parseCellValue(cell);
2297
+ if (!isNaN(parsed.value)) {
2298
+ return `<td class="sdoc-table-td"${style}>${escapeHtml(formatNumber(parsed.value, fmt))}</td>`;
2151
2299
  }
2152
- return `<td class="sdoc-table-td sdoc-formula-cell" title="${formula}">${display}</td>`;
2153
2300
  }
2154
- return `<td class="sdoc-table-td">${renderInline(cell)}</td>`;
2301
+
2302
+ return `<td class="sdoc-table-td"${style}>${renderInline(cell)}</td>`;
2155
2303
  })
2156
2304
  .join("");
2157
2305
  return `<tr>${cells}</tr>`;
@@ -2803,6 +2951,43 @@ const DEFAULT_STYLE = `
2803
2951
  display: none;
2804
2952
  }
2805
2953
 
2954
+ /* Meta sections (@about) — rendered with a distinct, subdued style
2955
+ so readers can tell at a glance this is document metadata, not body content. */
2956
+ .sdoc-scope.sdoc-meta-section {
2957
+ position: relative;
2958
+ margin: 1.2rem 3rem 1.6rem 3rem;
2959
+ padding: 0.7rem 1rem 0.7rem 1rem;
2960
+ background: rgba(127, 120, 112, 0.06);
2961
+ border: 1px dashed var(--sdoc-border);
2962
+ border-left: 3px solid var(--sdoc-muted);
2963
+ border-radius: 6px;
2964
+ color: var(--sdoc-muted);
2965
+ font-size: 0.95em;
2966
+ }
2967
+
2968
+ .sdoc-meta-section .sdoc-heading {
2969
+ margin-top: 0.2rem;
2970
+ color: var(--sdoc-muted);
2971
+ font-weight: 600;
2972
+ border-bottom: none;
2973
+ }
2974
+
2975
+ .sdoc-meta-section .sdoc-paragraph {
2976
+ margin: 0.3rem 0;
2977
+ font-style: italic;
2978
+ }
2979
+
2980
+ .sdoc-meta-section .sdoc-scope-children > .sdoc-scope {
2981
+ padding-left: 0;
2982
+ }
2983
+
2984
+ /* Place the collapse toggle in the gutter to the LEFT of the meta
2985
+ box (in the space created by margin-left), not on the colored
2986
+ left border. Default is left: -1.4em which lands on the border. */
2987
+ .sdoc-meta-section > .sdoc-heading > .sdoc-toggle {
2988
+ left: -2.6em;
2989
+ }
2990
+
2806
2991
  `;
2807
2992
 
2808
2993
  const PRINT_STYLE = `
@@ -2940,15 +3125,21 @@ function renderBodyNodes(nodes) {
2940
3125
  .join("\n");
2941
3126
  }
2942
3127
 
2943
- function renderHtmlBody(text) {
3128
+ function renderHtmlBody(text, options = {}) {
2944
3129
  const parsed = parseSdoc(text);
2945
3130
  const metaResult = extractMeta(parsed.nodes);
2946
3131
  const savedOptions = _renderOptions;
2947
3132
  _renderOptions = {};
2948
- const citationData = buildCitationNumbering(metaResult.nodes);
3133
+ // includeAbout defaults to false: HTML output is treated as "export-shape"
3134
+ // by default, hiding the discovery summary. The preview path in
3135
+ // extension.js opts in with `includeAbout: true` to keep the meta-section
3136
+ // visible during authoring.
3137
+ const includeAbout = options.includeAbout === true;
3138
+ const renderNodes = includeAbout ? metaResult.nodes : stripAboutScopes(metaResult.nodes);
3139
+ const citationData = buildCitationNumbering(renderNodes);
2949
3140
  _citationNumbering = citationData.numbering;
2950
3141
  _citationDefinitions = citationData.definitions;
2951
- const result = renderBodyNodes(metaResult.nodes);
3142
+ const result = renderBodyNodes(renderNodes);
2952
3143
  _citationNumbering = new Map();
2953
3144
  _citationDefinitions = new Map();
2954
3145
  _renderOptions = savedOptions;
@@ -2957,10 +3148,16 @@ function renderHtmlBody(text) {
2957
3148
 
2958
3149
  function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
2959
3150
  _renderOptions = options.renderOptions ?? {};
2960
- const citationData = buildCitationNumbering(parsed.nodes);
3151
+ // includeAbout defaults to false: HTML/PDF output is hidden-by-default
3152
+ // because exported files are normally sent to a specific recipient who has
3153
+ // already been asked to read the doc, so the "should I read this?" framing
3154
+ // in @about adds noise. The live preview opts in with `includeAbout: true`.
3155
+ const includeAbout = options.includeAbout === true;
3156
+ const renderNodes = includeAbout ? parsed.nodes : stripAboutScopes(parsed.nodes);
3157
+ const citationData = buildCitationNumbering(renderNodes);
2961
3158
  _citationNumbering = citationData.numbering;
2962
3159
  _citationDefinitions = citationData.definitions;
2963
- const body = renderBodyNodes(parsed.nodes);
3160
+ const body = renderBodyNodes(renderNodes);
2964
3161
  _citationNumbering = new Map();
2965
3162
  _citationDefinitions = new Map();
2966
3163
  _renderOptions = {};
@@ -2988,13 +3185,13 @@ function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
2988
3185
  const mermaidInit = mermaidTheme === "auto"
2989
3186
  ? `var isDark=window.matchMedia("(prefers-color-scheme:dark)").matches;mermaid.initialize({startOnLoad:true,theme:isDark?"dark":"neutral",themeCSS:".node rect, .node polygon, .node circle { rx: 4; ry: 4; }"});`
2990
3187
  : `mermaid.initialize({startOnLoad:true,theme:"${mermaidTheme}",themeCSS:".node rect, .node polygon, .node circle { rx: 4; ry: 4; }"});`;
2991
- const mermaidScript = hasMermaidBlocks(parsed.nodes)
3188
+ const mermaidScript = hasMermaidBlocks(renderNodes)
2992
3189
  ? `\n<script src="${MERMAID_CDN}"></script>\n<script>${mermaidInit}</script>`
2993
3190
  : "";
2994
3191
  const katexCssTag = body.includes('class="katex"')
2995
3192
  ? `\n<link rel="stylesheet" href="${KATEX_CDN_CSS}" />`
2996
3193
  : "";
2997
- const hasHljs = hasHighlightableCodeBlocks(parsed.nodes);
3194
+ const hasHljs = hasHighlightableCodeBlocks(renderNodes);
2998
3195
  // Highlight.js CSS is inlined (not a <link>) so it is extracted by parseDocHtml in the web viewer
2999
3196
  // and applied inside shadow DOM. The @media query handles dark mode in browsers.
3000
3197
  const hljsCssInline = hasHljs
@@ -3306,6 +3503,36 @@ function extractAbout(nodes) {
3306
3503
  return null;
3307
3504
  }
3308
3505
 
3506
+ // True when an @about scope has no meaningful content. Renderers use this to
3507
+ // skip emitting an empty meta-section / callout. Whitespace-only paragraphs
3508
+ // count as empty.
3509
+ function isAboutEmpty(scope) {
3510
+ if (!scope || !scope.children || scope.children.length === 0) return true;
3511
+ return scope.children.every(
3512
+ (child) => child.type === "paragraph" && (!child.text || child.text.trim() === "")
3513
+ );
3514
+ }
3515
+
3516
+ // Recursively remove @about scopes from an AST. Used by the HTML/PDF export
3517
+ // paths, which hide @about by default: when a doc is exported and sent to a
3518
+ // specific recipient, the "should I read this?" framing is moot — the sender
3519
+ // already decided the answer is yes. Pass-through for nodes without children.
3520
+ function stripAboutScopes(nodes) {
3521
+ if (!Array.isArray(nodes)) return nodes;
3522
+ const result = [];
3523
+ for (const node of nodes) {
3524
+ if (node && node.type === "scope" && node.id && node.id.toLowerCase() === "about") {
3525
+ continue;
3526
+ }
3527
+ if (node && node.type === "scope" && Array.isArray(node.children)) {
3528
+ result.push({ ...node, children: stripAboutScopes(node.children) });
3529
+ } else {
3530
+ result.push(node);
3531
+ }
3532
+ }
3533
+ return result;
3534
+ }
3535
+
3309
3536
  function collectAllIds(nodes) {
3310
3537
  const ids = new Set();
3311
3538
  function walk(nodeList) {
@@ -3531,6 +3758,8 @@ module.exports = {
3531
3758
  listSections,
3532
3759
  extractSection,
3533
3760
  extractAbout,
3761
+ isAboutEmpty,
3762
+ stripAboutScopes,
3534
3763
  extractDataBlocks,
3535
3764
  KNOWN_SCOPE_TYPES,
3536
3765
  // Validation
@@ -2,11 +2,12 @@
2
2
  // SDOC Document — CLI tool for HTML and PDF export
3
3
  //
4
4
  // Usage:
5
- // node tools/build-doc.js input.sdoc [-o output] [--html]
5
+ // node tools/build-doc.js input.sdoc [-o output] [--html] [--include-about]
6
6
  //
7
7
  // Default output is PDF (requires Chrome/Chromium).
8
8
  // Use --html for HTML-only output (no Chrome needed).
9
9
  // If -o is omitted, writes to input.pdf (or input.html with --html).
10
+ // The @about scope is hidden by default; pass --include-about to keep it.
10
11
 
11
12
  const fs = require("fs");
12
13
  const path = require("path");
@@ -16,7 +17,7 @@ const { parseSdoc, extractMeta, resolveIncludes, renderHtmlDocumentFromParsed }
16
17
  const CONFIG_FILENAME = "sdoc.config.json";
17
18
 
18
19
  function usage() {
19
- console.error("Usage: build-doc <input.sdoc> [-o output] [--html]");
20
+ console.error("Usage: build-doc <input.sdoc> [-o output] [--html] [--include-about]");
20
21
  process.exit(1);
21
22
  }
22
23
 
@@ -98,7 +99,8 @@ function resolveMetaStyles(meta, documentPath) {
98
99
  return result;
99
100
  }
100
101
 
101
- async function buildHtml(filePath) {
102
+ async function buildHtml(filePath, options = {}) {
103
+ const includeAbout = options.includeAbout === true;
102
104
  const resolvedPath = path.resolve(filePath);
103
105
  const text = fs.readFileSync(resolvedPath, "utf8");
104
106
  const parsed = parseSdoc(text);
@@ -141,6 +143,7 @@ async function buildHtml(filePath) {
141
143
  config,
142
144
  cssOverride: cssOverride || undefined,
143
145
  cssAppend: cssAppendParts.join("\n") || undefined,
146
+ includeAbout,
144
147
  }
145
148
  );
146
149
  }
@@ -150,12 +153,15 @@ async function main() {
150
153
  let inputPath = null;
151
154
  let outputPath = null;
152
155
  let htmlMode = false;
156
+ let includeAbout = false;
153
157
 
154
158
  for (let i = 0; i < args.length; i++) {
155
159
  if (args[i] === "-o" && i + 1 < args.length) {
156
160
  outputPath = args[++i];
157
161
  } else if (args[i] === "--html") {
158
162
  htmlMode = true;
163
+ } else if (args[i] === "--include-about") {
164
+ includeAbout = true;
159
165
  } else if (args[i] === "--help" || args[i] === "-h") {
160
166
  usage();
161
167
  } else if (!inputPath) {
@@ -174,7 +180,7 @@ async function main() {
174
180
  process.exit(1);
175
181
  }
176
182
 
177
- const html = await buildHtml(resolvedInput);
183
+ const html = await buildHtml(resolvedInput, { includeAbout });
178
184
 
179
185
  if (htmlMode) {
180
186
  if (!outputPath) {