@entropicwarrior/sdoc 0.2.20 → 0.2.21

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.
@@ -107,6 +107,18 @@
107
107
  reserved. \`--pptx\` runs the same check for free, because it has
108
108
  already measured the deck.
109
109
 
110
+ To override what a format does with the optional slides:
111
+
112
+ ```
113
+ node tools/build-slides.js deck.sdoc --pdf --with-optional
114
+ node tools/build-slides.js deck.sdoc --no-optional
115
+ ```
116
+
117
+ A slide marked \`optional: true\` is kept in the HTML build and
118
+ left out of \`--pdf\` and \`--pptx\`. Either default can be
119
+ overridden in any format: \`--with-optional\` keeps them,
120
+ \`--no-optional\` drops them. See [Optional Slides](#optional).
121
+
110
122
  To use a custom theme:
111
123
 
112
124
  ```
@@ -138,6 +150,7 @@
138
150
  \`footnote:\` | Small print pinned under the body: provenance, caveats, sources.
139
151
  \`status:\` | A second line under the kicker on a title slide, for a distribution notice.
140
152
  \`accent:\` | \`primary\`, \`secondary\`, \`tertiary\` or \`neutral\`. An accent names a role; the theme chooses the colour.
153
+ \`optional:\` | \`true\` holds the slide back from the exports, and from any build passed \`--no-optional\`. See [Optional Slides](#optional).
141
154
  \`numbered:\` | \`columns\` and \`rows\`. \`true\` numbers the children 01, 02, 03.
142
155
  \`variant:\` | \`columns\` and \`rows\`. A named treatment, such as \`panel\` or \`mono\`.
143
156
  \`weights:\` | \`split\` only. The ratio between panes, as in \`48 52\`.
@@ -150,11 +163,11 @@
150
163
  }
151
164
 
152
165
  A key is only configuration where it means something. \`config:\`,
153
- \`kicker:\`, \`lede:\`, \`footnote:\`, \`accent:\` and \`status:\` are
154
- understood by every scope; the rest are understood only by the
155
- layout named against them above. Anywhere else they are ordinary
156
- text — a slide opening "Value: the customer keeps their data."
157
- renders that sentence, it does not silently swallow it. A
166
+ \`kicker:\`, \`lede:\`, \`footnote:\`, \`accent:\`, \`status:\` and
167
+ \`optional:\` are understood by every scope; the rest are understood
168
+ only by the layout named against them above. Anywhere else they are
169
+ ordinary text — a slide opening "Value: the customer keeps their
170
+ data." renders that sentence, it does not silently swallow it. A
158
171
  paragraph spanning more than one source line is never
159
172
  configuration.
160
173
 
@@ -526,6 +539,114 @@
526
539
  spine 2, its details in order, and so on. This is the same order
527
540
  slides appear in the HTML, so PDF export needs no special case —
528
541
  every slide is just one page.
542
+
543
+ Every detail is a page, then, including the ones the audience
544
+ never saw. Mark a detail `optional: true` to keep it out of the
545
+ exported file — see [Optional Slides](#optional).
546
+ }
547
+ }
548
+
549
+ # Optional Slides @optional
550
+ {
551
+ Some slides exist for the room and not for the file that gets sent
552
+ on: the answer to a question that may not be asked, the figure
553
+ behind a figure, the rebuttal you hope to leave unused. Set
554
+ \`optional: true\` on the slide and it stays in the HTML build, which
555
+ is what you present from, and leaves the PDF and the PPTX, which are
556
+ what you send.
557
+
558
+ # Syntax @optional-syntax
559
+ {
560
+ ```
561
+ # Unit economics @unit-economics {
562
+ Headline numbers.
563
+
564
+ # Gross margin by cohort @margin :detail {
565
+ optional: true
566
+
567
+ The cohort table, if anyone asks for it.
568
+ }
569
+ }
570
+
571
+ # Pricing objections @objections {
572
+ optional: true
573
+
574
+ Held back unless pricing comes up.
575
+ }
576
+ ```
577
+
578
+ \`optional:\` is a slide property, not a scope annotation, and that
579
+ is deliberate: a heading carries one \`:type\`, and a drilldown has
580
+ already spent it on \`:detail\`. Reading optional-ness off the
581
+ configuration run keeps the two independent, so all four
582
+ combinations are available — a required spine, an optional spine,
583
+ a required detail, an optional detail.
584
+
585
+ A slide is the unit. Setting it on a cell inside a structured
586
+ layout — a column, a pipeline row, a pane of a \`split\` — has no
587
+ effect: the whole slide travels or none of it does. Mark the slide
588
+ that holds the cell.
589
+
590
+ It is set the way the other boolean properties are: \`true\`,
591
+ \`yes\`, \`on\` or \`1\` mark the slide; \`false\`, \`no\`, \`off\` or \`0\`
592
+ leave it alone. Like every slide property it must come before any
593
+ content.
594
+
595
+ A value that is none of those words is not configuration at all.
596
+ A slide whose first line is "Optional: a second seat costs
597
+ nothing" renders that sentence — a boolean key discards what it
598
+ cannot read, so it declines the line rather than swallowing it.
599
+ }
600
+
601
+ # What each format does @optional-formats
602
+ {
603
+ {[table]
604
+ Format | Default | Why
605
+ HTML | Included | It is the format you present from, and an optional slide is one you may want in the room.
606
+ PDF | Excluded | It is a format you send on.
607
+ PPTX | Excluded | The same.
608
+ }
609
+
610
+ The default is a guess about intent, not a property of the format,
611
+ so either can be overridden anywhere. An HTML deck is also
612
+ something you send someone:
613
+
614
+ ```
615
+ node tools/build-slides.js deck.sdoc --pdf --with-optional
616
+ node tools/build-slides.js deck.sdoc --no-optional
617
+ ```
618
+
619
+ When both are given the last one wins, as with \`--fit\`.
620
+
621
+ An optional spine takes its details with it. A detail belongs to
622
+ its spine, and there is nowhere for it to stand once the spine is
623
+ gone, so it goes whether or not it is marked itself.
624
+ }
625
+
626
+ # Numbering @optional-numbering
627
+ {
628
+ Optional slides are dropped before the deck is numbered, not
629
+ hidden afterwards, so the slide indicator counts what the reader
630
+ actually holds. A deck of four spines with one of them optional
631
+ reads \`3 / 3\` on its last page in the exported file and \`4 / 4\`
632
+ in the HTML build. The pages are consecutive either way; there is
633
+ no gap where a slide was removed.
634
+
635
+ A spine whose only details are optional loses its
636
+ \`slide-has-details\` class in the export too, so no theme draws a
637
+ drilldown chevron pointing at nothing.
638
+ }
639
+
640
+ # Themes @optional-theme
641
+ {
642
+ An optional slide carries the \`slide-optional\` class wherever it
643
+ is kept. A theme can use it to mark the slide on screen — a corner
644
+ tab, a tinted rule — so the presenter can see which slides are off
645
+ the main path. The built-in theme does not style it, so an optional
646
+ slide looks like any other until a theme says otherwise.
647
+
648
+ Nothing marked optional reaches a build it was excluded from, so
649
+ the class cannot appear in one.
529
650
  }
530
651
  }
531
652
 
@@ -768,6 +889,10 @@
768
889
  rather than an outlined box, so a hairline under a heading is a
769
890
  hairline rather than a rectangle with three invisible edges.
770
891
 
892
+ What is not in the file at all: slides marked \`optional: true\`,
893
+ unless \`--with-optional\` was passed. See
894
+ [Optional Slides](#optional).
895
+
771
896
  What does not carry: web fonts are named, not embedded, so the
772
897
  fonts a theme uses must be available to the viewer. Choosing faces
773
898
  that are on Google Fonts is the reliable route, because Slides
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.20",
5
+ "version": "0.2.21",
6
6
  "publisher": "entropicwarrior-msenfin",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -32,6 +32,7 @@ const COMMON_KEYS = new Set([
32
32
  "footnote",
33
33
  "accent",
34
34
  "status",
35
+ "optional",
35
36
  ]);
36
37
 
37
38
  const LAYOUT_KEYS = {
@@ -53,6 +54,17 @@ const CELL_KEYS = {
53
54
  bars: ["value", "fill"],
54
55
  };
55
56
 
57
+ // Keys whose value is a boolean. Every other key renders the text it is given,
58
+ // so consuming the line is visible in the output; a boolean key discards
59
+ // anything it does not recognise, which would make an opening sentence
60
+ // disappear. These therefore only take the line when the value is a word they
61
+ // actually understand — "Optional: a second seat costs nothing" is prose.
62
+ const BOOLEAN_KEYS = new Set(["optional", "numbered", "rule"]);
63
+ const BOOLEAN_WORDS = new Set([
64
+ "true", "yes", "on", "1",
65
+ "false", "no", "off", "0",
66
+ ]);
67
+
56
68
  // Every key any scope might understand. A leading paragraph opening with one
57
69
  // of these is a configuration *candidate*; whether it is kept depends on the
58
70
  // context resolved below.
@@ -136,6 +148,13 @@ function extractConfig(children, parentLayout) {
136
148
  rejected.push(candidate.node);
137
149
  continue;
138
150
  }
151
+ if (
152
+ BOOLEAN_KEYS.has(candidate.key) &&
153
+ !BOOLEAN_WORDS.has(candidate.value.trim().toLowerCase())
154
+ ) {
155
+ rejected.push(candidate.node);
156
+ continue;
157
+ }
139
158
  if (candidate.key === "config" || candidate.key === "layout") {
140
159
  config.layout = candidate.value.toLowerCase();
141
160
  } else {
@@ -505,10 +524,12 @@ function buildBody(layout, cell, ctx) {
505
524
  }
506
525
 
507
526
  module.exports = {
527
+ BOOLEAN_KEYS,
508
528
  CONFIG_KEYS,
509
529
  STRUCTURED_LAYOUTS,
510
530
  extractConfig,
511
531
  buildBody,
512
532
  accentClass,
513
533
  slug,
534
+ truthy,
514
535
  };
@@ -8,7 +8,7 @@
8
8
  // const html = renderSlides(nodes, { meta, themeCss, themeJs });
9
9
 
10
10
  const { parseInline, renderKatex, escapeHtml, escapeAttr, sanitizeSvg, colorSwatchHtml } = require("./sdoc");
11
- const { extractConfig, buildBody, accentClass, slug } = require("./slide-layouts");
11
+ const { extractConfig, buildBody, accentClass, slug, truthy } = require("./slide-layouts");
12
12
 
13
13
  // ---------------------------------------------------------------------------
14
14
  // Inline rendering — produces clean HTML without sdoc-* classes
@@ -211,6 +211,23 @@ function extractDetails(children) {
211
211
  return { details, contentNodes: rest };
212
212
  }
213
213
 
214
+ // Whether a slide scope carries `optional: true`.
215
+ //
216
+ // Optional-ness is a slide *property*, not a scope type: a heading carries one
217
+ // `:type` annotation and a drilldown has already spent it on `:detail`, so a
218
+ // detail slide could not also be annotated optional. Reading it off the
219
+ // configuration run keeps the two orthogonal — every combination of
220
+ // spine/detail and required/optional is expressible.
221
+ //
222
+ // The details are pulled out first and the config read without a parent
223
+ // layout, exactly as renderSlide() does it, so this answer and the one the
224
+ // slide renders under can never disagree.
225
+ function isOptionalSlide(scope) {
226
+ const { contentNodes } = extractDetails(scope.children || []);
227
+ const { config } = extractConfig(contentNodes);
228
+ return truthy(config.optional);
229
+ }
230
+
214
231
  // ---------------------------------------------------------------------------
215
232
  // Slide rendering
216
233
  // ---------------------------------------------------------------------------
@@ -246,6 +263,11 @@ function renderSlide(scope, slideIndex, overlayHtml, position) {
246
263
  if (position && position.detail > 0) {
247
264
  classes.push("slide-detail");
248
265
  }
266
+ // Present in the HTML build, which is the presenting format; dropped before
267
+ // this point when the deck is rendered for PDF or PPTX export.
268
+ if (truthy(config.optional)) {
269
+ classes.push("slide-optional");
270
+ }
249
271
 
250
272
  // On a title slide the kicker sits beneath the statement rather than above
251
273
  // it, so the eye lands on the name first.
@@ -312,7 +334,8 @@ function renderSlides(nodes, options = {}) {
312
334
  themeJs = "",
313
335
  darkMode = false,
314
336
  themeConfig = {},
315
- fit = null
337
+ fit = null,
338
+ includeOptional = true
316
339
  } = options;
317
340
 
318
341
  // The design box and print page come from the theme (themes/<name>/theme.json).
@@ -373,14 +396,28 @@ function renderSlides(nodes, options = {}) {
373
396
  footerParts.push(`<span class="nav-next">&rsaquo;</span>`);
374
397
  const overlayHtml = `\n<div class="slide-footer">${footerParts.join("")}</div>`;
375
398
 
399
+ // Optional slides are for the room, not the file that gets sent on. They are
400
+ // kept in the HTML build (includeOptional defaults to true, so every existing
401
+ // caller sees the deck it saw before) and dropped here when a deck is
402
+ // rendered for export. Dropping them before numbering — rather than hiding
403
+ // them later in print CSS — is what keeps the indicator honest: the
404
+ // denominator counts the spine slides the reader actually has.
405
+ //
406
+ // An optional spine takes its details with it. A detail belongs to its spine;
407
+ // there is nowhere for it to go once the spine is gone.
408
+ const kept = includeOptional ? slides : slides.filter((s) => !isOptionalSlide(s));
409
+
376
410
  // Build a flat emission order: each spine slide, followed immediately by its
377
411
  // :detail children in source order. The flat order matches what we want for
378
412
  // PDF export, so PDF needs no special case.
379
- const totalSpines = slides.length;
413
+ const totalSpines = kept.length;
380
414
  const emitted = [];
381
- slides.forEach((scope, i) => {
415
+ kept.forEach((scope, i) => {
382
416
  const spineIndex = i + 1; // 1-based
383
- const { details } = extractDetails(scope.children);
417
+ const { details: allDetails } = extractDetails(scope.children);
418
+ const details = includeOptional
419
+ ? allDetails
420
+ : allDetails.filter((d) => !isOptionalSlide(d));
384
421
  emitted.push({
385
422
  scope,
386
423
  position: {
@@ -573,4 +610,4 @@ ${jsTag}${mermaidTag}
573
610
  </html>`;
574
611
  }
575
612
 
576
- module.exports = { renderSlides, renderSlide, renderNode, renderInline };
613
+ module.exports = { renderSlides, renderSlide, renderNode, renderInline, isOptionalSlide };