slidemind 0.0.27

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.
@@ -0,0 +1,373 @@
1
+ // Materialize the add-in's design bundle into a local cache directory the
2
+ // skill and the HTML builder can read.
3
+ //
4
+ // The bundle is the per-corporate-design source of truth (tokens, css,
5
+ // layouts, design.md, brand assets, engines). The add-in serves it over the
6
+ // bridge as a wire response with short-lived (~15 min) signed URLs for binary
7
+ // assets; we download those immediately and lay everything out on disk keyed by
8
+ // the presentation fingerprint (`presFP`) so multiple decks sharing a CD share
9
+ // one materialized bundle.
10
+ //
11
+ // ${XDG_CACHE_HOME:-~/.cache}/slidemind/bundles/<presFP>/
12
+ // ├── corporate-design.css (from wire.css)
13
+ // ├── design.md (from wire.designRules)
14
+ // ├── layouts/layouts.css (from wire.layoutsCss, when present)
15
+ // ├── layouts/*.jsx (one per wire.layouts[])
16
+ // ├── assets/* (downloaded from wire.assets[].url)
17
+ // ├── samples/* (downloaded from wire.samples[].url — brand reference slides)
18
+ // ├── fonts/* (downloaded from wire.fonts[].url, optional)
19
+ // ├── master/empty-layout.pptx (downloaded from wire.master.url, optional)
20
+ // ├── engines.json (wire.engines[])
21
+ // ├── workflow/*.md (wire.workflowRefs — add-in-owned authoring craft)
22
+ // └── bundle.json ({ presFP, version, etag, tokenRoleMap, … })
23
+
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import http from "node:http";
28
+ import https from "node:https";
29
+
30
+ import { getDesignBundle } from "./client.mjs";
31
+
32
+ // Highest bundle wire-contract version this CLI understands. The add-in stamps
33
+ // `schemaVersion` on every bundle; a higher value means the CLI is too old.
34
+ const CLI_MAX_BUNDLE_SCHEMA = 1;
35
+
36
+ export function bundleCacheRoot() {
37
+ const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
38
+ return path.join(base, "slidemind", "bundles");
39
+ }
40
+
41
+ function sanitizeKey(value) {
42
+ return String(value).replace(/[^A-Za-z0-9._-]/g, "_");
43
+ }
44
+
45
+ export function bundleDirForPresFP(presFP) {
46
+ if (!presFP) throw new Error("bundleDirForPresFP: presFP is required");
47
+ return path.join(bundleCacheRoot(), sanitizeKey(presFP));
48
+ }
49
+
50
+ // Plain HTTPS/HTTP GET → Buffer. Follows a few redirects and tolerates the
51
+ // add-in's self-signed dev certificate on loopback hosts (the same posture the
52
+ // bridge client uses). Azure blob SAS URLs are public HTTPS and need no special
53
+ // handling beyond a normal GET.
54
+ function getBuffer(url, { redirects = 4 } = {}) {
55
+ return new Promise((resolve, reject) => {
56
+ let u;
57
+ try {
58
+ u = new URL(url);
59
+ } catch (err) {
60
+ reject(err);
61
+ return;
62
+ }
63
+ const isHttps = u.protocol === "https:";
64
+ const transport = isHttps ? https : http;
65
+ const isLoopback =
66
+ u.hostname === "127.0.0.1" ||
67
+ u.hostname === "localhost" ||
68
+ u.hostname === "::1";
69
+ const req = transport.request(
70
+ u,
71
+ {
72
+ method: "GET",
73
+ rejectUnauthorized: isHttps && isLoopback ? false : undefined,
74
+ },
75
+ (res) => {
76
+ const status = res.statusCode ?? 0;
77
+ if (status >= 300 && status < 400 && res.headers.location && redirects > 0) {
78
+ res.resume();
79
+ const next = new URL(res.headers.location, u).href;
80
+ getBuffer(next, { redirects: redirects - 1 }).then(resolve, reject);
81
+ return;
82
+ }
83
+ if (status < 200 || status >= 300) {
84
+ res.resume();
85
+ reject(new Error(`download ${status} for ${u.pathname}`));
86
+ return;
87
+ }
88
+ const chunks = [];
89
+ res.on("data", (c) => chunks.push(Buffer.from(c)));
90
+ res.on("end", () => resolve(Buffer.concat(chunks)));
91
+ },
92
+ );
93
+ req.on("error", reject);
94
+ req.end();
95
+ });
96
+ }
97
+
98
+ async function downloadInto(targetPath, url) {
99
+ const buf = await getBuffer(url);
100
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
101
+ fs.writeFileSync(targetPath, buf);
102
+ }
103
+
104
+ // File extension for a sample image, derived from its wire `contentType`. The
105
+ // sample's stable `id` is the basename; samples carry no `filename` on the wire.
106
+ function sampleExt(contentType) {
107
+ switch (contentType) {
108
+ case "image/jpeg":
109
+ return ".jpg";
110
+ case "image/webp":
111
+ return ".webp";
112
+ case "image/svg+xml":
113
+ return ".svg";
114
+ case "image/png":
115
+ default:
116
+ return ".png";
117
+ }
118
+ }
119
+
120
+ // Fetch + write the bundle for `presId`. Returns { dir, presFP, etag, version,
121
+ // cached }. Skips the re-download when the cached bundle's etag already matches
122
+ // (unless `force`).
123
+ export async function materializeBundle(presId, { force = false } = {}) {
124
+ const wire = await getDesignBundle(presId);
125
+ if (!wire || !wire.presFP) {
126
+ throw new Error("design-bundle response is missing presFP");
127
+ }
128
+ // Forward-compat guard: a bundle stamped with a newer wire-contract version
129
+ // than this CLI knows would be mis-parsed; fail with an actionable message.
130
+ if (
131
+ typeof wire.schemaVersion === "number" &&
132
+ wire.schemaVersion > CLI_MAX_BUNDLE_SCHEMA
133
+ ) {
134
+ throw new Error(
135
+ `This corporate design needs a newer slidemind (bundle schemaVersion ` +
136
+ `${wire.schemaVersion}; this CLI supports ${CLI_MAX_BUNDLE_SCHEMA}).\n` +
137
+ ` Run 'slidemind update' (or re-run the install one-liner), then retry.`,
138
+ );
139
+ }
140
+ const presFP = wire.presFP;
141
+ const dir = bundleDirForPresFP(presFP);
142
+ const metaPath = path.join(dir, "bundle.json");
143
+
144
+ if (!force && fs.existsSync(metaPath)) {
145
+ try {
146
+ const prev = JSON.parse(fs.readFileSync(metaPath, "utf8"));
147
+ // Prefer etag equality; fall back to version equality for CDs whose
148
+ // effective etag is empty (no stored blob etag), so the cache can still
149
+ // short-circuit instead of re-downloading on every call.
150
+ const sameEtag = prev.etag && wire.etag && prev.etag === wire.etag;
151
+ const sameVersion =
152
+ !wire.etag &&
153
+ prev.version != null &&
154
+ wire.version != null &&
155
+ prev.version === wire.version;
156
+ if (sameEtag || sameVersion) {
157
+ return {
158
+ dir,
159
+ presFP,
160
+ etag: wire.etag ?? null,
161
+ version: wire.version ?? null,
162
+ cached: true,
163
+ warnings: [],
164
+ };
165
+ }
166
+ } catch {
167
+ /* unreadable metadata — fall through and rewrite the bundle */
168
+ }
169
+ }
170
+
171
+ // Rewrite from scratch so a stale layout/asset can never linger.
172
+ fs.rmSync(dir, { recursive: true, force: true });
173
+ fs.mkdirSync(path.join(dir, "layouts"), { recursive: true });
174
+ fs.mkdirSync(path.join(dir, "assets"), { recursive: true });
175
+ fs.mkdirSync(path.join(dir, "samples"), { recursive: true });
176
+
177
+ fs.writeFileSync(
178
+ path.join(dir, "corporate-design.css"),
179
+ wire.css ?? "",
180
+ "utf8",
181
+ );
182
+ fs.writeFileSync(path.join(dir, "design.md"), wire.designRules ?? "", "utf8");
183
+ if (typeof wire.layoutsCss === "string" && wire.layoutsCss.length > 0) {
184
+ fs.writeFileSync(
185
+ path.join(dir, "layouts", "layouts.css"),
186
+ wire.layoutsCss,
187
+ "utf8",
188
+ );
189
+ }
190
+
191
+ for (const layout of wire.layouts ?? []) {
192
+ const name = layout.name || layout.componentName;
193
+ if (!name || typeof layout.source !== "string") continue;
194
+ const file = name.endsWith(".jsx") ? name : `${name}.jsx`;
195
+ fs.writeFileSync(
196
+ path.join(dir, "layouts", path.basename(file)),
197
+ layout.source,
198
+ "utf8",
199
+ );
200
+ }
201
+
202
+ fs.writeFileSync(
203
+ path.join(dir, "engines.json"),
204
+ JSON.stringify(wire.engines ?? [], null, 2),
205
+ "utf8",
206
+ );
207
+
208
+ // Brand-agnostic authoring workflow craft (create/edit/composition/layouts/
209
+ // authoring/build-export) — owned by the add-in, served on the bundle so the
210
+ // local agent reads the SAME workflow guidance the in-app design persona does.
211
+ if (wire.workflowRefs && typeof wire.workflowRefs === "object") {
212
+ fs.mkdirSync(path.join(dir, "workflow"), { recursive: true });
213
+ for (const [name, text] of Object.entries(wire.workflowRefs)) {
214
+ if (typeof text !== "string") continue;
215
+ fs.writeFileSync(
216
+ path.join(dir, "workflow", path.basename(name)),
217
+ text,
218
+ "utf8",
219
+ );
220
+ }
221
+ }
222
+
223
+ // Download signed URLs IMMEDIATELY and in PARALLEL — they expire ~15 min
224
+ // after the wire response is minted, so serializing N independent GETs only
225
+ // raises the odds a late one races an expiring URL. Failures are non-fatal
226
+ // (a missing brand asset degrades local HTML preview but never the server
227
+ // render) but are NOT silent: each is collected into `warnings` so the caller
228
+ // can tell the user the materialized bundle is incomplete.
229
+ const downloads = [];
230
+ for (const asset of wire.assets ?? []) {
231
+ if (!asset?.url || !asset?.filename) continue;
232
+ downloads.push({
233
+ label: `asset ${asset.filename}`,
234
+ target: path.join(dir, "assets", path.basename(asset.filename)),
235
+ url: asset.url,
236
+ });
237
+ }
238
+ for (const font of wire.fonts ?? []) {
239
+ if (!font?.url) continue;
240
+ const fname = font.filename || `${font.role || font.family || "font"}.bin`;
241
+ downloads.push({
242
+ label: `font ${fname}`,
243
+ target: path.join(dir, "fonts", path.basename(fname)),
244
+ url: font.url,
245
+ });
246
+ }
247
+ if (wire.master?.url) {
248
+ downloads.push({
249
+ label: "master empty-layout.pptx",
250
+ target: path.join(dir, "master", "empty-layout.pptx"),
251
+ url: wire.master.url,
252
+ });
253
+ }
254
+ // Per-CD sample slides (the brand's own reference slides) — the offline agent
255
+ // studies a random few before composing. Keyed by the sample's stable `id`;
256
+ // extension derived from `contentType`. Same non-fatal degradation as assets.
257
+ for (const sample of wire.samples ?? []) {
258
+ if (!sample?.url || !sample?.id) continue;
259
+ downloads.push({
260
+ label: `sample ${sample.id}`,
261
+ target: path.join(
262
+ dir,
263
+ "samples",
264
+ `${path.basename(sample.id)}${sampleExt(sample.contentType)}`,
265
+ ),
266
+ url: sample.url,
267
+ });
268
+ }
269
+
270
+ const warnings = [];
271
+ const settled = await Promise.allSettled(
272
+ downloads.map((d) => downloadInto(d.target, d.url)),
273
+ );
274
+ settled.forEach((r, i) => {
275
+ if (r.status === "rejected") {
276
+ const reason =
277
+ r.reason instanceof Error ? r.reason.message : String(r.reason);
278
+ warnings.push(
279
+ `failed to download ${downloads[i].label}: ${reason} (signed URL may have expired — re-run with --force)`,
280
+ );
281
+ }
282
+ });
283
+
284
+ const meta = {
285
+ presFP,
286
+ version: wire.version ?? null,
287
+ etag: wire.etag ?? null,
288
+ scope: wire.scope ?? null,
289
+ tokenRoleMap: wire.tokenRoleMap ?? null,
290
+ engines: wire.engines ?? [],
291
+ fetchedAt: new Date().toISOString(),
292
+ incompleteAssets: warnings.length > 0,
293
+ };
294
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf8");
295
+
296
+ return {
297
+ dir,
298
+ presFP,
299
+ etag: wire.etag ?? null,
300
+ version: wire.version ?? null,
301
+ cached: false,
302
+ warnings,
303
+ };
304
+ }
305
+
306
+ // Per-deck record of which CD bundle (presFP + etag) the deck was authored
307
+ // against — lets `render-pptx` pass the etag for skew detection, and lets the
308
+ // local HTML/editor resolve the right materialized bundle.
309
+ export function deckBundleRefPath(deckDir) {
310
+ return path.join(deckDir, ".bundle-ref.json");
311
+ }
312
+
313
+ export function writeDeckBundleRef(deckDir, { presFP, etag }) {
314
+ fs.mkdirSync(deckDir, { recursive: true });
315
+ fs.writeFileSync(
316
+ deckBundleRefPath(deckDir),
317
+ JSON.stringify(
318
+ { presFP, etag: etag ?? null, updatedAt: new Date().toISOString() },
319
+ null,
320
+ 2,
321
+ ),
322
+ "utf8",
323
+ );
324
+ }
325
+
326
+ export function readDeckBundleRef(deckDir) {
327
+ try {
328
+ return JSON.parse(fs.readFileSync(deckBundleRefPath(deckDir), "utf8"));
329
+ } catch {
330
+ return null;
331
+ }
332
+ }
333
+
334
+ // Resolve the materialized bundle directory a deck should read its design files
335
+ // from (via the deck's .bundle-ref.json). Returns null when no bundle has been
336
+ // materialized for the deck's CD yet.
337
+ export function resolveBundleDirForDeck(deckDir) {
338
+ const ref = readDeckBundleRef(deckDir);
339
+ if (!ref?.presFP) return null;
340
+ const dir = bundleDirForPresFP(ref.presFP);
341
+ return fs.existsSync(dir) ? dir : null;
342
+ }
343
+
344
+ // Read the engines[] descriptor list from a materialized bundle directory.
345
+ export function readEnginesFromDir(dir) {
346
+ if (!dir) return [];
347
+ try {
348
+ const parsed = JSON.parse(
349
+ fs.readFileSync(path.join(dir, "engines.json"), "utf8"),
350
+ );
351
+ return Array.isArray(parsed) ? parsed : [];
352
+ } catch {
353
+ return [];
354
+ }
355
+ }
356
+
357
+ export function loadEnginesForDeck(deckDir) {
358
+ return readEnginesFromDir(resolveBundleDirForDeck(deckDir));
359
+ }
360
+
361
+ // Pick the default engine to search: the first image/icon engine, else the
362
+ // first engine. Single source of truth for the heuristic shared by the CLI
363
+ // dispatcher (`slidemind search`) and the MCP `slidemind_search_engine` tool.
364
+ export function pickImageEngine(engines) {
365
+ const list = Array.isArray(engines) ? engines : [];
366
+ return (
367
+ list.find((e) =>
368
+ /image|icon/i.test(`${e.kind ?? ""} ${e.label ?? ""} ${e.description ?? ""}`),
369
+ ) ||
370
+ list[0] ||
371
+ null
372
+ );
373
+ }