@canopy-iiif/app 1.1.1 → 1.2.1
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/build/dev.js +85 -1
- package/lib/build/mdx.js +591 -4
- package/lib/build/pages.js +24 -1
- package/lib/search/search.js +13 -1
- package/package.json +1 -1
package/lib/build/dev.js
CHANGED
|
@@ -15,7 +15,9 @@ const {
|
|
|
15
15
|
ASSETS_DIR,
|
|
16
16
|
ensureDirSync,
|
|
17
17
|
} = require("../common");
|
|
18
|
-
|
|
18
|
+
const APP_COMPONENTS_DIR = path.join(process.cwd(), "app", "components");
|
|
19
|
+
|
|
20
|
+
function resolveTailwindCli() {
|
|
19
21
|
const bin = path.join(
|
|
20
22
|
process.cwd(),
|
|
21
23
|
"node_modules",
|
|
@@ -35,6 +37,11 @@ const UI_DIST_DIR = path.resolve(path.join(__dirname, "../../ui/dist"));
|
|
|
35
37
|
const APP_PACKAGE_ROOT = path.resolve(path.join(__dirname, "..", ".."));
|
|
36
38
|
const APP_LIB_DIR = path.join(APP_PACKAGE_ROOT, "lib");
|
|
37
39
|
const APP_UI_DIR = path.join(APP_PACKAGE_ROOT, "ui");
|
|
40
|
+
const CUSTOM_COMPONENT_EXTENSIONS = new Set(
|
|
41
|
+
[".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"].map((
|
|
42
|
+
ext
|
|
43
|
+
) => ext.toLowerCase())
|
|
44
|
+
);
|
|
38
45
|
let loadUiTheme = null;
|
|
39
46
|
try {
|
|
40
47
|
const uiTheme = require(path.join(APP_UI_DIR, "theme.js"));
|
|
@@ -338,6 +345,74 @@ function watchAssetsPerDir() {
|
|
|
338
345
|
};
|
|
339
346
|
}
|
|
340
347
|
|
|
348
|
+
function handleCustomComponentChange(fullPath, eventType) {
|
|
349
|
+
if (!fullPath) return;
|
|
350
|
+
const ext = path.extname(fullPath).toLowerCase();
|
|
351
|
+
if (ext && !CUSTOM_COMPONENT_EXTENSIONS.has(ext)) return;
|
|
352
|
+
try {
|
|
353
|
+
console.log(`[components] ${eventType}: ${prettyPath(fullPath)}`);
|
|
354
|
+
} catch (_) {}
|
|
355
|
+
nextBuildSkipIiif = true;
|
|
356
|
+
try {
|
|
357
|
+
onBuildStart();
|
|
358
|
+
} catch (_) {}
|
|
359
|
+
debounceBuild();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function tryRecursiveWatchAppComponents(dir) {
|
|
363
|
+
try {
|
|
364
|
+
return fs.watch(dir, { recursive: true }, (eventType, filename) => {
|
|
365
|
+
if (!filename) return;
|
|
366
|
+
const full = path.join(dir, filename);
|
|
367
|
+
handleCustomComponentChange(full, eventType);
|
|
368
|
+
});
|
|
369
|
+
} catch (_) {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function watchAppComponentsPerDir(dir) {
|
|
375
|
+
const watchers = new Map();
|
|
376
|
+
|
|
377
|
+
function watchDir(target) {
|
|
378
|
+
if (watchers.has(target)) return;
|
|
379
|
+
try {
|
|
380
|
+
const watcher = fs.watch(target, (eventType, filename) => {
|
|
381
|
+
const full = filename ? path.join(target, filename) : target;
|
|
382
|
+
handleCustomComponentChange(full, eventType);
|
|
383
|
+
scan(target);
|
|
384
|
+
});
|
|
385
|
+
watchers.set(target, watcher);
|
|
386
|
+
} catch (_) {}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function scan(target) {
|
|
390
|
+
let entries = [];
|
|
391
|
+
try {
|
|
392
|
+
entries = fs.readdirSync(target, { withFileTypes: true });
|
|
393
|
+
} catch (_) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
for (const entry of entries) {
|
|
397
|
+
if (!entry.isDirectory()) continue;
|
|
398
|
+
const next = path.join(target, entry.name);
|
|
399
|
+
watchDir(next);
|
|
400
|
+
scan(next);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
watchDir(dir);
|
|
405
|
+
scan(dir);
|
|
406
|
+
|
|
407
|
+
return () => {
|
|
408
|
+
for (const watcher of watchers.values()) {
|
|
409
|
+
try {
|
|
410
|
+
watcher.close();
|
|
411
|
+
} catch (_) {}
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
341
416
|
// Watch @canopy-iiif/app/ui dist output to enable live reload for UI edits during dev.
|
|
342
417
|
// When UI dist changes, rebuild the search runtime bundle and trigger a browser reload.
|
|
343
418
|
async function rebuildSearchBundle() {
|
|
@@ -1280,6 +1355,15 @@ async function dev() {
|
|
|
1280
1355
|
const urw = tryRecursiveWatchUiDist();
|
|
1281
1356
|
if (!urw) watchUiDistPerDir();
|
|
1282
1357
|
}
|
|
1358
|
+
if (fs.existsSync(APP_COMPONENTS_DIR)) {
|
|
1359
|
+
console.log(
|
|
1360
|
+
"[Watching]",
|
|
1361
|
+
prettyPath(APP_COMPONENTS_DIR),
|
|
1362
|
+
"(app components)"
|
|
1363
|
+
);
|
|
1364
|
+
const crw = tryRecursiveWatchAppComponents(APP_COMPONENTS_DIR);
|
|
1365
|
+
if (!crw) watchAppComponentsPerDir(APP_COMPONENTS_DIR);
|
|
1366
|
+
}
|
|
1283
1367
|
if (HAS_APP_WORKSPACE) {
|
|
1284
1368
|
watchAppSources();
|
|
1285
1369
|
}
|
package/lib/build/mdx.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const React = require("react");
|
|
2
2
|
const ReactDOMServer = require("react-dom/server");
|
|
3
3
|
const {pathToFileURL} = require("url");
|
|
4
|
+
const crypto = require("crypto");
|
|
4
5
|
const {
|
|
5
6
|
fs,
|
|
6
7
|
fsp,
|
|
@@ -114,9 +115,221 @@ async function getMdxProvider() {
|
|
|
114
115
|
let UI_COMPONENTS = null;
|
|
115
116
|
let UI_COMPONENTS_PATH = "";
|
|
116
117
|
let UI_COMPONENTS_MTIME = 0;
|
|
118
|
+
let MERGED_UI_COMPONENTS = null;
|
|
119
|
+
let MERGED_UI_KEY = "";
|
|
117
120
|
const DEBUG =
|
|
118
121
|
process.env.CANOPY_DEBUG === "1" || process.env.CANOPY_DEBUG === "true";
|
|
119
|
-
|
|
122
|
+
const APP_COMPONENTS_DIR = path.join(process.cwd(), "app", "components");
|
|
123
|
+
const CUSTOM_COMPONENT_ENTRY_CANDIDATES = [
|
|
124
|
+
"mdx.tsx",
|
|
125
|
+
"mdx.ts",
|
|
126
|
+
"mdx.mts",
|
|
127
|
+
"mdx.cts",
|
|
128
|
+
"mdx.jsx",
|
|
129
|
+
"mdx.js",
|
|
130
|
+
"mdx.mjs",
|
|
131
|
+
"mdx.cjs",
|
|
132
|
+
"mdx-components.tsx",
|
|
133
|
+
"mdx-components.ts",
|
|
134
|
+
"mdx-components.mts",
|
|
135
|
+
"mdx-components.cts",
|
|
136
|
+
"mdx-components.jsx",
|
|
137
|
+
"mdx-components.js",
|
|
138
|
+
"mdx-components.mjs",
|
|
139
|
+
"mdx-components.cjs",
|
|
140
|
+
];
|
|
141
|
+
const CUSTOM_COMPONENT_EXTENSIONS = new Set(
|
|
142
|
+
[".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"].map(
|
|
143
|
+
(ext) => ext.toLowerCase()
|
|
144
|
+
)
|
|
145
|
+
);
|
|
146
|
+
let CUSTOM_MDX_COMPONENTS = null;
|
|
147
|
+
let CUSTOM_MDX_SIGNATURE = "";
|
|
148
|
+
let CUSTOM_CLIENT_COMPONENT_ENTRIES = [];
|
|
149
|
+
let CUSTOM_CLIENT_COMPONENT_PLACEHOLDERS = new Map();
|
|
150
|
+
let SERVER_COMPONENT_CACHE = new Map();
|
|
151
|
+
|
|
152
|
+
function isPlainObject(val) {
|
|
153
|
+
if (!val || typeof val !== "object") return false;
|
|
154
|
+
const proto = Object.getPrototypeOf(val);
|
|
155
|
+
return !proto || proto === Object.prototype;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function serializeClientValue(value, depth = 0) {
|
|
159
|
+
if (value == null) return null;
|
|
160
|
+
if (depth > 4) return null;
|
|
161
|
+
const type = typeof value;
|
|
162
|
+
if (type === "string" || type === "number" || type === "boolean") {
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(value)) {
|
|
166
|
+
const items = value
|
|
167
|
+
.map((entry) => serializeClientValue(entry, depth + 1))
|
|
168
|
+
.filter((entry) => typeof entry !== "undefined" && entry !== null);
|
|
169
|
+
return items;
|
|
170
|
+
}
|
|
171
|
+
if (React.isValidElement && React.isValidElement(value)) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
if (isPlainObject(value)) {
|
|
175
|
+
const out = {};
|
|
176
|
+
Object.keys(value).forEach((key) => {
|
|
177
|
+
const result = serializeClientValue(value[key], depth + 1);
|
|
178
|
+
if (typeof result !== "undefined" && result !== null) {
|
|
179
|
+
out[key] = result;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function serializeClientProps(props) {
|
|
188
|
+
if (!props || typeof props !== "object") return {};
|
|
189
|
+
const out = {};
|
|
190
|
+
Object.keys(props).forEach((key) => {
|
|
191
|
+
if (key === "children") {
|
|
192
|
+
const child = props[key];
|
|
193
|
+
if (
|
|
194
|
+
typeof child === "string" ||
|
|
195
|
+
typeof child === "number" ||
|
|
196
|
+
typeof child === "boolean"
|
|
197
|
+
) {
|
|
198
|
+
out[key] = child;
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const serialized = serializeClientValue(props[key]);
|
|
203
|
+
if (typeof serialized !== "undefined" && serialized !== null) {
|
|
204
|
+
out[key] = serialized;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function createClientComponentPlaceholder(name) {
|
|
211
|
+
const safeName = String(name || "");
|
|
212
|
+
return function ClientComponentPlaceholder(props = {}) {
|
|
213
|
+
let payload = {};
|
|
214
|
+
try {
|
|
215
|
+
payload = serializeClientProps(props);
|
|
216
|
+
} catch (_) {
|
|
217
|
+
payload = {};
|
|
218
|
+
}
|
|
219
|
+
let json = "{}";
|
|
220
|
+
try {
|
|
221
|
+
json = JSON.stringify(payload || {});
|
|
222
|
+
} catch (_) {
|
|
223
|
+
json = "{}";
|
|
224
|
+
}
|
|
225
|
+
return React.createElement(
|
|
226
|
+
"div",
|
|
227
|
+
{
|
|
228
|
+
"data-canopy-client-component": safeName,
|
|
229
|
+
},
|
|
230
|
+
React.createElement("script", {type: "application/json"}, json)
|
|
231
|
+
);
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function getClientComponentPlaceholder(name) {
|
|
236
|
+
const cached = CUSTOM_CLIENT_COMPONENT_PLACEHOLDERS.get(name);
|
|
237
|
+
if (cached) return cached;
|
|
238
|
+
const placeholder = createClientComponentPlaceholder(name);
|
|
239
|
+
CUSTOM_CLIENT_COMPONENT_PLACEHOLDERS.set(name, placeholder);
|
|
240
|
+
return placeholder;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function loadServerComponentFromPath(name, spec) {
|
|
244
|
+
if (!spec || typeof spec !== "string") return null;
|
|
245
|
+
const esbuild = resolveEsbuild();
|
|
246
|
+
if (!esbuild)
|
|
247
|
+
throw new Error(
|
|
248
|
+
"Custom MDX components require esbuild. Install dependencies before building."
|
|
249
|
+
);
|
|
250
|
+
let resolved = spec;
|
|
251
|
+
if (!path.isAbsolute(resolved)) {
|
|
252
|
+
resolved = path.resolve(APP_COMPONENTS_DIR, spec);
|
|
253
|
+
}
|
|
254
|
+
if (!fs.existsSync(resolved)) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
'[canopy][mdx] Component "' + String(name) + '" not found at ' + String(spec) + '. Ensure the file exists under app/components.'
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
let mtime = 0;
|
|
260
|
+
try {
|
|
261
|
+
const st = fs.statSync(resolved);
|
|
262
|
+
mtime = Math.floor(st.mtimeMs || 0);
|
|
263
|
+
} catch (_) {}
|
|
264
|
+
const cacheKey = resolved;
|
|
265
|
+
const cached = SERVER_COMPONENT_CACHE.get(cacheKey);
|
|
266
|
+
if (cached && cached.mtime === mtime && cached.component) {
|
|
267
|
+
return cached.component;
|
|
268
|
+
}
|
|
269
|
+
ensureDirSync(CACHE_DIR);
|
|
270
|
+
const hash = crypto.createHash("sha1").update(resolved).digest("hex");
|
|
271
|
+
const outFile = path.join(CACHE_DIR, "server-comp-" + hash + ".mjs");
|
|
272
|
+
await esbuild.build({
|
|
273
|
+
entryPoints: [resolved],
|
|
274
|
+
outfile: outFile,
|
|
275
|
+
bundle: true,
|
|
276
|
+
platform: "node",
|
|
277
|
+
target: "node18",
|
|
278
|
+
format: "esm",
|
|
279
|
+
jsx: "automatic",
|
|
280
|
+
jsxImportSource: "react",
|
|
281
|
+
sourcemap: false,
|
|
282
|
+
logLevel: "silent",
|
|
283
|
+
external: [
|
|
284
|
+
"react",
|
|
285
|
+
"react-dom",
|
|
286
|
+
"react-dom/server",
|
|
287
|
+
"react-dom/client",
|
|
288
|
+
"@canopy-iiif/app",
|
|
289
|
+
"@canopy-iiif/app/*",
|
|
290
|
+
],
|
|
291
|
+
allowOverwrite: true,
|
|
292
|
+
});
|
|
293
|
+
const bust = mtime || Date.now();
|
|
294
|
+
const mod = await import(pathToFileURL(outFile).href + "?v=" + bust);
|
|
295
|
+
let component = null;
|
|
296
|
+
if (mod && typeof mod === "object") {
|
|
297
|
+
component = mod.default || mod[name] || null;
|
|
298
|
+
if (!isComponentLike(component)) {
|
|
299
|
+
component = Object.keys(mod)
|
|
300
|
+
.map((key) => mod[key])
|
|
301
|
+
.find((value) => isComponentLike(value));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!isComponentLike(component)) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
'[canopy][mdx] Component "' +
|
|
307
|
+
String(name) +
|
|
308
|
+
'" from ' +
|
|
309
|
+
String(spec) +
|
|
310
|
+
' did not export a valid React component. Ensure the module exports a default component or named export matching the key.'
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
SERVER_COMPONENT_CACHE.set(cacheKey, {mtime, component});
|
|
314
|
+
return component;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function resolveServerComponentMap(source) {
|
|
318
|
+
const entries = Object.entries(source || {});
|
|
319
|
+
if (!entries.length) return {};
|
|
320
|
+
const resolved = {};
|
|
321
|
+
for (const [key, value] of entries) {
|
|
322
|
+
if (value == null) continue;
|
|
323
|
+
if (typeof value === "string") {
|
|
324
|
+
const component = await loadServerComponentFromPath(key, value);
|
|
325
|
+
if (component) resolved[key] = component;
|
|
326
|
+
} else {
|
|
327
|
+
resolved[key] = value;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return resolved;
|
|
331
|
+
}
|
|
332
|
+
async function loadCoreUiComponents() {
|
|
120
333
|
// Do not rely on a cached mapping; re-import each time to avoid transient races.
|
|
121
334
|
try {
|
|
122
335
|
// Prefer the workspace dist path during dev to avoid export-map resolution issues
|
|
@@ -179,10 +392,10 @@ async function loadUiComponents() {
|
|
|
179
392
|
const attempts = 5;
|
|
180
393
|
for (let i = 0; i < attempts && !mod; i++) {
|
|
181
394
|
const bustVal = currentMtime
|
|
182
|
-
?
|
|
183
|
-
:
|
|
395
|
+
? String(currentMtime) + '-' + String(i)
|
|
396
|
+
: String(Date.now()) + '-' + String(i);
|
|
184
397
|
try {
|
|
185
|
-
mod = await import(fileUrl +
|
|
398
|
+
mod = await import(fileUrl + '?v=' + bustVal);
|
|
186
399
|
} catch (e) {
|
|
187
400
|
importErr = e;
|
|
188
401
|
if (DEBUG) {
|
|
@@ -271,6 +484,250 @@ async function loadUiComponents() {
|
|
|
271
484
|
return UI_COMPONENTS;
|
|
272
485
|
}
|
|
273
486
|
|
|
487
|
+
function resolveCustomComponentsEntry() {
|
|
488
|
+
const baseDir = APP_COMPONENTS_DIR;
|
|
489
|
+
if (!baseDir) return null;
|
|
490
|
+
try {
|
|
491
|
+
if (!fs.existsSync(baseDir)) return null;
|
|
492
|
+
} catch (_) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
for (const candidate of CUSTOM_COMPONENT_ENTRY_CANDIDATES) {
|
|
496
|
+
const full = path.join(baseDir, candidate);
|
|
497
|
+
try {
|
|
498
|
+
const st = fs.statSync(full);
|
|
499
|
+
if (st && st.isFile()) return full;
|
|
500
|
+
} catch (_) {}
|
|
501
|
+
}
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function computeCustomComponentsSignature(entryPath) {
|
|
506
|
+
const baseDir = fs.existsSync(APP_COMPONENTS_DIR)
|
|
507
|
+
? APP_COMPONENTS_DIR
|
|
508
|
+
: path.dirname(entryPath);
|
|
509
|
+
let newest = 0;
|
|
510
|
+
const stack = [];
|
|
511
|
+
if (baseDir && fs.existsSync(baseDir)) stack.push(baseDir);
|
|
512
|
+
while (stack.length) {
|
|
513
|
+
const dir = stack.pop();
|
|
514
|
+
let entries = [];
|
|
515
|
+
try {
|
|
516
|
+
entries = fs.readdirSync(dir, {withFileTypes: true});
|
|
517
|
+
} catch (_) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
for (const entry of entries) {
|
|
521
|
+
const full = path.join(dir, entry.name);
|
|
522
|
+
if (entry.isDirectory()) {
|
|
523
|
+
stack.push(full);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
const ext = path.extname(full).toLowerCase();
|
|
527
|
+
if (!CUSTOM_COMPONENT_EXTENSIONS.has(ext)) continue;
|
|
528
|
+
try {
|
|
529
|
+
const st = fs.statSync(full);
|
|
530
|
+
const mtime = st && st.mtimeMs ? Math.floor(st.mtimeMs) : 0;
|
|
531
|
+
if (mtime > newest) newest = mtime;
|
|
532
|
+
} catch (_) {}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const entryKey = (() => {
|
|
536
|
+
try {
|
|
537
|
+
return path.resolve(entryPath);
|
|
538
|
+
} catch (_) {
|
|
539
|
+
return entryPath;
|
|
540
|
+
}
|
|
541
|
+
})();
|
|
542
|
+
return String(entryKey) + ':' + String(newest);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function compileCustomComponentModule(entryPath, signature) {
|
|
546
|
+
const esbuild = resolveEsbuild();
|
|
547
|
+
if (!esbuild)
|
|
548
|
+
throw new Error(
|
|
549
|
+
"Custom MDX components require esbuild. Install dependencies before building."
|
|
550
|
+
);
|
|
551
|
+
ensureDirSync(CACHE_DIR);
|
|
552
|
+
const rel = (() => {
|
|
553
|
+
try {
|
|
554
|
+
return path
|
|
555
|
+
.relative(process.cwd(), entryPath)
|
|
556
|
+
.split(path.sep)
|
|
557
|
+
.join("/");
|
|
558
|
+
} catch (_) {
|
|
559
|
+
return entryPath;
|
|
560
|
+
}
|
|
561
|
+
})();
|
|
562
|
+
const tmpFile = path.join(CACHE_DIR, "custom-mdx-components.mjs");
|
|
563
|
+
try {
|
|
564
|
+
await esbuild.build({
|
|
565
|
+
entryPoints: [entryPath],
|
|
566
|
+
outfile: tmpFile,
|
|
567
|
+
bundle: true,
|
|
568
|
+
platform: "node",
|
|
569
|
+
target: "node18",
|
|
570
|
+
format: "esm",
|
|
571
|
+
sourcemap: false,
|
|
572
|
+
logLevel: "silent",
|
|
573
|
+
jsx: "automatic",
|
|
574
|
+
jsxImportSource: "react",
|
|
575
|
+
external: [
|
|
576
|
+
"react",
|
|
577
|
+
"react-dom",
|
|
578
|
+
"react-dom/server",
|
|
579
|
+
"react-dom/client",
|
|
580
|
+
"@canopy-iiif/app",
|
|
581
|
+
"@canopy-iiif/app/*",
|
|
582
|
+
"@canopy-iiif/app/ui",
|
|
583
|
+
"@canopy-iiif/app/ui/*",
|
|
584
|
+
],
|
|
585
|
+
allowOverwrite: true,
|
|
586
|
+
});
|
|
587
|
+
} catch (err) {
|
|
588
|
+
const msg = err && (err.message || err.stack) ? err.message || err.stack : err;
|
|
589
|
+
throw new Error(
|
|
590
|
+
'Failed to compile custom MDX components (' +
|
|
591
|
+
rel +
|
|
592
|
+
').\n' +
|
|
593
|
+
(msg || 'Unknown error')
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const cacheBust = signature || String(Date.now());
|
|
597
|
+
return import(pathToFileURL(tmpFile).href + '?custom=' + cacheBust);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function isComponentLike(value) {
|
|
601
|
+
if (value == null) return false;
|
|
602
|
+
if (typeof value === "function") return true;
|
|
603
|
+
if (typeof value === "object") {
|
|
604
|
+
if (value.$$typeof) return true;
|
|
605
|
+
if (value.render && typeof value.render === "function") return true;
|
|
606
|
+
}
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function cloneComponentMap(source) {
|
|
611
|
+
const out = {};
|
|
612
|
+
Object.keys(source || {}).forEach((key) => {
|
|
613
|
+
const value = source[key];
|
|
614
|
+
if (typeof value === "undefined" || value === null) return;
|
|
615
|
+
out[key] = value;
|
|
616
|
+
});
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function normalizeCustomComponentExports(mod) {
|
|
621
|
+
if (!mod || typeof mod !== "object") return {components: {}, clientEntries: []};
|
|
622
|
+
const candidateKeys = ["components", "mdxComponents", "MDXComponents"];
|
|
623
|
+
let components = {};
|
|
624
|
+
for (const key of candidateKeys) {
|
|
625
|
+
if (mod[key] && typeof mod[key] === "object") {
|
|
626
|
+
const cloned = cloneComponentMap(mod[key]);
|
|
627
|
+
if (Object.keys(cloned).length) {
|
|
628
|
+
components = cloned;
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (!Object.keys(components).length && mod.default && typeof mod.default === "object") {
|
|
634
|
+
const cloned = cloneComponentMap(mod.default);
|
|
635
|
+
if (Object.keys(cloned).length) components = cloned;
|
|
636
|
+
}
|
|
637
|
+
if (!Object.keys(components).length) {
|
|
638
|
+
const fallback = {};
|
|
639
|
+
Object.keys(mod).forEach((key) => {
|
|
640
|
+
if (key === "default" || key === "__esModule") return;
|
|
641
|
+
const value = mod[key];
|
|
642
|
+
if (!isComponentLike(value)) return;
|
|
643
|
+
fallback[key] = value;
|
|
644
|
+
});
|
|
645
|
+
components = fallback;
|
|
646
|
+
}
|
|
647
|
+
if (Object.keys(components).length) {
|
|
648
|
+
components = await resolveServerComponentMap(components);
|
|
649
|
+
}
|
|
650
|
+
const clientEntries = [];
|
|
651
|
+
const rawClient = mod && typeof mod === "object" ? mod.clientComponents : null;
|
|
652
|
+
if (rawClient && typeof rawClient === "object") {
|
|
653
|
+
Object.keys(rawClient).forEach((key) => {
|
|
654
|
+
const spec = rawClient[key];
|
|
655
|
+
if (typeof spec !== "string" || !spec.trim()) return;
|
|
656
|
+
let resolved = spec.trim();
|
|
657
|
+
if (!path.isAbsolute(resolved)) {
|
|
658
|
+
resolved = path.resolve(APP_COMPONENTS_DIR, resolved);
|
|
659
|
+
}
|
|
660
|
+
if (!fs.existsSync(resolved)) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`[canopy][mdx] Client component "${key}" not found at ${spec}. Ensure the file exists under app/components.`
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
clientEntries.push({
|
|
666
|
+
name: String(key),
|
|
667
|
+
source: spec,
|
|
668
|
+
path: resolved,
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
if (clientEntries.length) {
|
|
673
|
+
clientEntries.forEach((entry) => {
|
|
674
|
+
components[entry.name] = getClientComponentPlaceholder(entry.name);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return {components, clientEntries};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function loadCustomMdxComponents() {
|
|
681
|
+
const entry = resolveCustomComponentsEntry();
|
|
682
|
+
if (!entry) {
|
|
683
|
+
CUSTOM_MDX_COMPONENTS = null;
|
|
684
|
+
CUSTOM_MDX_SIGNATURE = "";
|
|
685
|
+
CUSTOM_CLIENT_COMPONENT_ENTRIES = [];
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
const signature = computeCustomComponentsSignature(entry);
|
|
689
|
+
if (
|
|
690
|
+
CUSTOM_MDX_COMPONENTS &&
|
|
691
|
+
CUSTOM_MDX_SIGNATURE &&
|
|
692
|
+
CUSTOM_MDX_SIGNATURE === signature
|
|
693
|
+
) {
|
|
694
|
+
return {
|
|
695
|
+
components: CUSTOM_MDX_COMPONENTS,
|
|
696
|
+
signature,
|
|
697
|
+
clientEntries: CUSTOM_CLIENT_COMPONENT_ENTRIES.slice(),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
const mod = await compileCustomComponentModule(entry, signature);
|
|
701
|
+
const {components, clientEntries} = await normalizeCustomComponentExports(mod);
|
|
702
|
+
CUSTOM_MDX_COMPONENTS = components;
|
|
703
|
+
CUSTOM_MDX_SIGNATURE = signature;
|
|
704
|
+
CUSTOM_CLIENT_COMPONENT_ENTRIES = Array.isArray(clientEntries)
|
|
705
|
+
? clientEntries.slice()
|
|
706
|
+
: [];
|
|
707
|
+
return {components, signature, clientEntries: CUSTOM_CLIENT_COMPONENT_ENTRIES};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function loadUiComponents() {
|
|
711
|
+
const baseComponents = await loadCoreUiComponents();
|
|
712
|
+
const custom = await loadCustomMdxComponents();
|
|
713
|
+
const customKey = custom && custom.signature ? custom.signature : "";
|
|
714
|
+
const compositeKey = `${UI_COMPONENTS_PATH || ""}:${
|
|
715
|
+
UI_COMPONENTS_MTIME || 0
|
|
716
|
+
}:${customKey}`;
|
|
717
|
+
if (MERGED_UI_COMPONENTS && MERGED_UI_KEY === compositeKey) {
|
|
718
|
+
return MERGED_UI_COMPONENTS;
|
|
719
|
+
}
|
|
720
|
+
const merged =
|
|
721
|
+
custom &&
|
|
722
|
+
custom.components &&
|
|
723
|
+
Object.keys(custom.components).length > 0
|
|
724
|
+
? {...baseComponents, ...custom.components}
|
|
725
|
+
: baseComponents;
|
|
726
|
+
MERGED_UI_COMPONENTS = merged;
|
|
727
|
+
MERGED_UI_KEY = compositeKey;
|
|
728
|
+
return MERGED_UI_COMPONENTS;
|
|
729
|
+
}
|
|
730
|
+
|
|
274
731
|
function slugifyHeading(text) {
|
|
275
732
|
try {
|
|
276
733
|
return String(text || "")
|
|
@@ -835,6 +1292,116 @@ async function ensureClientRuntime() {
|
|
|
835
1292
|
return cloverRuntimePromise;
|
|
836
1293
|
}
|
|
837
1294
|
|
|
1295
|
+
function getCustomClientComponentEntries() {
|
|
1296
|
+
return Array.isArray(CUSTOM_CLIENT_COMPONENT_ENTRIES)
|
|
1297
|
+
? CUSTOM_CLIENT_COMPONENT_ENTRIES.slice()
|
|
1298
|
+
: [];
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
let customClientRuntimePromise = null;
|
|
1302
|
+
let customClientRuntimeSignature = "";
|
|
1303
|
+
|
|
1304
|
+
function computeClientRuntimeSignature(entries) {
|
|
1305
|
+
if (!entries || !entries.length) return "";
|
|
1306
|
+
const parts = entries
|
|
1307
|
+
.map((entry) => {
|
|
1308
|
+
const abs = entry && entry.path ? entry.path : "";
|
|
1309
|
+
let mtime = 0;
|
|
1310
|
+
if (abs) {
|
|
1311
|
+
try {
|
|
1312
|
+
const st = fs.statSync(abs);
|
|
1313
|
+
mtime = Math.floor(st.mtimeMs || 0);
|
|
1314
|
+
} catch (_) {}
|
|
1315
|
+
}
|
|
1316
|
+
return `${entry.name || ""}:${abs}:${mtime}`;
|
|
1317
|
+
})
|
|
1318
|
+
.sort();
|
|
1319
|
+
return parts.join("|");
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
async function buildCustomClientRuntime(entries) {
|
|
1323
|
+
const esbuild = resolveEsbuild();
|
|
1324
|
+
if (!esbuild) {
|
|
1325
|
+
throw new Error(
|
|
1326
|
+
"Custom client component hydration requires esbuild. Install dependencies before building."
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
ensureDirSync(OUT_DIR);
|
|
1330
|
+
const scriptsDir = path.join(OUT_DIR, "scripts");
|
|
1331
|
+
ensureDirSync(scriptsDir);
|
|
1332
|
+
const outFile = path.join(scriptsDir, "canopy-custom-components.js");
|
|
1333
|
+
const imports = entries
|
|
1334
|
+
.map((entry, index) => {
|
|
1335
|
+
const ident = `Component${index}`;
|
|
1336
|
+
return `import ${ident} from ${JSON.stringify(entry.path)}; registry.set(${JSON.stringify(
|
|
1337
|
+
entry.name
|
|
1338
|
+
)}, ${ident});`;
|
|
1339
|
+
})
|
|
1340
|
+
.join("\n");
|
|
1341
|
+
const runtimeSource = `
|
|
1342
|
+
import React from 'react';
|
|
1343
|
+
import { createRoot } from 'react-dom/client';
|
|
1344
|
+
const registry = new Map();
|
|
1345
|
+
${imports}
|
|
1346
|
+
function ready(fn){ if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', fn, { once: true }); } else { fn(); } }
|
|
1347
|
+
function parseProps(node){ try { const script = node.querySelector('script[type="application/json"]'); if (!script) return {}; const text = script.textContent || '{}'; const data = JSON.parse(text); if (script.parentNode) { script.parentNode.removeChild(script); } return (data && typeof data === 'object') ? data : {}; } catch (_) { return {}; } }
|
|
1348
|
+
const roots = new WeakMap();
|
|
1349
|
+
function mount(node, Component){ if(!node || !Component) return; const props = parseProps(node); let root = roots.get(node); if(!root){ root = createRoot(node); roots.set(node, root); } root.render(React.createElement(Component, props)); }
|
|
1350
|
+
function hydrateAll(){
|
|
1351
|
+
const nodes = document.querySelectorAll('[data-canopy-client-component]');
|
|
1352
|
+
nodes.forEach((node) => {
|
|
1353
|
+
if (!node || node.__canopyClientMounted) return;
|
|
1354
|
+
const name = node.getAttribute('data-canopy-client-component');
|
|
1355
|
+
const Component = registry.get(name);
|
|
1356
|
+
if (!Component) return;
|
|
1357
|
+
mount(node, Component);
|
|
1358
|
+
node.__canopyClientMounted = true;
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
ready(hydrateAll);
|
|
1362
|
+
`;
|
|
1363
|
+
await esbuild.build({
|
|
1364
|
+
stdin: {
|
|
1365
|
+
contents: runtimeSource,
|
|
1366
|
+
resolveDir: process.cwd(),
|
|
1367
|
+
sourcefile: "canopy-custom-client-runtime.js",
|
|
1368
|
+
loader: "js",
|
|
1369
|
+
},
|
|
1370
|
+
outfile: outFile,
|
|
1371
|
+
bundle: true,
|
|
1372
|
+
platform: "browser",
|
|
1373
|
+
target: ["es2018"],
|
|
1374
|
+
format: "esm",
|
|
1375
|
+
sourcemap: false,
|
|
1376
|
+
minify: true,
|
|
1377
|
+
logLevel: "silent",
|
|
1378
|
+
plugins: [createReactShimPlugin()],
|
|
1379
|
+
});
|
|
1380
|
+
try {
|
|
1381
|
+
const {logLine} = require("./log");
|
|
1382
|
+
let size = 0;
|
|
1383
|
+
try {
|
|
1384
|
+
const st = fs.statSync(outFile);
|
|
1385
|
+
size = st && st.size ? st.size : 0;
|
|
1386
|
+
} catch (_) {}
|
|
1387
|
+
const kb = size ? ` (${(size / 1024).toFixed(1)} KB)` : "";
|
|
1388
|
+
const rel = path.relative(process.cwd(), outFile).split(path.sep).join("/");
|
|
1389
|
+
logLine(`✓ Wrote ${rel}${kb}`, "cyan");
|
|
1390
|
+
} catch (_) {}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
async function ensureCustomClientRuntime() {
|
|
1394
|
+
const entries = getCustomClientComponentEntries();
|
|
1395
|
+
if (!entries.length) return null;
|
|
1396
|
+
const signature = computeClientRuntimeSignature(entries);
|
|
1397
|
+
if (customClientRuntimePromise && customClientRuntimeSignature === signature) {
|
|
1398
|
+
return customClientRuntimePromise;
|
|
1399
|
+
}
|
|
1400
|
+
customClientRuntimeSignature = signature;
|
|
1401
|
+
customClientRuntimePromise = buildCustomClientRuntime(entries);
|
|
1402
|
+
return customClientRuntimePromise;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
838
1405
|
// Facets runtime: fetches /api/search/facets.json, picks a value per label (random from top 3),
|
|
839
1406
|
// and renders a Slider for each.
|
|
840
1407
|
async function ensureFacetsRuntime() {
|
|
@@ -1187,12 +1754,32 @@ module.exports = {
|
|
|
1187
1754
|
ensureHeroRuntime,
|
|
1188
1755
|
ensureFacetsRuntime,
|
|
1189
1756
|
ensureReactGlobals,
|
|
1757
|
+
ensureCustomClientRuntime,
|
|
1190
1758
|
resetMdxCaches: function () {
|
|
1191
1759
|
try {
|
|
1192
1760
|
DIR_LAYOUTS.clear();
|
|
1193
1761
|
} catch (_) {}
|
|
1194
1762
|
APP_WRAPPER = null;
|
|
1195
1763
|
UI_COMPONENTS = null;
|
|
1764
|
+
UI_COMPONENTS_PATH = "";
|
|
1765
|
+
UI_COMPONENTS_MTIME = 0;
|
|
1766
|
+
MERGED_UI_COMPONENTS = null;
|
|
1767
|
+
MERGED_UI_KEY = "";
|
|
1768
|
+
CUSTOM_MDX_COMPONENTS = null;
|
|
1769
|
+
CUSTOM_MDX_SIGNATURE = "";
|
|
1770
|
+
CUSTOM_CLIENT_COMPONENT_ENTRIES = [];
|
|
1771
|
+
try {
|
|
1772
|
+
CUSTOM_CLIENT_COMPONENT_PLACEHOLDERS.clear();
|
|
1773
|
+
} catch (_) {
|
|
1774
|
+
CUSTOM_CLIENT_COMPONENT_PLACEHOLDERS = new Map();
|
|
1775
|
+
}
|
|
1776
|
+
try {
|
|
1777
|
+
SERVER_COMPONENT_CACHE.clear();
|
|
1778
|
+
} catch (_) {
|
|
1779
|
+
SERVER_COMPONENT_CACHE = new Map();
|
|
1780
|
+
}
|
|
1781
|
+
customClientRuntimePromise = null;
|
|
1782
|
+
customClientRuntimeSignature = "";
|
|
1196
1783
|
cloverRuntimePromise = null;
|
|
1197
1784
|
},
|
|
1198
1785
|
};
|
package/lib/build/pages.js
CHANGED
|
@@ -178,6 +178,7 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}, source
|
|
|
178
178
|
const needsTimeline = body.includes('data-canopy-timeline');
|
|
179
179
|
const needsSearchForm = true; // search form runtime is global
|
|
180
180
|
const needsFacets = body.includes('data-canopy-related-items');
|
|
181
|
+
const needsCustomClients = body.includes('data-canopy-client-component');
|
|
181
182
|
const viewerRel = needsHydrateViewer
|
|
182
183
|
? path.relative(path.dirname(outPath), path.join(OUT_DIR, 'scripts', 'canopy-viewer.js')).split(path.sep).join('/')
|
|
183
184
|
: null;
|
|
@@ -203,9 +204,25 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}, source
|
|
|
203
204
|
try { const st = fs.statSync(runtimeAbs); rel += `?v=${Math.floor(st.mtimeMs || Date.now())}`; } catch (_) {}
|
|
204
205
|
searchFormRel = rel;
|
|
205
206
|
}
|
|
207
|
+
let customClientRel = null;
|
|
208
|
+
if (needsCustomClients) {
|
|
209
|
+
try {
|
|
210
|
+
await mdx.ensureCustomClientRuntime();
|
|
211
|
+
const customAbs = path.join(OUT_DIR, 'scripts', 'canopy-custom-components.js');
|
|
212
|
+
let rel = path.relative(path.dirname(outPath), customAbs).split(path.sep).join('/');
|
|
213
|
+
try {
|
|
214
|
+
const st = fs.statSync(customAbs);
|
|
215
|
+
rel += `?v=${Math.floor(st.mtimeMs || Date.now())}`;
|
|
216
|
+
} catch (_) {}
|
|
217
|
+
customClientRel = rel;
|
|
218
|
+
} catch (e) {
|
|
219
|
+
console.warn('[canopy][mdx] failed to build custom client runtime:', e && e.message ? e.message : e);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
206
222
|
const moduleScriptRels = [];
|
|
207
223
|
if (viewerRel) moduleScriptRels.push(viewerRel);
|
|
208
224
|
if (sliderRel) moduleScriptRels.push(sliderRel);
|
|
225
|
+
if (customClientRel) moduleScriptRels.push(customClientRel);
|
|
209
226
|
const primaryClassicScripts = [];
|
|
210
227
|
if (heroRel) primaryClassicScripts.push(heroRel);
|
|
211
228
|
if (timelineRel) primaryClassicScripts.push(timelineRel);
|
|
@@ -217,7 +234,13 @@ async function renderContentMdxToHtml(filePath, outPath, extraProps = {}, source
|
|
|
217
234
|
jsRel = primaryClassicScripts.shift();
|
|
218
235
|
}
|
|
219
236
|
const classicScriptRels = primaryClassicScripts.concat(secondaryClassicScripts);
|
|
220
|
-
const needsReact = !!(
|
|
237
|
+
const needsReact = !!(
|
|
238
|
+
needsHydrateViewer ||
|
|
239
|
+
needsHydrateSlider ||
|
|
240
|
+
needsFacets ||
|
|
241
|
+
needsTimeline ||
|
|
242
|
+
(customClientRel && needsCustomClients)
|
|
243
|
+
);
|
|
221
244
|
let vendorTag = '';
|
|
222
245
|
if (needsReact) {
|
|
223
246
|
try {
|
package/lib/search/search.js
CHANGED
|
@@ -278,7 +278,19 @@ async function buildSearchPage() {
|
|
|
278
278
|
return rel;
|
|
279
279
|
}
|
|
280
280
|
const vendorTags = `<script src="${verRel(vendorReactAbs)}"></script><script src="${verRel(vendorFlexAbs)}"></script><script src="${verRel(vendorSearchFormAbs)}"></script>`;
|
|
281
|
-
let
|
|
281
|
+
let customRuntimeTag = '';
|
|
282
|
+
if (body && body.indexOf('data-canopy-client-component') !== -1) {
|
|
283
|
+
try {
|
|
284
|
+
await mdx.ensureCustomClientRuntime();
|
|
285
|
+
const runtimeAbs = path.join(OUT_DIR, 'scripts', 'canopy-custom-components.js');
|
|
286
|
+
let rel = path.relative(path.dirname(outPath), runtimeAbs).split(path.sep).join('/');
|
|
287
|
+
try { const st = require('fs').statSync(runtimeAbs); rel += `?v=${Math.floor(st.mtimeMs || Date.now())}`; } catch (_) {}
|
|
288
|
+
customRuntimeTag = `<script type="module" src="${rel}"></script>`;
|
|
289
|
+
} catch (e) {
|
|
290
|
+
console.warn('[search] failed to build custom client runtime:', e && (e.message || e));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
let headExtra = vendorTags + head + importMap + customRuntimeTag;
|
|
282
294
|
try {
|
|
283
295
|
const { BASE_PATH } = require('../common');
|
|
284
296
|
if (BASE_PATH) {
|