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.
@@ -32,7 +32,7 @@ import fs from "node:fs";
32
32
  import path from "node:path";
33
33
  import { fileURLToPath, pathToFileURL } from "node:url";
34
34
 
35
- import { lintSlidesOrThrow } from "./lint-slides.mjs";
35
+ import { lintSlidesOrThrow } from "./lint.mjs";
36
36
  import { resolveSkillForDeck } from "./resolve-skill.mjs";
37
37
  import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
38
38
 
@@ -50,7 +50,10 @@ function safeInlineJson(data) {
50
50
  return JSON.stringify(data).replace(/<\/script>/g, "<\\/script>");
51
51
  }
52
52
 
53
- function stripImports(src) {
53
+ // Exported for tests: the regex keeps the line's trailing newline, so line
54
+ // numbers stay aligned to the author's pristine file — which the browser-side
55
+ // data-sm-loc instrumentation depends on.
56
+ export function stripImports(src) {
54
57
  // Remove `import ... from "..."` lines. Layouts live in global scope in
55
58
  // the generated HTML, so slide files never need to import them explicitly.
56
59
  return src.replace(
@@ -354,9 +357,49 @@ __RUNTIME_APPLIER__
354
357
  }).join("");
355
358
  }
356
359
 
360
+ // Stamp every intrinsic JSX element with data-sm-loc="<prefix>:<line>:<col>"
361
+ // so the preview-review overlay (review-overlay.js) can resolve a click back
362
+ // to the exact slide source location. Runs as a Babel plugin BEFORE the react
363
+ // preset (plugins run before presets) so it sees JSX nodes, not createElement
364
+ // calls. Inert without the overlay. Mirrors the add-in's instrument-source-loc.
365
+ function makeSourceLocPlugin(locPrefix) {
366
+ return function (babel) {
367
+ var t = babel.types;
368
+ return {
369
+ visitor: {
370
+ JSXOpeningElement: function (path) {
371
+ var node = path.node;
372
+ var nameNode = node.name;
373
+ // Intrinsic elements only: a lowercase JSXIdentifier. Skip
374
+ // components (<SlideCover>), member/namespaced names, fragments.
375
+ if (!nameNode || nameNode.type !== "JSXIdentifier") return;
376
+ if (!/^[a-z]/.test(nameNode.name)) return;
377
+ for (var i = 0; i < node.attributes.length; i++) {
378
+ var a = node.attributes[i];
379
+ if (a.type === "JSXAttribute" && a.name &&
380
+ a.name.name === "data-sm-loc") return;
381
+ }
382
+ var start = node.loc && node.loc.start;
383
+ if (!start) return;
384
+ var value = locPrefix + ":" + start.line + ":" + (start.column + 1);
385
+ node.attributes.push(
386
+ t.jsxAttribute(t.jsxIdentifier("data-sm-loc"),
387
+ t.stringLiteral(value))
388
+ );
389
+ }
390
+ }
391
+ };
392
+ };
393
+ }
394
+
357
395
  function evalJsx(src, filename, extra) {
396
+ // The inliner wraps each source as "\\n" + src + "\\n" inside the <script>
397
+ // block; strip that one leading newline so Babel's line numbers (and thus
398
+ // data-sm-loc) align with the author's pristine workspace file.
399
+ if (src.charAt(0) === "\\n") src = src.slice(1);
358
400
  var result = Babel.transform(src, {
359
401
  presets: [["react", { runtime: "classic" }]],
402
+ plugins: [makeSourceLocPlugin(filename)],
360
403
  sourceType: "module",
361
404
  filename: filename
362
405
  });
@@ -466,7 +509,11 @@ __RUNTIME_APPLIER__
466
509
  document.querySelectorAll('script[data-kind="layout"]').forEach(function (s) {
467
510
  var name = s.dataset.binding || layoutBindingName(s.dataset.name);
468
511
  try {
469
- LAYOUTS[name] = evalJsx(s.textContent, s.dataset.name + ".jsx", {});
512
+ LAYOUTS[name] = evalJsx(
513
+ s.textContent,
514
+ "layouts/" + s.dataset.name + ".jsx",
515
+ {}
516
+ );
470
517
  window[name] = LAYOUTS[name];
471
518
  } catch (err) {
472
519
  console.error("Layout " + name + " failed to load:", err);
@@ -481,7 +528,7 @@ __RUNTIME_APPLIER__
481
528
  try {
482
529
  var Component = evalJsx(
483
530
  s.textContent,
484
- name + ".jsx",
531
+ "slides/" + name + ".jsx",
485
532
  LAYOUTS
486
533
  );
487
534
  slides[idx] = { name: name, Component: Component };
@@ -591,7 +638,7 @@ __RUNTIME_APPLIER__
591
638
  </html>
592
639
  `;
593
640
 
594
- export function buildDeck(deckDir, opts = {}) {
641
+ export async function buildDeck(deckDir, opts = {}) {
595
642
  const skill = resolveSkillForDeck(deckDir);
596
643
  const slidesDir = path.join(deckDir, "slides");
597
644
  if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
@@ -640,17 +687,18 @@ export function buildDeck(deckDir, opts = {}) {
640
687
  throw new Error(`No layouts in ${skill.layoutsDir}`);
641
688
  }
642
689
 
643
- // Merge the brand primitive registry with the active skill's registry so a
644
- // sibling skill's aliases and the brand's freeform layouts can coexist in one
645
- // deck. (With only the brand skill present this is a single registry.)
646
- lintSlidesOrThrow(slideFiles, {
647
- primitivesPath: skill.primitivesPath,
648
- brandPrimitivesPath: skill.brand.primitivesPath,
649
- // Derive valid layout component names from the materialized layouts/*.jsx so
650
- // the lint recognizes the bundle's layouts without a primitives.json
651
- // `layouts` list (the materialized bundle doesn't ship one).
652
- layoutsDirs: layoutDirs,
653
- });
690
+ // Lint with the add-in's engine from the materialized bundle (the CLI owns
691
+ // no lint source — see src/builders/lint.mjs). The registry derives layout
692
+ // component names from the RAW layout JSX (not the rewriteAssetsPath-mutated
693
+ // copy inlineLayout produces); the primitives registry rides next to the
694
+ // engine in the bundle cache.
695
+ const layoutSources = new Map(
696
+ [...layoutByStem].map(([stem, file]) => [
697
+ `${stem}.jsx`,
698
+ fs.readFileSync(file, "utf8"),
699
+ ]),
700
+ );
701
+ await lintSlidesOrThrow({ deckDir, slideFiles, layoutSources });
654
702
 
655
703
  copyDesignSystem(deckDir, skill);
656
704
 
@@ -673,13 +721,16 @@ export function buildDeck(deckDir, opts = {}) {
673
721
  return { out, slideCount: slideFiles.length };
674
722
  }
675
723
 
676
- function openInBrowser(file) {
724
+ // Open a local file path OR an http URL in the user's default browser. The
725
+ // platform openers (open / start / xdg-open) accept both, so review.mjs reuses
726
+ // this to launch the local review server URL.
727
+ export function openInBrowser(target) {
677
728
  const opener =
678
729
  process.platform === "darwin"
679
- ? { cmd: "open", args: [file] }
730
+ ? { cmd: "open", args: [target] }
680
731
  : process.platform === "win32"
681
- ? { cmd: "cmd", args: ["/c", "start", "", file] }
682
- : { cmd: "xdg-open", args: [file] };
732
+ ? { cmd: "cmd", args: ["/c", "start", "", target] }
733
+ : { cmd: "xdg-open", args: [target] };
683
734
  spawn(opener.cmd, opener.args, { stdio: "ignore", detached: true }).unref();
684
735
  }
685
736
 
@@ -709,20 +760,18 @@ function parseArgs(argv) {
709
760
  return args;
710
761
  }
711
762
 
712
- function main() {
763
+ async function main() {
713
764
  const args = parseArgs(process.argv.slice(2));
714
765
  const deckDir = path.resolve(args.deck);
715
- const { out, slideCount } = buildDeck(deckDir);
766
+ const { out, slideCount } = await buildDeck(deckDir);
716
767
  const rel = path.relative(process.cwd(), out) || out;
717
768
  process.stdout.write(`Built ${rel} (${slideCount} slides)\n`);
718
769
  if (args.open) openInBrowser(out);
719
770
  }
720
771
 
721
772
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
722
- try {
723
- main();
724
- } catch (err) {
773
+ main().catch((err) => {
725
774
  process.stderr.write(`build: ${err.message || err}\n`);
726
775
  process.exit(1);
727
- }
776
+ });
728
777
  }
@@ -0,0 +1,112 @@
1
+ // Slide lint — thin wrapper over the add-in's lint engine.
2
+ //
3
+ // This CLI owns NO lint source. The add-in is the single source of truth: its
4
+ // capability-table engine (the same one that gates `build_and_push` server-
5
+ // side) is served on the design-bundle wire as a self-contained ESM artifact
6
+ // and materialized into the bundle cache as `lint-engine.mjs` next to its
7
+ // `primitives.json` registry (see src/bridge/bundle.mjs). This module resolves
8
+ // the deck's bundle, dynamic-imports the cached engine, and mirrors the old
9
+ // lintSlidesOrThrow contract: findings to stderr, throw on errors.
10
+ //
11
+ // Lint therefore works fully offline once the bundle is materialized, and
12
+ // rule changes propagate on the next bundle fetch — no CLI release needed.
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+
18
+ import { resolveBundleDirForDeck } from "../bridge/bundle.mjs";
19
+
20
+ // Resolve the cached engine for a deck and explain every miss actionably.
21
+ // Returns { enginePath, version, primitives }.
22
+ function resolveEngine(deckDir) {
23
+ const bundleDir = resolveBundleDirForDeck(deckDir);
24
+ if (!bundleDir) {
25
+ throw new Error(
26
+ `No materialized design bundle for deck ${path.basename(deckDir)}.\n` +
27
+ ` Run 'slidemind download-bundle ${path.basename(deckDir)}' first.`,
28
+ );
29
+ }
30
+ const enginePath = path.join(bundleDir, "lint-engine.mjs");
31
+ const metaPath = path.join(bundleDir, "bundle.json");
32
+ let meta = {};
33
+ try {
34
+ meta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
35
+ } catch {
36
+ /* treated as a pre-engine cache below */
37
+ }
38
+
39
+ if (!fs.existsSync(enginePath)) {
40
+ if (meta.lintEngineVersion === null) {
41
+ // The backend answered but served no engine: it predates the feature.
42
+ throw new Error(
43
+ `The paired slidemind add-in does not serve a lint engine yet.\n` +
44
+ ` Update/redeploy the add-in backend, then re-run ` +
45
+ `'slidemind download-bundle ${path.basename(deckDir)}'.`,
46
+ );
47
+ }
48
+ // Cache written before the engine existed (meta lacks the key), or the
49
+ // file went missing — refetch repairs both.
50
+ throw new Error(
51
+ `This bundle cache predates the lint engine.\n` +
52
+ ` Run 'slidemind download-bundle ${path.basename(deckDir)}'.`,
53
+ );
54
+ }
55
+
56
+ let primitives = {};
57
+ try {
58
+ primitives = JSON.parse(
59
+ fs.readFileSync(path.join(bundleDir, "primitives.json"), "utf8"),
60
+ );
61
+ } catch {
62
+ // The registry is closed and deliberately empty today — lint still works,
63
+ // but flag it so a broken materialization doesn't pass silently.
64
+ process.stderr.write(
65
+ "warning: bundle has no readable primitives.json — linting with an empty registry\n",
66
+ );
67
+ }
68
+
69
+ return { enginePath, version: meta.lintEngineVersion ?? "0", primitives };
70
+ }
71
+
72
+ // Lint slide files with the add-in's engine. `layoutSources` is the registry
73
+ // input the engine derives layout component names from: Map<"Cover.jsx", raw
74
+ // JSX source> — same shape the add-in's in-app lint feeds it.
75
+ // Prints findings to stderr; throws when any [lint:error] is present.
76
+ export async function lintSlidesOrThrow({ deckDir, slideFiles, layoutSources }) {
77
+ const { enginePath, version, primitives } = resolveEngine(deckDir);
78
+
79
+ // `?v=` busts Node's per-process module cache so a long-lived process (the
80
+ // MCP server) picks up a refreshed engine at the same path.
81
+ const engine = await import(
82
+ `${pathToFileURL(enginePath).href}?v=${encodeURIComponent(version)}`
83
+ );
84
+ if (typeof engine.lintSlidesInMemory !== "function") {
85
+ throw new Error(
86
+ `Cached lint engine is corrupt (no lintSlidesInMemory export).\n` +
87
+ ` Run 'slidemind download-bundle ${path.basename(deckDir)}'.`,
88
+ );
89
+ }
90
+
91
+ const slides = slideFiles.map((file) => ({
92
+ fileName: path.basename(file),
93
+ source: fs.readFileSync(file, "utf8"),
94
+ }));
95
+ const result = engine.lintSlidesInMemory(slides, {
96
+ layoutSources,
97
+ primitives,
98
+ });
99
+
100
+ for (const line of result.lines) {
101
+ process.stderr.write(`${line}\n`);
102
+ }
103
+ if (!result.ok) {
104
+ const summary =
105
+ `slide lint: ${result.errors} error${result.errors === 1 ? "" : "s"}` +
106
+ (result.warnings
107
+ ? `, ${result.warnings} warning${result.warnings === 1 ? "" : "s"}`
108
+ : "");
109
+ throw new Error(`${summary}\n${result.lines.join("\n")}`);
110
+ }
111
+ return result;
112
+ }
@@ -1,6 +1,8 @@
1
1
  // Discover which skill a deck was authored against, and return the paths the
2
- // rest of the local preview pipeline (build.mjs, lint-slides.mjs) needs to
3
- // load layouts, tokens, primitives and assets from.
2
+ // rest of the local preview pipeline (build.mjs) needs to load layouts,
3
+ // tokens and assets from. (The lint registry's primitives.json now rides in
4
+ // the materialized bundle next to the add-in's lint engine — see
5
+ // src/builders/lint.mjs.)
4
6
  //
5
7
  // Post-connect, the corporate design (css, layouts, assets) is no longer
6
8
  // shipped inside the skill — it lives in the slidemind add-in and is
@@ -31,12 +33,6 @@ const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
31
33
 
32
34
  const DEFAULT_SKILL = "create-slides";
33
35
 
34
- // Shipped fallback layout-contract registry for the LOCAL HTML preview.
35
- // The add-in derives layout contracts from the bundle's layouts directly, so
36
- // this is only consulted by the local preview when neither the deck nor the
37
- // materialized bundle carries its own primitives.json.
38
- const SHIPPED_PRIMITIVES = path.join(__dirname, "primitives.json");
39
-
40
36
  function firstExisting(candidates) {
41
37
  for (const c of candidates) {
42
38
  if (c && fs.existsSync(c)) return c;
@@ -104,9 +100,6 @@ export function resolveSkill(skillName, planMeta = {}, opts = {}) {
104
100
 
105
101
  const deckLayouts = opts.deckDir ? path.join(opts.deckDir, "layouts") : null;
106
102
  const deckAssets = opts.deckDir ? path.join(opts.deckDir, "assets") : null;
107
- const deckPrimitives = opts.deckDir
108
- ? path.join(opts.deckDir, "primitives.json")
109
- : null;
110
103
 
111
104
  const cssFile = firstExistingOr(
112
105
  designRoots.map((r) => path.join(r, cssName)),
@@ -124,23 +117,26 @@ export function resolveSkill(skillName, planMeta = {}, opts = {}) {
124
117
  [deckAssets, ...designRoots.map((r) => path.join(r, "assets"))],
125
118
  path.join(designRoots[0], "assets"),
126
119
  );
127
- const primitivesPath = firstExistingOr(
128
- [
129
- deckPrimitives,
130
- ...designRoots.map((r) => path.join(r, "primitives.json")),
131
- SHIPPED_PRIMITIVES,
132
- ],
133
- SHIPPED_PRIMITIVES,
120
+ // Brand assets ALWAYS resolve from the design root (the bundle): logos, decor,
121
+ // and the per-layout chrome backgrounds (`assets/chrome-*`). They must be
122
+ // resolved INDEPENDENTLY of the deck — `assetsDir` above prefers the deck's own
123
+ // `assets/` (engine downloads), so if `brand.assetsDir` mirrored it, a deck that
124
+ // already has any local asset would collapse both layers to the deck dir and
125
+ // copyDesignSystem would never copy the bundle's brand assets (chrome included)
126
+ // into the preview. Keeping the brand layer on the bundle lets the deck's assets
127
+ // layer ON TOP and override by name, without suppressing the brand chrome.
128
+ const brandAssetsDir = firstExistingOr(
129
+ designRoots.map((r) => path.join(r, "assets")),
130
+ path.join(designRoots[0], "assets"),
134
131
  );
135
132
  // One truth: brand paths mirror the resolved design roots (the bundle IS the
136
133
  // brand source; there is no separate brand skill anymore).
137
134
  const brand = {
138
135
  skillDir: designRoots[0],
139
- assetsDir,
136
+ assetsDir: brandAssetsDir,
140
137
  cssFile,
141
138
  layoutsDir,
142
139
  layoutsCssFile,
143
- primitivesPath,
144
140
  };
145
141
 
146
142
  // The PPTX slide-master template only fed the now-archived local exporter;
@@ -167,7 +163,6 @@ export function resolveSkill(skillName, planMeta = {}, opts = {}) {
167
163
  cssFile,
168
164
  layoutsDir,
169
165
  assetsDir,
170
- primitivesPath,
171
166
  brand,
172
167
  templateKey,
173
168
  templatePath,