slidemind 0.1.3 → 0.1.4

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.
@@ -14,20 +14,30 @@
14
14
  // corporate-design fingerprint, deliberately shared across decks/presentations
15
15
  // on the same CD — so it must NOT be keyed by backendURL, which would break that
16
16
  // sharing). If two backends ever mint the same presFP for different content,
17
- // they would collide here.
17
+ // they would collide here. The lint engine (deployment-owned, not CD-owned)
18
+ // shares this posture: paired backends on different add-in versions flip
19
+ // lint-engine.mjs to whichever backend synced last.
18
20
  //
19
21
  // ${XDG_CACHE_HOME:-~/.cache}/slidemind/bundles/<presFP>/
20
22
  // ├── corporate-design.css (from wire.css)
21
23
  // ├── design.md (from wire.designRules)
22
24
  // ├── layouts/layouts.css (from wire.layoutsCss, when present)
23
25
  // ├── layouts/*.jsx (one per wire.layouts[])
24
- // ├── assets/* (downloaded from wire.assets[].url)
26
+ // ├── assets/* (downloaded from wire.assets[].url — role-keyed brand assets
27
+ // │ plus reusable master images from wire.extractedImages[],
28
+ // │ each landed at its own `workspacePath`, e.g.
29
+ // │ assets/master-<sha8>.<ext>)
30
+ // ├── assets/brand-images.md (on-demand manifest of the reusable master images,
31
+ // │ built from wire.extractedImages — design.md only
32
+ // │ carries a sparse pointer + per-kind counts)
25
33
  // ├── samples/* (downloaded from wire.samples[].url — brand reference slides)
26
34
  // ├── fonts/* (downloaded from wire.fonts[].url, optional)
27
35
  // ├── master/empty-layout.pptx (downloaded from wire.master.url, optional)
28
36
  // ├── engines.json (wire.engines[])
29
37
  // ├── workflow/*.md (wire.workflowRefs — add-in-owned authoring craft)
30
- // └── bundle.json ({ presFP, version, etag, tokenRoleMap, … })
38
+ // ├── lint-engine.mjs (wire.lintEngine.source — the add-in's slide-lint engine)
39
+ // ├── primitives.json (wire.lintEngine.primitives — closed component registry)
40
+ // └── bundle.json ({ presFP, version, etag, lintEngineVersion, tokenRoleMap, … })
31
41
 
32
42
  import fs from "node:fs";
33
43
  import os from "node:os";
@@ -55,6 +65,29 @@ export function bundleDirForPresFP(presFP) {
55
65
  return path.join(bundleCacheRoot(), sanitizeKey(presFP));
56
66
  }
57
67
 
68
+ // Wipe the whole materialized bundle cache so the next build/push/download
69
+ // re-fetches every design bundle fresh from the add-in. `slidemind init` calls
70
+ // this: init is the deliberate (re)setup point — and `slidemind update`
71
+ // redeploys through it — so a stale binary or an orphaned per-presFP dir can
72
+ // never survive a re-init. (materializeBundle rewrites text every sync but only
73
+ // re-downloads a binary when its etag changes, and a presFP dir for a CD that
74
+ // no longer exists is never pruned, so the only clean reset is to remove the
75
+ // root.) The dirs are recreated on demand by the next materialize. Returns the
76
+ // number of per-presentation bundle dirs removed.
77
+ export function clearBundleCache() {
78
+ const root = bundleCacheRoot();
79
+ let removed = 0;
80
+ try {
81
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
82
+ if (entry.isDirectory()) removed += 1;
83
+ }
84
+ } catch {
85
+ return 0; // No cache root yet — nothing to clear.
86
+ }
87
+ fs.rmSync(root, { recursive: true, force: true });
88
+ return removed;
89
+ }
90
+
58
91
  // Plain HTTPS/HTTP GET → Buffer. Follows a few redirects and tolerates the
59
92
  // add-in's self-signed dev certificate on loopback hosts (the same posture the
60
93
  // bridge client uses). Azure blob SAS URLs are public HTTPS and need no special
@@ -125,14 +158,75 @@ function sampleExt(contentType) {
125
158
  }
126
159
  }
127
160
 
128
- // Fetch + write the bundle for `presId`. Returns { dir, presFP, etag, version,
129
- // cached }. Skips the re-download when the cached bundle's etag already matches
130
- // (unless `force`).
161
+ const REUSABLE_KIND_ORDER = [
162
+ "logo",
163
+ "icon",
164
+ "illustration",
165
+ "decoration",
166
+ "photo",
167
+ "other",
168
+ ];
169
+ const REUSABLE_KIND_PLURAL = {
170
+ logo: "logos",
171
+ icon: "icons",
172
+ illustration: "illustrations",
173
+ decoration: "decorations",
174
+ photo: "photos",
175
+ other: "others",
176
+ };
177
+ const pluralKind = (kind) => REUSABLE_KIND_PLURAL[kind] ?? `${kind}s`;
178
+
179
+ // Build the `assets/brand-images.md` manifest from the wire's extracted images —
180
+ // every reusable master image as `- ./assets/<file> — <description>`, grouped by
181
+ // kind. Mirrors the add-in's `buildReusableImagesManifest` so the local agent
182
+ // reads the same catalogue the in-app design persona does. Returns "" when none.
183
+ function buildReusableImagesManifest(extractedImages) {
184
+ const rows = [];
185
+ for (const img of extractedImages ?? []) {
186
+ if (!img || typeof img.workspacePath !== "string") continue;
187
+ rows.push({
188
+ file: path.basename(img.workspacePath),
189
+ kind: typeof img.kind === "string" ? img.kind : "other",
190
+ description:
191
+ typeof img.description === "string" ? img.description.trim() : "",
192
+ });
193
+ }
194
+ if (rows.length === 0) return "";
195
+ const present = new Set(rows.map((r) => r.kind));
196
+ const kinds = REUSABLE_KIND_ORDER.filter((k) => present.has(k));
197
+ for (const k of present) if (!kinds.includes(k)) kinds.push(k);
198
+ const lines = [
199
+ "# Brand reusable images",
200
+ "",
201
+ 'Images lifted from the brand’s PowerPoint master. Reference one from slide JSX with `<img src="./assets/<file>">` — the files are already in `assets/`.',
202
+ ];
203
+ for (const kind of kinds) {
204
+ lines.push("", `## ${pluralKind(kind)}`);
205
+ for (const row of rows.filter((r) => r.kind === kind)) {
206
+ lines.push(
207
+ `- ./assets/${row.file}${row.description ? ` — ${row.description}` : ""}`,
208
+ );
209
+ }
210
+ }
211
+ return `${lines.join("\n")}\n`;
212
+ }
213
+
214
+ // Sync the bundle for `presId`. Idempotent: every call rewrites all inline
215
+ // text from the freshly fetched wire (the wire is fetched on every call
216
+ // anyway), downloads only binaries whose per-item blob etag changed (samples
217
+ // are write-once by id), and prunes files the wire no longer serves. There is
218
+ // deliberately NO staleness token to compare — the materialized dir is always
219
+ // a per-item projection of the last wire, so authored-layer edits that bypass
220
+ // a bundle re-produce (engines, image-source guidelines, sample curation) and
221
+ // deployment-owned content (workflow refs, the lint engine) can never go
222
+ // stale here. `force` re-downloads binaries even when their etags match.
223
+ // Returns { dir, presFP, etag, version, downloaded, pruned, engineUpdated,
224
+ // warnings }.
131
225
  export async function materializeBundle(
132
226
  presId,
133
- { force = false, backendURL = null } = {},
227
+ { force = false, backendURL = null, preferBrandId = null } = {},
134
228
  ) {
135
- const wire = await getDesignBundle(presId, { backendURL });
229
+ const wire = await getDesignBundle(presId, { backendURL, preferBrandId });
136
230
  if (!wire || !wire.presFP) {
137
231
  throw new Error("design-bundle response is missing presFP");
138
232
  }
@@ -152,146 +246,203 @@ export async function materializeBundle(
152
246
  const dir = bundleDirForPresFP(presFP);
153
247
  const metaPath = path.join(dir, "bundle.json");
154
248
 
155
- if (!force && fs.existsSync(metaPath)) {
156
- try {
157
- const prev = JSON.parse(fs.readFileSync(metaPath, "utf8"));
158
- // Prefer etag equality; fall back to version equality for CDs whose
159
- // effective etag is empty (no stored blob etag), so the cache can still
160
- // short-circuit instead of re-downloading on every call.
161
- const sameEtag = prev.etag && wire.etag && prev.etag === wire.etag;
162
- const sameVersion =
163
- !wire.etag &&
164
- prev.version != null &&
165
- wire.version != null &&
166
- prev.version === wire.version;
167
- if (sameEtag || sameVersion) {
168
- return {
169
- dir,
170
- presFP,
171
- etag: wire.etag ?? null,
172
- version: wire.version ?? null,
173
- cached: true,
174
- warnings: [],
175
- };
176
- }
177
- } catch {
178
- /* unreadable metadata — fall through and rewrite the bundle */
179
- }
249
+ let prev = {};
250
+ try {
251
+ prev = JSON.parse(fs.readFileSync(metaPath, "utf8"));
252
+ } catch {
253
+ /* first sync or unreadable meta — sync from scratch */
180
254
  }
255
+ const prevBinaries =
256
+ prev.binaries && typeof prev.binaries === "object" ? prev.binaries : {};
181
257
 
182
- // Rewrite from scratch so a stale layout/asset can never linger.
183
- fs.rmSync(dir, { recursive: true, force: true });
184
- fs.mkdirSync(path.join(dir, "layouts"), { recursive: true });
185
- fs.mkdirSync(path.join(dir, "assets"), { recursive: true });
186
- fs.mkdirSync(path.join(dir, "samples"), { recursive: true });
258
+ // Everything this sync owns, as dir-relative paths; drives the prune pass.
259
+ const expected = new Set(["bundle.json"]);
260
+ const writeText = (rel, text) => {
261
+ const target = path.join(dir, rel);
262
+ fs.mkdirSync(path.dirname(target), { recursive: true });
263
+ fs.writeFileSync(target, text, "utf8");
264
+ expected.add(rel);
265
+ };
187
266
 
188
- fs.writeFileSync(
189
- path.join(dir, "corporate-design.css"),
190
- wire.css ?? "",
191
- "utf8",
192
- );
193
- fs.writeFileSync(path.join(dir, "design.md"), wire.designRules ?? "", "utf8");
267
+ // --- Inline text: always rewritten (the bytes are already in hand). ---
268
+ writeText("corporate-design.css", wire.css ?? "");
269
+ writeText("design.md", wire.designRules ?? "");
194
270
  if (typeof wire.layoutsCss === "string" && wire.layoutsCss.length > 0) {
195
- fs.writeFileSync(
196
- path.join(dir, "layouts", "layouts.css"),
197
- wire.layoutsCss,
198
- "utf8",
199
- );
271
+ writeText(path.join("layouts", "layouts.css"), wire.layoutsCss);
200
272
  }
201
-
202
273
  for (const layout of wire.layouts ?? []) {
203
274
  const name = layout.name || layout.componentName;
204
275
  if (!name || typeof layout.source !== "string") continue;
205
276
  const file = name.endsWith(".jsx") ? name : `${name}.jsx`;
206
- fs.writeFileSync(
207
- path.join(dir, "layouts", path.basename(file)),
208
- layout.source,
209
- "utf8",
210
- );
277
+ writeText(path.join("layouts", path.basename(file)), layout.source);
211
278
  }
212
-
213
- fs.writeFileSync(
214
- path.join(dir, "engines.json"),
215
- JSON.stringify(wire.engines ?? [], null, 2),
216
- "utf8",
217
- );
279
+ writeText("engines.json", JSON.stringify(wire.engines ?? [], null, 2));
218
280
 
219
281
  // Brand-agnostic authoring workflow craft (create/edit/composition/layouts/
220
282
  // authoring/build-export) — owned by the add-in, served on the bundle so the
221
283
  // local agent reads the SAME workflow guidance the in-app design persona does.
222
284
  if (wire.workflowRefs && typeof wire.workflowRefs === "object") {
223
- fs.mkdirSync(path.join(dir, "workflow"), { recursive: true });
224
285
  for (const [name, text] of Object.entries(wire.workflowRefs)) {
225
286
  if (typeof text !== "string") continue;
226
- fs.writeFileSync(
227
- path.join(dir, "workflow", path.basename(name)),
228
- text,
229
- "utf8",
230
- );
287
+ writeText(path.join("workflow", path.basename(name)), text);
231
288
  }
232
289
  }
233
290
 
234
- // Download signed URLs IMMEDIATELY and in PARALLEL — they expire ~15 min
235
- // after the wire response is minted, so serializing N independent GETs only
236
- // raises the odds a late one races an expiring URL. Failures are non-fatal
237
- // (a missing brand asset degrades local HTML preview but never the server
238
- // render) but are NOT silent: each is collected into `warnings` so the caller
239
- // can tell the user the materialized bundle is incomplete.
291
+ // The add-in's slide-lint engine + its primitives registry (deployment-
292
+ // owned). `version` feeds the lint wrapper's import cache-bust key, not a
293
+ // staleness check — the files re-sync like all other text. Absent when the
294
+ // paired backend predates the engine; src/builders/lint.mjs surfaces an
295
+ // actionable error in that case rather than skipping the gate.
296
+ const engineUpdated = Boolean(
297
+ wire.lintEngine && prev.lintEngineVersion !== wire.lintEngine.version,
298
+ );
299
+ if (wire.lintEngine && typeof wire.lintEngine.source === "string") {
300
+ writeText("lint-engine.mjs", wire.lintEngine.source);
301
+ writeText(
302
+ "primitives.json",
303
+ JSON.stringify(wire.lintEngine.primitives ?? {}, null, 2),
304
+ );
305
+ }
306
+
307
+ // The add-in's slide-capture engine (deployment-owned, puppeteer-external).
308
+ // The CLI dynamic-imports this to capture decks LOCALLY with its own Chrome,
309
+ // then POSTs the result to the server's /translate route. Absent when the
310
+ // paired backend predates the feature; src/bridge/build-and-push.mjs falls
311
+ // back to the server-side render path in that case.
312
+ const captureEngineUpdated = Boolean(
313
+ wire.captureEngine &&
314
+ prev.captureEngineVersion !== wire.captureEngine.version,
315
+ );
316
+ if (wire.captureEngine && typeof wire.captureEngine.source === "string") {
317
+ writeText("capture-engine.mjs", wire.captureEngine.source);
318
+ }
319
+
320
+ // --- Binaries: per-item sync keyed by the wire's blob etags. Signed URLs
321
+ // expire ~15 min after the wire is minted, so changed items download
322
+ // IMMEDIATELY and in PARALLEL. Failures are non-fatal (a missing brand
323
+ // asset degrades local HTML preview but never the server render) but NOT
324
+ // silent: each is collected into `warnings`. ---
240
325
  const downloads = [];
326
+ const binaries = {};
327
+ const queueBinary = (rel, url, etag, label) => {
328
+ expected.add(rel);
329
+ const target = path.join(dir, rel);
330
+ const exists = fs.existsSync(target);
331
+ if (!force && exists && etag && prevBinaries[rel] === etag) {
332
+ binaries[rel] = etag; // current — carry the record forward
333
+ return;
334
+ }
335
+ if (!force && exists && !etag) {
336
+ return; // write-once binary (samples carry no etag) — presence is currency
337
+ }
338
+ downloads.push({ rel, target, url, etag: etag ?? null, label });
339
+ };
340
+
241
341
  for (const asset of wire.assets ?? []) {
242
342
  if (!asset?.url || !asset?.filename) continue;
243
- downloads.push({
244
- label: `asset ${asset.filename}`,
245
- target: path.join(dir, "assets", path.basename(asset.filename)),
246
- url: asset.url,
247
- });
343
+ queueBinary(
344
+ path.join("assets", path.basename(asset.filename)),
345
+ asset.url,
346
+ asset.etag ?? null,
347
+ `asset ${asset.filename}`,
348
+ );
248
349
  }
249
350
  for (const font of wire.fonts ?? []) {
250
351
  if (!font?.url) continue;
251
352
  const fname = font.filename || `${font.role || font.family || "font"}.bin`;
252
- downloads.push({
253
- label: `font ${fname}`,
254
- target: path.join(dir, "fonts", path.basename(fname)),
255
- url: font.url,
256
- });
353
+ queueBinary(
354
+ path.join("fonts", path.basename(fname)),
355
+ font.url,
356
+ font.etag ?? null,
357
+ `font ${fname}`,
358
+ );
257
359
  }
258
360
  if (wire.master?.url) {
259
- downloads.push({
260
- label: "master empty-layout.pptx",
261
- target: path.join(dir, "master", "empty-layout.pptx"),
262
- url: wire.master.url,
263
- });
361
+ queueBinary(
362
+ path.join("master", "empty-layout.pptx"),
363
+ wire.master.url,
364
+ wire.master.etag ?? null,
365
+ "master empty-layout.pptx",
366
+ );
264
367
  }
265
- // Per-CD sample slides (the brand's own reference slides) — the offline agent
266
- // studies a random few before composing. Keyed by the sample's stable `id`;
267
- // extension derived from `contentType`. Same non-fatal degradation as assets.
368
+ // Per-CD sample slides (the brand's own reference slides) — write-once
369
+ // captures keyed by the sample's stable `id`; extension derived from
370
+ // `contentType`.
268
371
  for (const sample of wire.samples ?? []) {
269
372
  if (!sample?.url || !sample?.id) continue;
270
- downloads.push({
271
- label: `sample ${sample.id}`,
272
- target: path.join(
273
- dir,
373
+ queueBinary(
374
+ path.join(
274
375
  "samples",
275
376
  `${path.basename(sample.id)}${sampleExt(sample.contentType)}`,
276
377
  ),
277
- url: sample.url,
278
- });
378
+ sample.url,
379
+ null,
380
+ `sample ${sample.id}`,
381
+ );
382
+ }
383
+ // Reusable master images (logos, decorations, illustrations the add-in's
384
+ // vision pass lifted from the brand's own master and flagged `reusable`).
385
+ // Each carries a `workspacePath` (e.g. `assets/master-<sha8>.<ext>`) that the
386
+ // add-in's design persona references from slide JSX as `<img src="./assets/
387
+ // <file>">`; land them at that exact path so copyDesignSystem ships them into
388
+ // the deck next to the role-keyed brand assets. Content-addressed name →
389
+ // write-once (a changed image gets a new sha8 path; the old one prunes out).
390
+ // The catalogue of these (name/kind/description) rides in design.md's
391
+ // `### Brand Reusable Images` block, so the local agent sees both halves.
392
+ for (const image of wire.extractedImages ?? []) {
393
+ if (!image?.url || typeof image.workspacePath !== "string") continue;
394
+ const rel = path.join("assets", path.basename(image.workspacePath));
395
+ queueBinary(rel, image.url, null, `master image ${rel}`);
396
+ }
397
+ // The brand-reusable-images catalogue is kept OUT of design.md (which only
398
+ // carries a sparse pointer + per-kind counts, to stay prompt-light). The full
399
+ // per-image descriptions live in this manifest the agent reads on demand. Built
400
+ // from the same wire.extractedImages we just hydrated; pruned when none.
401
+ const reusableManifest = buildReusableImagesManifest(wire.extractedImages);
402
+ if (reusableManifest) {
403
+ writeText(path.join("assets", "brand-images.md"), reusableManifest);
404
+ }
405
+ // Per-layout chrome images — the add-in's server-side render of each layout
406
+ // (background + decor + logos + key-visual), one per layout. Codegen sets each
407
+ // as the `.layout-<slug>` background via `url("../assets/<file>")`, which the
408
+ // HTML builder rewrites to `./assets/…`, so the local preview shows real brand
409
+ // chrome instead of bare layouts. Each carries a `workspacePath`
410
+ // (`assets/chrome-<componentName>.<ext>`); land it next to the other assets so
411
+ // copyDesignSystem ships it into the deck. Etag-keyed → re-fetch only on change.
412
+ for (const chrome of wire.layoutBackgrounds ?? []) {
413
+ if (!chrome?.url || typeof chrome.workspacePath !== "string") continue;
414
+ const rel = path.join("assets", path.basename(chrome.workspacePath));
415
+ queueBinary(rel, chrome.url, chrome.etag ?? null, `layout chrome ${rel}`);
279
416
  }
280
417
 
281
418
  const warnings = [];
419
+ let downloaded = 0;
282
420
  const settled = await Promise.allSettled(
283
421
  downloads.map((d) => downloadInto(d.target, d.url)),
284
422
  );
285
423
  settled.forEach((r, i) => {
424
+ const d = downloads[i];
286
425
  if (r.status === "rejected") {
287
426
  const reason =
288
427
  r.reason instanceof Error ? r.reason.message : String(r.reason);
289
428
  warnings.push(
290
- `failed to download ${downloads[i].label}: ${reason} (signed URL may have expired — re-run with --force)`,
429
+ `failed to download ${d.label}: ${reason} (signed URL may have expired — re-run with --force)`,
291
430
  );
431
+ // Keep the previous record when an older copy is still on disk: its
432
+ // etag differs from the wire's, so the next sync retries.
433
+ if (fs.existsSync(d.target) && prevBinaries[d.rel]) {
434
+ binaries[d.rel] = prevBinaries[d.rel];
435
+ }
436
+ return;
292
437
  }
438
+ downloaded += 1;
439
+ if (d.etag) binaries[d.rel] = d.etag;
293
440
  });
294
441
 
442
+ // --- Prune: anything on disk this sync doesn't own goes away, so a stale
443
+ // layout/asset can never linger — without the old rmSync-everything. ---
444
+ const pruned = pruneUnexpected(dir, expected);
445
+
295
446
  const meta = {
296
447
  presFP,
297
448
  version: wire.version ?? null,
@@ -299,6 +450,29 @@ export async function materializeBundle(
299
450
  scope: wire.scope ?? null,
300
451
  tokenRoleMap: wire.tokenRoleMap ?? null,
301
452
  engines: wire.engines ?? [],
453
+ // Role-keyed brand assets land flat at assets/<filename>; the role→filename
454
+ // map can't be reconstructed from the files alone, so carry it (like fonts).
455
+ // Lets consumers resolve an asset by role, e.g. the review window's brand
456
+ // `logoMark` (the add-in-rendered orb). Absent roles simply aren't listed.
457
+ assets: (wire.assets ?? [])
458
+ .filter((a) => a?.role && a?.filename)
459
+ .map((a) => ({ role: a.role, filename: path.basename(a.filename) })),
460
+ // Import cache-bust key for src/builders/lint.mjs; null = the paired
461
+ // backend serves no engine (predates the feature).
462
+ lintEngineVersion: wire.lintEngine?.version ?? null,
463
+ // Same, for the local-capture engine (src/bridge/capture.mjs).
464
+ captureEngineVersion: wire.captureEngine?.version ?? null,
465
+ // Font role/family manifest — the fonts land flat at fonts/<filename> but
466
+ // local capture must rebuild the @font-face roles the server uses, so carry
467
+ // the mapping the wire knows (the cache files alone can't reconstruct it).
468
+ fonts: (wire.fonts ?? [])
469
+ .filter((f) => f?.role && f?.family && f?.filename)
470
+ .map((f) => ({
471
+ role: f.role,
472
+ family: f.family,
473
+ filename: path.basename(f.filename),
474
+ })),
475
+ binaries,
302
476
  fetchedAt: new Date().toISOString(),
303
477
  incompleteAssets: warnings.length > 0,
304
478
  };
@@ -309,11 +483,38 @@ export async function materializeBundle(
309
483
  presFP,
310
484
  etag: wire.etag ?? null,
311
485
  version: wire.version ?? null,
312
- cached: false,
486
+ downloaded,
487
+ pruned,
488
+ engineUpdated,
489
+ captureEngineUpdated,
313
490
  warnings,
314
491
  };
315
492
  }
316
493
 
494
+ // Delete files the current sync doesn't own and collapse empty directories.
495
+ // `expected` holds dir-relative paths exactly as `path.join` built them, so
496
+ // the comparison is platform-consistent.
497
+ function pruneUnexpected(dir, expected) {
498
+ let pruned = 0;
499
+ const walk = (rel) => {
500
+ const abs = rel ? path.join(dir, rel) : dir;
501
+ for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
502
+ const childRel = rel ? path.join(rel, entry.name) : entry.name;
503
+ if (entry.isDirectory()) {
504
+ walk(childRel);
505
+ if (fs.readdirSync(path.join(dir, childRel)).length === 0) {
506
+ fs.rmdirSync(path.join(dir, childRel));
507
+ }
508
+ } else if (!expected.has(childRel)) {
509
+ fs.rmSync(path.join(dir, childRel), { force: true });
510
+ pruned += 1;
511
+ }
512
+ }
513
+ };
514
+ walk("");
515
+ return pruned;
516
+ }
517
+
317
518
  // Per-deck record of which CD bundle (presFP + etag) the deck was authored
318
519
  // against — lets `render-pptx` pass the etag for skew detection, and lets the
319
520
  // local HTML/editor resolve the right materialized bundle.
@@ -352,6 +553,31 @@ export function resolveBundleDirForDeck(deckDir) {
352
553
  return fs.existsSync(dir) ? dir : null;
353
554
  }
354
555
 
556
+ // Resolve a role-keyed brand asset (e.g. "logoMark") for a deck to its
557
+ // materialized file path, trying the requested roles in order. Returns
558
+ // { role, filename, path } for the first role present on disk, else null.
559
+ // The role→filename map lives in the bundle's bundle.json (see materializeBundle).
560
+ export function resolveBrandAsset(deckDir, roles) {
561
+ const dir = resolveBundleDirForDeck(deckDir);
562
+ if (!dir) return null;
563
+ let meta;
564
+ try {
565
+ meta = JSON.parse(fs.readFileSync(path.join(dir, "bundle.json"), "utf8"));
566
+ } catch {
567
+ return null;
568
+ }
569
+ const list = Array.isArray(meta.assets) ? meta.assets : [];
570
+ for (const role of roles) {
571
+ const hit = list.find((a) => a.role === role && a.filename);
572
+ if (!hit) continue;
573
+ const filePath = path.join(dir, "assets", hit.filename);
574
+ if (fs.existsSync(filePath)) {
575
+ return { role, filename: hit.filename, path: filePath };
576
+ }
577
+ }
578
+ return null;
579
+ }
580
+
355
581
  // Read the engines[] descriptor list from a materialized bundle directory.
356
582
  export function readEnginesFromDir(dir) {
357
583
  if (!dir) return [];