slidemind 0.2.1 → 0.2.3

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.
@@ -113,7 +113,7 @@ const CURSOR_SHOW = "\x1b[?25h";
113
113
  // uniform.
114
114
  function startWordmarkAnimation() {
115
115
  const lines = wordmarkFrame(0);
116
- process.stdout.write("\n" + lines.join("\n") + "\n\n");
116
+ tui.out("\n" + lines.join("\n") + "\n\n");
117
117
  return () => {};
118
118
  }
119
119
 
@@ -125,6 +125,30 @@ export function isForce(flags) {
125
125
  return flags.includes("--force") || flags.includes("-F");
126
126
  }
127
127
 
128
+ // Is `name` an executable on the user's PATH? Used as the second half of
129
+ // harness detection: a coding agent's config dir (~/.claude, ~/.codex,
130
+ // ~/.config/opencode) is created by USE, not by install, so a freshly installed
131
+ // agent that has never been launched has no directory at all — its binary is
132
+ // the only evidence it exists. Resolved by walking PATH rather than shelling
133
+ // out to which/where, so it costs nothing and behaves the same on Windows.
134
+ export function binaryOnPath(name) {
135
+ const exts =
136
+ process.platform === "win32"
137
+ ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";")
138
+ : [""];
139
+ for (const dir of (process.env.PATH || "").split(path.delimiter)) {
140
+ if (!dir) continue;
141
+ for (const ext of exts) {
142
+ try {
143
+ if (fs.statSync(path.join(dir, name + ext)).isFile()) return true;
144
+ } catch {
145
+ /* not there / unreadable — keep looking */
146
+ }
147
+ }
148
+ }
149
+ return false;
150
+ }
151
+
128
152
  export function expandHome(p) {
129
153
  if (p.startsWith("~/")) return path.join(homeDir(), p.slice(2));
130
154
  return p;
@@ -234,6 +258,12 @@ let _startedAt = 0;
234
258
  let _stopWordmark = null;
235
259
 
236
260
  export const ui = {
261
+ // Suppress ALL decorative output. `slidemind setup --json` runs the real
262
+ // installers but owns stdout for its machine-readable payload, so the rail
263
+ // would corrupt the very thing the caller is parsing.
264
+ silence(value = true) {
265
+ tui.setSilent(value);
266
+ },
237
267
  // `wordmark: false` skips painting the wordmark — used when the caller
238
268
  // (e.g. the install.sh bootstrap) already printed it, so we only emit the
239
269
  // `v… · action` intro line and continue the rail.
@@ -276,12 +306,12 @@ export const ui = {
276
306
  // Manual emission so we can animate the title in place. Symbol
277
307
  // sits at col 1; " " padding lands the title at col 4.
278
308
  tui.bumpRow(1);
279
- process.stdout.write(tui.rail() + "\n");
309
+ tui.out(tui.rail() + "\n");
280
310
  tui.bumpRow(1);
281
- process.stdout.write(SYM.step + " ");
311
+ tui.out(SYM.step + " ");
282
312
  await tui.shimmer(title, { startCol: 4, restColor: (s) => s });
283
- if (sub) process.stdout.write(" " + brand.slate(tildify(sub)));
284
- process.stdout.write("\n");
313
+ if (sub) tui.out(" " + brand.slate(tildify(sub)));
314
+ tui.out("\n");
285
315
  },
286
316
  step({ status = "ok", label, value, note }) {
287
317
  const symbol =
@@ -328,15 +358,15 @@ export const ui = {
328
358
 
329
359
  const boxLine = (content) => {
330
360
  const pad = Math.max(0, inner - visibleLength(content));
331
- process.stdout.write(slate("│") + content + " ".repeat(pad) + slate("│") + "\n");
361
+ tui.out(slate("│") + content + " ".repeat(pad) + slate("│") + "\n");
332
362
  };
333
363
 
334
- process.stdout.write("\n");
364
+ tui.out("\n");
335
365
 
336
366
  // Top: ╭─ installed ────╮
337
367
  const labelText = " installed ";
338
368
  const fillerLen = Math.max(2, inner - 1 - labelText.length);
339
- process.stdout.write(
369
+ tui.out(
340
370
  slate("╭─") +
341
371
  dim(labelText) +
342
372
  slate("─".repeat(fillerLen)) +
@@ -359,10 +389,10 @@ export const ui = {
359
389
  boxLine(" " + dim(stats));
360
390
 
361
391
  // Bottom: ╰────────╯
362
- process.stdout.write(slate("╰" + "─".repeat(inner) + "╯") + "\n");
392
+ tui.out(slate("╰" + "─".repeat(inner) + "╯") + "\n");
363
393
 
364
- process.stdout.write("\n");
365
- process.stdout.write(`${SYM.success} ${brand.bold(action)}\n`);
394
+ tui.out("\n");
395
+ tui.out(`${SYM.success} ${brand.bold(action)}\n`);
366
396
 
367
397
  // Stop the wordmark animation last — that resets the scroll region
368
398
  // and shows the cursor. The wordmark itself stays painted at the
@@ -401,16 +431,16 @@ export const ui = {
401
431
  // Reserve two rows: rail spacer + the active spinner line. Both
402
432
  // count toward the gradient row counter.
403
433
  tui.bumpRow(1);
404
- process.stdout.write(tui.rail() + "\n");
434
+ tui.out(tui.rail() + "\n");
405
435
  tui.bumpRow(1);
406
- process.stdout.write(
436
+ tui.out(
407
437
  `${brand.techBlue40(SPINNER_FRAMES[0])} ${fmtStep({ label, value, note })}`,
408
438
  );
409
439
  let frame = 0;
410
440
  const timer = setInterval(() => {
411
441
  frame = (frame + 1) % SPINNER_FRAMES.length;
412
- process.stdout.write("\r\x1b[2K");
413
- process.stdout.write(
442
+ tui.out("\r\x1b[2K");
443
+ tui.out(
414
444
  `${brand.techBlue40(SPINNER_FRAMES[frame])} ${fmtStep({ label, value, note })}`,
415
445
  );
416
446
  }, 80);
@@ -418,15 +448,15 @@ export const ui = {
418
448
  const result = await fn();
419
449
  const time = Date.now() - start;
420
450
  clearInterval(timer);
421
- process.stdout.write("\r\x1b[2K");
422
- process.stdout.write(
451
+ tui.out("\r\x1b[2K");
452
+ tui.out(
423
453
  `${SYM.success} ${fmtStep({ label, value, note: finalNote ?? note, time })}\n`,
424
454
  );
425
455
  return result;
426
456
  } catch (err) {
427
457
  clearInterval(timer);
428
- process.stdout.write("\r\x1b[2K");
429
- process.stdout.write(
458
+ tui.out("\r\x1b[2K");
459
+ tui.out(
430
460
  `${SYM.error} ${fmtStep({ label, value, note: "failed" })}\n`,
431
461
  );
432
462
  throw err;
@@ -17,6 +17,7 @@
17
17
  import fs from "node:fs";
18
18
  import path from "node:path";
19
19
  import {
20
+ binaryOnPath,
20
21
  expandHome,
21
22
  installDir,
22
23
  isForce,
@@ -117,6 +118,21 @@ function removeLegacyArtifacts() {
117
118
  }
118
119
  }
119
120
 
121
+ // See claude.mjs `detect()`. opencode has no slidemind plugin channel, so the
122
+ // skills can only ever come from here.
123
+ export function detect() {
124
+ const root = opencodeRoot();
125
+ const configured = fs.existsSync(root);
126
+ const onPath = binaryOnPath("opencode");
127
+ return {
128
+ root,
129
+ configured,
130
+ onPath,
131
+ present: configured || onPath,
132
+ pluginInstalled: false,
133
+ };
134
+ }
135
+
120
136
  export async function install({ packageRoot, flags }) {
121
137
  const link = isLinkMode(flags);
122
138
  const force = isForce(flags);
@@ -23,6 +23,17 @@ const FADE_ROWS = 28; // rail finishes fading after this many emitted rows
23
23
 
24
24
  let _row = 0;
25
25
 
26
+ // Every terminal write in the installer UI funnels through here, so a caller
27
+ // whose stdout carries machine-readable output (`slidemind setup --json`) can
28
+ // silence the decorative rail without each writer knowing about it.
29
+ let _silent = false;
30
+ export function setSilent(value) {
31
+ _silent = Boolean(value);
32
+ }
33
+ export function out(text) {
34
+ if (!_silent) process.stdout.write(text);
35
+ }
36
+
26
37
  // Linear interpolate between #9999FF (techBlue40) and #6C7D8C (slate).
27
38
  // Top of the rail is bright tech-blue, fading to brand slate as the
28
39
  // install progresses — feels like the deck's tech-blue gradation card.
@@ -52,7 +63,7 @@ export function bumpRow(n = 1) {
52
63
 
53
64
  export function intro(text) {
54
65
  _row = 0;
55
- process.stdout.write("\n" + paint(_row, "┌") + " " + text + "\n");
66
+ out("\n" + paint(_row, "┌") + " " + text + "\n");
56
67
  }
57
68
 
58
69
  // Each message starts with its own gradient-coloured rail spacer, then
@@ -60,28 +71,28 @@ export function intro(text) {
60
71
  // (◆ ● ▲ ■ etc.); when omitted we fall back to a continuation rail.
61
72
  export function message(text, { symbol } = {}) {
62
73
  _row += 1;
63
- process.stdout.write(paint(_row, "│") + "\n");
74
+ out(paint(_row, "│") + "\n");
64
75
  _row += 1;
65
76
  const sym = symbol ?? paint(_row, "│");
66
- process.stdout.write(sym + " " + text + "\n");
77
+ out(sym + " " + text + "\n");
67
78
  }
68
79
 
69
80
  export function outro(text) {
70
81
  _row += 1;
71
- process.stdout.write(paint(_row, "│") + "\n");
82
+ out(paint(_row, "│") + "\n");
72
83
  _row += 1;
73
- process.stdout.write(paint(_row, "└") + " " + text + "\n\n");
84
+ out(paint(_row, "└") + " " + text + "\n\n");
74
85
  _row = 0;
75
86
  }
76
87
 
77
88
  // Reserve the next two rows for a spin step: emits the rail spacer,
78
89
  // returns the row index the spinner is written on. Callers should
79
- // `process.stdout.write(...)` the spinner content themselves (without a
90
+ // `out(...)` the spinner content themselves (without a
80
91
  // trailing `\n`) so they can overwrite it via `\r`. When the operation
81
92
  // completes they call `commitSpinRow()` after writing the final line.
82
93
  export function reserveSpinRows() {
83
94
  _row += 1;
84
- process.stdout.write(paint(_row, "│") + "\n");
95
+ out(paint(_row, "│") + "\n");
85
96
  _row += 1;
86
97
  return _row;
87
98
  }
@@ -96,7 +107,7 @@ const ANIMATE = !NO_COLOR && process.stdout.isTTY;
96
107
  export async function shimmer(text, { startCol = 1, restColor }) {
97
108
  const final = brand.bold(restColor(text));
98
109
  if (!ANIMATE) {
99
- process.stdout.write(final);
110
+ out(final);
100
111
  return;
101
112
  }
102
113
  const len = text.length;
@@ -117,9 +128,9 @@ export async function shimmer(text, { startCol = 1, restColor }) {
117
128
  else painter = (s) => brand.dim(brand.slate(s));
118
129
  line += brand.bold(painter(text[i]));
119
130
  }
120
- process.stdout.write(`\x1b[${startCol}G\x1b[K` + line);
131
+ out(`\x1b[${startCol}G\x1b[K` + line);
121
132
  if (f < frames) await new Promise((r) => setTimeout(r, frameMs));
122
133
  }
123
134
  // Final settle.
124
- process.stdout.write(`\x1b[${startCol}G\x1b[K` + final);
135
+ out(`\x1b[${startCol}G\x1b[K` + final);
125
136
  }
@@ -1,5 +1,5 @@
1
1
  // The fill-template deck builder — the host-free `build-pptx` path for
2
- // proposal-style packs (mode: "fill-template" in the pack's skill.json).
2
+ // template packs (mode: "fill-template" in the pack's skill.json).
3
3
  //
4
4
  // Assembly direction: the template .pptx is cloned WHOLE (masters, layouts,
5
5
  // media, tags, embeddings), then only declared shapes inside the clone are
@@ -74,7 +74,7 @@ export async function buildFillTemplateDeck({ deckDir, skill, outPath, onLog })
74
74
  problems.push(err.message);
75
75
  continue;
76
76
  }
77
- const layout = layoutNumber(parsed.component, locale);
77
+ const layout = layoutNumber(parsed.component, locale, pack.components);
78
78
  if (!layout) {
79
79
  problems.push(`${file}: <${parsed.component}> is a freeform (create-slides) slide with no ` +
80
80
  `rendered artifact next to it — 'slidemind build-pptx' renders slides/${file.replace(/\.jsx$/, "")}.pptx ` +
@@ -190,6 +190,9 @@ function loadPack(skill) {
190
190
  fields,
191
191
  repeats: read("repeats.json", {}),
192
192
  policy: skill.manifest.policy ?? {},
193
+ // The pack's layout-component naming contract (see parse-slides.mjs).
194
+ // skillDir rides along so a bad pattern names the pack that shipped it.
195
+ components: { ...(skill.manifest.components ?? {}), skillDir: skill.skillDir },
193
196
  };
194
197
  }
195
198
 
@@ -283,7 +286,7 @@ function checkIdentities(sequence, warnings, problems) {
283
286
  problems.push(`WP count mismatch: slide 10 declares ${wpItems.length} work packages but there are ${details.length} WP-detail files (slide 11)`);
284
287
  }
285
288
  // Every WP detail must carry FULL bullet lists — a section left empty (or a
286
- // one-liner) is a defect in a client proposal, not a style choice.
289
+ // one-liner) is a defect in a client deliverable, not a style choice.
287
290
  details.forEach((d, i) => {
288
291
  for (const section of ["services", "assumptions", "deliverables"]) {
289
292
  const lines = asLines(d.props?.[section]);
@@ -1,12 +1,12 @@
1
1
  // In-deck freeform slides for fill-template decks.
2
2
  //
3
- // Contract: a proposal deck's slides/ may carry create-slides freeform JSX
3
+ // Contract: a fill-template deck's slides/ may carry create-slides freeform JSX
4
4
  // (NN-name.jsx composing the design bundle's layouts) directly next to the
5
5
  // master-backed slides — no sibling deck. The rendered native artifact lives
6
6
  // beside its source as NN-name.pptx; the fill engine inserts that artifact at
7
7
  // the filename position like any native slide. This module keeps the
8
8
  // artifacts fresh: stale or missing renders go through the add-in's render
9
- // path (the ONLY host-dependent step of a proposal build) and are folded to
9
+ // path (the ONLY host-dependent step of such a build) and are folded to
10
10
  // pruned one-slide packages. Unchanged freeform slides rebuild fully offline.
11
11
 
12
12
  import crypto from "node:crypto";
@@ -22,22 +22,26 @@ import { layoutNumber } from "./parse-slides.mjs";
22
22
 
23
23
  const SIDE_CAR = ".freeform-renders.json";
24
24
 
25
- // The template's Challenge/Solution slides carry FIXED titles; freeform
26
- // replacements must keep them so the deck reads like the master.
27
- const FIXED_TITLES = {
28
- en: [
29
- { stem: /challenge/i, title: "Challenge" },
30
- { stem: /solution/i, title: "Solution" },
31
- ],
32
- de: [
33
- { stem: /challenge|herausforderung/i, title: "Herausforderung" },
34
- { stem: /solution|loesung|lösung/i, title: "Lösung" },
35
- ],
36
- };
25
+ // Some templates fix a slide's title (the master prints it, so a freeform
26
+ // replacement must keep it or the deck stops reading like the master). WHICH
27
+ // slides, and in which locale, is the pack's business — it declares them in
28
+ // skill.json as policy.fixedTitles:
29
+ //
30
+ // "fixedTitles": {
31
+ // "en": [ { "stem": "agenda", "title": "Agenda" } ],
32
+ // "de": [ { "stem": "agenda|ablauf", "title": "Ablauf" } ]
33
+ // }
34
+ //
35
+ // `stem` is a case-insensitive regex matched against the slide file's stem.
36
+ // A pack that declares none simply gets no title warnings.
37
+ function fixedTitleRules(policy, locale) {
38
+ const all = policy?.fixedTitles ?? {};
39
+ return all[locale] ?? all.en ?? [];
40
+ }
37
41
 
38
- function isFreeformSource(src) {
42
+ function isFreeformSource(src, components) {
39
43
  const comp = src.match(/<([A-Z][A-Za-z0-9]*)/)?.[1];
40
- return Boolean(comp) && layoutNumber(comp, "en") === null;
44
+ return Boolean(comp) && layoutNumber(comp, "en", components) === null;
41
45
  }
42
46
 
43
47
  function contentHash(parts) {
@@ -53,9 +57,10 @@ function slideText(bytes) {
53
57
  return [...(xml ?? "").matchAll(/<a:t>([^<]*)<\/a:t>/g)].map((m) => m[1]).join("\n");
54
58
  }
55
59
 
56
- function fixedTitleWarning(stem, bytes, locale) {
57
- const rules = FIXED_TITLES[locale] ?? FIXED_TITLES.en;
58
- const rule = rules.find((r) => r.stem.test(stem));
60
+ function fixedTitleWarning(stem, bytes, locale, policy) {
61
+ const rule = fixedTitleRules(policy, locale).find((r) =>
62
+ new RegExp(r.stem, "i").test(stem),
63
+ );
59
64
  if (!rule) return null;
60
65
  if (slideText(bytes).includes(rule.title)) return null;
61
66
  return (
@@ -69,7 +74,14 @@ function fixedTitleWarning(stem, bytes, locale) {
69
74
  * `{ rendered, warnings }`; throws with the offending file named when a stale
70
75
  * render is needed but the paired backend cannot render.
71
76
  */
72
- export async function ensureFreeformRenders({ deckDir, locale = "en", srcDir, onLog = () => {} }) {
77
+ export async function ensureFreeformRenders({
78
+ deckDir,
79
+ locale = "en",
80
+ srcDir,
81
+ components,
82
+ policy = {},
83
+ onLog = () => {},
84
+ }) {
73
85
  const slidesDir = path.join(deckDir, "slides");
74
86
  const jsxFiles = fs.existsSync(slidesDir)
75
87
  ? fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx")).sort()
@@ -77,7 +89,9 @@ export async function ensureFreeformRenders({ deckDir, locale = "en", srcDir, on
77
89
  const items = [];
78
90
  for (const file of jsxFiles) {
79
91
  const src = fs.readFileSync(path.join(slidesDir, file), "utf8");
80
- if (isFreeformSource(src)) items.push({ file, stem: file.replace(/\.jsx$/, ""), src });
92
+ if (isFreeformSource(src, components)) {
93
+ items.push({ file, stem: file.replace(/\.jsx$/, ""), src });
94
+ }
81
95
  }
82
96
  const warnings = [];
83
97
  if (items.length === 0) return { rendered: 0, warnings };
@@ -149,7 +163,7 @@ export async function ensureFreeformRenders({ deckDir, locale = "en", srcDir, on
149
163
  for (const it of items) {
150
164
  const target = path.join(slidesDir, `${it.stem}.pptx`);
151
165
  if (!fs.existsSync(target)) continue;
152
- const warn = fixedTitleWarning(it.stem, fs.readFileSync(target), locale);
166
+ const warn = fixedTitleWarning(it.stem, fs.readFileSync(target), locale, policy);
153
167
  if (warn) warnings.push(warn);
154
168
  }
155
169
  return { rendered: stale.length, warnings };
@@ -1,27 +1,61 @@
1
- // Parser for proposal slide files. Proposal JSX is declarative by contract —
2
- // one default export returning exactly one <SlideProposalLayoutNN …/> element
3
- // whose props are data literals. That constraint is what makes a real JSX
4
- // runtime unnecessary: attributes are scanned textually and brace expressions
5
- // are evaluated as plain JS literals in an empty vm sandbox.
1
+ // Parser for fill-template slide files. Their JSX is declarative by contract —
2
+ // one default export returning exactly one <SomeLayout …/> element whose props
3
+ // are data literals. That constraint is what makes a real JSX runtime
4
+ // unnecessary: attributes are scanned textually and brace expressions are
5
+ // evaluated as plain JS literals in an empty vm sandbox.
6
+ //
7
+ // The component NAMES are the pack's business, not this engine's: a pack ships
8
+ // its own layouts under its own naming scheme, so it declares the scheme in
9
+ // skill.json and this file only applies it.
6
10
 
7
11
  import vm from "node:vm";
8
12
 
9
- // Accepted component spellings → template slide number.
10
- // SlideProposalLayout07 (canonical, locale-neutral)
11
- // SlideProposalEn07Slide7 / SlideProposalDe07Slide7 (generated concrete names)
12
- export function layoutNumber(component, locale) {
13
- let m = component.match(/^SlideProposalLayout(\d{2})$/);
13
+ // Compiled once per distinct pattern — layoutNumber runs per slide file.
14
+ const patternCache = new Map();
15
+ function compile(pattern, which, skillDir) {
16
+ let re = patternCache.get(pattern);
17
+ if (re) return re;
18
+ try {
19
+ re = new RegExp(pattern);
20
+ } catch (err) {
21
+ throw new Error(
22
+ `pack ${skillDir ?? "(unknown)"}: skill.json components.${which} is not a ` +
23
+ `valid regular expression (${err.message})`,
24
+ );
25
+ }
26
+ patternCache.set(pattern, re);
27
+ return re;
28
+ }
29
+
30
+ // Map a component name → the template slide number it fills, or null when the
31
+ // component is not master-backed at all (i.e. a freeform slide).
32
+ //
33
+ // `components` comes from the pack's skill.json and declares two patterns:
34
+ // canonical — locale-neutral spelling; capture group 1 is the slide number
35
+ // localized — optional generated spelling; group 1 is the locale, group 2
36
+ // the slide number (so a locale mismatch can be reported)
37
+ export function layoutNumber(component, locale, components) {
38
+ if (!components || !components.canonical) {
39
+ throw new Error(
40
+ "fill-template pack declares no components.canonical pattern in " +
41
+ "skill.json — the pack owns its layout naming contract and must " +
42
+ "declare it, e.g. { \"components\": { \"canonical\": \"^MyLayout(\\\\d{2})$\" } }",
43
+ );
44
+ }
45
+ let m = component.match(compile(components.canonical, "canonical", components.skillDir));
14
46
  if (m) return { no: Number(m[1]) };
15
- m = component.match(/^SlideProposal(En|De)(\d{2})Slide\d+$/);
16
- if (m) {
17
- const compLocale = m[1].toLowerCase();
18
- return {
19
- no: Number(m[2]),
20
- warn:
21
- compLocale === locale
22
- ? `use the canonical <SlideProposalLayout${m[2]}> alias`
23
- : `component locale "${compLocale}" does not match deck locale "${locale}"`,
24
- };
47
+ if (components.localized) {
48
+ m = component.match(compile(components.localized, "localized", components.skillDir));
49
+ if (m) {
50
+ const compLocale = String(m[1]).toLowerCase();
51
+ return {
52
+ no: Number(m[2]),
53
+ warn:
54
+ compLocale === locale
55
+ ? `use the canonical alias for slide ${Number(m[2])}`
56
+ : `component locale "${compLocale}" does not match deck locale "${locale}"`,
57
+ };
58
+ }
25
59
  }
26
60
  return null;
27
61
  }
@@ -81,7 +115,7 @@ function evalLiteral(expr, file) {
81
115
  return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
82
116
  } catch (err) {
83
117
  throw new Error(`${file}: cannot evaluate prop expression (${err.message}). ` +
84
- `Proposal props must be data literals — strings, numbers, arrays, objects.`);
118
+ `Slide props must be data literals — strings, numbers, arrays, objects.`);
85
119
  }
86
120
  }
87
121
 
@@ -136,7 +170,7 @@ export function parseSlideFile(source, file) {
136
170
  const inner = closeIdx > 0 ? src.slice(i + 1, closeIdx).trim() : "";
137
171
  if (inner) {
138
172
  throw new Error(
139
- `${file}: <${component}> has children. Master-backed proposal slides are ` +
173
+ `${file}: <${component}> has children. Master-backed slides are ` +
140
174
  `self-closing props-only elements; freeform content belongs to create-slides.`,
141
175
  );
142
176
  }
@@ -30,7 +30,7 @@ const stripHighlight = (rPr) =>
30
30
 
31
31
  const SHAPE_TAGS = ["sp", "pic", "cxnSp", "graphicFrame"];
32
32
 
33
- // Top-level shape blocks of one kind. The proposal masters keep all editable
33
+ // Top-level shape blocks of one kind. Template masters keep all editable
34
34
  // shapes at spTree top level (verified by inventory); grouped shapes are not
35
35
  // addressed by the pack metadata.
36
36
  export function* blocks(xml, tag) {