@entropicwarrior/sdoc 0.2.20 → 0.2.22
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/slide-authoring.sdoc +137 -8
- package/package.json +1 -1
- package/src/slide-geometry.js +6 -1
- package/src/slide-layouts.js +21 -0
- package/src/slide-renderer.js +199 -7
- package/src/theme.js +40 -1
|
@@ -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
|
|
154
|
-
understood by every scope; the rest are understood
|
|
155
|
-
layout named against them above. Anywhere else they are
|
|
156
|
-
text — a slide opening "Value: the customer keeps their
|
|
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
|
|
|
@@ -445,9 +458,13 @@
|
|
|
445
458
|
\`variant: card\`, an image at the top of each column with a heading
|
|
446
459
|
and a line of body beneath gives the standard figure row.
|
|
447
460
|
|
|
448
|
-
Paths are resolved relative to the \`.sdoc\` file
|
|
449
|
-
|
|
450
|
-
|
|
461
|
+
Paths are resolved relative to the \`.sdoc\` file, in every format.
|
|
462
|
+
A local image is embedded in the built deck itself — HTML, PDF and
|
|
463
|
+
PPTX alike — so the file you send someone carries its own pictures
|
|
464
|
+
and can be moved anywhere. A remote URL is left as a link, and the
|
|
465
|
+
PPTX export says which ones it could not embed. An image the build
|
|
466
|
+
cannot read is left as a plain reference and named in a warning,
|
|
467
|
+
rather than becoming a broken picture nobody was told about.
|
|
451
468
|
}
|
|
452
469
|
}
|
|
453
470
|
|
|
@@ -526,6 +543,114 @@
|
|
|
526
543
|
spine 2, its details in order, and so on. This is the same order
|
|
527
544
|
slides appear in the HTML, so PDF export needs no special case —
|
|
528
545
|
every slide is just one page.
|
|
546
|
+
|
|
547
|
+
Every detail is a page, then, including the ones the audience
|
|
548
|
+
never saw. Mark a detail `optional: true` to keep it out of the
|
|
549
|
+
exported file — see [Optional Slides](#optional).
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
# Optional Slides @optional
|
|
554
|
+
{
|
|
555
|
+
Some slides exist for the room and not for the file that gets sent
|
|
556
|
+
on: the answer to a question that may not be asked, the figure
|
|
557
|
+
behind a figure, the rebuttal you hope to leave unused. Set
|
|
558
|
+
\`optional: true\` on the slide and it stays in the HTML build, which
|
|
559
|
+
is what you present from, and leaves the PDF and the PPTX, which are
|
|
560
|
+
what you send.
|
|
561
|
+
|
|
562
|
+
# Syntax @optional-syntax
|
|
563
|
+
{
|
|
564
|
+
```
|
|
565
|
+
# Unit economics @unit-economics {
|
|
566
|
+
Headline numbers.
|
|
567
|
+
|
|
568
|
+
# Gross margin by cohort @margin :detail {
|
|
569
|
+
optional: true
|
|
570
|
+
|
|
571
|
+
The cohort table, if anyone asks for it.
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
# Pricing objections @objections {
|
|
576
|
+
optional: true
|
|
577
|
+
|
|
578
|
+
Held back unless pricing comes up.
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
\`optional:\` is a slide property, not a scope annotation, and that
|
|
583
|
+
is deliberate: a heading carries one \`:type\`, and a drilldown has
|
|
584
|
+
already spent it on \`:detail\`. Reading optional-ness off the
|
|
585
|
+
configuration run keeps the two independent, so all four
|
|
586
|
+
combinations are available — a required spine, an optional spine,
|
|
587
|
+
a required detail, an optional detail.
|
|
588
|
+
|
|
589
|
+
A slide is the unit. Setting it on a cell inside a structured
|
|
590
|
+
layout — a column, a pipeline row, a pane of a \`split\` — has no
|
|
591
|
+
effect: the whole slide travels or none of it does. Mark the slide
|
|
592
|
+
that holds the cell.
|
|
593
|
+
|
|
594
|
+
It is set the way the other boolean properties are: \`true\`,
|
|
595
|
+
\`yes\`, \`on\` or \`1\` mark the slide; \`false\`, \`no\`, \`off\` or \`0\`
|
|
596
|
+
leave it alone. Like every slide property it must come before any
|
|
597
|
+
content.
|
|
598
|
+
|
|
599
|
+
A value that is none of those words is not configuration at all.
|
|
600
|
+
A slide whose first line is "Optional: a second seat costs
|
|
601
|
+
nothing" renders that sentence — a boolean key discards what it
|
|
602
|
+
cannot read, so it declines the line rather than swallowing it.
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
# What each format does @optional-formats
|
|
606
|
+
{
|
|
607
|
+
{[table]
|
|
608
|
+
Format | Default | Why
|
|
609
|
+
HTML | Included | It is the format you present from, and an optional slide is one you may want in the room.
|
|
610
|
+
PDF | Excluded | It is a format you send on.
|
|
611
|
+
PPTX | Excluded | The same.
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
The default is a guess about intent, not a property of the format,
|
|
615
|
+
so either can be overridden anywhere. An HTML deck is also
|
|
616
|
+
something you send someone:
|
|
617
|
+
|
|
618
|
+
```
|
|
619
|
+
node tools/build-slides.js deck.sdoc --pdf --with-optional
|
|
620
|
+
node tools/build-slides.js deck.sdoc --no-optional
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
When both are given the last one wins, as with \`--fit\`.
|
|
624
|
+
|
|
625
|
+
An optional spine takes its details with it. A detail belongs to
|
|
626
|
+
its spine, and there is nowhere for it to stand once the spine is
|
|
627
|
+
gone, so it goes whether or not it is marked itself.
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
# Numbering @optional-numbering
|
|
631
|
+
{
|
|
632
|
+
Optional slides are dropped before the deck is numbered, not
|
|
633
|
+
hidden afterwards, so the slide indicator counts what the reader
|
|
634
|
+
actually holds. A deck of four spines with one of them optional
|
|
635
|
+
reads \`3 / 3\` on its last page in the exported file and \`4 / 4\`
|
|
636
|
+
in the HTML build. The pages are consecutive either way; there is
|
|
637
|
+
no gap where a slide was removed.
|
|
638
|
+
|
|
639
|
+
A spine whose only details are optional loses its
|
|
640
|
+
\`slide-has-details\` class in the export too, so no theme draws a
|
|
641
|
+
drilldown chevron pointing at nothing.
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
# Themes @optional-theme
|
|
645
|
+
{
|
|
646
|
+
An optional slide carries the \`slide-optional\` class wherever it
|
|
647
|
+
is kept. A theme can use it to mark the slide on screen — a corner
|
|
648
|
+
tab, a tinted rule — so the presenter can see which slides are off
|
|
649
|
+
the main path. The built-in theme does not style it, so an optional
|
|
650
|
+
slide looks like any other until a theme says otherwise.
|
|
651
|
+
|
|
652
|
+
Nothing marked optional reaches a build it was excluded from, so
|
|
653
|
+
the class cannot appear in one.
|
|
529
654
|
}
|
|
530
655
|
}
|
|
531
656
|
|
|
@@ -768,6 +893,10 @@
|
|
|
768
893
|
rather than an outlined box, so a hairline under a heading is a
|
|
769
894
|
hairline rather than a rectangle with three invisible edges.
|
|
770
895
|
|
|
896
|
+
What is not in the file at all: slides marked \`optional: true\`,
|
|
897
|
+
unless \`--with-optional\` was passed. See
|
|
898
|
+
[Optional Slides](#optional).
|
|
899
|
+
|
|
771
900
|
What does not carry: web fonts are named, not embedded, so the
|
|
772
901
|
fonts a theme uses must be available to the viewer. Choosing faces
|
|
773
902
|
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.
|
|
5
|
+
"version": "0.2.22",
|
|
6
6
|
"publisher": "entropicwarrior-msenfin",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
package/src/slide-geometry.js
CHANGED
|
@@ -143,7 +143,12 @@ const MEASURE_SCRIPT = `
|
|
|
143
143
|
// The nav chevrons are a screen affordance, not deck content: nothing
|
|
144
144
|
// clicks them in a PDF or a .pptx. (The theme runtime also hides one of
|
|
145
145
|
// the pair at load time, so measuring them exported a lone arrow.)
|
|
146
|
-
|
|
146
|
+
// .nav-vert is the vertical drilldown pair and its container; it sits in
|
|
147
|
+
// the bottom margin, so counting it would report an overflow on any
|
|
148
|
+
// slide whose content reaches near the bottom of the design box.
|
|
149
|
+
if (el.classList && (el.classList.contains("nav-prev") ||
|
|
150
|
+
el.classList.contains("nav-next") ||
|
|
151
|
+
el.classList.contains("nav-vert"))) return;
|
|
147
152
|
|
|
148
153
|
var cs = getComputedStyle(el);
|
|
149
154
|
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity) === 0) return;
|
package/src/slide-layouts.js
CHANGED
|
@@ -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
|
};
|
package/src/slide-renderer.js
CHANGED
|
@@ -7,8 +7,101 @@
|
|
|
7
7
|
// const { nodes, meta } = extractMeta(parsed.nodes);
|
|
8
8
|
// const html = renderSlides(nodes, { meta, themeCss, themeJs });
|
|
9
9
|
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
10
12
|
const { parseInline, renderKatex, escapeHtml, escapeAttr, sanitizeSvg, colorSwatchHtml } = require("./sdoc");
|
|
11
|
-
const { extractConfig, buildBody, accentClass, slug } = require("./slide-layouts");
|
|
13
|
+
const { extractConfig, buildBody, accentClass, slug, truthy } = require("./slide-layouts");
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Image inlining
|
|
17
|
+
//
|
|
18
|
+
// renderSlides emits `<img src>` exactly as the document wrote it, and the
|
|
19
|
+
// documentation is explicit that those paths are relative to the .sdoc file.
|
|
20
|
+
// A built deck, though, is a single file that gets written wherever -o says
|
|
21
|
+
// and then moved, mailed and opened from somewhere else entirely, at which
|
|
22
|
+
// point a relative path no longer names anything. The PDF and PPTX exporters
|
|
23
|
+
// dodged this by reading the deck from a temp copy beside the input; the HTML
|
|
24
|
+
// build had no such trick and silently shipped broken images whenever the
|
|
25
|
+
// output went to another directory.
|
|
26
|
+
//
|
|
27
|
+
// Inlining resolves the paths once, against the .sdoc, and makes the question
|
|
28
|
+
// of where the file ends up irrelevant for every format. It is the same thing
|
|
29
|
+
// loadTheme already does for a theme's fonts and backgrounds, for the same
|
|
30
|
+
// reason, and readImage in slide-pptx.js already decodes data: URIs, so the
|
|
31
|
+
// PPTX export embeds them exactly as it did loose files.
|
|
32
|
+
//
|
|
33
|
+
// This reads from disk, so it is not part of renderSlides: the renderer stays
|
|
34
|
+
// a pure AST-to-HTML function and the builder calls this afterwards.
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const INLINE_IMAGE_TYPES = {
|
|
38
|
+
".png": "image/png",
|
|
39
|
+
".jpg": "image/jpeg",
|
|
40
|
+
".jpeg": "image/jpeg",
|
|
41
|
+
".gif": "image/gif",
|
|
42
|
+
".svg": "image/svg+xml",
|
|
43
|
+
".webp": "image/webp",
|
|
44
|
+
".avif": "image/avif",
|
|
45
|
+
".bmp": "image/bmp",
|
|
46
|
+
".ico": "image/x-icon",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Returns { html, inlined, missing }. `missing` describes every local image
|
|
50
|
+
// that could not be embedded — the silent failure this exists to stop — as
|
|
51
|
+
// { src, resolved, reason }, so the caller can say where it looked and not
|
|
52
|
+
// just what it wanted. Naming the resolved path is what makes the rule
|
|
53
|
+
// visible at the moment it bites: a deck written against a different
|
|
54
|
+
// convention otherwise sees only that a file it can see plainly is "missing".
|
|
55
|
+
// A remote or data: URI is neither inlined nor missing: nothing to resolve.
|
|
56
|
+
function inlineDeckImages(html, baseDir) {
|
|
57
|
+
const inlined = [];
|
|
58
|
+
const missing = [];
|
|
59
|
+
|
|
60
|
+
const out = html.replace(/(<img\b[^>]*?\bsrc=")([^"]*)(")/gi, (match, pre, src, post) => {
|
|
61
|
+
const raw = src.trim();
|
|
62
|
+
if (!raw) return match;
|
|
63
|
+
// Already embedded, or somewhere this build cannot reach.
|
|
64
|
+
if (/^data:/i.test(raw)) return match;
|
|
65
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(raw)) return match;
|
|
66
|
+
|
|
67
|
+
// The src sits in an HTML attribute, so entities are decoded before it is
|
|
68
|
+
// read as a path, and any ?query or #fragment dropped.
|
|
69
|
+
const decoded = raw
|
|
70
|
+
.replace(/&/g, "&")
|
|
71
|
+
.replace(/'/g, "'")
|
|
72
|
+
.replace(/"/g, '"')
|
|
73
|
+
.replace(/[?#].*$/, "");
|
|
74
|
+
|
|
75
|
+
let filePath;
|
|
76
|
+
try {
|
|
77
|
+
filePath = path.isAbsolute(decoded)
|
|
78
|
+
? decoded
|
|
79
|
+
: path.resolve(baseDir, decodeURIComponent(decoded));
|
|
80
|
+
} catch {
|
|
81
|
+
filePath = path.isAbsolute(decoded) ? decoded : path.resolve(baseDir, decoded);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
85
|
+
const mime = INLINE_IMAGE_TYPES[ext];
|
|
86
|
+
if (!mime) {
|
|
87
|
+
missing.push({ src: decoded, resolved: filePath, reason: "unsupported-type" });
|
|
88
|
+
return match;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let data;
|
|
92
|
+
try {
|
|
93
|
+
data = fs.readFileSync(filePath);
|
|
94
|
+
} catch {
|
|
95
|
+
missing.push({ src: decoded, resolved: filePath, reason: "not-found" });
|
|
96
|
+
return match;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
inlined.push(decoded);
|
|
100
|
+
return `${pre}data:${mime};base64,${data.toString("base64")}${post}`;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return { html: out, inlined, missing };
|
|
104
|
+
}
|
|
12
105
|
|
|
13
106
|
// ---------------------------------------------------------------------------
|
|
14
107
|
// Inline rendering — produces clean HTML without sdoc-* classes
|
|
@@ -211,6 +304,23 @@ function extractDetails(children) {
|
|
|
211
304
|
return { details, contentNodes: rest };
|
|
212
305
|
}
|
|
213
306
|
|
|
307
|
+
// Whether a slide scope carries `optional: true`.
|
|
308
|
+
//
|
|
309
|
+
// Optional-ness is a slide *property*, not a scope type: a heading carries one
|
|
310
|
+
// `:type` annotation and a drilldown has already spent it on `:detail`, so a
|
|
311
|
+
// detail slide could not also be annotated optional. Reading it off the
|
|
312
|
+
// configuration run keeps the two orthogonal — every combination of
|
|
313
|
+
// spine/detail and required/optional is expressible.
|
|
314
|
+
//
|
|
315
|
+
// The details are pulled out first and the config read without a parent
|
|
316
|
+
// layout, exactly as renderSlide() does it, so this answer and the one the
|
|
317
|
+
// slide renders under can never disagree.
|
|
318
|
+
function isOptionalSlide(scope) {
|
|
319
|
+
const { contentNodes } = extractDetails(scope.children || []);
|
|
320
|
+
const { config } = extractConfig(contentNodes);
|
|
321
|
+
return truthy(config.optional);
|
|
322
|
+
}
|
|
323
|
+
|
|
214
324
|
// ---------------------------------------------------------------------------
|
|
215
325
|
// Slide rendering
|
|
216
326
|
// ---------------------------------------------------------------------------
|
|
@@ -246,6 +356,11 @@ function renderSlide(scope, slideIndex, overlayHtml, position) {
|
|
|
246
356
|
if (position && position.detail > 0) {
|
|
247
357
|
classes.push("slide-detail");
|
|
248
358
|
}
|
|
359
|
+
// Present in the HTML build, which is the presenting format; dropped before
|
|
360
|
+
// this point when the deck is rendered for PDF or PPTX export.
|
|
361
|
+
if (truthy(config.optional)) {
|
|
362
|
+
classes.push("slide-optional");
|
|
363
|
+
}
|
|
249
364
|
|
|
250
365
|
// On a title slide the kicker sits beneath the statement rather than above
|
|
251
366
|
// it, so the eye lands on the name first.
|
|
@@ -312,7 +427,8 @@ function renderSlides(nodes, options = {}) {
|
|
|
312
427
|
themeJs = "",
|
|
313
428
|
darkMode = false,
|
|
314
429
|
themeConfig = {},
|
|
315
|
-
fit = null
|
|
430
|
+
fit = null,
|
|
431
|
+
includeOptional = true
|
|
316
432
|
} = options;
|
|
317
433
|
|
|
318
434
|
// The design box and print page come from the theme (themes/<name>/theme.json).
|
|
@@ -371,16 +487,53 @@ function renderSlides(nodes, options = {}) {
|
|
|
371
487
|
}
|
|
372
488
|
footerParts.push(`<span class="slide-indicator">__SLIDE_INDICATOR__</span>`);
|
|
373
489
|
footerParts.push(`<span class="nav-next">›</span>`);
|
|
374
|
-
|
|
490
|
+
|
|
491
|
+
// The vertical pair, stacked up-over-down at bottom centre. Both are emitted
|
|
492
|
+
// on every slide and start hidden; the theme runtime turns each on only when
|
|
493
|
+
// that move exists from the slide you are actually on. That is the same
|
|
494
|
+
// contract as .nav-prev / .nav-next, and it is why these cannot be a CSS
|
|
495
|
+
// pseudo-element on a build-time class: whether you can go up or down is a
|
|
496
|
+
// property of the current position, not of the slide.
|
|
497
|
+
// One chevron path, drawn twice, mirrored for the up arrow. Text arrowheads
|
|
498
|
+
// cannot do this: U+2303 and U+2304 are not designed as a pair and measure
|
|
499
|
+
// ~26% apart in ink width, and because few fonts carry either codepoint the
|
|
500
|
+
// metrics come from whatever the OS falls back to — so the mismatch is not
|
|
501
|
+
// even consistent between platforms. A path is identical by construction
|
|
502
|
+
// everywhere. The viewBox is symmetric about its own centre (y spans
|
|
503
|
+
// 1.25..5.75 of 7), so the mirrored copy occupies the same box.
|
|
504
|
+
const chevronSvg =
|
|
505
|
+
`<svg viewBox="0 0 12 7" aria-hidden="true" focusable="false">` +
|
|
506
|
+
`<path d="M1 1.25 L6 5.75 L11 1.25" fill="none" stroke="currentColor" ` +
|
|
507
|
+
`stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
508
|
+
const vertNavHtml =
|
|
509
|
+
`\n<div class="nav-vert">` +
|
|
510
|
+
`<span class="nav-up">${chevronSvg}</span>` +
|
|
511
|
+
`<span class="nav-down">${chevronSvg}</span>` +
|
|
512
|
+
`</div>`;
|
|
513
|
+
const overlayHtml = `\n<div class="slide-footer">${footerParts.join("")}</div>${vertNavHtml}`;
|
|
514
|
+
|
|
515
|
+
// Optional slides are for the room, not the file that gets sent on. They are
|
|
516
|
+
// kept in the HTML build (includeOptional defaults to true, so every existing
|
|
517
|
+
// caller sees the deck it saw before) and dropped here when a deck is
|
|
518
|
+
// rendered for export. Dropping them before numbering — rather than hiding
|
|
519
|
+
// them later in print CSS — is what keeps the indicator honest: the
|
|
520
|
+
// denominator counts the spine slides the reader actually has.
|
|
521
|
+
//
|
|
522
|
+
// An optional spine takes its details with it. A detail belongs to its spine;
|
|
523
|
+
// there is nowhere for it to go once the spine is gone.
|
|
524
|
+
const kept = includeOptional ? slides : slides.filter((s) => !isOptionalSlide(s));
|
|
375
525
|
|
|
376
526
|
// Build a flat emission order: each spine slide, followed immediately by its
|
|
377
527
|
// :detail children in source order. The flat order matches what we want for
|
|
378
528
|
// PDF export, so PDF needs no special case.
|
|
379
|
-
const totalSpines =
|
|
529
|
+
const totalSpines = kept.length;
|
|
380
530
|
const emitted = [];
|
|
381
|
-
|
|
531
|
+
kept.forEach((scope, i) => {
|
|
382
532
|
const spineIndex = i + 1; // 1-based
|
|
383
|
-
const { details } = extractDetails(scope.children);
|
|
533
|
+
const { details: allDetails } = extractDetails(scope.children);
|
|
534
|
+
const details = includeOptional
|
|
535
|
+
? allDetails
|
|
536
|
+
: allDetails.filter((d) => !isOptionalSlide(d));
|
|
384
537
|
emitted.push({
|
|
385
538
|
scope,
|
|
386
539
|
position: {
|
|
@@ -465,6 +618,43 @@ function renderSlides(nodes, options = {}) {
|
|
|
465
618
|
cursor: pointer; pointer-events: auto;
|
|
466
619
|
user-select: none;
|
|
467
620
|
}
|
|
621
|
+
/* Vertical drilldown pair. The container is anchored by its bottom edge and
|
|
622
|
+
grows upward, so .nav-down keeps the exact position the old pseudo-element
|
|
623
|
+
chevron had and .nav-up stacks above it. */
|
|
624
|
+
.nav-vert {
|
|
625
|
+
position: absolute;
|
|
626
|
+
/* 16px, not 18px: the old chevron was a text glyph whose ink ran to the
|
|
627
|
+
bottom of its line box, so an 18px box offset put the visible mark 18px
|
|
628
|
+
up. The SVG carries a little padding below the stroke, so the box sits
|
|
629
|
+
2px lower to land the mark in the same place. Measured, not guessed. */
|
|
630
|
+
bottom: 16px; left: 50%;
|
|
631
|
+
transform: translateX(-50%);
|
|
632
|
+
display: flex; flex-direction: column; align-items: center;
|
|
633
|
+
/* The two arrows are sized by their own boxes now, not by a line box with
|
|
634
|
+
leading around a small glyph, which is where the old slack came from. */
|
|
635
|
+
gap: 0.42em;
|
|
636
|
+
line-height: 0;
|
|
637
|
+
pointer-events: none;
|
|
638
|
+
}
|
|
639
|
+
.nav-up, .nav-down {
|
|
640
|
+
/* Hidden until a runtime turns them on. A custom theme.js written before
|
|
641
|
+
these elements existed does not know to hide them, and a dead arrowhead
|
|
642
|
+
on every slide is worse than no arrowhead at all, so the safe state is
|
|
643
|
+
the default and the runtime opts in. */
|
|
644
|
+
visibility: hidden;
|
|
645
|
+
display: block;
|
|
646
|
+
font-size: 1.2em; color: #ccc;
|
|
647
|
+
cursor: pointer; pointer-events: auto;
|
|
648
|
+
user-select: none;
|
|
649
|
+
}
|
|
650
|
+
.nav-up svg, .nav-down svg {
|
|
651
|
+
display: block;
|
|
652
|
+
width: 0.62em; height: auto;
|
|
653
|
+
stroke: currentColor;
|
|
654
|
+
}
|
|
655
|
+
/* The mirror. Same path, flipped about its own centre, so the pair matches to
|
|
656
|
+
the pixel whatever font or platform the deck is presented on. */
|
|
657
|
+
.nav-up svg { transform: scaleY(-1); }
|
|
468
658
|
.sdoc-company-footer {
|
|
469
659
|
font-size: 0.7em; color: rgba(0,0,0,0.35);
|
|
470
660
|
letter-spacing: 0.04em;
|
|
@@ -524,6 +714,7 @@ function renderSlides(nodes, options = {}) {
|
|
|
524
714
|
transform-origin: top left;
|
|
525
715
|
}
|
|
526
716
|
.nav-prev, .nav-next { display: none !important; }
|
|
717
|
+
.nav-vert { display: none !important; }
|
|
527
718
|
.notes { display: none; }
|
|
528
719
|
}`;
|
|
529
720
|
|
|
@@ -540,6 +731,7 @@ p code, li code { background: rgba(255, 255, 255, 0.08); }
|
|
|
540
731
|
blockquote { border-left-color: #5b9bd5; color: #9d9d9d; }
|
|
541
732
|
blockquote p { color: #9d9d9d; }
|
|
542
733
|
.nav-prev, .nav-next { color: rgba(255, 255, 255, 0.7); }
|
|
734
|
+
.nav-up, .nav-down { color: rgba(255, 255, 255, 0.5); }
|
|
543
735
|
.sdoc-company-footer { color: rgba(255, 255, 255, 0.35); }
|
|
544
736
|
.sdoc-confidential-notice { color: rgba(235, 120, 120, 0.7); }
|
|
545
737
|
.slide-indicator { color: rgba(255, 255, 255, 0.35); }
|
|
@@ -573,4 +765,4 @@ ${jsTag}${mermaidTag}
|
|
|
573
765
|
</html>`;
|
|
574
766
|
}
|
|
575
767
|
|
|
576
|
-
module.exports = { renderSlides, renderSlide, renderNode, renderInline };
|
|
768
|
+
module.exports = { renderSlides, renderSlide, renderNode, renderInline, isOptionalSlide, inlineDeckImages };
|
package/src/theme.js
CHANGED
|
@@ -100,6 +100,44 @@ function readThemeConfig(themeDir) {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
// The drilldown chevron used to be a pseudo-element on .slide-has-details.
|
|
104
|
+
// It is now two real elements, .nav-up and .nav-down, which the runtime shows
|
|
105
|
+
// and hides per position. A theme derived from the default before that change
|
|
106
|
+
// still carries the old rule, and the old rule still renders: `content` on
|
|
107
|
+
// .slide-has-details::after paints a chevron of its own whatever the renderer
|
|
108
|
+
// emits. The result is two chevrons a few pixels apart on every spine slide
|
|
109
|
+
// that has details, and nothing in the build otherwise notices, because the
|
|
110
|
+
// duplicate is in the theme and the original is in the renderer.
|
|
111
|
+
//
|
|
112
|
+
// Only a rule that actually paints something is worth warning about, so a
|
|
113
|
+
// `content` of none/normal/empty is left alone.
|
|
114
|
+
const NO_CONTENT = new Set(["none", "normal", '""', "''"]);
|
|
115
|
+
|
|
116
|
+
function staleChevronWarnings(css) {
|
|
117
|
+
// Comments first: a theme that merely *documents* the old rule is fine.
|
|
118
|
+
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
119
|
+
const warnings = [];
|
|
120
|
+
// Innermost declaration blocks: the selector cannot contain braces, so this
|
|
121
|
+
// also reaches rules nested inside @media without tripping on the wrapper.
|
|
122
|
+
const rule = /([^{}]*)\{([^{}]*)\}/g;
|
|
123
|
+
let match;
|
|
124
|
+
while ((match = rule.exec(stripped)) !== null) {
|
|
125
|
+
const selector = match[1];
|
|
126
|
+
if (!/\.slide-has-details\s*::?after\b/.test(selector)) continue;
|
|
127
|
+
const content = /(?:^|[;{\s])content\s*:\s*([^;]+)/i.exec(match[2]);
|
|
128
|
+
if (!content) continue;
|
|
129
|
+
if (NO_CONTENT.has(content[1].trim().toLowerCase())) continue;
|
|
130
|
+
warnings.push(
|
|
131
|
+
"theme still draws the old drilldown chevron with " +
|
|
132
|
+
"`.slide-has-details::after { content: ... }`. That is now a duplicate of " +
|
|
133
|
+
".nav-down, which the renderer emits and the runtime drives, so spine " +
|
|
134
|
+
"slides with details show two chevrons. Remove the rule from the theme."
|
|
135
|
+
);
|
|
136
|
+
break; // one rule is enough to say it; listing every copy adds no information
|
|
137
|
+
}
|
|
138
|
+
return warnings;
|
|
139
|
+
}
|
|
140
|
+
|
|
103
141
|
// Reads a theme directory. `fallbackDir` supplies theme.js when the theme
|
|
104
142
|
// ships none, which is how a theme opts into the default runtime (keyboard
|
|
105
143
|
// navigation, touch, fit-to-window scaling) without copying it.
|
|
@@ -122,6 +160,7 @@ function loadTheme(themeDir, fallbackDir) {
|
|
|
122
160
|
for (const ref of missing) {
|
|
123
161
|
warnings.push(`theme asset not found, left as a plain reference: ${ref}`);
|
|
124
162
|
}
|
|
163
|
+
warnings.push(...staleChevronWarnings(raw));
|
|
125
164
|
} else {
|
|
126
165
|
warnings.push(`theme.css not found at ${cssPath}`);
|
|
127
166
|
}
|
|
@@ -138,4 +177,4 @@ function loadTheme(themeDir, fallbackDir) {
|
|
|
138
177
|
return { themeCss, themeJs, themeConfig, warnings, dir: resolved };
|
|
139
178
|
}
|
|
140
179
|
|
|
141
|
-
module.exports = { loadTheme, inlineCssAssets, readThemeConfig, DEFAULT_THEME_CONFIG };
|
|
180
|
+
module.exports = { loadTheme, inlineCssAssets, readThemeConfig, staleChevronWarnings, DEFAULT_THEME_CONFIG };
|