@entropicwarrior/sdoc 0.2.13 → 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.
@@ -554,7 +554,7 @@ Content of Section B.
554
554
  {
555
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:
556
556
 
557
- ```
557
+ ```sdoc
558
558
  @meta {
559
559
  type: doc
560
560
 
@@ -604,15 +604,20 @@ Content of Section B.
604
604
 
605
605
  # About Scope @about-scope
606
606
  {
607
- 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:
608
608
 
609
- ```
609
+ ```sdoc
610
610
  @about {
611
- 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.
612
613
  }
613
614
  ```
614
615
 
615
- 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.
616
621
  }
617
622
 
618
623
  # Escaping @escaping
@@ -982,6 +982,29 @@ Content of Section B.
982
982
  }
983
983
  }
984
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
+ }
985
1008
  }
986
1009
 
987
1010
  # Interactive Preview @interactive-preview
@@ -1146,9 +1169,12 @@ Content of Section A.
1146
1169
  | "left" | "center" | "right" ;
1147
1170
  percentage = digit { digit } [ "." digit { digit } ] "%" ;
1148
1171
  pixels = digit { digit } "px" ;
1149
- table_body = [ directive_row ] 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 *)
1150
1176
  directive_row = directive_cell { "|" directive_cell } ;
1151
- directive_cell = [ align_char ] [ ws format_spec ] ;
1177
+ directive_cell = [ align_char ] [ format_spec ] ;
1152
1178
  align_char = "<" | ">" | "=" ;
1153
1179
  format_spec = "$" [ "." digits ]
1154
1180
  | "," [ "." digits ]
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.13",
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
@@ -243,7 +243,8 @@ function detectImplicitRoot(cursor) {
243
243
  if (nextTrimmed === COMMAND_SCOPE_OPEN ||
244
244
  nextTrimmed === COMMAND_LIST_BULLET ||
245
245
  nextTrimmed === COMMAND_LIST_NUMBER ||
246
- isTableCommand(nextTrimmed)) {
246
+ isTableCommand(nextTrimmed) ||
247
+ isCitationsCommand(nextTrimmed)) {
247
248
  isImplicit = false;
248
249
  } else if (tryParseInlineBlock(nextTrimmed) !== null) {
249
250
  isImplicit = false;
@@ -1817,8 +1818,17 @@ function renderInlineNodes(nodes) {
1817
1818
  function renderScope(scope, depth, isTitleScope = false) {
1818
1819
  // :comment scopes are not rendered
1819
1820
  if (scope.scopeType === "comment") return "";
1820
- // @about is document metadata, not rendered in output
1821
- 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
+ }
1822
1832
 
1823
1833
  const level = Math.min(6, Math.max(1, depth));
1824
1834
  const children = scope.children.map((child) => renderNode(child, depth + 1)).join("\n");
@@ -1826,9 +1836,10 @@ function renderScope(scope, depth, isTitleScope = false) {
1826
1836
  const dl = dataLineAttrs(scope);
1827
1837
  const typeAttr = scope.scopeType ? ` data-scope-type="${escapeAttr(scope.scopeType)}"` : "";
1828
1838
  const typeClass = scope.scopeType ? ` sdoc-scope-type-${scope.scopeType}` : "";
1839
+ const metaClass = isAbout ? " sdoc-meta-section" : "";
1829
1840
 
1830
1841
  if (scope.hasHeading === false) {
1831
- 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>`;
1832
1843
  }
1833
1844
 
1834
1845
  const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
@@ -1836,7 +1847,7 @@ function renderScope(scope, depth, isTitleScope = false) {
1836
1847
  const toggle = hasChildren ? `<span class="sdoc-toggle"></span>` : "";
1837
1848
  const heading = `<h${level}${idAttr} class="sdoc-heading sdoc-depth-${level}"${dl}>${toggle}${renderInline(scope.title)}</h${level}>`;
1838
1849
  const childrenHtml = children ? `\n<div class="sdoc-scope-children">${children}</div>` : "";
1839
- return `<section class="sdoc-scope${rootClass}${typeClass}"${typeAttr}>${heading}${childrenHtml}</section>`;
1850
+ return `<section class="sdoc-scope${rootClass}${typeClass}${metaClass}"${typeAttr}>${heading}${childrenHtml}</section>`;
1840
1851
  }
1841
1852
 
1842
1853
  function renderCitations(node) {
@@ -2940,6 +2951,43 @@ const DEFAULT_STYLE = `
2940
2951
  display: none;
2941
2952
  }
2942
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
+
2943
2991
  `;
2944
2992
 
2945
2993
  const PRINT_STYLE = `
@@ -3077,15 +3125,21 @@ function renderBodyNodes(nodes) {
3077
3125
  .join("\n");
3078
3126
  }
3079
3127
 
3080
- function renderHtmlBody(text) {
3128
+ function renderHtmlBody(text, options = {}) {
3081
3129
  const parsed = parseSdoc(text);
3082
3130
  const metaResult = extractMeta(parsed.nodes);
3083
3131
  const savedOptions = _renderOptions;
3084
3132
  _renderOptions = {};
3085
- 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);
3086
3140
  _citationNumbering = citationData.numbering;
3087
3141
  _citationDefinitions = citationData.definitions;
3088
- const result = renderBodyNodes(metaResult.nodes);
3142
+ const result = renderBodyNodes(renderNodes);
3089
3143
  _citationNumbering = new Map();
3090
3144
  _citationDefinitions = new Map();
3091
3145
  _renderOptions = savedOptions;
@@ -3094,10 +3148,16 @@ function renderHtmlBody(text) {
3094
3148
 
3095
3149
  function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
3096
3150
  _renderOptions = options.renderOptions ?? {};
3097
- 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);
3098
3158
  _citationNumbering = citationData.numbering;
3099
3159
  _citationDefinitions = citationData.definitions;
3100
- const body = renderBodyNodes(parsed.nodes);
3160
+ const body = renderBodyNodes(renderNodes);
3101
3161
  _citationNumbering = new Map();
3102
3162
  _citationDefinitions = new Map();
3103
3163
  _renderOptions = {};
@@ -3125,13 +3185,13 @@ function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
3125
3185
  const mermaidInit = mermaidTheme === "auto"
3126
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; }"});`
3127
3187
  : `mermaid.initialize({startOnLoad:true,theme:"${mermaidTheme}",themeCSS:".node rect, .node polygon, .node circle { rx: 4; ry: 4; }"});`;
3128
- const mermaidScript = hasMermaidBlocks(parsed.nodes)
3188
+ const mermaidScript = hasMermaidBlocks(renderNodes)
3129
3189
  ? `\n<script src="${MERMAID_CDN}"></script>\n<script>${mermaidInit}</script>`
3130
3190
  : "";
3131
3191
  const katexCssTag = body.includes('class="katex"')
3132
3192
  ? `\n<link rel="stylesheet" href="${KATEX_CDN_CSS}" />`
3133
3193
  : "";
3134
- const hasHljs = hasHighlightableCodeBlocks(parsed.nodes);
3194
+ const hasHljs = hasHighlightableCodeBlocks(renderNodes);
3135
3195
  // Highlight.js CSS is inlined (not a <link>) so it is extracted by parseDocHtml in the web viewer
3136
3196
  // and applied inside shadow DOM. The @media query handles dark mode in browsers.
3137
3197
  const hljsCssInline = hasHljs
@@ -3443,6 +3503,36 @@ function extractAbout(nodes) {
3443
3503
  return null;
3444
3504
  }
3445
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
+
3446
3536
  function collectAllIds(nodes) {
3447
3537
  const ids = new Set();
3448
3538
  function walk(nodeList) {
@@ -3668,6 +3758,8 @@ module.exports = {
3668
3758
  listSections,
3669
3759
  extractSection,
3670
3760
  extractAbout,
3761
+ isAboutEmpty,
3762
+ stripAboutScopes,
3671
3763
  extractDataBlocks,
3672
3764
  KNOWN_SCOPE_TYPES,
3673
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) {