@entropicwarrior/sdoc 0.2.21 → 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 +7 -3
- package/package.json +1 -1
- package/src/slide-geometry.js +6 -1
- package/src/slide-renderer.js +157 -2
- package/src/theme.js +40 -1
|
@@ -458,9 +458,13 @@
|
|
|
458
458
|
\`variant: card\`, an image at the top of each column with a heading
|
|
459
459
|
and a line of body beneath gives the standard figure row.
|
|
460
460
|
|
|
461
|
-
Paths are resolved relative to the \`.sdoc\` file
|
|
462
|
-
|
|
463
|
-
|
|
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.
|
|
464
468
|
}
|
|
465
469
|
}
|
|
466
470
|
|
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-renderer.js
CHANGED
|
@@ -7,9 +7,102 @@
|
|
|
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
13
|
const { extractConfig, buildBody, accentClass, slug, truthy } = require("./slide-layouts");
|
|
12
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
|
+
}
|
|
105
|
+
|
|
13
106
|
// ---------------------------------------------------------------------------
|
|
14
107
|
// Inline rendering — produces clean HTML without sdoc-* classes
|
|
15
108
|
// ---------------------------------------------------------------------------
|
|
@@ -394,7 +487,30 @@ function renderSlides(nodes, options = {}) {
|
|
|
394
487
|
}
|
|
395
488
|
footerParts.push(`<span class="slide-indicator">__SLIDE_INDICATOR__</span>`);
|
|
396
489
|
footerParts.push(`<span class="nav-next">›</span>`);
|
|
397
|
-
|
|
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}`;
|
|
398
514
|
|
|
399
515
|
// Optional slides are for the room, not the file that gets sent on. They are
|
|
400
516
|
// kept in the HTML build (includeOptional defaults to true, so every existing
|
|
@@ -502,6 +618,43 @@ function renderSlides(nodes, options = {}) {
|
|
|
502
618
|
cursor: pointer; pointer-events: auto;
|
|
503
619
|
user-select: none;
|
|
504
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); }
|
|
505
658
|
.sdoc-company-footer {
|
|
506
659
|
font-size: 0.7em; color: rgba(0,0,0,0.35);
|
|
507
660
|
letter-spacing: 0.04em;
|
|
@@ -561,6 +714,7 @@ function renderSlides(nodes, options = {}) {
|
|
|
561
714
|
transform-origin: top left;
|
|
562
715
|
}
|
|
563
716
|
.nav-prev, .nav-next { display: none !important; }
|
|
717
|
+
.nav-vert { display: none !important; }
|
|
564
718
|
.notes { display: none; }
|
|
565
719
|
}`;
|
|
566
720
|
|
|
@@ -577,6 +731,7 @@ p code, li code { background: rgba(255, 255, 255, 0.08); }
|
|
|
577
731
|
blockquote { border-left-color: #5b9bd5; color: #9d9d9d; }
|
|
578
732
|
blockquote p { color: #9d9d9d; }
|
|
579
733
|
.nav-prev, .nav-next { color: rgba(255, 255, 255, 0.7); }
|
|
734
|
+
.nav-up, .nav-down { color: rgba(255, 255, 255, 0.5); }
|
|
580
735
|
.sdoc-company-footer { color: rgba(255, 255, 255, 0.35); }
|
|
581
736
|
.sdoc-confidential-notice { color: rgba(235, 120, 120, 0.7); }
|
|
582
737
|
.slide-indicator { color: rgba(255, 255, 255, 0.35); }
|
|
@@ -610,4 +765,4 @@ ${jsTag}${mermaidTag}
|
|
|
610
765
|
</html>`;
|
|
611
766
|
}
|
|
612
767
|
|
|
613
|
-
module.exports = { renderSlides, renderSlide, renderNode, renderInline, isOptionalSlide };
|
|
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 };
|