slidemind 0.2.3 → 0.2.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.
@@ -141,6 +141,25 @@ export function resolveAssetExt(mimeType, buf) {
141
141
  return extFromBytes(buf) ?? declared;
142
142
  }
143
143
 
144
+ // The recolour descriptor, with a pane-free TINT REFUSAL folded in: the bytes
145
+ // on disk are then the original ones, so `applied` has to read false and the
146
+ // note has to name the one route that does bake a colour in.
147
+ function describeRecolorWithRefusal(markup, mimeType, iconColor, refusedColor) {
148
+ const described = describeRecolor(
149
+ markup,
150
+ mimeType,
151
+ refusedColor ? null : (iconColor ?? null),
152
+ );
153
+ if (!refusedColor) return described;
154
+ return {
155
+ ...described,
156
+ note:
157
+ `Saved in its ORIGINAL colours — recolouring to ${refusedColor} runs in the slidemind add-in. ` +
158
+ "Re-run this download with --to <presId> (a PowerPoint window with the slidemind pane open) " +
159
+ "to bake the colour in.",
160
+ };
161
+ }
162
+
144
163
  // Stream one result into assetsDir via the add-in's streaming route.
145
164
  //
146
165
  // Returns the same shape as `downloadOneResult`, or null when the paired add-in
@@ -156,21 +175,29 @@ async function streamOneResult({
156
175
  iconColor,
157
176
  }) {
158
177
  if (typeof client.engineDownloadToFile !== "function") return null;
178
+ // `presId: null` + a resolved backendURL is the pane-free route (the client
179
+ // picks the direct stream); a presId relays through that window's pane.
159
180
  // Write under a provisional name: the true extension is only knowable from the
160
181
  // bytes, and buffering the image just to sniff its header would defeat the
161
182
  // purpose of streaming.
162
183
  const tempPath = path.join(assetsDir, `.engine-${searchId}-${selection}.part`);
163
- let res;
164
- try {
165
- res = await client.engineDownloadToFile({
184
+ const fetchOnce = (color) =>
185
+ client.engineDownloadToFile({
166
186
  presId,
167
187
  backendURL,
168
188
  searchId,
169
189
  selection,
170
190
  destPath: tempPath,
171
- iconColor: iconColor ?? null,
191
+ iconColor: color ?? null,
172
192
  onProgress: createTransferProgress(`downloading result ${selection}`),
173
193
  });
194
+ let res;
195
+ // Set when the pane-free route refused the TINT (not the asset): the download
196
+ // then repeats without a colour so the caller still gets the icon, and the
197
+ // recolour is reported as not applied instead of silently claimed.
198
+ let recolorRefused = null;
199
+ try {
200
+ res = await fetchOnce(iconColor);
174
201
  } catch (err) {
175
202
  fs.rmSync(tempPath, { force: true });
176
203
  // Route absent → this add-in predates streaming; let the caller fall back.
@@ -178,7 +205,13 @@ async function streamOneResult({
178
205
  // for a stale selection), not Express saying "no such path" — retrying that
179
206
  // on the capped path would just fail again, more slowly.
180
207
  if ((err?.status === 404 && !err?.code) || err?.status === 501) return null;
181
- throw err;
208
+ // The recolour classifier is DOM-based and lives in the add-in, so the
209
+ // pane-free route refuses a tint rather than guessing at one. Refusing the
210
+ // whole DOWNLOAD over it would be worse: the icon is fetchable, only the
211
+ // colour is not. Take the bytes and say so.
212
+ if (err?.code !== "recolor_needs_host" || !iconColor) throw err;
213
+ recolorRefused = iconColor;
214
+ res = await fetchOnce(null);
182
215
  }
183
216
 
184
217
  try {
@@ -193,7 +226,12 @@ async function streamOneResult({
193
226
  path: target,
194
227
  reference: `./assets/${filename}`,
195
228
  mimeType: res.mimeType ?? "image/svg+xml",
196
- recolor: describeRecolor(res.svgMarkup, res.mimeType, iconColor ?? null),
229
+ recolor: describeRecolorWithRefusal(
230
+ res.svgMarkup,
231
+ res.mimeType,
232
+ iconColor,
233
+ recolorRefused,
234
+ ),
197
235
  size: svgSizeFromMarkup(res.svgMarkup),
198
236
  };
199
237
  }
@@ -222,7 +260,12 @@ async function streamOneResult({
222
260
  path: target,
223
261
  reference: `./assets/${filename}`,
224
262
  mimeType: res.mimeType ?? null,
225
- recolor: describeRecolor(null, res.mimeType, iconColor ?? null),
263
+ recolor: describeRecolorWithRefusal(
264
+ null,
265
+ res.mimeType,
266
+ iconColor,
267
+ recolorRefused,
268
+ ),
226
269
  size: imageSizeFromBuffer(probe),
227
270
  };
228
271
  } catch (err) {
@@ -237,7 +280,7 @@ async function streamOneResult({
237
280
  // the rest.
238
281
  export async function downloadOneResult({
239
282
  client,
240
- presId,
283
+ presId = null,
241
284
  backendURL = null,
242
285
  searchId,
243
286
  assetsDir,
@@ -259,6 +302,16 @@ export async function downloadOneResult({
259
302
  });
260
303
  if (streamed) return streamed;
261
304
 
305
+ // The capped base64 fallback below is a PANE route. On the pane-free path
306
+ // there is no window to relay through — and an add-in that serves the direct
307
+ // routes always serves the direct stream — so a missing stream there is a real
308
+ // failure, not a reason to reach for a window that may not exist.
309
+ if (!presId) {
310
+ throw new Error(
311
+ "the add-in's engine routes returned no bytes for that result — re-run the search, then pick again",
312
+ );
313
+ }
314
+
262
315
  // Fallback for an add-in that predates the streaming route: bytes ride the
263
316
  // pane's Socket.IO ack, capped by its 10 MB frame limit.
264
317
  const res = await client.engineDownload({
@@ -68,8 +68,11 @@ function firstExistingOr(candidates, fallback) {
68
68
  return firstExisting(candidates) ?? fallback;
69
69
  }
70
70
 
71
- export function resolveSkillForDeck(deckDir) {
71
+ export function resolveSkillForDeck(deckDir, opts = {}) {
72
72
  const planMeta = readPlanFrontmatter(deckDir);
73
+ if (opts.packDir) {
74
+ return resolveTemplatePack(opts.packDir, planMeta, { deckDir });
75
+ }
73
76
  const skillName = planMeta.skill || DEFAULT_SKILL;
74
77
  const bundleDir = deckDir ? resolveBundleDirForDeck(deckDir) : null;
75
78
  return resolveSkill(skillName, planMeta, { deckDir, bundleDir });
@@ -80,7 +83,12 @@ export function resolveSkillForDeck(deckDir) {
80
83
  // fill-template) must not pay resolveSkill's design-root requirement — a
81
84
  // compose deck with an unmaterialized bundle self-heals inside the render
82
85
  // path, and an early resolver throw here would break that.
83
- export function resolveDeckMode(deckDir) {
86
+ export function resolveDeckMode(deckDir, opts = {}) {
87
+ if (opts.packDir) {
88
+ const planMeta = readPlanFrontmatter(deckDir);
89
+ const pack = resolveTemplatePack(opts.packDir, planMeta, { deckDir });
90
+ return { skillName: pack.skillName, mode: pack.mode, skillDir: pack.skillDir };
91
+ }
84
92
  try {
85
93
  const planMeta = readPlanFrontmatter(deckDir);
86
94
  const skillName = planMeta.skill || DEFAULT_SKILL;
@@ -92,6 +100,60 @@ export function resolveDeckMode(deckDir) {
92
100
  }
93
101
  }
94
102
 
103
+ // Resolve a runtime template pack from an explicit directory. This path is
104
+ // deliberately independent of agent/harness installation and of the compose
105
+ // design-bundle resolver below. A plugin can locate its own pack and pass this
106
+ // path to the CLI without the CLI knowing the plugin exists.
107
+ export function resolveTemplatePack(packDir, planMeta = {}, opts = {}) {
108
+ const skillDir = path.resolve(String(packDir));
109
+ if (!fs.existsSync(skillDir) || !fs.statSync(skillDir).isDirectory()) {
110
+ throw new Error(`template pack directory does not exist: ${skillDir}`);
111
+ }
112
+ const manifest = readManifest(skillDir);
113
+ if (!manifest || typeof manifest !== "object" || !manifest.mode) {
114
+ throw new Error(`template pack ${skillDir} has no valid skill.json manifest`);
115
+ }
116
+
117
+ let templateKey = null;
118
+ let templatePath = null;
119
+ if (manifest.templates) {
120
+ templateKey =
121
+ planMeta.template ||
122
+ manifest.defaultTemplate ||
123
+ Object.keys(manifest.templates)[0];
124
+ const rel = templateKey ? manifest.templates[templateKey] : null;
125
+ if (rel) {
126
+ const candidate = path.resolve(skillDir, rel);
127
+ const relative = path.relative(skillDir, candidate);
128
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
129
+ throw new Error(`template path escapes pack directory: ${rel}`);
130
+ }
131
+ if (fs.existsSync(candidate)) templatePath = candidate;
132
+ }
133
+ }
134
+
135
+ return {
136
+ skillName: manifest.name || path.basename(skillDir),
137
+ skillDir,
138
+ manifest,
139
+ mode: manifest.mode || "compose",
140
+ // Fill-template callers only need the pack payload. These legacy design
141
+ // fields remain null so callers can distinguish this path from compose
142
+ // skill resolution without paying a design-root requirement.
143
+ cssFile: null,
144
+ layoutsDir: null,
145
+ layoutsCssFile: null,
146
+ assetsDir: opts.deckDir ? path.join(opts.deckDir, "assets") : null,
147
+ brand: null,
148
+ templateKey,
149
+ templatePath,
150
+ locale: planMeta.locale || null,
151
+ planMeta,
152
+ packageRoot: PACKAGE_ROOT,
153
+ bundleDir: opts.deckDir ? resolveBundleDirForDeck(opts.deckDir) : null,
154
+ };
155
+ }
156
+
95
157
  // Skill packs can live outside the npm package: private, org-internal packs
96
158
  // (templates, brand-specific assets) are distributed on their own and this
97
159
  // package never carries them. Lookup order:
@@ -35,69 +35,11 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
35
35
  const locale = skill.locale || "en";
36
36
  const fieldsByNo = pack.fields[locale] ?? {};
37
37
  const repeatsByNo = pack.repeats[locale] ?? {};
38
-
39
- if (!skill.templatePath || !fs.existsSync(skill.templatePath)) {
40
- throw new Error(
41
- `template "${skill.templateKey}" not found in pack ${skill.skillDir} — check plan.md frontmatter and skill.json templates`,
42
- );
43
- }
44
-
45
- // ---- deck reading ---------------------------------------------------------
46
- const slidesDir = path.join(deckDir, "slides");
47
- const entries = fs.readdirSync(slidesDir)
48
- .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
49
- .sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
50
- if (entries.length === 0) throw new Error(`no .jsx or .pptx slides in ${slidesDir}`);
51
-
52
- const problems = [];
53
- const warnings = [];
54
- const sequence = []; // { kind: "master", no, props, file } | { kind: "insert", file, buf }
55
-
56
- // In-deck freeform pairing: NN-name.jsx (create-slides source) renders to a
57
- // sibling NN-name.pptx artifact — when both exist, the .pptx carries the
58
- // slide and the .jsx is source only.
59
- const renderedStems = new Set(
60
- entries.filter((f) => f.endsWith(".pptx")).map((f) => f.replace(/\.pptx$/, "")),
61
- );
62
-
63
- for (const file of entries) {
64
- if (file.endsWith(".pptx")) {
65
- sequence.push({ kind: "insert", file, buf: fs.readFileSync(path.join(slidesDir, file)) });
66
- continue;
67
- }
68
- if (renderedStems.has(file.replace(/\.jsx$/, ""))) continue;
69
- const src = fs.readFileSync(path.join(slidesDir, file), "utf8");
70
- let parsed;
71
- try {
72
- parsed = parseSlideFile(src, file);
73
- } catch (err) {
74
- problems.push(err.message);
75
- continue;
76
- }
77
- const layout = layoutNumber(parsed.component, locale, pack.components);
78
- if (!layout) {
79
- problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
80
- `rendered artifact next to it — 'slidemind build-pptx' renders slides/${file.replace(/\.jsx$/, "")}.pptx ` +
81
- `automatically when a paired add-in backend is reachable`);
82
- continue;
83
- }
84
- if (layout.warn) warnings.push(`${file}: ${layout.warn}`);
85
- const no = String(layout.no);
86
-
87
- if ((pack.policy.freeform ?? []).includes(no)) {
88
- if (Object.keys(parsed.props).length > 0) {
89
- problems.push(`${file}: slide ${no} is a freeform placeholder — it takes no props. ` +
90
- `Keep it bare to keep the master placeholder, or delete the file to drop the slide.`);
91
- continue;
92
- }
93
- warnings.push(`${file}: keeping the master's slide-${no} placeholder (freeform replacement pending)`);
94
- }
95
-
96
- validateProps(parsed.props, fieldsByNo[no] ?? {}, repeatsByNo[no] ?? {}, pack.policy, no, file, problems);
97
- sequence.push({ kind: "master", no: Number(no), props: parsed.props, file });
98
- }
99
-
100
- checkIdentities(sequence, warnings, problems);
38
+ const { sequence, warnings, problems } = inspectFillTemplateDeck({
39
+ deckDir,
40
+ skill,
41
+ includeBuffers: true,
42
+ });
101
43
  if (problems.length) {
102
44
  const err = new Error(`deck has ${problems.length} authoring error(s):\n - ${problems.join("\n - ")}`);
103
45
  err.problems = problems;
@@ -174,6 +116,113 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
174
116
  return { outPath: finalOut, slideCount: output.length, warnings };
175
117
  }
176
118
 
119
+ // Parse and structurally validate a fill-template deck without mutating it.
120
+ // External packs use this generic inspection surface to run their own domain
121
+ // checks before invoking build-pptx. Buffers are opt-in because JSON callers
122
+ // must receive a compact, serializable sequence.
123
+ export function inspectFillTemplateDeck({
124
+ deckDir,
125
+ skill,
126
+ allowUnrenderedFreeform = false,
127
+ includeBuffers = false,
128
+ }) {
129
+ const pack = loadPack(skill);
130
+ const locale = skill.locale || "en";
131
+ const fieldsByNo = pack.fields[locale] ?? {};
132
+ const repeatsByNo = pack.repeats[locale] ?? {};
133
+ const slidesDir = path.join(deckDir, "slides");
134
+ const problems = [];
135
+ const warnings = [];
136
+ const sequence = [];
137
+
138
+ if (!skill.templatePath || !fs.existsSync(skill.templatePath)) {
139
+ problems.push(
140
+ `template "${skill.templateKey}" not found in pack ${skill.skillDir} — check the pack manifest templates`,
141
+ );
142
+ }
143
+ if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
144
+ problems.push(`expected 'slides/' folder in ${deckDir}`);
145
+ return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
146
+ }
147
+
148
+ const entries = fs.readdirSync(slidesDir)
149
+ .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
150
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
151
+ if (entries.length === 0) {
152
+ problems.push(`no .jsx or .pptx slides in ${slidesDir}`);
153
+ return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
154
+ }
155
+
156
+ const renderedStems = new Set(
157
+ entries.filter((f) => f.endsWith(".pptx")).map((f) => f.replace(/\.pptx$/, "")),
158
+ );
159
+
160
+ for (const file of entries) {
161
+ const full = path.join(slidesDir, file);
162
+ if (file.endsWith(".pptx")) {
163
+ sequence.push({
164
+ kind: "insert",
165
+ file,
166
+ ...(includeBuffers ? { buf: fs.readFileSync(full) } : {}),
167
+ });
168
+ continue;
169
+ }
170
+ if (renderedStems.has(file.replace(/\.jsx$/, ""))) continue;
171
+
172
+ const src = fs.readFileSync(full, "utf8");
173
+ const component = src.match(/<([A-Z][A-Za-z0-9]*)/)?.[1] ?? null;
174
+ let layout = null;
175
+ if (component) {
176
+ try {
177
+ layout = layoutNumber(component, locale, pack.components);
178
+ } catch (err) {
179
+ problems.push(err.message);
180
+ continue;
181
+ }
182
+ }
183
+
184
+ // A pack validator needs to see freeform source positions even before the
185
+ // paired add-in artifact exists. The build path still rejects such a
186
+ // source until ensureFreeformRenders has produced its .pptx sibling.
187
+ if (!layout && allowUnrenderedFreeform) {
188
+ sequence.push({ kind: "freeform", file, component });
189
+ warnings.push(`${file}: freeform source has no rendered .pptx artifact yet`);
190
+ continue;
191
+ }
192
+
193
+ let parsed;
194
+ try {
195
+ parsed = parseSlideFile(src, file);
196
+ } catch (err) {
197
+ problems.push(err.message);
198
+ continue;
199
+ }
200
+ layout = layoutNumber(parsed.component, locale, pack.components);
201
+ if (!layout) {
202
+ problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
203
+ `rendered artifact next to it — render slides/${file.replace(/\.jsx$/, "")}.pptx ` +
204
+ `through the paired add-in before building`);
205
+ continue;
206
+ }
207
+ if (layout.warn) warnings.push(`${file}: ${layout.warn}`);
208
+ const no = String(layout.no);
209
+
210
+ if ((pack.policy.freeform ?? []).includes(no)) {
211
+ if (Object.keys(parsed.props).length > 0) {
212
+ problems.push(`${file}: slide ${no} is a freeform placeholder — it takes no props. ` +
213
+ `Keep it bare to keep the master placeholder, or delete the file to drop the slide.`);
214
+ continue;
215
+ }
216
+ warnings.push(`${file}: keeping the master's slide-${no} placeholder (freeform replacement pending)`);
217
+ }
218
+
219
+ validateProps(parsed.props, fieldsByNo[no] ?? {}, repeatsByNo[no] ?? {}, pack.policy, no, file, problems);
220
+ sequence.push({ kind: "master", no: Number(no), props: parsed.props, file });
221
+ }
222
+
223
+ return { sequence, warnings, problems, locale, templateKey: skill.templateKey };
224
+ }
225
+
177
226
  // ---------------------------------------------------------------------------
178
227
  // Pack
179
228
  // ---------------------------------------------------------------------------
@@ -266,77 +315,6 @@ function editDistance(a, b) {
266
315
  return dp[a.length][b.length];
267
316
  }
268
317
 
269
- // ---------------------------------------------------------------------------
270
- // Cross-slide identities (the catalog's consistency table, as code)
271
- // ---------------------------------------------------------------------------
272
-
273
- const asLines = (v) => (Array.isArray(v) ? v : String(v ?? "").split("\n")).map((s) => s.trim()).filter(Boolean);
274
-
275
- function checkIdentities(sequence, warnings, problems) {
276
- const slide = (no) => sequence.filter((s) => s.kind === "master" && s.no === no);
277
- const approach = slide(10)[0];
278
- const details = slide(11);
279
- const gantt = slide(13)[0];
280
- const calc = slide(16)[0];
281
-
282
- const wpItems = approach?.props?.repeat?.workPackages ?? null;
283
- const ganttItems = gantt?.props?.repeat?.workPackages ?? null;
284
-
285
- if (wpItems && details.length && wpItems.length !== details.length) {
286
- problems.push(`WP count mismatch: slide 10 declares ${wpItems.length} work packages but there are ${details.length} WP-detail files (slide 11)`);
287
- }
288
- // Every WP detail must carry FULL bullet lists — a section left empty (or a
289
- // one-liner) is a defect in a client deliverable, not a style choice.
290
- details.forEach((d, i) => {
291
- for (const section of ["services", "assumptions", "deliverables"]) {
292
- const lines = asLines(d.props?.[section]);
293
- if (lines.length === 0) {
294
- problems.push(`${d.file}: WP detail ${i + 1} has no "${section}" — every WP detail needs all three bullet lists filled`);
295
- } else if (lines.length < 3) {
296
- warnings.push(`${d.file}: "${section}" has only ${lines.length} bullet(s) — WP details expect full lists (3–5)`);
297
- }
298
- }
299
- });
300
- if (wpItems && ganttItems && wpItems.length !== ganttItems.length) {
301
- problems.push(`WP count mismatch: slide 10 has ${wpItems.length} work packages, the project plan (13) has ${ganttItems.length} rows`);
302
- }
303
- if (ganttItems && details.length === ganttItems.length) {
304
- ganttItems.forEach((item, i) => {
305
- const canonical = asLines(details[i]?.props?.deliverables);
306
- const row = asLines(item.deliverable);
307
- if (canonical.length && row.length && canonical.join("\n") !== row.join("\n")) {
308
- problems.push(`deliverables drift: gantt row ${i + 1} must repeat WP-${i + 1}'s deliverables verbatim (slide 11 owns them)`);
309
- }
310
- });
311
- }
312
- if (wpItems && ganttItems) {
313
- ganttItems.forEach((item, i) => {
314
- if (item.title && wpItems[i]?.title && item.title !== wpItems[i].title) {
315
- problems.push(`WP name drift: slide 10 card ${i + 1} is "${wpItems[i].title}" but gantt row ${i + 1} is "${item.title}"`);
316
- }
317
- });
318
- }
319
- for (const [frameId, rows] of Object.entries(calc?.props?.tables ?? {})) {
320
- if (!Array.isArray(rows) || !wpItems) continue;
321
- const labels = rows.slice(1, -1).map((r) => String(r[0] ?? ""));
322
- labels.forEach((label, i) => {
323
- if (wpItems[i]?.title && label && label !== wpItems[i].title) {
324
- problems.push(`WP name drift: calculation table ${frameId} row ${i + 1} is "${label}" but slide 10 card ${i + 1} is "${wpItems[i].title}"`);
325
- }
326
- });
327
- }
328
- // Price echo: the contracted total should appear in the management summary.
329
- const costRows = calc?.props?.tables?.["15"];
330
- if (Array.isArray(costRows)) {
331
- const grand = String(costRows[costRows.length - 1]?.slice(-1)[0] ?? "");
332
- const digits = grand.replace(/[^\d.,]/g, "");
333
- const summary = slide(7)[0];
334
- if (digits && summary?.props?.approach && !String(summary.props.approach).includes(digits)) {
335
- warnings.push(`slide 7's approach paragraph does not mention the contracted total ${grand} from table 15`);
336
- }
337
- }
338
- }
339
-
340
318
  // ---------------------------------------------------------------------------
341
319
  // Per-slide ops
342
320
  // ---------------------------------------------------------------------------