@entropicwarrior/sdoc 0.2.0 → 0.2.1

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
@@ -87,20 +87,26 @@ All `.sdoc` files are designed for progressive disclosure — read the `@about`
87
87
  {
88
88
  Unlimited nesting. Each scope is independently addressable.
89
89
 
90
- {[code lang=python]
91
- def hello():
92
- print("Hello from SDOC")
93
- }
90
+ ```python
91
+ def hello():
92
+ print("Hello from SDOC")
93
+ ```
94
94
  }
95
95
 
96
- # A List
96
+ # Status :example
97
97
  {
98
- {[.]
99
- - First item
100
- - Second item with **bold**
101
- - Third item
98
+ {[table 60% center]
99
+ Endpoint | Status
100
+ /v2/api | {+Active+}
101
+ /v1/api | {-Deprecated-}
102
102
  }
103
103
  }
104
+
105
+ # Internal Notes :comment
106
+ {
107
+ This scope is invisible in rendered output but
108
+ stays in the AST for tooling and agents.
109
+ }
104
110
  }
105
111
  ```
106
112
 
@@ -153,7 +159,7 @@ Markdown-style images with optional width and alignment:
153
159
 
154
160
  ### Tables
155
161
 
156
- Pipe-delimited tables with optional `borderless` and `headerless` flags.
162
+ Pipe-delimited tables with optional flags for appearance (`borderless`, `headerless`), width (`auto`, `60%`, `400px`), and alignment (`left`, `center`, `right`). All flags compose freely.
157
163
 
158
164
  ### Lists
159
165
 
@@ -167,6 +173,18 @@ Tag any section with `@id` and cross-reference it anywhere with `@id` — render
167
173
 
168
174
  Turn any SDOC file into an HTML slide deck with themes, layouts (center, two-column), speaker notes, and PDF export.
169
175
 
176
+ ### Scope Types
177
+
178
+ Classify scopes with a `:type` annotation — `:schema`, `:warning`, `:deprecated`, `:example`, or any custom label. Types render as `data-scope-type` attributes and CSS classes for styling.
179
+
180
+ ### Data Blocks
181
+
182
+ Tag a JSON code fence with `:data` and the parser validates and stores the parsed result on the AST node. `extractDataBlocks()` gives programmatic access. Ideal for embedding schemas, configs, and structured metadata alongside prose.
183
+
184
+ ### Comment Scopes
185
+
186
+ A `:comment` scope is excluded from rendered output but stays in the AST — perfect for agent instructions, internal notes, and build metadata that readers shouldn't see.
187
+
170
188
  ### Custom Styling
171
189
 
172
190
  Per-folder `sdoc.config.json` or per-file `@meta` scope for custom CSS, headers, footers, and confidentiality banners. Configs cascade from workspace root to file.
@@ -286,7 +286,7 @@ Content of Section B.
286
286
  \`\{~text~\}\` | Highlight (yellow)
287
287
  }
288
288
 
289
- Links: \`[Link text](https://example.com)\`
289
+ Links: \`[Link text](https://example.com)\` or \`[Other doc](./other-file.sdoc)\`. Relative paths resolve from the document's directory.
290
290
 
291
291
  Images: \`![Alt text](path/to/image.png)\`
292
292
 
@@ -671,5 +671,20 @@ Content of Section B.
671
671
  }
672
672
  ```
673
673
  }
674
+
675
+ # @References Inside Link Labels @refs-in-link-labels
676
+ {
677
+ Inline \`@references\` are parsed everywhere, including inside link labels. If you mention a scope ID in a link label, escape the \`@\` to prevent it being treated as a reference to the current document:
678
+
679
+ **Wrong:** \`[See domain-model.sdoc @my-section](./domain-model.sdoc#my-section)\`
680
+
681
+ The \`@my-section\` is parsed as a reference and flagged as broken (it does not exist in *this* file).
682
+
683
+ **Right:** \`[See domain-model.sdoc \\@my-section](./domain-model.sdoc#my-section)\`
684
+
685
+ Or simply omit the \`@\` from the label — the URL fragment already carries the target:
686
+
687
+ **Also right:** \`[See domain-model.sdoc § my-section](./domain-model.sdoc#my-section)\`
688
+ }
674
689
  }
675
690
  }
@@ -395,16 +395,26 @@ Content of Section B.
395
395
  - A reference is `@id` in text (unescaped)
396
396
  - References link to the scope with that ID
397
397
  - ID uniqueness is strongly recommended; tooling may warn on duplicates
398
+ - References are parsed inside link labels — use `\@` to include a literal `@` in a link label without triggering a reference
398
399
  }
399
400
  }
400
401
 
401
- # External Links @external-links
402
+ # Links @links
402
403
  {
403
- Markdown-style links:
404
+ Markdown-style links with absolute URLs or relative file paths:
404
405
 
405
406
  ```
406
407
  [label](https://example.com)
408
+ [other doc](./other-file.sdoc)
409
+ [parent doc](../guide/intro.sdoc)
407
410
  ```
411
+
412
+ {[.]
413
+ - Absolute URLs (any scheme) open externally
414
+ - Relative paths are resolved from the document's directory
415
+ - Fragments (`./file.sdoc#section`) and query strings are stripped for file resolution
416
+ - Tooling may warn on broken relative links (target file does not exist)
417
+ }
408
418
  }
409
419
 
410
420
  # Autolinks @autolinks
package/package.json CHANGED
@@ -2,12 +2,12 @@
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.0",
5
+ "version": "0.2.1",
6
6
  "publisher": "entropicwarrior",
7
7
  "license": "MIT",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/entropicwarrior/sdoc"
10
+ "url": "git+https://github.com/entropicwarrior/sdoc.git"
11
11
  },
12
12
  "homepage": "https://github.com/entropicwarrior/sdoc",
13
13
  "bugs": {
@@ -22,7 +22,7 @@
22
22
  "ai-agent"
23
23
  ],
24
24
  "bin": {
25
- "sdoc-sync-notion": "./tools/sync-notion.js"
25
+ "sdoc-sync-notion": "tools/sync-notion.js"
26
26
  },
27
27
  "exports": {
28
28
  ".": "./index.js",
package/src/sdoc.js CHANGED
@@ -1379,6 +1379,9 @@ function renderInlineNodes(nodes) {
1379
1379
  return escapeHtml(node.value);
1380
1380
  case "ref": {
1381
1381
  const href = `#${escapeAttr(node.id)}`;
1382
+ if (_renderOptions.brokenRefIds && _renderOptions.brokenRefIds.has(node.id)) {
1383
+ return `<a class="sdoc-ref sdoc-broken-ref" href="${href}"><span class="sdoc-broken-icon">\u26A0</span>@${escapeHtml(node.id)}</a>`;
1384
+ }
1382
1385
  return `<a class="sdoc-ref" href="${href}">@${escapeHtml(node.id)}</a>`;
1383
1386
  }
1384
1387
  case "code":
@@ -1404,6 +1407,11 @@ function renderInlineNodes(nodes) {
1404
1407
  case "mark_highlight":
1405
1408
  return `<mark class="sdoc-mark sdoc-mark-highlight">${renderInlineNodes(node.children)}</mark>`;
1406
1409
  case "link":
1410
+ if (_renderOptions.brokenLinkHrefs && _renderOptions.brokenLinkHrefs.has(node.href)) {
1411
+ return `<a class="sdoc-link sdoc-broken-link" href="${escapeAttr(node.href)}" target="_blank" rel="noopener noreferrer"><span class="sdoc-broken-icon">\u26A0</span>${renderInlineNodes(
1412
+ node.children
1413
+ )}</a>`;
1414
+ }
1407
1415
  return `<a class="sdoc-link" href="${escapeAttr(node.href)}" target="_blank" rel="noopener noreferrer">${renderInlineNodes(
1408
1416
  node.children
1409
1417
  )}</a>`;
@@ -1981,6 +1989,19 @@ const DEFAULT_STYLE = `
1981
1989
  .sdoc-mark-negative { background-color: rgba(210, 25, 25, 0.18); color: #a81414; }
1982
1990
  .sdoc-mark-highlight { background-color: rgba(255, 255, 0, 0.75); }
1983
1991
 
1992
+ .sdoc-broken-ref, .sdoc-link.sdoc-broken-link {
1993
+ color: #c33;
1994
+ text-decoration: wavy underline #c33;
1995
+ text-underline-offset: 2px;
1996
+ background: rgba(204, 51, 51, 0.08);
1997
+ border-radius: 2px;
1998
+ padding: 0 0.15em;
1999
+ }
2000
+ .sdoc-broken-icon {
2001
+ font-size: 0.75em;
2002
+ margin-right: 0.15em;
2003
+ }
2004
+
1984
2005
  .sdoc-image {
1985
2006
  display: inline-block;
1986
2007
  max-width: 100%;
@@ -2093,6 +2114,9 @@ const PRINT_STYLE = `
2093
2114
  word-wrap: break-word;
2094
2115
  }
2095
2116
  .sdoc-mark { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
2117
+ .sdoc-table { break-inside: avoid; }
2118
+ .sdoc-code { break-inside: avoid; }
2119
+ .sdoc-blockquote { break-inside: avoid; }
2096
2120
  }
2097
2121
  `;
2098
2122
 
@@ -2112,9 +2136,8 @@ function hasMermaidBlocks(nodes) {
2112
2136
  return false;
2113
2137
  }
2114
2138
 
2115
- function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
2116
- _renderOptions = options.renderOptions ?? {};
2117
- const body = parsed.nodes
2139
+ function renderBodyNodes(nodes) {
2140
+ return nodes
2118
2141
  .map((node, index) => {
2119
2142
  if (node.type === "scope" && index === 0) {
2120
2143
  return renderScope(node, 1, true);
@@ -2122,6 +2145,17 @@ function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
2122
2145
  return renderNode(node, 1);
2123
2146
  })
2124
2147
  .join("\n");
2148
+ }
2149
+
2150
+ function renderHtmlBody(text) {
2151
+ const parsed = parseSdoc(text);
2152
+ const metaResult = extractMeta(parsed.nodes);
2153
+ return renderBodyNodes(metaResult.nodes);
2154
+ }
2155
+
2156
+ function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
2157
+ _renderOptions = options.renderOptions ?? {};
2158
+ const body = renderBodyNodes(parsed.nodes);
2125
2159
  _renderOptions = {};
2126
2160
  const errorHtml = renderErrors(parsed.errors);
2127
2161
 
@@ -2430,6 +2464,127 @@ function extractAbout(nodes) {
2430
2464
  return null;
2431
2465
  }
2432
2466
 
2467
+ function collectAllIds(nodes) {
2468
+ const ids = new Set();
2469
+ function walk(nodeList) {
2470
+ for (const node of nodeList) {
2471
+ if (node.type === "scope") {
2472
+ if (node.id) ids.add(node.id);
2473
+ if (node.title) ids.add(slugify(node.title));
2474
+ }
2475
+ if (node.children) walk(node.children);
2476
+ if (node.type === "list" && node.items) {
2477
+ walk(node.items);
2478
+ }
2479
+ }
2480
+ }
2481
+ walk(nodes);
2482
+ return ids;
2483
+ }
2484
+
2485
+ function collectInlineRefs(nodes) {
2486
+ const refs = [];
2487
+ const links = [];
2488
+
2489
+ function walkInlineNodes(inlineNodes, lineStart, lineEnd) {
2490
+ for (const node of inlineNodes) {
2491
+ if (node.type === "ref") {
2492
+ refs.push({ id: node.id, lineStart, lineEnd });
2493
+ } else if (node.type === "link") {
2494
+ links.push({ href: node.href, lineStart, lineEnd });
2495
+ }
2496
+ if (node.children) {
2497
+ walkInlineNodes(node.children, lineStart, lineEnd);
2498
+ }
2499
+ }
2500
+ }
2501
+
2502
+ function processText(text, lineStart, lineEnd) {
2503
+ const inlineNodes = parseInline(text);
2504
+ walkInlineNodes(inlineNodes, lineStart, lineEnd);
2505
+ }
2506
+
2507
+ function walk(nodeList) {
2508
+ for (const node of nodeList) {
2509
+ if (node.type === "paragraph" && node.text) {
2510
+ processText(node.text, node.lineStart, node.lineEnd);
2511
+ } else if (node.type === "blockquote" && node.paragraphs) {
2512
+ for (const para of node.paragraphs) {
2513
+ processText(para, node.lineStart, node.lineEnd);
2514
+ }
2515
+ } else if (node.type === "scope") {
2516
+ if (node.title) {
2517
+ processText(node.title, node.lineStart, node.lineStart);
2518
+ }
2519
+ if (node.children) walk(node.children);
2520
+ } else if (node.type === "list" && node.items) {
2521
+ walk(node.items);
2522
+ } else if (node.type === "table") {
2523
+ if (node.headers) {
2524
+ for (const cell of node.headers) {
2525
+ processText(cell, node.lineStart, node.lineEnd);
2526
+ }
2527
+ }
2528
+ if (node.rows) {
2529
+ for (const row of node.rows) {
2530
+ for (const cell of row) {
2531
+ processText(cell, node.lineStart, node.lineEnd);
2532
+ }
2533
+ }
2534
+ }
2535
+ }
2536
+ // Handle list items (no type field, but have title/children)
2537
+ if (!node.type) {
2538
+ if (node.title) processText(node.title, node.lineStart || 0, node.lineEnd || node.lineStart || 0);
2539
+ if (node.children) walk(node.children);
2540
+ }
2541
+ }
2542
+ }
2543
+ walk(nodes);
2544
+ return { refs, links };
2545
+ }
2546
+
2547
+ function validateRefs(nodes, options = {}) {
2548
+ const ids = collectAllIds(nodes);
2549
+ const externalIds = options.externalIds || new Set();
2550
+ const { refs, links } = collectInlineRefs(nodes);
2551
+ const warnings = [];
2552
+
2553
+ for (const ref of refs) {
2554
+ if (!ids.has(ref.id) && !externalIds.has(ref.id)) {
2555
+ warnings.push({
2556
+ type: "broken-ref",
2557
+ id: ref.id,
2558
+ message: `Broken reference: @${ref.id} does not match any scope ID or title`,
2559
+ lineStart: ref.lineStart,
2560
+ lineEnd: ref.lineEnd
2561
+ });
2562
+ }
2563
+ }
2564
+
2565
+ for (const link of links) {
2566
+ const href = link.href;
2567
+ if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href) || href.startsWith("#") || href.startsWith("data:")) {
2568
+ continue;
2569
+ }
2570
+ if (options.resolveFilePath) {
2571
+ const filePath = href.split("#")[0].split("?")[0];
2572
+ if (!filePath) continue;
2573
+ if (!options.resolveFilePath(filePath)) {
2574
+ warnings.push({
2575
+ type: "broken-link",
2576
+ href,
2577
+ message: `Broken link: file not found — ${href}`,
2578
+ lineStart: link.lineStart,
2579
+ lineEnd: link.lineEnd
2580
+ });
2581
+ }
2582
+ }
2583
+ }
2584
+
2585
+ return warnings;
2586
+ }
2587
+
2433
2588
  async function resolveIncludes(nodes, resolverFn) {
2434
2589
  for (const node of nodes) {
2435
2590
  if (node.type === "code" && node.src) {
@@ -2466,6 +2621,7 @@ module.exports = {
2466
2621
  resolveIncludes,
2467
2622
  renderFragment,
2468
2623
  renderTextParagraphs,
2624
+ renderHtmlBody,
2469
2625
  renderHtmlDocumentFromParsed,
2470
2626
  renderHtmlDocument,
2471
2627
  formatSdoc,
@@ -2476,6 +2632,10 @@ module.exports = {
2476
2632
  extractAbout,
2477
2633
  extractDataBlocks,
2478
2634
  KNOWN_SCOPE_TYPES,
2635
+ // Validation
2636
+ collectAllIds,
2637
+ collectInlineRefs,
2638
+ validateRefs,
2479
2639
  // Low-level helpers for custom renderers (e.g. slide-renderer)
2480
2640
  parseInline,
2481
2641
  renderKatex,
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // SDOC → HTML fragment translator
3
+ //
4
+ // Usage:
5
+ // node tools/sdoc2html input.sdoc # read from file
6
+ // cat input.sdoc | node tools/sdoc2html # read from stdin
7
+ //
8
+ // Writes an HTML fragment to stdout (no <!DOCTYPE>, no <html>/<body> wrapper).
9
+ // Designed for integration with github/markup as a translator script.
10
+
11
+ const fs = require("fs");
12
+ const { renderHtmlBody } = require("../src/sdoc");
13
+
14
+ function main() {
15
+ let input;
16
+ const filename = process.argv[2];
17
+
18
+ if (filename) {
19
+ try {
20
+ input = fs.readFileSync(filename, "utf8");
21
+ } catch (err) {
22
+ process.stderr.write(`sdoc2html: ${err.message}\n`);
23
+ process.exit(1);
24
+ }
25
+ } else {
26
+ input = fs.readFileSync(0, "utf8");
27
+ }
28
+
29
+ process.stdout.write(renderHtmlBody(input) + "\n");
30
+ }
31
+
32
+ main();