@entropicwarrior/sdoc 0.2.12 → 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.
@@ -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
 
@@ -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.
@@ -1083,7 +1146,14 @@ Content of Section A.
1083
1146
  | "left" | "center" | "right" ;
1084
1147
  percentage = digit { digit } [ "." digit { digit } ] "%" ;
1085
1148
  pixels = digit { digit } "px" ;
1086
- 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 ] ;
1087
1157
  table_row = cell { "|" cell } ;
1088
1158
  citations_scope = citations_open ws? citations_body "}" ;
1089
1159
  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.13",
6
6
  "publisher": "entropicwarrior-msenfin",
7
7
  "license": "MIT",
8
8
  "repository": {
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;
@@ -835,18 +934,31 @@ function parseTableBody(cursor, tableStartLine, options) {
835
934
  cursor.next();
836
935
  }
837
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
+
838
949
  const hasOptions = options.borderless || options.headerless || options.width || options.align;
839
950
 
951
+ let tableNode;
840
952
  if (options.headerless) {
841
- const tableNode = { type: "table", headers: [], rows, lineStart: tableStartLine, lineEnd: cursor.index };
842
- if (hasOptions) tableNode.options = options;
843
- return tableNode;
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 };
844
958
  }
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
959
  if (hasOptions) tableNode.options = options;
960
+ if (columnAlign) tableNode.columnAlign = columnAlign;
961
+ if (columnFormat) tableNode.columnFormat = columnFormat;
850
962
  return tableNode;
851
963
  }
852
964
 
@@ -2122,15 +2234,23 @@ function formatFormulaResult(cell) {
2122
2234
  function renderTable(table) {
2123
2235
  const dl = dataLineAttrs(table);
2124
2236
  const opts = table.options || {};
2237
+ const colAlign = table.columnAlign || [];
2238
+ const colFormat = table.columnFormat || [];
2125
2239
  const classes = ["sdoc-table"];
2126
2240
  if (opts.borderless) classes.push("sdoc-table-borderless");
2127
2241
  if (opts.headerless) classes.push("sdoc-table-headerless");
2128
2242
  const classAttr = classes.join(" ");
2129
2243
 
2244
+ function cellStyle(colIndex) {
2245
+ const align = colAlign[colIndex];
2246
+ if (!align || align === "left") return "";
2247
+ return ` style="text-align:${align}"`;
2248
+ }
2249
+
2130
2250
  let thead = "";
2131
2251
  if (table.headers.length > 0) {
2132
2252
  const headerCells = table.headers
2133
- .map((cell) => `<th class="sdoc-table-th">${renderInline(cell)}</th>`)
2253
+ .map((cell, c) => `<th class="sdoc-table-th"${cellStyle(c)}>${renderInline(cell)}</th>`)
2134
2254
  .join("");
2135
2255
  thead = `<thead class="sdoc-table-head"><tr>${headerCells}</tr></thead>`;
2136
2256
  }
@@ -2142,16 +2262,33 @@ function renderTable(table) {
2142
2262
  .map((row, r) => {
2143
2263
  const cells = row
2144
2264
  .map((cell, c) => {
2265
+ const style = cellStyle(c);
2266
+ const fmt = colFormat[c] || null;
2267
+
2145
2268
  if (isFormulaCell(cell)) {
2146
2269
  const result = grid[r][c];
2147
- const display = escapeHtml(formatFormulaResult(result));
2270
+ let display;
2271
+ if (!result.error && fmt) {
2272
+ display = escapeHtml(formatNumber(result.value, fmt));
2273
+ } else {
2274
+ display = escapeHtml(formatFormulaResult(result));
2275
+ }
2148
2276
  const formula = escapeAttr(cell.trim());
2149
2277
  if (result.error) {
2150
- 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>`;
2151
2279
  }
2152
- return `<td class="sdoc-table-td sdoc-formula-cell" title="${formula}">${display}</td>`;
2280
+ return `<td class="sdoc-table-td sdoc-formula-cell"${style} title="${formula}">${display}</td>`;
2153
2281
  }
2154
- return `<td class="sdoc-table-td">${renderInline(cell)}</td>`;
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>`;
2288
+ }
2289
+ }
2290
+
2291
+ return `<td class="sdoc-table-td"${style}>${renderInline(cell)}</td>`;
2155
2292
  })
2156
2293
  .join("");
2157
2294
  return `<tr>${cells}</tr>`;