@canopy-iiif/app 0.7.16 → 0.7.18

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/lib/AGENTS.md CHANGED
@@ -57,6 +57,8 @@ Logbook
57
57
  -------
58
58
  - 2025-09-26 / chatgpt: Hardened runtime bundlers to throw when esbuild or source compilation fails and required `content/works/_layout.mdx`; build now aborts instead of silently writing placeholder assets.
59
59
  - 2025-09-26 / chatgpt: Replaced the legacy command runtime stub with an esbuild-bundled runtime (`search/command-runtime.js`); `prepareCommandRuntime()` now builds `site/scripts/canopy-command.js` and fails if esbuild is missing.
60
+ - 2025-09-27 / chatgpt: Documented Tailwind token flow in `app/styles/tailwind.config.js`, compiled UI Sass variables during config load, and exposed `stylesheetHref`/`Stylesheet` helpers via `@canopy-iiif/app/head` so `_app.mdx` can reference the generated CSS directly.
61
+ - 2025-09-27 / chatgpt: Expanded search indexing to harvest MDX pages (respecting frontmatter/layout types), injected BASE_PATH hydration data into search.html, and reworked `mdx.extractTitle()` so generated records surface real headings instead of `Untitled`.
60
62
 
61
63
  Verification Commands
62
64
  ---------------------
package/lib/build/dev.js CHANGED
@@ -2,7 +2,6 @@ const fs = require("fs");
2
2
  const fsp = fs.promises;
3
3
  const path = require("path");
4
4
  const { spawn, spawnSync } = require("child_process");
5
- const { build } = require("../build/build");
6
5
  const http = require("http");
7
6
  const url = require("url");
8
7
  const {
@@ -21,12 +20,30 @@ function resolveTailwindCli() {
21
20
  if (fs.existsSync(bin)) return { cmd: bin, args: [] };
22
21
  return { cmd: 'tailwindcss', args: [] };
23
22
  }
24
- const PORT = Number(process.env.PORT || 3000);
23
+ const PORT = Number(process.env.PORT || 5001);
24
+ const BUILD_MODULE_PATH = path.resolve(__dirname, "build.js");
25
25
  let onBuildSuccess = () => {};
26
26
  let onBuildStart = () => {};
27
27
  let onCssChange = () => {};
28
28
  let nextBuildSkipIiif = false; // hint set by watchers
29
29
  const UI_DIST_DIR = path.resolve(path.join(__dirname, "../../ui/dist"));
30
+ const APP_PACKAGE_ROOT = path.resolve(path.join(__dirname, "..", ".."));
31
+ const APP_LIB_DIR = path.join(APP_PACKAGE_ROOT, "lib");
32
+ const APP_UI_DIR = path.join(APP_PACKAGE_ROOT, "ui");
33
+ const APP_WATCH_TARGETS = [
34
+ { dir: APP_LIB_DIR, label: "@canopy-iiif/app/lib" },
35
+ { dir: APP_UI_DIR, label: "@canopy-iiif/app/ui" },
36
+ ];
37
+ const HAS_APP_WORKSPACE = (() => {
38
+ try {
39
+ return fs.existsSync(path.join(APP_PACKAGE_ROOT, "package.json"));
40
+ } catch (_) {
41
+ return false;
42
+ }
43
+ })();
44
+ let pendingModuleReload = false;
45
+ let building = false;
46
+ let buildAgain = false;
30
47
 
31
48
  function prettyPath(p) {
32
49
  try {
@@ -40,16 +57,71 @@ function prettyPath(p) {
40
57
  }
41
58
  }
42
59
 
60
+ function loadBuildFunction() {
61
+ let mod = null;
62
+ try {
63
+ mod = require(BUILD_MODULE_PATH);
64
+ } catch (error) {
65
+ throw new Error(
66
+ `[watch] Failed to load build module (${BUILD_MODULE_PATH}): ${
67
+ error && error.message ? error.message : error
68
+ }`
69
+ );
70
+ }
71
+ const fn =
72
+ mod && typeof mod.build === "function"
73
+ ? mod.build
74
+ : mod && mod.default && typeof mod.default.build === "function"
75
+ ? mod.default.build
76
+ : null;
77
+ if (typeof fn !== "function") {
78
+ throw new Error("[watch] Invalid build module export: expected build() function");
79
+ }
80
+ return fn;
81
+ }
82
+
83
+ function clearAppModuleCache() {
84
+ try {
85
+ const prefix = APP_PACKAGE_ROOT.endsWith(path.sep)
86
+ ? APP_PACKAGE_ROOT
87
+ : APP_PACKAGE_ROOT + path.sep;
88
+ for (const key of Object.keys(require.cache || {})) {
89
+ if (!key) continue;
90
+ try {
91
+ if (key === APP_PACKAGE_ROOT || key.startsWith(prefix)) {
92
+ delete require.cache[key];
93
+ }
94
+ } catch (_) {}
95
+ }
96
+ } catch (_) {}
97
+ }
98
+
43
99
  async function runBuild() {
100
+ if (building) {
101
+ buildAgain = true;
102
+ return;
103
+ }
104
+ building = true;
105
+ const hint = { skipIiif: !!nextBuildSkipIiif };
106
+ nextBuildSkipIiif = false;
44
107
  try {
45
- const hint = { skipIiif: !!nextBuildSkipIiif };
46
- nextBuildSkipIiif = false;
47
- await build(hint);
108
+ if (pendingModuleReload) {
109
+ clearAppModuleCache();
110
+ pendingModuleReload = false;
111
+ }
112
+ const buildFn = loadBuildFunction();
113
+ await buildFn(hint);
48
114
  try {
49
115
  onBuildSuccess();
50
116
  } catch (_) {}
51
117
  } catch (e) {
52
118
  console.error("Build failed:", e && e.message ? e.message : e);
119
+ } finally {
120
+ building = false;
121
+ if (buildAgain) {
122
+ buildAgain = false;
123
+ debounceBuild();
124
+ }
53
125
  }
54
126
  }
55
127
 
@@ -334,6 +406,131 @@ function watchUiDistPerDir() {
334
406
  };
335
407
  }
336
408
 
409
+ const APP_WATCH_EXTENSIONS = new Set([".js", ".jsx", ".scss"]);
410
+
411
+ function shouldIgnoreAppSourcePath(p) {
412
+ try {
413
+ const resolved = path.resolve(p);
414
+ const rel = path.relative(APP_PACKAGE_ROOT, resolved);
415
+ if (!rel || rel === "") return false;
416
+ if (rel.startsWith("..")) return true;
417
+ const segments = rel.split(path.sep).filter(Boolean);
418
+ if (!segments.length) return false;
419
+ if (segments.includes("node_modules")) return true;
420
+ if (segments.includes(".git")) return true;
421
+ if (segments[0] === "ui" && segments[1] === "dist") return true;
422
+ return false;
423
+ } catch (_) {
424
+ return true;
425
+ }
426
+ }
427
+
428
+ function handleAppSourceChange(baseDir, eventType, filename, label) {
429
+ if (!filename) return;
430
+ const full = path.resolve(baseDir, filename);
431
+ if (shouldIgnoreAppSourcePath(full)) return;
432
+ const ext = path.extname(full).toLowerCase();
433
+ if (!APP_WATCH_EXTENSIONS.has(ext)) return;
434
+ try {
435
+ const relLib = path.relative(APP_LIB_DIR, full);
436
+ if (!relLib.startsWith("..") && !path.isAbsolute(relLib)) {
437
+ pendingModuleReload = true;
438
+ }
439
+ } catch (_) {}
440
+ try {
441
+ console.log(
442
+ `[pkg] ${eventType}: ${prettyPath(full)}${label ? ` (${label})` : ""}`
443
+ );
444
+ } catch (_) {}
445
+ nextBuildSkipIiif = true;
446
+ try {
447
+ onBuildStart();
448
+ } catch (_) {}
449
+ debounceBuild();
450
+ }
451
+
452
+ function tryRecursiveWatchAppDir(dir, label) {
453
+ try {
454
+ return fs.watch(dir, { recursive: true }, (eventType, filename) => {
455
+ handleAppSourceChange(dir, eventType, filename, label);
456
+ });
457
+ } catch (_) {
458
+ return null;
459
+ }
460
+ }
461
+
462
+ function watchAppDirPerDir(dir, label) {
463
+ const watchers = new Map();
464
+
465
+ function watchDir(target) {
466
+ if (watchers.has(target)) return;
467
+ if (shouldIgnoreAppSourcePath(target)) return;
468
+ try {
469
+ const w = fs.watch(target, (eventType, filename) => {
470
+ if (filename) {
471
+ handleAppSourceChange(target, eventType, filename, label);
472
+ }
473
+ scan(target);
474
+ });
475
+ watchers.set(target, w);
476
+ } catch (_) {}
477
+ }
478
+
479
+ function scan(target) {
480
+ let entries;
481
+ try {
482
+ entries = fs.readdirSync(target, { withFileTypes: true });
483
+ } catch (_) {
484
+ return;
485
+ }
486
+ for (const entry of entries) {
487
+ if (!entry.isDirectory()) continue;
488
+ const sub = path.join(target, entry.name);
489
+ if (shouldIgnoreAppSourcePath(sub)) continue;
490
+ watchDir(sub);
491
+ scan(sub);
492
+ }
493
+ }
494
+
495
+ watchDir(dir);
496
+ scan(dir);
497
+
498
+ return () => {
499
+ for (const w of watchers.values()) {
500
+ try {
501
+ w.close();
502
+ } catch (_) {}
503
+ }
504
+ };
505
+ }
506
+
507
+ function watchAppSources() {
508
+ if (!HAS_APP_WORKSPACE) return () => {};
509
+ const stops = [];
510
+ for (const target of APP_WATCH_TARGETS) {
511
+ const { dir, label } = target;
512
+ if (!dir || !fs.existsSync(dir)) continue;
513
+ console.log(`[Watching] ${prettyPath(dir)} (${label})`);
514
+ const watcher = tryRecursiveWatchAppDir(dir, label);
515
+ if (!watcher) {
516
+ stops.push(watchAppDirPerDir(dir, label));
517
+ } else {
518
+ stops.push(() => {
519
+ try {
520
+ watcher.close();
521
+ } catch (_) {}
522
+ });
523
+ }
524
+ }
525
+ return () => {
526
+ for (const stop of stops) {
527
+ try {
528
+ if (typeof stop === "function") stop();
529
+ } catch (_) {}
530
+ }
531
+ };
532
+ }
533
+
337
534
  const MIME = {
338
535
  ".html": "text/html; charset=utf-8",
339
536
  ".css": "text/css; charset=utf-8",
@@ -900,6 +1097,9 @@ async function dev() {
900
1097
  const urw = tryRecursiveWatchUiDist();
901
1098
  if (!urw) watchUiDistPerDir();
902
1099
  }
1100
+ if (HAS_APP_WORKSPACE) {
1101
+ watchAppSources();
1102
+ }
903
1103
  }
904
1104
 
905
1105
  module.exports = { dev };
package/lib/build/iiif.js CHANGED
@@ -828,13 +828,6 @@ async function buildIiifCollectionPages(CONFIG) {
828
828
  React.createElement(app.Head)
829
829
  )
830
830
  : "";
831
- const cssRel = path
832
- .relative(
833
- path.dirname(outPath),
834
- path.join(OUT_DIR, "styles", "styles.css")
835
- )
836
- .split(path.sep)
837
- .join("/");
838
831
  const needsHydrateViewer = body.includes("data-canopy-viewer");
839
832
  const needsRelated = body.includes("data-canopy-related-items");
840
833
  const needsHero = body.includes("data-canopy-hero");
@@ -942,7 +935,7 @@ async function buildIiifCollectionPages(CONFIG) {
942
935
  let html = htmlShell({
943
936
  title,
944
937
  body: pageBody,
945
- cssHref: cssRel || "styles/styles.css",
938
+ cssHref: null,
946
939
  scriptHref: jsRel,
947
940
  headExtra: vendorTag + headExtra,
948
941
  });
package/lib/build/mdx.js CHANGED
@@ -139,8 +139,11 @@ async function loadUiComponents() {
139
139
  }
140
140
 
141
141
  function extractTitle(mdxSource) {
142
- const { content } = parseFrontmatter(String(mdxSource || ""));
143
- const m = content.match(/^\s*#\s+(.+)\s*$/m);
142
+ const { data, content } = parseFrontmatter(String(mdxSource || ""));
143
+ if (data && typeof data.title === "string" && data.title.trim()) {
144
+ return data.title.trim();
145
+ }
146
+ const m = content.match(/^\s*#{1,6}\s+(.+?)\s*$/m);
144
147
  return m ? m[1].trim() : "Untitled";
145
148
  }
146
149
 
@@ -45,10 +45,6 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}) {
45
45
  const source = await fsp.readFile(filePath, 'utf8');
46
46
  const title = mdx.extractTitle(source);
47
47
  const { body, head } = await mdx.compileMdxFile(filePath, outPath, null, extraProps);
48
- const cssRel = path
49
- .relative(path.dirname(outPath), path.join(OUT_DIR, 'styles', 'styles.css'))
50
- .split(path.sep)
51
- .join('/');
52
48
  const needsHydrateViewer = body.includes('data-canopy-viewer');
53
49
  const needsHydrateSlider = body.includes('data-canopy-slider');
54
50
  const needsCommand = true; // command runtime is global
@@ -96,7 +92,7 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}) {
96
92
  if (sliderRel && jsRel !== sliderRel) extraScripts.push(`<script defer src="${sliderRel}"></script>`);
97
93
  if (commandRel && jsRel !== commandRel) extraScripts.push(`<script defer src="${commandRel}"></script>`);
98
94
  if (extraScripts.length) headExtra = extraScripts.join('') + headExtra;
99
- const html = htmlShell({ title, body, cssHref: cssRel || 'styles.css', scriptHref: jsRel, headExtra: vendorTag + headExtra });
95
+ const html = htmlShell({ title, body, cssHref: null, scriptHref: jsRel, headExtra: vendorTag + headExtra });
100
96
  const { applyBaseToHtml } = require('../common');
101
97
  return applyBaseToHtml(html);
102
98
  }
@@ -165,10 +165,6 @@ async function writeFacetsSearchApi() {
165
165
  await fsp.writeFile(dest, JSON.stringify(data, null, 2), 'utf8');
166
166
  }
167
167
 
168
- module.exports = { buildFacetsForWorks, writeFacetCollections, writeFacetsSearchApi };
169
-
170
-
171
-
172
168
  async function collectMdxPageRecords() {
173
169
  const { fs, fsp, path, CONTENT_DIR, rootRelativeHref } = require('../common');
174
170
  const mdx = require('./mdx');
@@ -183,7 +179,8 @@ async function collectMdxPageRecords() {
183
179
  const base = path.basename(p).toLowerCase();
184
180
  const src = await fsp.readFile(p, 'utf8');
185
181
  const fm = mdx.parseFrontmatter(src);
186
- const title = mdx.extractTitle(src);
182
+ const titleRaw = mdx.extractTitle(src);
183
+ const title = typeof titleRaw === 'string' ? titleRaw.trim() : '';
187
184
  const rel = path.relative(CONTENT_DIR, p).replace(/\.mdx$/i, '.html');
188
185
  if (base !== 'sitemap.mdx') {
189
186
  const href = rootRelativeHref(rel.split(path.sep).join('/'));
package/lib/common.js CHANGED
@@ -26,13 +26,13 @@ function readYamlConfigBaseUrl() {
26
26
  // Priority:
27
27
  // 1) CANOPY_BASE_URL env
28
28
  // 2) canopy.yml → site.baseUrl
29
- // 3) dev server default http://localhost:PORT (PORT env or 3000)
29
+ // 3) dev server default http://localhost:PORT (PORT env or 5001)
30
30
  const BASE_ORIGIN = (() => {
31
31
  const env = String(process.env.CANOPY_BASE_URL || '').trim();
32
32
  if (env) return env.replace(/\/$/, '');
33
33
  const cfg = readYamlConfigBaseUrl();
34
34
  if (cfg) return cfg.replace(/\/$/, '');
35
- const port = Number(process.env.PORT || 3000);
35
+ const port = Number(process.env.PORT || 5001);
36
36
  return `http://localhost:${port}`;
37
37
  })();
38
38
 
@@ -50,7 +50,8 @@ async function cleanDir(dir) {
50
50
  function htmlShell({ title, body, cssHref, scriptHref, headExtra }) {
51
51
  const scriptTag = scriptHref ? `<script defer src="${scriptHref}"></script>` : '';
52
52
  const extra = headExtra ? String(headExtra) : '';
53
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>${title}</title>${extra}<link rel="stylesheet" href="${cssHref}">${scriptTag}</head><body>${body}</body></html>`;
53
+ const cssTag = cssHref ? `<link rel="stylesheet" href="${cssHref}">` : '';
54
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>${title}</title>${extra}${cssTag}${scriptTag}</head><body>${body}</body></html>`;
54
55
  }
55
56
 
56
57
  function withBase(href) {
@@ -114,11 +115,18 @@ function absoluteUrl(p) {
114
115
  function applyBaseToHtml(html) {
115
116
  if (!BASE_PATH) return html;
116
117
  try {
117
- let out = String(html || '');
118
- // Avoid protocol-relative (//example.com) by using a negative lookahead
119
- out = out.replace(/(href|src)=(\")\/(?!\/)/g, `$1=$2${BASE_PATH}/`);
120
- out = out.replace(/(href|src)=(\')\/(?!\/)/g, `$1=$2${BASE_PATH}/`);
121
- return out;
118
+ const out = String(html || '');
119
+ const normalizedBase = BASE_PATH.startsWith('/')
120
+ ? BASE_PATH.replace(/\/$/, '')
121
+ : `/${BASE_PATH.replace(/\/$/, '')}`;
122
+ if (!normalizedBase || normalizedBase === '/') return out;
123
+ const pattern = /(href|src)=(['"])(\/(?!\/)[^'"\s]*)\2/g;
124
+ return out.replace(pattern, (match, attr, quote, path) => {
125
+ if (path === normalizedBase || path.startsWith(`${normalizedBase}/`)) {
126
+ return match;
127
+ }
128
+ return `${attr}=${quote}${normalizedBase}${path}${quote}`;
129
+ });
122
130
  } catch (_) {
123
131
  return html;
124
132
  }
package/lib/head.js ADDED
@@ -0,0 +1,21 @@
1
+ const React = require('react');
2
+ const { withBase, rootRelativeHref } = require('./common');
3
+
4
+ const DEFAULT_STYLESHEET_PATH = '/styles/styles.css';
5
+
6
+ function stylesheetHref(href = DEFAULT_STYLESHEET_PATH) {
7
+ const normalized = rootRelativeHref(href || DEFAULT_STYLESHEET_PATH);
8
+ return withBase(normalized);
9
+ }
10
+
11
+ function Stylesheet(props = {}) {
12
+ const { href = DEFAULT_STYLESHEET_PATH, rel = 'stylesheet', ...rest } = props;
13
+ const resolved = stylesheetHref(href);
14
+ return React.createElement('link', { rel, href: resolved, ...rest });
15
+ }
16
+
17
+ module.exports = {
18
+ stylesheetHref,
19
+ Stylesheet,
20
+ DEFAULT_STYLESHEET_PATH,
21
+ };
package/lib/index.js CHANGED
@@ -1,4 +1,8 @@
1
+ const { stylesheetHref, Stylesheet } = require('./head');
2
+
1
3
  module.exports = {
2
4
  build: require('./build/build').build,
3
- dev: require('./build/dev').dev
5
+ dev: require('./build/dev').dev,
6
+ stylesheetHref,
7
+ Stylesheet,
4
8
  };
@@ -0,0 +1,203 @@
1
+ const { spawn } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const log = (msg) => console.log(`[canopy] ${msg}`);
6
+ const warn = (msg) => console.warn(`[canopy][warn] ${msg}`);
7
+ const err = (msg) => console.error(`[canopy][error] ${msg}`);
8
+
9
+ let uiWatcherChild = null;
10
+
11
+ const workspacePackageJsonPath = path.resolve(process.cwd(), 'packages/app/package.json');
12
+ const hasAppWorkspace = fs.existsSync(workspacePackageJsonPath);
13
+
14
+ function getMode(argv = process.argv.slice(2), env = process.env) {
15
+ const cli = new Set(argv);
16
+ if (cli.has('--dev')) return 'dev';
17
+ if (cli.has('--build')) return 'build';
18
+
19
+ if (env.CANOPY_MODE === 'dev') return 'dev';
20
+ if (env.CANOPY_MODE === 'build') return 'build';
21
+
22
+ const npmScript = env.npm_lifecycle_event;
23
+ if (npmScript === 'dev') return 'dev';
24
+ if (npmScript === 'build') return 'build';
25
+
26
+ return 'build';
27
+ }
28
+
29
+ function runOnce(cmd, args, opts = {}) {
30
+ return new Promise((resolve, reject) => {
31
+ const child = spawn(cmd, args, { stdio: 'inherit', shell: false, ...opts });
32
+ child.on('error', reject);
33
+ child.on('exit', (code) => {
34
+ if (code === 0) {
35
+ resolve();
36
+ } else {
37
+ reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
38
+ }
39
+ });
40
+ });
41
+ }
42
+
43
+ function start(cmd, args, opts = {}) {
44
+ const child = spawn(cmd, args, { stdio: 'inherit', shell: false, ...opts });
45
+ child.on('error', (error) => {
46
+ const message = error && error.message ? error.message : String(error);
47
+ warn(`Subprocess error (${cmd}): ${message}`);
48
+ });
49
+ return child;
50
+ }
51
+
52
+ async function prepareUi(mode, env = process.env) {
53
+ if (!hasAppWorkspace) {
54
+ log('Using bundled UI assets from @canopy-iiif/app (workspace not detected)');
55
+ return null;
56
+ }
57
+
58
+ if (mode === 'build') {
59
+ log('Building UI assets (@canopy-iiif/app/ui)');
60
+ try {
61
+ await runOnce('npm', ['-w', '@canopy-iiif/app', 'run', 'ui:build'], { env });
62
+ log('UI assets built');
63
+ } catch (error) {
64
+ warn(`UI build skipped: ${(error && error.message) || String(error)}`);
65
+ }
66
+ return null;
67
+ }
68
+
69
+ try {
70
+ log('Prebuilding UI assets (@canopy-iiif/app/ui)');
71
+ await runOnce('npm', ['-w', '@canopy-iiif/app', 'run', 'ui:build'], { env });
72
+ } catch (error) {
73
+ warn(`UI prebuild skipped: ${(error && error.message) || String(error)}`);
74
+ }
75
+
76
+ log('Starting UI watcher (@canopy-iiif/app/ui)');
77
+ try {
78
+ uiWatcherChild = start('npm', ['-w', '@canopy-iiif/app', 'run', 'ui:watch'], { env });
79
+ } catch (error) {
80
+ warn(`UI watch skipped: ${(error && error.message) || String(error)}`);
81
+ uiWatcherChild = null;
82
+ }
83
+ return uiWatcherChild;
84
+ }
85
+
86
+ function loadLibraryApi() {
87
+ let lib;
88
+ try {
89
+ lib = require('./index.js');
90
+ } catch (e) {
91
+ const hint = [
92
+ 'Unable to load @canopy-iiif/app.',
93
+ 'Ensure dependencies are installed (npm install)',
94
+ "and that peer deps like 'react' are present.",
95
+ ].join(' ');
96
+ const detail = e && e.message ? `\nCaused by: ${e.message}` : '';
97
+ throw new Error(`${hint}${detail}`);
98
+ }
99
+
100
+ const api = lib && (typeof lib.build === 'function' || typeof lib.dev === 'function')
101
+ ? lib
102
+ : lib && lib.default
103
+ ? lib.default
104
+ : lib;
105
+
106
+ if (!api || (typeof api.build !== 'function' && typeof api.dev !== 'function')) {
107
+ throw new TypeError('Invalid @canopy-iiif/app export: expected functions build() and/or dev().');
108
+ }
109
+
110
+ return api;
111
+ }
112
+
113
+ function attachSignalHandlers() {
114
+ const clean = () => {
115
+ if (uiWatcherChild && !uiWatcherChild.killed) {
116
+ try { uiWatcherChild.kill(); } catch (_) {}
117
+ }
118
+ };
119
+
120
+ process.on('SIGINT', () => {
121
+ clean();
122
+ process.exit(130);
123
+ });
124
+ process.on('SIGTERM', () => {
125
+ clean();
126
+ process.exit(143);
127
+ });
128
+ process.on('exit', clean);
129
+ }
130
+
131
+ function verifyBuildOutput(outDir = 'site') {
132
+ const root = path.resolve(outDir);
133
+ function walk(dir) {
134
+ let count = 0;
135
+ if (!fs.existsSync(dir)) return 0;
136
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
137
+ for (const entry of entries) {
138
+ const p = path.join(dir, entry.name);
139
+ if (entry.isDirectory()) count += walk(p);
140
+ else if (entry.isFile() && p.toLowerCase().endsWith('.html')) count += 1;
141
+ }
142
+ return count;
143
+ }
144
+ const pages = walk(root);
145
+ if (!pages) {
146
+ throw new Error('CI check failed: no HTML pages generated in "site/".');
147
+ }
148
+ log(`CI check: found ${pages} HTML page(s) in ${root}.`);
149
+ }
150
+
151
+ async function orchestrate(options = {}) {
152
+ const argv = options.argv || process.argv.slice(2);
153
+ const env = options.env || process.env;
154
+
155
+ process.title = 'canopy-app';
156
+ const mode = getMode(argv, env);
157
+ log(`Mode: ${mode}`);
158
+
159
+ const cli = new Set(argv);
160
+ if (cli.has('--verify')) {
161
+ verifyBuildOutput(env.CANOPY_OUT_DIR || 'site');
162
+ return;
163
+ }
164
+
165
+ await prepareUi(mode, env);
166
+
167
+ const api = loadLibraryApi();
168
+ try {
169
+ if (mode === 'dev') {
170
+ attachSignalHandlers();
171
+ log('Starting dev server...');
172
+ await (typeof api.dev === 'function' ? api.dev() : Promise.resolve());
173
+ } else {
174
+ log('Building site...');
175
+ if (typeof api.build === 'function') {
176
+ await api.build();
177
+ }
178
+ log('Build complete');
179
+ if (env.CANOPY_VERIFY === '1' || env.CANOPY_VERIFY === 'true') {
180
+ verifyBuildOutput(env.CANOPY_OUT_DIR || 'site');
181
+ }
182
+ }
183
+ } finally {
184
+ if (uiWatcherChild && !uiWatcherChild.killed) {
185
+ try { uiWatcherChild.kill(); } catch (_) {}
186
+ }
187
+ }
188
+ }
189
+
190
+ module.exports = {
191
+ orchestrate,
192
+ verifyBuildOutput,
193
+ _internals: {
194
+ getMode,
195
+ prepareUi,
196
+ loadLibraryApi,
197
+ runOnce,
198
+ start,
199
+ },
200
+ log,
201
+ warn,
202
+ err,
203
+ };
@@ -346,10 +346,11 @@ async function attachCommand(host) {
346
346
 
347
347
  host.addEventListener('click', (event) => {
348
348
  const trigger = event.target && event.target.closest && event.target.closest('[data-canopy-command-trigger]');
349
- if (trigger) {
350
- event.preventDefault();
351
- openPanel();
352
- }
349
+ if (!trigger) return;
350
+ const mode = (trigger.dataset && trigger.dataset.canopyCommandTrigger) || '';
351
+ if (mode === 'submit' || mode === 'form') return;
352
+ event.preventDefault();
353
+ openPanel();
353
354
  });
354
355
 
355
356
  try {