@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.
- package/docs/reference/sdoc-authoring.sdoc +10 -5
- package/docs/reference/slide-authoring.sdoc +89 -5
- package/lexica/specification.sdoc +28 -2
- package/package.json +1 -1
- package/src/notion-renderer.js +49 -1
- package/src/sdoc.js +104 -12
- package/src/slide-renderer.js +107 -8
- package/tools/build-doc.js +47 -5
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
362
|
+
Touch swipe horizontal / vertical on mobile devices.
|
|
281
363
|
|
|
282
|
-
Slide counter shown in the bottom-right
|
|
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 ]
|
|
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 ] [
|
|
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.
|
|
5
|
+
"version": "0.2.15",
|
|
6
6
|
"publisher": "entropicwarrior-msenfin",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
package/src/notion-renderer.js
CHANGED
|
@@ -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
|
-
|
|
1821
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
package/src/slide-renderer.js
CHANGED
|
@@ -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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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:
|
|
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">‹</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">›</span>`);
|
|
296
344
|
const overlayHtml = `\n<div class="slide-footer">${footerParts.join("")}</div>`;
|
|
297
345
|
|
|
298
|
-
|
|
299
|
-
|
|
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:
|
|
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>`;
|
package/tools/build-doc.js
CHANGED
|
@@ -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
|
-
|
|
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(/&/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");
|