@entropicwarrior/sdoc 0.2.15 → 0.2.17

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.
@@ -333,6 +333,22 @@ Content of Section B.
333
333
 
334
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
335
  }
336
+
337
+ # Copyable Columns
338
+ {
339
+ A \`copy\` token in the directive row gives every body cell in that column a click-to-copy icon (copying the cell's raw text). Ideal for columns of sequences, IDs, or commands.
340
+
341
+ ```
342
+ {[table]
343
+ Gene | Sequence
344
+ | copy
345
+ BRCA1 | ACGTACGTGGCA
346
+ TP53 | GGCATTACGATT
347
+ }
348
+ ```
349
+
350
+ \`copy\` can share a directive row with alignment/format directives on other columns.
351
+ }
336
352
  }
337
353
 
338
354
  # Table Formulas @table-formulas
@@ -384,8 +400,14 @@ Content of Section B.
384
400
  \`\{!text!\}\` | Warning marker (orange)
385
401
  \`\{-text-\}\` | Negative marker (red)
386
402
  \`\{~text~\}\` | Highlight (yellow)
403
+ \`#1a73e8\` | Hex color swatch (auto-readable text)
404
+ \`\{copy\}text\{/copy\}\` | Copyable text with click-to-copy icon
387
405
  }
388
406
 
407
+ Hex color codes (\`#rgb\`, \`#rgba\`, \`#rrggbb\`, \`#rrggbbaa\`) render as inline swatches — a rounded box filled with the color and labelled with the code, with the text color chosen automatically (black or white) for legibility. They are detected in normal text, list items, table cells, and headings, but not inside \`\\\`inline code\\\`\` (which stays literal). A bare \`#\` at the start of a line is a heading; escape with \`\\#\` to keep a literal hash.
408
+
409
+ Copyable text: \`\{copy\}ACGTACGTGGCA\{/copy\}\` renders the inner text verbatim in monospace with a copy icon; clicking it copies the text. Great for DNA/protein sequences, IDs, hashes, and commands. Escape with \`\\\{copy\}\` for a literal. Copy icons show in HTML output (preview, exported HTML, web viewer) and are hidden when printed/exported to PDF.
410
+
389
411
  Links: \`[Link text](https://example.com)\` or \`[Other doc](./other-file.sdoc)\`. Relative paths resolve from the document's directory.
390
412
 
391
413
  Images: \`![Alt text](path/to/image.png)\`
@@ -435,9 +435,25 @@ Content of Section B.
435
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
436
  }
437
437
 
438
+ # Copyable Columns @column-copy
439
+ {
440
+ A `copy` token (case-insensitive) in the directive row marks a column copyable: every body cell in that column is rendered with a click-to-copy icon whose target is the cell's raw text. Useful for sequences, identifiers, hashes, and commands.
441
+
442
+ ```
443
+ {[table]
444
+ Gene | Sequence
445
+ | copy
446
+ BRCA1 | ACGTACGTGGCA
447
+ TP53 | GGCATTACGATT
448
+ }
449
+ ```
450
+
451
+ The `copy` token occupies a directive cell of its own and may coexist with alignment/format directives on other columns in the same row. The literal (unformatted) cell text is copied. Copy icons are interactive HTML only — they appear in preview, exported HTML, and the web viewer, and are hidden in printed/PDF output.
452
+ }
453
+
438
454
  # Directive Row Detection @directive-detection
439
455
  {
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.
456
+ A row is identified as a directive row when every non-empty cell contains only an alignment character (`<`, `>`, `=`), a format token, the `copy` keyword, or a combination of alignment and format. 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
457
 
442
458
  For headerless tables, the directive row functions as a virtual header — it is consumed for its directives but no `<thead>` is rendered.
443
459
  }
@@ -719,12 +735,18 @@ Content of Section B.
719
735
  - Warning marker: `{!text!}` (orange highlight)
720
736
  - Negative marker: `{-text-}` (red highlight)
721
737
  - Highlight: `{~text~}` (yellow highlight)
738
+ - Hex color swatch: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` (e.g. `#9aa0a8`) renders as a filled inline box labelled with the code
739
+ - Copyable text: `{copy}...{/copy}` renders the inner text verbatim (monospace) with a click-to-copy icon
722
740
  - Citation reference: `[@key]` (numbered superscript link to citation entry)
723
741
  - Multiple citation references: `[@key1, @key2]` (each individually linked)
724
742
  }
725
743
 
726
744
  Inline math requires non-whitespace immediately after the opening `$` and before the closing `$`. A plain `$` followed by a digit (e.g. `$100`) does not trigger math mode because there is no closing `$`.
727
745
 
746
+ Hex color swatches are detected only when the `#` is at the start of the inline text or preceded by a non-alphanumeric character, the digit run is exactly 3, 4, 6, or 8 hexadecimal digits, and it is not immediately followed by another letter, digit, or underscore. The background is the literal color and the text color (black or white) is chosen by perceptual brightness (YIQ); for `#rgba`/`#rrggbbaa` the alpha channel is ignored when choosing the text color. Hex inside `` `inline code` `` stays literal, and a `#` at the start of a line is parsed as a heading (escape with `\#`).
747
+
748
+ Copyable text spans from `{copy}` to the next unescaped `{/copy}`. The inner text is captured verbatim — no nested inline formatting is applied — and is both displayed (monospace) and used as the copy target. An unterminated `{copy}` (no closing `{/copy}`) falls through as literal text. Escape with `\{copy}` to write a literal. As with other `{`-delimited constructs, a `{copy}` at the very start of a line is subject to block-level parsing; use it mid-line or within a cell.
749
+
728
750
  Citation references (`[@key]`) are parsed before link syntax. `[@key]` is always a citation reference even if followed by `(url)`. Use `\[@key]` for a literal `[@key]` in text.
729
751
  }
730
752
 
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.15",
5
+ "version": "0.2.17",
6
6
  "publisher": "entropicwarrior-msenfin",
7
7
  "license": "MIT",
8
8
  "repository": {
package/src/sdoc.js CHANGED
@@ -72,6 +72,7 @@ function isDirectiveRow(cells) {
72
72
  for (const cell of cells) {
73
73
  const trimmed = cell.trim();
74
74
  if (trimmed === "") continue;
75
+ if (trimmed.toLowerCase() === "copy") { hasDirective = true; continue; }
75
76
  if (!pattern.test(trimmed)) return false;
76
77
  hasDirective = true;
77
78
  }
@@ -102,12 +103,23 @@ function parseFormatSpec(spec) {
102
103
  function parseDirectiveRow(cells) {
103
104
  const align = [];
104
105
  const format = [];
106
+ const copy = [];
105
107
  let hasAlign = false;
106
108
  let hasFormat = false;
109
+ let hasCopy = false;
107
110
  const fmtPattern = /(\$(?:\.\d+)?|,(?:\.\d+)?|\.\d+|%(?:\.\d+)?)$/;
108
111
 
109
112
  for (const cell of cells) {
110
113
  const trimmed = cell.trim();
114
+
115
+ if (trimmed.toLowerCase() === "copy") {
116
+ align.push(null);
117
+ format.push(null);
118
+ copy.push(true);
119
+ hasCopy = true;
120
+ continue;
121
+ }
122
+
111
123
  const alignMatch = trimmed.match(/^([<>=])/);
112
124
  const fmtMatch = trimmed.match(fmtPattern);
113
125
 
@@ -124,11 +136,13 @@ function parseDirectiveRow(cells) {
124
136
  hasFormat = true;
125
137
  }
126
138
  format.push(f);
139
+ copy.push(false);
127
140
  }
128
141
 
129
142
  return {
130
143
  align: hasAlign ? align : null,
131
144
  format: hasFormat ? format : null,
145
+ copy: hasCopy ? copy : null,
132
146
  };
133
147
  }
134
148
 
@@ -940,10 +954,12 @@ function parseTableBody(cursor, tableStartLine, options) {
940
954
  const directiveIndex = options.headerless ? 0 : 1;
941
955
  let columnAlign = null;
942
956
  let columnFormat = null;
957
+ let columnCopy = null;
943
958
  if (rows.length > directiveIndex && isDirectiveRow(rows[directiveIndex])) {
944
959
  const directives = parseDirectiveRow(rows[directiveIndex]);
945
960
  columnAlign = directives.align;
946
961
  columnFormat = directives.format;
962
+ columnCopy = directives.copy;
947
963
  rows.splice(directiveIndex, 1);
948
964
  }
949
965
 
@@ -960,6 +976,7 @@ function parseTableBody(cursor, tableStartLine, options) {
960
976
  if (hasOptions) tableNode.options = options;
961
977
  if (columnAlign) tableNode.columnAlign = columnAlign;
962
978
  if (columnFormat) tableNode.columnFormat = columnFormat;
979
+ if (columnCopy) tableNode.columnCopy = columnCopy;
963
980
  return tableNode;
964
981
  }
965
982
 
@@ -1414,6 +1431,33 @@ function parseInline(text) {
1414
1431
  }
1415
1432
  }
1416
1433
 
1434
+ // Hex color codes (#rgb, #rgba, #rrggbb, #rrggbbaa) render as swatches.
1435
+ // Require a non-word char before the # so we don't match inside identifiers,
1436
+ // and a non-word char after the digits so partial/over-long runs are ignored.
1437
+ if (ch === "#" && (i === 0 || !/[0-9A-Za-z]/.test(text[i - 1]))) {
1438
+ const m = /^#([0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9A-Za-z_])/.exec(
1439
+ text.slice(i)
1440
+ );
1441
+ if (m) {
1442
+ flush();
1443
+ nodes.push({ type: "color_swatch", value: m[0] });
1444
+ i += m[0].length;
1445
+ continue;
1446
+ }
1447
+ }
1448
+
1449
+ // Copyable inline text: {copy}literal text{/copy}. The inner text is copied
1450
+ // verbatim (no nested formatting), rendered monospace with a copy icon.
1451
+ if (text.startsWith("{copy}", i)) {
1452
+ const end = findUnescaped(text, i + 6, "{/copy}");
1453
+ if (end !== -1) {
1454
+ flush();
1455
+ nodes.push({ type: "copyable", value: text.slice(i + 6, end) });
1456
+ i = end + 7; // length of "{/copy}"
1457
+ continue;
1458
+ }
1459
+ }
1460
+
1417
1461
  if (ch === "{") {
1418
1462
  const mc = next;
1419
1463
  let mt = null;
@@ -1728,6 +1772,46 @@ function escapeAttr(value) {
1728
1772
  return escapeHtml(value).replace(/'/g, "&#39;");
1729
1773
  }
1730
1774
 
1775
+ // Expand a #rgb/#rgba/#rrggbb/#rrggbbaa hex string to [r, g, b] (alpha ignored).
1776
+ function hexToRgb(hex) {
1777
+ let h = hex.replace(/^#/, "");
1778
+ if (h.length === 3 || h.length === 4) {
1779
+ h = h.slice(0, 3).split("").map((c) => c + c).join("");
1780
+ } else {
1781
+ h = h.slice(0, 6);
1782
+ }
1783
+ return [
1784
+ parseInt(h.slice(0, 2), 16),
1785
+ parseInt(h.slice(2, 4), 16),
1786
+ parseInt(h.slice(4, 6), 16),
1787
+ ];
1788
+ }
1789
+
1790
+ // Pick black or white text for legibility over the given background color
1791
+ // using the perceptual YIQ brightness threshold.
1792
+ function readableTextColor(hex) {
1793
+ const [r, g, b] = hexToRgb(hex);
1794
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
1795
+ return yiq >= 128 ? "#000000" : "#ffffff";
1796
+ }
1797
+
1798
+ // Render a hex color as a self-contained inline swatch: a rounded box filled
1799
+ // with the color, labelled with the hex code in a legible text color. Styling
1800
+ // is inline so it survives in slides, exports, and other CSS-free contexts.
1801
+ function colorSwatchHtml(value) {
1802
+ const fg = readableTextColor(value);
1803
+ // A soft neutral-grey outline on every swatch (the same for all of them) so
1804
+ // the box edge stays visible even when the fill matches the page background.
1805
+ // Semi-transparent grey reads on both light and dark backgrounds without the
1806
+ // harsh contrast of a black/white border.
1807
+ const style =
1808
+ `background-color:${value};color:${fg};` +
1809
+ "border:1px solid rgba(128,128,128,0.5);border-radius:4px;padding:0.15em 0.9em;" +
1810
+ "font-family:'JetBrains Mono','Fira Code','Source Code Pro',monospace;font-size:0.95em;" +
1811
+ "-webkit-print-color-adjust:exact;print-color-adjust:exact";
1812
+ return `<span class="sdoc-color-swatch" style="${style}">${escapeHtml(value)}</span>`;
1813
+ }
1814
+
1731
1815
  function renderInline(text) {
1732
1816
  const nodes = parseInline(text);
1733
1817
  return renderInlineNodes(nodes);
@@ -1766,6 +1850,10 @@ function renderInlineNodes(nodes) {
1766
1850
  }
1767
1851
  case "code":
1768
1852
  return `<code class="sdoc-inline-code">${escapeHtml(node.value)}</code>`;
1853
+ case "copyable":
1854
+ return `<span class="sdoc-copyable"><code class="sdoc-inline-code">${escapeHtml(node.value)}</code><button class="sdoc-copy-btn sdoc-copy-inline" data-copy="${escapeAttr(node.value)}" title="Copy">⧉</button></span>`;
1855
+ case "color_swatch":
1856
+ return colorSwatchHtml(node.value);
1769
1857
  case "em":
1770
1858
  return `<em>${renderInlineNodes(node.children)}</em>`;
1771
1859
  case "strong":
@@ -2247,6 +2335,14 @@ function renderTable(table) {
2247
2335
  const opts = table.options || {};
2248
2336
  const colAlign = table.columnAlign || [];
2249
2337
  const colFormat = table.columnFormat || [];
2338
+ const colCopy = table.columnCopy || [];
2339
+
2340
+ // Copy icon appended to body cells in a `copy` directive column. data-copy
2341
+ // carries the raw cell text so the shared copy handler copies it verbatim.
2342
+ function copyBtn(cell, c) {
2343
+ if (!colCopy[c]) return "";
2344
+ return ` <button class="sdoc-copy-btn sdoc-copy-inline" data-copy="${escapeAttr(cell.trim())}" title="Copy">⧉</button>`;
2345
+ }
2250
2346
  const classes = ["sdoc-table"];
2251
2347
  if (opts.borderless) classes.push("sdoc-table-borderless");
2252
2348
  if (opts.headerless) classes.push("sdoc-table-headerless");
@@ -2286,20 +2382,20 @@ function renderTable(table) {
2286
2382
  }
2287
2383
  const formula = escapeAttr(cell.trim());
2288
2384
  if (result.error) {
2289
- return `<td class="sdoc-table-td sdoc-formula-error"${style} title="${formula}">${display}</td>`;
2385
+ return `<td class="sdoc-table-td sdoc-formula-error"${style} title="${formula}">${display}${copyBtn(cell, c)}</td>`;
2290
2386
  }
2291
- return `<td class="sdoc-table-td sdoc-formula-cell"${style} title="${formula}">${display}</td>`;
2387
+ return `<td class="sdoc-table-td sdoc-formula-cell"${style} title="${formula}">${display}${copyBtn(cell, c)}</td>`;
2292
2388
  }
2293
2389
 
2294
2390
  // Apply column format to numeric data cells
2295
2391
  if (fmt) {
2296
2392
  const parsed = parseCellValue(cell);
2297
2393
  if (!isNaN(parsed.value)) {
2298
- return `<td class="sdoc-table-td"${style}>${escapeHtml(formatNumber(parsed.value, fmt))}</td>`;
2394
+ return `<td class="sdoc-table-td"${style}>${escapeHtml(formatNumber(parsed.value, fmt))}${copyBtn(cell, c)}</td>`;
2299
2395
  }
2300
2396
  }
2301
2397
 
2302
- return `<td class="sdoc-table-td"${style}>${renderInline(cell)}</td>`;
2398
+ return `<td class="sdoc-table-td"${style}>${renderInline(cell)}${copyBtn(cell, c)}</td>`;
2303
2399
  })
2304
2400
  .join("");
2305
2401
  return `<tr>${cells}</tr>`;
@@ -2995,10 +3091,6 @@ const PRINT_STYLE = `
2995
3091
  position: relative;
2996
3092
  }
2997
3093
  .sdoc-copy-btn {
2998
- position: absolute;
2999
- top: 6px;
3000
- right: 6px;
3001
- z-index: 1;
3002
3094
  background: transparent;
3003
3095
  border: none;
3004
3096
  border-radius: 4px;
@@ -3006,15 +3098,35 @@ const PRINT_STYLE = `
3006
3098
  font-size: 0.85rem;
3007
3099
  line-height: 1;
3008
3100
  padding: 2px 6px;
3101
+ color: inherit;
3102
+ }
3103
+ .sdoc-code-wrap .sdoc-copy-btn {
3104
+ position: absolute;
3105
+ top: 6px;
3106
+ right: 6px;
3107
+ z-index: 1;
3009
3108
  opacity: 0;
3010
3109
  transition: opacity 0.15s;
3011
3110
  }
3012
3111
  .sdoc-code-wrap:hover .sdoc-copy-btn {
3013
3112
  opacity: 0.75;
3014
3113
  }
3015
- .sdoc-copy-btn:hover {
3114
+ .sdoc-code-wrap .sdoc-copy-btn:hover {
3016
3115
  opacity: 1 !important;
3017
3116
  }
3117
+ .sdoc-copyable {
3118
+ white-space: nowrap;
3119
+ }
3120
+ .sdoc-copy-inline {
3121
+ font-size: 0.8em;
3122
+ padding: 0 0.15em;
3123
+ opacity: 0.45;
3124
+ vertical-align: baseline;
3125
+ transition: opacity 0.15s;
3126
+ }
3127
+ .sdoc-copy-inline:hover {
3128
+ opacity: 1;
3129
+ }
3018
3130
  @media print {
3019
3131
  html {
3020
3132
  font-size: 80%;
@@ -3053,7 +3165,7 @@ const PRINT_STYLE = `
3053
3165
 
3054
3166
  const COLLAPSE_SCRIPT = `document.addEventListener("click",function(e){if(!e.target.classList.contains("sdoc-toggle"))return;e.stopPropagation();var s=e.target.closest(".sdoc-scope");if(s)s.classList.toggle("sdoc-collapsed")});`;
3055
3167
 
3056
- const COPY_SCRIPT = `document.addEventListener("click",function(e){if(!e.target.classList.contains("sdoc-copy-btn"))return;e.stopPropagation();e.preventDefault();var w=e.target.closest(".sdoc-code-wrap");if(!w)return;var c=w.querySelector("code");if(!c)return;var t=c.textContent;var b=e.target;if(navigator.clipboard){navigator.clipboard.writeText(t).then(function(){b.textContent="\\u2713";setTimeout(function(){b.textContent="\\u29C9"},1500)})}else{var a=document.createElement("textarea");a.value=t;a.style.position="fixed";a.style.opacity="0";document.body.appendChild(a);a.select();document.execCommand("copy");document.body.removeChild(a);b.textContent="\\u2713";setTimeout(function(){b.textContent="\\u29C9"},1500)}});`;
3168
+ const COPY_SCRIPT = `document.addEventListener("click",function(e){var b=e.target.closest(".sdoc-copy-btn");if(!b)return;e.stopPropagation();e.preventDefault();var t=b.getAttribute("data-copy");if(t===null){var w=b.closest(".sdoc-code-wrap");if(!w)return;var c=w.querySelector("code");if(!c)return;t=c.textContent}if(navigator.clipboard){navigator.clipboard.writeText(t).then(function(){b.textContent="\\u2713";setTimeout(function(){b.textContent="\\u29C9"},1500)})}else{var a=document.createElement("textarea");a.value=t;a.style.position="fixed";a.style.opacity="0";document.body.appendChild(a);a.select();document.execCommand("copy");document.body.removeChild(a);b.textContent="\\u2713";setTimeout(function(){b.textContent="\\u29C9"},1500)}});`;
3057
3169
 
3058
3170
  const MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
3059
3171
  const KATEX_CDN_CSS = "https://cdn.jsdelivr.net/npm/katex@0.16/dist/katex.min.css";
@@ -3773,5 +3885,7 @@ module.exports = {
3773
3885
  renderKatex,
3774
3886
  escapeHtml,
3775
3887
  escapeAttr,
3776
- sanitizeSvg
3888
+ sanitizeSvg,
3889
+ colorSwatchHtml,
3890
+ readableTextColor
3777
3891
  };
@@ -7,7 +7,7 @@
7
7
  // const { nodes, meta } = extractMeta(parsed.nodes);
8
8
  // const html = renderSlides(nodes, { meta, themeCss, themeJs });
9
9
 
10
- const { parseInline, renderKatex, escapeHtml, escapeAttr, sanitizeSvg } = require("./sdoc");
10
+ const { parseInline, renderKatex, escapeHtml, escapeAttr, sanitizeSvg, colorSwatchHtml } = require("./sdoc");
11
11
 
12
12
  // ---------------------------------------------------------------------------
13
13
  // Inline rendering — produces clean HTML without sdoc-* classes
@@ -21,6 +21,11 @@ function renderInlineNodes(nodes) {
21
21
  return escapeHtml(node.value);
22
22
  case "code":
23
23
  return `<code>${escapeHtml(node.value)}</code>`;
24
+ case "copyable":
25
+ // Slides have no copy handler; render the literal text as monospace.
26
+ return `<code>${escapeHtml(node.value)}</code>`;
27
+ case "color_swatch":
28
+ return colorSwatchHtml(node.value);
24
29
  case "em":
25
30
  return `<em>${renderInlineNodes(node.children)}</em>`;
26
31
  case "strong":