@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 +2 -0
- package/lib/build/dev.js +205 -5
- package/lib/build/iiif.js +1 -8
- package/lib/build/mdx.js +5 -2
- package/lib/build/pages.js +1 -5
- package/lib/build/search.js +2 -5
- package/lib/common.js +16 -8
- package/lib/head.js +21 -0
- package/lib/index.js +5 -1
- package/lib/orchestrator.js +203 -0
- package/lib/search/command-runtime.js +5 -4
- package/lib/search/search-app.jsx +548 -111
- package/lib/search/search.js +8 -2
- package/package.json +10 -3
- package/types/orchestrator.d.ts +18 -0
- package/ui/dist/index.mjs +280 -47
- package/ui/dist/index.mjs.map +4 -4
- package/ui/dist/server.mjs +139 -43
- package/ui/dist/server.mjs.map +4 -4
- package/ui/styles/base/_common.scss +8 -0
- package/ui/styles/base/index.scss +1 -0
- package/ui/styles/components/_command.scss +85 -1
- package/ui/styles/components/_header.scss +0 -0
- package/ui/styles/components/_hero.scss +21 -0
- package/ui/styles/components/index.scss +2 -3
- package/ui/styles/index.css +105 -1
- package/ui/styles/index.scss +3 -2
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 ||
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/lib/build/pages.js
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/lib/build/search.js
CHANGED
|
@@ -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
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
@@ -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
|
-
|
|
351
|
-
|
|
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 {
|