slidemind 0.1.2 → 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.
- package/CLAUDE.md +31 -19
- package/README.md +31 -6
- package/bin/slidemind.mjs +437 -18
- package/package.json +2 -6
- package/skills/create-slides/SKILL.md +14 -8
- package/skills/search/SKILL.md +75 -0
- package/src/bridge/build-and-push.mjs +178 -34
- package/src/bridge/bundle.mjs +325 -99
- package/src/bridge/capture.mjs +187 -0
- package/src/bridge/client.mjs +628 -47
- package/src/bridge/config.mjs +8 -2
- package/src/builders/build.mjs +75 -26
- package/src/builders/lint.mjs +112 -0
- package/src/builders/resolve-skill.mjs +16 -21
- package/src/builders/review-overlay.js +821 -0
- package/src/builders/review.mjs +388 -0
- package/src/lib/reused-slides.mjs +67 -0
- package/src/lib/slide-order.mjs +4 -1
- package/src/mcp/server.mjs +368 -27
- package/src/builders/lint-slides.mjs +0 -796
- package/src/builders/primitives.json +0 -19
package/src/bridge/bundle.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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) —
|
|
266
|
-
//
|
|
267
|
-
//
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
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 [];
|