@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.
@@ -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. They are embedded
462
- in the PPTX export; a remote URL is left out of it, and the build
463
- says which.
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.21",
5
+ "version": "0.2.22",
6
6
  "publisher": "entropicwarrior-msenfin",
7
7
  "license": "MIT",
8
8
  "repository": {
@@ -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
- if (el.classList && (el.classList.contains("nav-prev") || el.classList.contains("nav-next"))) return;
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;
@@ -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(/&amp;/g, "&")
71
+ .replace(/&#39;/g, "'")
72
+ .replace(/&quot;/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">&rsaquo;</span>`);
397
- const overlayHtml = `\n<div class="slide-footer">${footerParts.join("")}</div>`;
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 };