@entropicwarrior/sdoc 0.2.13 → 0.2.15

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
@@ -239,6 +239,84 @@
239
239
  }
240
240
  }
241
241
 
242
+ # Drilldown Slides @drilldown
243
+ {
244
+ A slide deck is normally a 1D spine: Right and Left move along it. A
245
+ spine slide can also have *vertical detail slides* — extra material
246
+ you can pull up on demand without cluttering the main flow. Mark a
247
+ child scope with the `:detail` annotation and it becomes a vertical
248
+ slide under its parent.
249
+
250
+ # Syntax @drilldown-syntax
251
+ {
252
+ ```
253
+ # ETHyR Mechanism @ethyr {
254
+ Main spine content.
255
+
256
+ # PCR primer @pcr :detail {
257
+ Detail slide one.
258
+ }
259
+
260
+ # HCR primer @hcr :detail {
261
+ Detail slide two.
262
+ }
263
+
264
+ # Notes @notes {
265
+ Speaker notes still work alongside details.
266
+ }
267
+ }
268
+ ```
269
+
270
+ A detail slide is a regular slide. It can have its own `config:
271
+ center`, two-column layout, speaker `@notes`, and any other slide
272
+ content. Drilldowns do not nest further: a `:detail` inside a
273
+ `:detail` is treated as a plain nested scope.
274
+ }
275
+
276
+ # Navigation @drilldown-navigation
277
+ {
278
+ Right — advance one step. From a spine slide, move to the next
279
+ spine. From a detail slide, advance to the next detail in the
280
+ same column; at the last detail, return to the parent spine and
281
+ advance to the next spine (reveal.js convention).
282
+
283
+ Left — mirror of Right. From a detail, previous detail or back
284
+ to parent spine; from spine, previous spine.
285
+
286
+ Down — drill into the first detail of the current spine; from
287
+ within a column, advance to the next detail.
288
+
289
+ Up — return from a detail to its parent spine. No-op on a spine.
290
+
291
+ Swipe — horizontal swipes mirror Left / Right; vertical swipes
292
+ mirror Up / Down.
293
+
294
+ URL hash — spine slides use `#N`; detail K of spine N uses
295
+ `#N.K`.
296
+ }
297
+
298
+ # Visual Affordances @drilldown-visual
299
+ {
300
+ Spine slides that have detail children get the
301
+ `slide-has-details` class — the default theme shows a small
302
+ animated chevron at the bottom of the slide. Themes can replace
303
+ this rule to fit their own visual language.
304
+
305
+ Every slide also gets a `.slide-indicator` element in the
306
+ bottom-right corner: `5 / 14` on a spine, `5.2 / 14` on detail 2
307
+ of slide 5. The denominator is always the spine count, so the
308
+ indicator stays stable as you drill in.
309
+ }
310
+
311
+ # PDF Export @drilldown-pdf
312
+ {
313
+ PDF export flattens the deck: spine 1, its details in order,
314
+ spine 2, its details in order, and so on. This is the same order
315
+ slides appear in the HTML, so PDF export needs no special case —
316
+ every slide is just one page.
317
+ }
318
+ }
319
+
242
320
  # Theme System @themes
243
321
  {
244
322
  Themes control the visual appearance of slides. A theme is a directory
@@ -269,17 +347,23 @@
269
347
  {
270
348
  All themes include these controls by default:
271
349
 
272
- Arrow right or Space — next slide.
350
+ Arrow right or Space — next slide (or next detail).
351
+
352
+ Arrow left — previous slide (or previous detail).
353
+
354
+ Arrow down — drill into details (if any).
273
355
 
274
- Arrow left — previous slide.
356
+ Arrow up — return from details to the spine slide.
275
357
 
276
358
  Home — first slide.
277
359
 
278
- End — last slide.
360
+ End — last spine slide.
279
361
 
280
- Touch swipe left/right on mobile devices.
362
+ Touch swipe horizontal / vertical on mobile devices.
281
363
 
282
- Slide counter shown in the bottom-right corner.
364
+ Slide counter (`.slide-indicator`) shown in the bottom-right
365
+ corner — `N / TOTAL` on spine slides, `N.K / TOTAL` on detail K
366
+ of spine N. See [Drilldown Slides](#drilldown) for details.
283
367
  }
284
368
  }
285
369
 
@@ -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.15",
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
@@ -206,18 +206,45 @@ function extractNotes(children) {
206
206
  return { notes, contentNodes: rest };
207
207
  }
208
208
 
209
+ // Separates :detail child scopes (drilldown / vertical slides) from other
210
+ // children. Detail scopes are NOT removed from rendering; they are emitted as
211
+ // sibling slides positioned vertically under the spine slide.
212
+ function extractDetails(children) {
213
+ const details = [];
214
+ const rest = [];
215
+ for (const child of children) {
216
+ if (child.type === "scope" && child.scopeType === "detail") {
217
+ details.push(child);
218
+ } else {
219
+ rest.push(child);
220
+ }
221
+ }
222
+ return { details, contentNodes: rest };
223
+ }
224
+
209
225
  // ---------------------------------------------------------------------------
210
226
  // Slide rendering
211
227
  // ---------------------------------------------------------------------------
212
228
 
213
- function renderSlide(scope, slideIndex, overlayHtml) {
214
- const { config, contentNodes: afterConfig } = extractSlideConfig(scope.children);
229
+ // position: { spine: 1-based spine index, detail: 0 for spine, 1..N for details,
230
+ // totalSpines: total number of spine slides, hasDetails: bool (spine only) }
231
+ function renderSlide(scope, slideIndex, overlayHtml, position) {
232
+ // Pull :detail children out first so they don't appear inline in the spine
233
+ // slide's content; they're rendered as sibling vertical slides instead.
234
+ const { contentNodes: afterDetails } = extractDetails(scope.children);
235
+ const { config, contentNodes: afterConfig } = extractSlideConfig(afterDetails);
215
236
  const { notes, contentNodes } = extractNotes(afterConfig);
216
237
 
217
238
  const classes = ["slide"];
218
239
  if (config.layout) {
219
240
  classes.push(config.layout);
220
241
  }
242
+ if (position && position.detail === 0 && position.hasDetails) {
243
+ classes.push("slide-has-details");
244
+ }
245
+ if (position && position.detail > 0) {
246
+ classes.push("slide-detail");
247
+ }
221
248
 
222
249
  const title = scope.hasHeading !== false && scope.title
223
250
  ? `<h2>${renderInline(scope.title)}</h2>`
@@ -248,9 +275,25 @@ function renderSlide(scope, slideIndex, overlayHtml) {
248
275
  : "";
249
276
 
250
277
  const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
251
- const overlay = overlayHtml || "";
252
278
 
253
- return `<div class="${classes.join(" ")}"${idAttr}>\n${title}\n${bodyHtml}${notesHtml}${overlay}\n</div>`;
279
+ // Drilldown metadata + slide indicator label (substituted into the footer)
280
+ let dataAttrs = "";
281
+ let indicatorLabel = "";
282
+ if (position) {
283
+ dataAttrs = ` data-spine="${position.spine}" data-detail="${position.detail}"`;
284
+ const denom = position.totalSpines;
285
+ indicatorLabel = position.detail === 0
286
+ ? `${position.spine} / ${denom}`
287
+ : `${position.spine}.${position.detail} / ${denom}`;
288
+ }
289
+ const overlay = (overlayHtml || "").replace("__SLIDE_INDICATOR__", escapeHtml(indicatorLabel));
290
+
291
+ // Wrap title + body in a scale container so PDF export can apply
292
+ // transform: scale() to fit the content onto a fixed page size. In
293
+ // screen mode the wrapper is display:contents (invisible to layout); in
294
+ // print mode it becomes a real block that the beforeprint handler can
295
+ // measure and scale.
296
+ return `<div class="${classes.join(" ")}"${idAttr}${dataAttrs}>\n<div class="slide-content-scale">\n${title}\n${bodyHtml}\n</div>${notesHtml}${overlay}\n</div>`;
254
297
  }
255
298
 
256
299
  // ---------------------------------------------------------------------------
@@ -277,7 +320,11 @@ function renderSlides(nodes, options = {}) {
277
320
  // Filter to scope nodes only (skip stray paragraphs and :comment scopes)
278
321
  const slides = slideScopes.filter((n) => n.type === "scope" && n.scopeType !== "comment");
279
322
 
280
- // Build per-slide footer: < CONFIDENTIAL ---gap--- Company >
323
+ // Build per-slide footer:
324
+ // < CONFIDENTIAL ---gap--- Company N/Total >
325
+ // The indicator slot is rendered as a literal token here and substituted
326
+ // per-slide inside renderSlide() so all the right-edge elements share one
327
+ // flexbox row (avoids the page number stacking on top of the company name).
281
328
  const footerParts = [];
282
329
  footerParts.push(`<span class="nav-prev">&lsaquo;</span>`);
283
330
  if (meta.confidential) {
@@ -292,11 +339,42 @@ function renderSlides(nodes, options = {}) {
292
339
  if (meta.company) {
293
340
  footerParts.push(`<span class="sdoc-company-footer">${escapeHtml(meta.company)}</span>`);
294
341
  }
342
+ footerParts.push(`<span class="slide-indicator">__SLIDE_INDICATOR__</span>`);
295
343
  footerParts.push(`<span class="nav-next">&rsaquo;</span>`);
296
344
  const overlayHtml = `\n<div class="slide-footer">${footerParts.join("")}</div>`;
297
345
 
298
- const slidesHtml = slides
299
- .map((scope, index) => renderSlide(scope, index, overlayHtml))
346
+ // Build a flat emission order: each spine slide, followed immediately by its
347
+ // :detail children in source order. The flat order matches what we want for
348
+ // PDF export, so PDF needs no special case.
349
+ const totalSpines = slides.length;
350
+ const emitted = [];
351
+ slides.forEach((scope, i) => {
352
+ const spineIndex = i + 1; // 1-based
353
+ const { details } = extractDetails(scope.children);
354
+ emitted.push({
355
+ scope,
356
+ position: {
357
+ spine: spineIndex,
358
+ detail: 0,
359
+ totalSpines,
360
+ hasDetails: details.length > 0
361
+ }
362
+ });
363
+ details.forEach((detail, j) => {
364
+ emitted.push({
365
+ scope: detail,
366
+ position: {
367
+ spine: spineIndex,
368
+ detail: j + 1,
369
+ totalSpines,
370
+ hasDetails: false
371
+ }
372
+ });
373
+ });
374
+ });
375
+
376
+ const slidesHtml = emitted
377
+ .map(({ scope, position }, index) => renderSlide(scope, index, overlayHtml, position))
300
378
  .join("\n\n");
301
379
 
302
380
  const title = meta.properties?.title
@@ -327,19 +405,39 @@ function renderSlides(nodes, options = {}) {
327
405
  color: rgba(160, 40, 40, 0.6);
328
406
  margin-left: 0.8em;
329
407
  }
408
+ .slide-indicator {
409
+ font-size: 0.7em; color: rgba(0,0,0,0.35);
410
+ font-variant-numeric: tabular-nums;
411
+ letter-spacing: 0.04em;
412
+ pointer-events: none;
413
+ user-select: none;
414
+ margin-right: 0.6em;
415
+ }
416
+ /* Scale wrapper: invisible to layout in screen mode so existing slide
417
+ styles (flex centering, two-column grid, etc.) work as-is. In print
418
+ mode it becomes a real block element whose transform is set by the
419
+ beforeprint handler to shrink overflowing content to fit the page. */
420
+ .slide-content-scale { display: contents; }
421
+
330
422
  @media print {
331
423
  @page { size: 13.333in 7.5in; margin: 0; }
332
424
  body { overflow: visible; height: auto; }
333
425
  .slide {
334
- display: flex !important;
426
+ display: block !important;
335
427
  position: relative !important;
336
428
  opacity: 1 !important;
337
429
  pointer-events: auto !important;
338
430
  page-break-after: always; break-after: page;
339
431
  width: 100vw; height: 100vh; max-width: none;
432
+ overflow: hidden;
340
433
  page-break-inside: avoid; break-inside: avoid;
341
434
  }
342
435
  .slide:last-child { page-break-after: auto; break-after: auto; }
436
+ .slide-content-scale {
437
+ display: block;
438
+ width: 100%;
439
+ transform-origin: top left;
440
+ }
343
441
  .nav-prev, .nav-next { display: none !important; }
344
442
  .notes { display: none; }
345
443
  }`;
@@ -359,6 +457,7 @@ blockquote p { color: #9d9d9d; }
359
457
  .nav-prev, .nav-next { color: rgba(255, 255, 255, 0.7); }
360
458
  .sdoc-company-footer { color: rgba(255, 255, 255, 0.35); }
361
459
  .sdoc-confidential-notice { color: rgba(235, 120, 120, 0.7); }
460
+ .slide-indicator { color: rgba(255, 255, 255, 0.35); }
362
461
  ` : "";
363
462
 
364
463
  const cssTag = `<style>\n${structuralCss}\n${themeCss}\n${darkCss}</style>`;
@@ -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);
@@ -133,7 +135,7 @@ async function buildHtml(filePath) {
133
135
 
134
136
  const title = path.basename(resolvedPath, ".sdoc");
135
137
 
136
- return renderHtmlDocumentFromParsed(
138
+ const html = renderHtmlDocumentFromParsed(
137
139
  { nodes: metaResult.nodes, errors: parsed.errors },
138
140
  title,
139
141
  {
@@ -141,8 +143,44 @@ 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
  );
149
+
150
+ // Inline local images as data URIs so output is self-contained. PDF export
151
+ // renders from a temp directory, where relative image paths (e.g.
152
+ // diagrams/foo.svg) no longer resolve; inlining also makes HTML portable.
153
+ return inlineLocalImages(html, docDir);
154
+ }
155
+
156
+ const IMAGE_MIME = {
157
+ ".svg": "image/svg+xml",
158
+ ".png": "image/png",
159
+ ".jpg": "image/jpeg",
160
+ ".jpeg": "image/jpeg",
161
+ ".gif": "image/gif",
162
+ ".webp": "image/webp",
163
+ };
164
+
165
+ function inlineLocalImages(html, docDir) {
166
+ return html.replace(/(<img\b[^>]*?\bsrc=")([^"]*)(")/gi, (match, pre, src, post) => {
167
+ // Leave remote URLs and already-inlined data URIs untouched.
168
+ if (/^(https?:|data:|file:)/i.test(src)) return match;
169
+ const decoded = src.replace(/&amp;/g, "&");
170
+ const ext = path.extname(decoded).toLowerCase();
171
+ const mime = IMAGE_MIME[ext];
172
+ if (!mime) return match;
173
+ const abs = path.isAbsolute(decoded) ? decoded : path.join(docDir, decoded);
174
+ let data;
175
+ try {
176
+ data = fs.readFileSync(abs);
177
+ } catch {
178
+ console.error(`Warning: could not inline image (not found): ${decoded}`);
179
+ return match;
180
+ }
181
+ const uri = `data:${mime};base64,${data.toString("base64")}`;
182
+ return `${pre}${uri}${post}`;
183
+ });
146
184
  }
147
185
 
148
186
  async function main() {
@@ -150,12 +188,15 @@ async function main() {
150
188
  let inputPath = null;
151
189
  let outputPath = null;
152
190
  let htmlMode = false;
191
+ let includeAbout = false;
153
192
 
154
193
  for (let i = 0; i < args.length; i++) {
155
194
  if (args[i] === "-o" && i + 1 < args.length) {
156
195
  outputPath = args[++i];
157
196
  } else if (args[i] === "--html") {
158
197
  htmlMode = true;
198
+ } else if (args[i] === "--include-about") {
199
+ includeAbout = true;
159
200
  } else if (args[i] === "--help" || args[i] === "-h") {
160
201
  usage();
161
202
  } else if (!inputPath) {
@@ -174,7 +215,7 @@ async function main() {
174
215
  process.exit(1);
175
216
  }
176
217
 
177
- const html = await buildHtml(resolvedInput);
218
+ const html = await buildHtml(resolvedInput, { includeAbout });
178
219
 
179
220
  if (htmlMode) {
180
221
  if (!outputPath) {
@@ -191,6 +232,7 @@ async function main() {
191
232
  outputPath = resolvedInput.replace(/\.sdoc$/i, "") + ".pdf";
192
233
  }
193
234
  const resolvedOutput = path.resolve(outputPath);
235
+ fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
194
236
 
195
237
  // Write HTML to temp file for Chrome
196
238
  const tmpHtml = path.join(os.tmpdir(), "sdoc-doc-" + Date.now() + ".html");