@entropicwarrior/sdoc 0.2.14 → 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/slide-authoring.sdoc +89 -5
- package/package.json +1 -1
- package/src/slide-renderer.js +107 -8
- package/tools/build-doc.js +37 -1
|
@@ -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
|
|
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/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
|
@@ -135,7 +135,7 @@ async function buildHtml(filePath, options = {}) {
|
|
|
135
135
|
|
|
136
136
|
const title = path.basename(resolvedPath, ".sdoc");
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
const html = renderHtmlDocumentFromParsed(
|
|
139
139
|
{ nodes: metaResult.nodes, errors: parsed.errors },
|
|
140
140
|
title,
|
|
141
141
|
{
|
|
@@ -146,6 +146,41 @@ async function buildHtml(filePath, options = {}) {
|
|
|
146
146
|
includeAbout,
|
|
147
147
|
}
|
|
148
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
|
+
});
|
|
149
184
|
}
|
|
150
185
|
|
|
151
186
|
async function main() {
|
|
@@ -197,6 +232,7 @@ async function main() {
|
|
|
197
232
|
outputPath = resolvedInput.replace(/\.sdoc$/i, "") + ".pdf";
|
|
198
233
|
}
|
|
199
234
|
const resolvedOutput = path.resolve(outputPath);
|
|
235
|
+
fs.mkdirSync(path.dirname(resolvedOutput), { recursive: true });
|
|
200
236
|
|
|
201
237
|
// Write HTML to temp file for Chrome
|
|
202
238
|
const tmpHtml = path.join(os.tmpdir(), "sdoc-doc-" + Date.now() + ".html");
|