@canopy-iiif/app 0.6.28
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.js +762 -0
- package/lib/common.js +124 -0
- package/lib/components/IIIFCard.js +102 -0
- package/lib/dev.js +721 -0
- package/lib/devtoast.config.json +6 -0
- package/lib/devtoast.css +14 -0
- package/lib/iiif.js +1145 -0
- package/lib/index.js +5 -0
- package/lib/log.js +64 -0
- package/lib/mdx.js +690 -0
- package/lib/runtime/command-entry.jsx +44 -0
- package/lib/search-app.jsx +273 -0
- package/lib/search.js +477 -0
- package/lib/thumbnail.js +87 -0
- package/package.json +50 -0
- package/ui/dist/index.mjs +692 -0
- package/ui/dist/index.mjs.map +7 -0
- package/ui/dist/server.mjs +344 -0
- package/ui/dist/server.mjs.map +7 -0
- package/ui/styles/components/_card.scss +69 -0
- package/ui/styles/components/_command.scss +80 -0
- package/ui/styles/components/index.scss +5 -0
- package/ui/styles/index.css +127 -0
- package/ui/styles/index.scss +3 -0
- package/ui/styles/variables.emit.scss +72 -0
- package/ui/styles/variables.scss +66 -0
- package/ui/tailwind-canopy-iiif-plugin.js +35 -0
- package/ui/tailwind-canopy-iiif-preset.js +105 -0
package/lib/dev.js
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const fsp = fs.promises;
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
const { build } = require('./build');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const url = require('url');
|
|
8
|
+
const { CONTENT_DIR, OUT_DIR, ASSETS_DIR, ensureDirSync } = require('./common');
|
|
9
|
+
const twHelper = (() => { try { return require('../helpers/build-tailwind'); } catch (_) { return null; } })();
|
|
10
|
+
function resolveTailwindCli() {
|
|
11
|
+
try {
|
|
12
|
+
const cliJs = require.resolve('tailwindcss/lib/cli.js');
|
|
13
|
+
return { cmd: process.execPath, args: [cliJs] };
|
|
14
|
+
} catch (_) {}
|
|
15
|
+
try {
|
|
16
|
+
const bin = path.join(process.cwd(), 'node_modules', '.bin', process.platform === 'win32' ? 'tailwindcss.cmd' : 'tailwindcss');
|
|
17
|
+
if (fs.existsSync(bin)) return { cmd: bin, args: [] };
|
|
18
|
+
} catch (_) {}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const PORT = Number(process.env.PORT || 3000);
|
|
22
|
+
let onBuildSuccess = () => {};
|
|
23
|
+
let onBuildStart = () => {};
|
|
24
|
+
let onCssChange = () => {};
|
|
25
|
+
let nextBuildSkipIiif = false; // hint set by watchers
|
|
26
|
+
const UI_DIST_DIR = path.resolve(path.join(__dirname, '../ui/dist'));
|
|
27
|
+
|
|
28
|
+
function prettyPath(p) {
|
|
29
|
+
try {
|
|
30
|
+
let rel = path.relative(process.cwd(), p);
|
|
31
|
+
if (!rel) rel = '.';
|
|
32
|
+
rel = rel.split(path.sep).join('/');
|
|
33
|
+
if (!rel.startsWith('./') && !rel.startsWith('../')) rel = './' + rel;
|
|
34
|
+
return rel;
|
|
35
|
+
} catch (_) { return p; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function runBuild() {
|
|
39
|
+
try {
|
|
40
|
+
const hint = { skipIiif: !!nextBuildSkipIiif };
|
|
41
|
+
nextBuildSkipIiif = false;
|
|
42
|
+
await build(hint);
|
|
43
|
+
try { onBuildSuccess(); } catch (_) {}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.error('Build failed:', e && e.message ? e.message : e);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function tryRecursiveWatch() {
|
|
50
|
+
try {
|
|
51
|
+
const watcher = fs.watch(CONTENT_DIR, { recursive: true }, (eventType, filename) => {
|
|
52
|
+
if (!filename) return;
|
|
53
|
+
try { console.log(`[watch] ${eventType}: ${prettyPath(path.join(CONTENT_DIR, filename))}`); } catch (_) {}
|
|
54
|
+
// If an MDX file changed, we can skip IIIF for the next build
|
|
55
|
+
try { if (/\.mdx$/i.test(filename)) nextBuildSkipIiif = true; } catch (_) {}
|
|
56
|
+
try { onBuildStart(); } catch (_) {}
|
|
57
|
+
debounceBuild();
|
|
58
|
+
});
|
|
59
|
+
return watcher;
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let buildTimer = null;
|
|
66
|
+
function debounceBuild() {
|
|
67
|
+
clearTimeout(buildTimer);
|
|
68
|
+
buildTimer = setTimeout(runBuild, 150);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function watchPerDir() {
|
|
72
|
+
const watchers = new Map();
|
|
73
|
+
|
|
74
|
+
function watchDir(dir) {
|
|
75
|
+
if (watchers.has(dir)) return;
|
|
76
|
+
try {
|
|
77
|
+
const w = fs.watch(dir, (eventType, filename) => {
|
|
78
|
+
try { console.log(`[watch] ${eventType}: ${prettyPath(path.join(dir, filename || ''))}`); } catch (_) {}
|
|
79
|
+
// If a new directory appears, add a watcher for it on next scan
|
|
80
|
+
scan(dir);
|
|
81
|
+
try { if (filename && /\.mdx$/i.test(filename)) nextBuildSkipIiif = true; } catch (_) {}
|
|
82
|
+
try { onBuildStart(); } catch (_) {}
|
|
83
|
+
debounceBuild();
|
|
84
|
+
});
|
|
85
|
+
watchers.set(dir, w);
|
|
86
|
+
} catch (_) {
|
|
87
|
+
// ignore
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function scan(dir) {
|
|
92
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
93
|
+
for (const e of entries) {
|
|
94
|
+
const p = path.join(dir, e.name);
|
|
95
|
+
if (e.isDirectory()) watchDir(p);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
watchDir(CONTENT_DIR);
|
|
100
|
+
scan(CONTENT_DIR);
|
|
101
|
+
|
|
102
|
+
return () => {
|
|
103
|
+
for (const w of watchers.values()) w.close();
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Asset live-reload: copy changed file(s) into site/ without full rebuild
|
|
108
|
+
async function syncAsset(relativePath) {
|
|
109
|
+
try {
|
|
110
|
+
if (!relativePath) return;
|
|
111
|
+
const src = path.join(ASSETS_DIR, relativePath);
|
|
112
|
+
const rel = path.normalize(relativePath);
|
|
113
|
+
const dest = path.join(OUT_DIR, rel);
|
|
114
|
+
const exists = fs.existsSync(src);
|
|
115
|
+
if (exists) {
|
|
116
|
+
const st = fs.statSync(src);
|
|
117
|
+
if (st.isDirectory()) {
|
|
118
|
+
ensureDirSync(dest);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
ensureDirSync(path.dirname(dest));
|
|
122
|
+
await fsp.copyFile(src, dest);
|
|
123
|
+
console.log(`[assets] Copied ${relativePath} -> ${path.relative(process.cwd(), dest)}`);
|
|
124
|
+
} else {
|
|
125
|
+
// Removed or renamed away: remove dest
|
|
126
|
+
try { await fsp.rm(dest, { force: true, recursive: true }); } catch (_) {}
|
|
127
|
+
console.log(`[assets] Removed ${relativePath}`);
|
|
128
|
+
}
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.warn('[assets] sync failed:', e && e.message ? e.message : e);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function tryRecursiveWatchAssets() {
|
|
135
|
+
try {
|
|
136
|
+
const watcher = fs.watch(ASSETS_DIR, { recursive: true }, (eventType, filename) => {
|
|
137
|
+
if (!filename) return;
|
|
138
|
+
try { console.log(`[assets] ${eventType}: ${prettyPath(path.join(ASSETS_DIR, filename))}`); } catch (_) {}
|
|
139
|
+
// Copy just the changed asset and trigger reload
|
|
140
|
+
syncAsset(filename).then(() => { try { onBuildSuccess(); } catch (_) {} });
|
|
141
|
+
});
|
|
142
|
+
return watcher;
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function watchAssetsPerDir() {
|
|
149
|
+
const watchers = new Map();
|
|
150
|
+
|
|
151
|
+
function watchDir(dir) {
|
|
152
|
+
if (watchers.has(dir)) return;
|
|
153
|
+
try {
|
|
154
|
+
const w = fs.watch(dir, (eventType, filename) => {
|
|
155
|
+
const rel = filename ? path.relative(ASSETS_DIR, path.join(dir, filename)) : path.relative(ASSETS_DIR, dir);
|
|
156
|
+
try { console.log(`[assets] ${eventType}: ${prettyPath(path.join(dir, filename || ''))}`); } catch (_) {}
|
|
157
|
+
// If a new directory appears, add a watcher for it on next scan
|
|
158
|
+
scan(dir);
|
|
159
|
+
syncAsset(rel).then(() => { try { onBuildSuccess(); } catch (_) {} });
|
|
160
|
+
});
|
|
161
|
+
watchers.set(dir, w);
|
|
162
|
+
} catch (_) {
|
|
163
|
+
// ignore
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function scan(dir) {
|
|
168
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
169
|
+
for (const e of entries) {
|
|
170
|
+
const p = path.join(dir, e.name);
|
|
171
|
+
if (e.isDirectory()) watchDir(p);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (fs.existsSync(ASSETS_DIR)) {
|
|
176
|
+
watchDir(ASSETS_DIR);
|
|
177
|
+
scan(ASSETS_DIR);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return () => {
|
|
181
|
+
for (const w of watchers.values()) w.close();
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Watch @canopy-iiif/app/ui dist output to enable live reload for UI edits during dev.
|
|
186
|
+
// When UI dist changes, rebuild the search runtime bundle and trigger a browser reload.
|
|
187
|
+
async function rebuildSearchBundle() {
|
|
188
|
+
try {
|
|
189
|
+
const search = require('./search');
|
|
190
|
+
if (search && typeof search.ensureSearchRuntime === 'function') {
|
|
191
|
+
await search.ensureSearchRuntime();
|
|
192
|
+
}
|
|
193
|
+
} catch (_) {}
|
|
194
|
+
try { onBuildSuccess(); } catch (_) {}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function tryRecursiveWatchUiDist() {
|
|
198
|
+
try {
|
|
199
|
+
if (!fs.existsSync(UI_DIST_DIR)) return null;
|
|
200
|
+
const watcher = fs.watch(UI_DIST_DIR, { recursive: true }, (eventType, filename) => {
|
|
201
|
+
if (!filename) return;
|
|
202
|
+
try { console.log(`[ui] ${eventType}: ${prettyPath(path.join(UI_DIST_DIR, filename))}`); } catch (_) {}
|
|
203
|
+
// Lightweight path: rebuild only the search runtime bundle
|
|
204
|
+
rebuildSearchBundle();
|
|
205
|
+
// If the server-side UI bundle changed, trigger a site rebuild (skip IIIF)
|
|
206
|
+
try {
|
|
207
|
+
if (/server\.(js|mjs)$/.test(filename)) {
|
|
208
|
+
nextBuildSkipIiif = true;
|
|
209
|
+
try { onBuildStart(); } catch (_) {}
|
|
210
|
+
debounceBuild();
|
|
211
|
+
}
|
|
212
|
+
} catch (_) {}
|
|
213
|
+
});
|
|
214
|
+
return watcher;
|
|
215
|
+
} catch (_) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function watchUiDistPerDir() {
|
|
221
|
+
if (!fs.existsSync(UI_DIST_DIR)) return () => {};
|
|
222
|
+
const watchers = new Map();
|
|
223
|
+
function watchDir(dir) {
|
|
224
|
+
if (watchers.has(dir)) return;
|
|
225
|
+
try {
|
|
226
|
+
const w = fs.watch(dir, (eventType, filename) => {
|
|
227
|
+
try { console.log(`[ui] ${eventType}: ${prettyPath(path.join(dir, filename || ''))}`); } catch (_) {}
|
|
228
|
+
scan(dir);
|
|
229
|
+
rebuildSearchBundle();
|
|
230
|
+
try {
|
|
231
|
+
if (/server\.(js|mjs)$/.test(filename || '')) {
|
|
232
|
+
nextBuildSkipIiif = true;
|
|
233
|
+
try { onBuildStart(); } catch (_) {}
|
|
234
|
+
debounceBuild();
|
|
235
|
+
}
|
|
236
|
+
} catch (_) {}
|
|
237
|
+
});
|
|
238
|
+
watchers.set(dir, w);
|
|
239
|
+
} catch (_) {}
|
|
240
|
+
}
|
|
241
|
+
function scan(dir) {
|
|
242
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
243
|
+
for (const e of entries) {
|
|
244
|
+
const p = path.join(dir, e.name);
|
|
245
|
+
if (e.isDirectory()) watchDir(p);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
watchDir(UI_DIST_DIR);
|
|
249
|
+
scan(UI_DIST_DIR);
|
|
250
|
+
return () => { for (const w of watchers.values()) w.close(); };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const MIME = {
|
|
254
|
+
'.html': 'text/html; charset=utf-8',
|
|
255
|
+
'.css': 'text/css; charset=utf-8',
|
|
256
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
257
|
+
'.json': 'application/json; charset=utf-8',
|
|
258
|
+
'.png': 'image/png',
|
|
259
|
+
'.jpg': 'image/jpeg',
|
|
260
|
+
'.jpeg': 'image/jpeg',
|
|
261
|
+
'.gif': 'image/gif',
|
|
262
|
+
'.svg': 'image/svg+xml',
|
|
263
|
+
'.txt': 'text/plain; charset=utf-8'
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
function startServer() {
|
|
267
|
+
const clients = new Set();
|
|
268
|
+
function broadcast(type) {
|
|
269
|
+
for (const res of clients) {
|
|
270
|
+
try { res.write(`data: ${type}\n\n`); } catch (_) {}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
onBuildStart = () => broadcast('building');
|
|
274
|
+
onBuildSuccess = () => broadcast('reload');
|
|
275
|
+
onCssChange = () => broadcast('css');
|
|
276
|
+
|
|
277
|
+
const server = http.createServer((req, res) => {
|
|
278
|
+
const parsed = url.parse(req.url || '/');
|
|
279
|
+
let pathname = decodeURI(parsed.pathname || '/');
|
|
280
|
+
// Serve dev toast assets and config
|
|
281
|
+
if (pathname === '/__livereload-config') {
|
|
282
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
283
|
+
const cfgPath = path.join(__dirname, 'devtoast.config.json');
|
|
284
|
+
let cfg = { buildingText: 'Rebuilding…', reloadedText: 'Reloaded', fadeMs: 800, reloadDelayMs: 200 };
|
|
285
|
+
try {
|
|
286
|
+
if (fs.existsSync(cfgPath)) {
|
|
287
|
+
const raw = fs.readFileSync(cfgPath, 'utf8');
|
|
288
|
+
const parsedCfg = JSON.parse(raw);
|
|
289
|
+
cfg = { ...cfg, ...parsedCfg };
|
|
290
|
+
}
|
|
291
|
+
} catch (_) {}
|
|
292
|
+
res.end(JSON.stringify(cfg));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (pathname === '/__livereload.css') {
|
|
296
|
+
res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
297
|
+
const cssPath = path.join(__dirname, 'devtoast.css');
|
|
298
|
+
let css = `#__lr_toast{position:fixed;bottom:12px;left:12px;background:rgba(0,0,0,.8);color:#fff;padding:6px 10px;border-radius:6px;font:12px/1.2 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;z-index:99999;box-shadow:0 2px 8px rgba(0,0,0,.3);opacity:0;transition:opacity .15s ease}`;
|
|
299
|
+
try {
|
|
300
|
+
if (fs.existsSync(cssPath)) css = fs.readFileSync(cssPath, 'utf8');
|
|
301
|
+
} catch (_) {}
|
|
302
|
+
res.end(css);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (pathname === '/__livereload') {
|
|
306
|
+
res.writeHead(200, {
|
|
307
|
+
'Content-Type': 'text/event-stream',
|
|
308
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
309
|
+
Connection: 'keep-alive'
|
|
310
|
+
});
|
|
311
|
+
res.write(': connected\n\n');
|
|
312
|
+
clients.add(res);
|
|
313
|
+
const keepAlive = setInterval(() => {
|
|
314
|
+
try { res.write(': ping\n\n'); } catch (_) {}
|
|
315
|
+
}, 30000);
|
|
316
|
+
req.on('close', () => {
|
|
317
|
+
clearInterval(keepAlive);
|
|
318
|
+
clients.delete(res);
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (pathname === '/') pathname = '/index.html';
|
|
323
|
+
|
|
324
|
+
// Resolve candidate paths in order:
|
|
325
|
+
// 1) as-is
|
|
326
|
+
// 2) add .html for extensionless
|
|
327
|
+
// 3) if a directory, use its index.html
|
|
328
|
+
let filePath = null;
|
|
329
|
+
const candidateA = path.join(OUT_DIR, pathname);
|
|
330
|
+
const candidateB = path.join(OUT_DIR, pathname + '.html');
|
|
331
|
+
if (fs.existsSync(candidateA)) {
|
|
332
|
+
filePath = candidateA;
|
|
333
|
+
} else if (fs.existsSync(candidateB)) {
|
|
334
|
+
filePath = candidateB;
|
|
335
|
+
}
|
|
336
|
+
if (!filePath) {
|
|
337
|
+
// Try directory index for extensionless or folder routes
|
|
338
|
+
const maybeDir = path.join(OUT_DIR, pathname);
|
|
339
|
+
if (fs.existsSync(maybeDir)) {
|
|
340
|
+
try {
|
|
341
|
+
const st = fs.statSync(maybeDir);
|
|
342
|
+
if (st.isDirectory()) {
|
|
343
|
+
const idx = path.join(maybeDir, 'index.html');
|
|
344
|
+
if (fs.existsSync(idx)) filePath = idx;
|
|
345
|
+
}
|
|
346
|
+
} catch (_) {}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (!filePath) {
|
|
350
|
+
res.statusCode = 404;
|
|
351
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
352
|
+
res.end('Not Found');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Prevent path traversal by ensuring resolved path stays under SITE_DIR
|
|
357
|
+
let resolved = path.resolve(filePath);
|
|
358
|
+
if (!resolved.startsWith(OUT_DIR)) {
|
|
359
|
+
res.statusCode = 403;
|
|
360
|
+
res.end('Forbidden');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// If a directory slipped through, try its index.html
|
|
365
|
+
try {
|
|
366
|
+
const st = fs.statSync(resolved);
|
|
367
|
+
if (st.isDirectory()) {
|
|
368
|
+
const idx = path.join(resolved, 'index.html');
|
|
369
|
+
if (fs.existsSync(idx)) {
|
|
370
|
+
filePath = idx;
|
|
371
|
+
resolved = path.resolve(filePath);
|
|
372
|
+
} else {
|
|
373
|
+
res.statusCode = 404;
|
|
374
|
+
res.end('Not Found');
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} catch (_) {}
|
|
379
|
+
|
|
380
|
+
// Ensure resolved reflects the final filePath
|
|
381
|
+
resolved = path.resolve(filePath);
|
|
382
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
383
|
+
res.statusCode = 200;
|
|
384
|
+
res.setHeader('Content-Type', MIME[ext] || 'application/octet-stream');
|
|
385
|
+
// Dev: always disable caching so reloads fetch fresh assets
|
|
386
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
387
|
+
res.setHeader('Pragma', 'no-cache');
|
|
388
|
+
res.setHeader('Expires', '0');
|
|
389
|
+
if (ext === '.html') {
|
|
390
|
+
try {
|
|
391
|
+
let html = fs.readFileSync(resolved, 'utf8');
|
|
392
|
+
const snippet = `
|
|
393
|
+
<link rel="stylesheet" href="/__livereload.css">
|
|
394
|
+
<script>(function(){
|
|
395
|
+
var t, cfg = { buildingText: 'Rebuilding…', reloadedText: 'Reloaded', fadeMs: 800, reloadDelayMs: 200 };
|
|
396
|
+
fetch('/__livereload-config').then(function(r){ return r.json(); }).then(function(j){ cfg = j; }).catch(function(){});
|
|
397
|
+
function toast(m){ var el = document.getElementById('__lr_toast'); if(!el){ el=document.createElement('div'); el.id='__lr_toast'; document.body.appendChild(el); } el.textContent=m; el.style.opacity='1'; clearTimeout(t); t=setTimeout(function(){ el.style.opacity='0'; }, cfg.fadeMs); }
|
|
398
|
+
(function(){
|
|
399
|
+
var lastCssSwap = 0;
|
|
400
|
+
function swapLink(l){
|
|
401
|
+
try{
|
|
402
|
+
var href = l.getAttribute('href') || '';
|
|
403
|
+
var base = href.split('?')[0];
|
|
404
|
+
var next = l.cloneNode();
|
|
405
|
+
next.setAttribute('href', base + '?v=' + Date.now());
|
|
406
|
+
// Load new stylesheet off-screen, then atomically switch to avoid FOUC
|
|
407
|
+
next.media = 'print';
|
|
408
|
+
next.onload = function(){ try { next.media = 'all'; l.remove(); } catch(_){} };
|
|
409
|
+
l.parentNode.insertBefore(next, l.nextSibling);
|
|
410
|
+
}catch(_){ }
|
|
411
|
+
}
|
|
412
|
+
window.__canopyReloadCss = function(){
|
|
413
|
+
var now = Date.now();
|
|
414
|
+
if (now - lastCssSwap < 200) return; // throttle spammy events
|
|
415
|
+
lastCssSwap = now;
|
|
416
|
+
try {
|
|
417
|
+
var links = document.querySelectorAll('link[rel="stylesheet"]');
|
|
418
|
+
links.forEach(function(l){
|
|
419
|
+
try {
|
|
420
|
+
var href = l.getAttribute('href') || '';
|
|
421
|
+
var base = href.split('?')[0];
|
|
422
|
+
if (base.indexOf('styles/styles.css') !== -1) swapLink(l);
|
|
423
|
+
} catch(_) {}
|
|
424
|
+
});
|
|
425
|
+
} catch(_) {}
|
|
426
|
+
};
|
|
427
|
+
})();
|
|
428
|
+
var es = new EventSource('/__livereload');
|
|
429
|
+
es.onmessage = function(e){
|
|
430
|
+
if (e.data === 'building') { /* no toast for css-only builds to reduce blinking */ }
|
|
431
|
+
else if (e.data === 'css') { if (window.__canopyReloadCss) window.__canopyReloadCss(); }
|
|
432
|
+
else if (e.data === 'reload') { toast(cfg.reloadedText); setTimeout(function(){ location.reload(); }, cfg.reloadDelayMs); }
|
|
433
|
+
};
|
|
434
|
+
window.addEventListener('beforeunload', function(){ try { es.close(); } catch(e) {} });
|
|
435
|
+
})();</script>`;
|
|
436
|
+
html = html.includes('</body>') ? html.replace('</body>', snippet + '</body>') : html + snippet;
|
|
437
|
+
res.end(html);
|
|
438
|
+
} catch (e) {
|
|
439
|
+
res.statusCode = 500;
|
|
440
|
+
res.end('Error serving HTML');
|
|
441
|
+
}
|
|
442
|
+
} else {
|
|
443
|
+
fs.createReadStream(resolved).pipe(res);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
server.listen(PORT, () => {
|
|
448
|
+
console.log(`Serving site on http://localhost:${PORT}`);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
return server;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function dev() {
|
|
455
|
+
if (!fs.existsSync(CONTENT_DIR)) {
|
|
456
|
+
console.error('No content directory found at', CONTENT_DIR);
|
|
457
|
+
process.exit(1);
|
|
458
|
+
}
|
|
459
|
+
// Start server before the initial build so build logs follow server standup
|
|
460
|
+
startServer();
|
|
461
|
+
console.log('Initial build...');
|
|
462
|
+
// Expose a base URL for builders to construct absolute ids/links
|
|
463
|
+
if (!process.env.CANOPY_BASE_URL) {
|
|
464
|
+
process.env.CANOPY_BASE_URL = `http://localhost:${PORT}`;
|
|
465
|
+
}
|
|
466
|
+
// In dev, let the Tailwind watcher own CSS generation to avoid duplicate
|
|
467
|
+
// one-off builds that print "Rebuilding..." messages. Skip ensureStyles()
|
|
468
|
+
// within build() by setting an environment flag.
|
|
469
|
+
process.env.CANOPY_SKIP_STYLES = process.env.DEV_ONCE ? '' : '1';
|
|
470
|
+
// Suppress noisy Browserslist old data warning in dev/tailwind
|
|
471
|
+
process.env.BROWSERSLIST_IGNORE_OLD_DATA = '1';
|
|
472
|
+
if (process.env.DEV_ONCE) {
|
|
473
|
+
// Build once and exit (used for tests/CI)
|
|
474
|
+
runBuild().then(() => process.exit(0)).catch(() => process.exit(1));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// Run the initial build synchronously now that the server is up
|
|
478
|
+
await runBuild();
|
|
479
|
+
|
|
480
|
+
// Start Tailwind watcher if config + input exist (after initial build)
|
|
481
|
+
try {
|
|
482
|
+
const root = process.cwd();
|
|
483
|
+
const appStylesDir = path.join(root, 'app', 'styles');
|
|
484
|
+
const twConfigsRoot = ['tailwind.config.js','tailwind.config.cjs','tailwind.config.mjs','tailwind.config.ts']
|
|
485
|
+
.map((n) => path.join(root, n));
|
|
486
|
+
const twConfigsApp = ['tailwind.config.js','tailwind.config.cjs','tailwind.config.mjs','tailwind.config.ts']
|
|
487
|
+
.map((n) => path.join(appStylesDir, n));
|
|
488
|
+
let configPath = [...twConfigsApp, ...twConfigsRoot].find((p) => { try { return fs.existsSync(p); } catch (_) { return false; } });
|
|
489
|
+
const inputCandidates = [path.join(appStylesDir, 'index.css'), path.join(CONTENT_DIR, '_styles.css')];
|
|
490
|
+
let inputCss = inputCandidates.find((p) => { try { return fs.existsSync(p); } catch (_) { return false; } });
|
|
491
|
+
// Generate fallback config and input if missing
|
|
492
|
+
if (!configPath) {
|
|
493
|
+
try {
|
|
494
|
+
const { CACHE_DIR } = require('./common');
|
|
495
|
+
const genDir = path.join(CACHE_DIR, 'tailwind');
|
|
496
|
+
ensureDirSync(genDir);
|
|
497
|
+
const genCfg = path.join(genDir, 'tailwind.config.js');
|
|
498
|
+
const cfg = `module.exports = {\n presets: [require('@canopy-iiif/app/ui/canopy-iiif-preset')],\n content: [\n './content/**/*.{mdx,html}',\n './site/**/*.html',\n './site/**/*.js',\n './packages/app/ui/**/*.{js,jsx,ts,tsx}',\n './packages/app/lib/components/**/*.{js,jsx}',\n ],\n theme: { extend: {} },\n plugins: [require('@canopy-iiif/app/ui/canopy-iiif-plugin')],\n};\n`;
|
|
499
|
+
fs.writeFileSync(genCfg, cfg, 'utf8');
|
|
500
|
+
configPath = genCfg;
|
|
501
|
+
} catch (_) { configPath = null; }
|
|
502
|
+
}
|
|
503
|
+
if (!inputCss) {
|
|
504
|
+
try {
|
|
505
|
+
const { CACHE_DIR } = require('./common');
|
|
506
|
+
const genDir = path.join(CACHE_DIR, 'tailwind');
|
|
507
|
+
ensureDirSync(genDir);
|
|
508
|
+
const genCss = path.join(genDir, 'index.css');
|
|
509
|
+
fs.writeFileSync(genCss, `@tailwind base;\n@tailwind components;\n@tailwind utilities;\n`, 'utf8');
|
|
510
|
+
inputCss = genCss;
|
|
511
|
+
} catch (_) { inputCss = null; }
|
|
512
|
+
}
|
|
513
|
+
const outputCss = path.join(OUT_DIR, 'styles', 'styles.css');
|
|
514
|
+
if (configPath && inputCss) {
|
|
515
|
+
// Ensure output dir exists and start watcher
|
|
516
|
+
ensureDirSync(path.dirname(outputCss));
|
|
517
|
+
let child = null;
|
|
518
|
+
// Ensure output file exists (fallback minimal CSS if CLI/compile fails)
|
|
519
|
+
function writeFallbackCssIfMissing() {
|
|
520
|
+
try {
|
|
521
|
+
if (!fs.existsSync(outputCss)) {
|
|
522
|
+
const base = `:root{--max-w:760px;--muted:#6b7280}*{box-sizing:border-box}body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Helvetica,Arial,sans-serif;max-width:var(--max-w);margin:2rem auto;padding:0 1rem;line-height:1.6}a{color:#2563eb;text-decoration:none}a:hover{text-decoration:underline}`;
|
|
523
|
+
ensureDirSync(path.dirname(outputCss));
|
|
524
|
+
fs.writeFileSync(outputCss, base + '\n', 'utf8');
|
|
525
|
+
console.log('[tailwind] wrote fallback CSS to', prettyPath(outputCss));
|
|
526
|
+
}
|
|
527
|
+
} catch (_) {}
|
|
528
|
+
}
|
|
529
|
+
function fileSizeKb(p) { try { const st = fs.statSync(p); return st && st.size ? (st.size/1024).toFixed(1) : '0.0'; } catch (_) { return '0.0'; } }
|
|
530
|
+
// Initial one-off compile so the CSS exists before watcher starts
|
|
531
|
+
try {
|
|
532
|
+
const cliOnce = resolveTailwindCli();
|
|
533
|
+
if (cliOnce) {
|
|
534
|
+
const { spawnSync } = require('child_process');
|
|
535
|
+
const argsOnce = ['-i', inputCss, '-o', outputCss, '-c', configPath, '--minify'];
|
|
536
|
+
const res = spawnSync(cliOnce.cmd, [...cliOnce.args, ...argsOnce], { stdio: ['ignore','pipe','pipe'], env: { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: '1' } });
|
|
537
|
+
if (res && res.status === 0) {
|
|
538
|
+
console.log(`[tailwind] initial build ok (${fileSizeKb(outputCss)} KB) →`, prettyPath(outputCss));
|
|
539
|
+
} else {
|
|
540
|
+
console.warn('[tailwind] initial build failed; using fallback CSS');
|
|
541
|
+
try { if (res && res.stderr) process.stderr.write(res.stderr); } catch (_) {}
|
|
542
|
+
writeFallbackCssIfMissing();
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
console.warn('[tailwind] CLI not found; using fallback CSS');
|
|
546
|
+
writeFallbackCssIfMissing();
|
|
547
|
+
}
|
|
548
|
+
} catch (_) {}
|
|
549
|
+
// Prefer direct CLI spawn so we can mute initial rebuild logs
|
|
550
|
+
const cli = resolveTailwindCli();
|
|
551
|
+
if (cli) {
|
|
552
|
+
const args = ['-i', inputCss, '-o', outputCss, '--watch', '-c', configPath, '--minify'];
|
|
553
|
+
let unmuted = false;
|
|
554
|
+
let cssWatcherAttached = false;
|
|
555
|
+
function attachCssWatcherOnce() {
|
|
556
|
+
if (cssWatcherAttached) return;
|
|
557
|
+
cssWatcherAttached = true;
|
|
558
|
+
try {
|
|
559
|
+
fs.watch(outputCss, { persistent: false }, () => {
|
|
560
|
+
if (!unmuted) {
|
|
561
|
+
unmuted = true;
|
|
562
|
+
console.log(`[tailwind] watching ${prettyPath(inputCss)} — compiled (${fileSizeKb(outputCss)} KB)`);
|
|
563
|
+
}
|
|
564
|
+
try { onCssChange(); } catch (_) {}
|
|
565
|
+
});
|
|
566
|
+
} catch (_) {}
|
|
567
|
+
}
|
|
568
|
+
function compileTailwindOnce() {
|
|
569
|
+
try {
|
|
570
|
+
const { spawnSync } = require('child_process');
|
|
571
|
+
const res = spawnSync(cli.cmd, [...cli.args, '-i', inputCss, '-o', outputCss, '-c', configPath, '--minify'], { stdio: ['ignore','pipe','pipe'], env: { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: '1' } });
|
|
572
|
+
if (res && res.status === 0) {
|
|
573
|
+
console.log(`[tailwind] compiled (${fileSizeKb(outputCss)} KB) →`, prettyPath(outputCss));
|
|
574
|
+
try { onCssChange(); } catch (_) {}
|
|
575
|
+
} else {
|
|
576
|
+
console.warn('[tailwind] on-demand compile failed');
|
|
577
|
+
try { if (res && res.stderr) process.stderr.write(res.stderr); } catch (_) {}
|
|
578
|
+
}
|
|
579
|
+
} catch (_) {}
|
|
580
|
+
}
|
|
581
|
+
function startTailwindWatcher() {
|
|
582
|
+
unmuted = false;
|
|
583
|
+
const proc = spawn(cli.cmd, [...cli.args, ...args], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: '1' } });
|
|
584
|
+
if (proc.stdout) proc.stdout.on('data', (d) => {
|
|
585
|
+
const s = d ? String(d) : '';
|
|
586
|
+
if (!unmuted) {
|
|
587
|
+
if (/error/i.test(s)) { try { process.stdout.write('[tailwind] ' + s); } catch (_) {} }
|
|
588
|
+
} else {
|
|
589
|
+
try { process.stdout.write(s); } catch (_) {}
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
if (proc.stderr) proc.stderr.on('data', (d) => {
|
|
593
|
+
const s = d ? String(d) : '';
|
|
594
|
+
if (!unmuted) {
|
|
595
|
+
if (s.trim()) { try { process.stderr.write('[tailwind] ' + s); } catch (_) {} }
|
|
596
|
+
} else {
|
|
597
|
+
try { process.stderr.write(s); } catch (_) {}
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
proc.on('exit', (code) => {
|
|
601
|
+
// Ignore null exits (expected when we intentionally restart the watcher)
|
|
602
|
+
if (code !== 0 && code !== null) {
|
|
603
|
+
console.error('[tailwind] watcher exited with code', code);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
attachCssWatcherOnce();
|
|
607
|
+
return proc;
|
|
608
|
+
}
|
|
609
|
+
child = startTailwindWatcher();
|
|
610
|
+
// Unmute Tailwind logs after the first successful CSS write
|
|
611
|
+
// Watch UI Tailwind plugin/preset files and restart Tailwind to pick up code changes
|
|
612
|
+
try {
|
|
613
|
+
const uiPlugin = path.join(__dirname, '../ui', 'tailwind-canopy-iiif-plugin.js');
|
|
614
|
+
const uiPreset = path.join(__dirname, '../ui', 'tailwind-canopy-iiif-preset.js');
|
|
615
|
+
const uiStylesDir = path.join(__dirname, '../ui', 'styles');
|
|
616
|
+
const files = [uiPlugin, uiPreset].filter((p) => {
|
|
617
|
+
try { return fs.existsSync(p); } catch (_) { return false; }
|
|
618
|
+
});
|
|
619
|
+
let restartTimer = null;
|
|
620
|
+
const restart = () => {
|
|
621
|
+
clearTimeout(restartTimer);
|
|
622
|
+
restartTimer = setTimeout(() => {
|
|
623
|
+
console.log('[tailwind] detected UI plugin/preset change — restarting Tailwind');
|
|
624
|
+
try { if (child && !child.killed) child.kill(); } catch (_) {}
|
|
625
|
+
// Force a compile immediately so new CSS lands before reload
|
|
626
|
+
compileTailwindOnce();
|
|
627
|
+
child = startTailwindWatcher();
|
|
628
|
+
// Notify clients that a rebuild is in progress; CSS watcher will trigger reload on write
|
|
629
|
+
try { onBuildStart(); } catch (_) {}
|
|
630
|
+
}, 50);
|
|
631
|
+
};
|
|
632
|
+
for (const f of files) {
|
|
633
|
+
try { fs.watch(f, { persistent: false }, restart); } catch (_) {}
|
|
634
|
+
}
|
|
635
|
+
// Watch UI styles directory (Sass partials used by the plugin); restart Tailwind on Sass changes
|
|
636
|
+
try {
|
|
637
|
+
if (fs.existsSync(uiStylesDir)) {
|
|
638
|
+
try {
|
|
639
|
+
fs.watch(uiStylesDir, { persistent: false, recursive: true }, (evt, fn) => {
|
|
640
|
+
try {
|
|
641
|
+
if (fn && /\.s[ac]ss$/i.test(String(fn))) restart();
|
|
642
|
+
} catch (_) {}
|
|
643
|
+
});
|
|
644
|
+
} catch (_) {
|
|
645
|
+
// Fallback: per-dir watch without recursion
|
|
646
|
+
const watchers = new Map();
|
|
647
|
+
const watchDir = (dir) => {
|
|
648
|
+
if (watchers.has(dir)) return;
|
|
649
|
+
try {
|
|
650
|
+
const w = fs.watch(dir, { persistent: false }, (evt, fn) => {
|
|
651
|
+
try { if (fn && /\.s[ac]ss$/i.test(String(fn))) restart(); } catch (_) {}
|
|
652
|
+
});
|
|
653
|
+
watchers.set(dir, w);
|
|
654
|
+
} catch (_) {}
|
|
655
|
+
};
|
|
656
|
+
const scan = (dir) => {
|
|
657
|
+
try {
|
|
658
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
659
|
+
for (const e of entries) {
|
|
660
|
+
const p = path.join(dir, e.name);
|
|
661
|
+
if (e.isDirectory()) { watchDir(p); scan(p); }
|
|
662
|
+
}
|
|
663
|
+
} catch (_) {}
|
|
664
|
+
};
|
|
665
|
+
watchDir(uiStylesDir);
|
|
666
|
+
scan(uiStylesDir);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
} catch (_) {}
|
|
670
|
+
// Also watch the app Tailwind config; restart Tailwind when it changes
|
|
671
|
+
try { if (configPath && fs.existsSync(configPath)) fs.watch(configPath, { persistent: false }, () => {
|
|
672
|
+
console.log('[tailwind] tailwind.config change — restarting Tailwind');
|
|
673
|
+
restart();
|
|
674
|
+
}); } catch (_) {}
|
|
675
|
+
// If the input CSS lives under app/styles, watch the directory for direct edits to CSS/partials
|
|
676
|
+
try {
|
|
677
|
+
const stylesDir = path.dirname(inputCss || '');
|
|
678
|
+
if (stylesDir && stylesDir.includes(path.join('app','styles'))) {
|
|
679
|
+
let cssDebounce = null;
|
|
680
|
+
fs.watch(stylesDir, { persistent: false }, (evt, fn) => {
|
|
681
|
+
clearTimeout(cssDebounce);
|
|
682
|
+
cssDebounce = setTimeout(() => {
|
|
683
|
+
try { onBuildStart(); } catch (_) {}
|
|
684
|
+
// Force a compile so changes in index.css or partials are reflected immediately
|
|
685
|
+
try { compileTailwindOnce(); } catch (_) {}
|
|
686
|
+
try { onCssChange(); } catch (_) {}
|
|
687
|
+
}, 50);
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
} catch (_) {}
|
|
691
|
+
} catch (_) {}
|
|
692
|
+
} else if (twHelper && typeof twHelper.watchTailwind === 'function') {
|
|
693
|
+
// Fallback to helper (cannot mute its initial logs)
|
|
694
|
+
child = twHelper.watchTailwind({ input: inputCss, output: outputCss, config: configPath, minify: false });
|
|
695
|
+
if (child) {
|
|
696
|
+
console.log('[tailwind] watching', prettyPath(inputCss));
|
|
697
|
+
try { fs.watch(outputCss, { persistent: false }, () => { try { onCssChange(); } catch (_) {} }); } catch (_) {}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
} catch (_) {}
|
|
702
|
+
console.log('[Watching]', prettyPath(CONTENT_DIR), '(Ctrl+C to stop)');
|
|
703
|
+
const rw = tryRecursiveWatch();
|
|
704
|
+
if (!rw) watchPerDir();
|
|
705
|
+
// Watch assets for live copy without full rebuild
|
|
706
|
+
if (fs.existsSync(ASSETS_DIR)) {
|
|
707
|
+
console.log('[Watching]', prettyPath(ASSETS_DIR), '(assets live-reload)');
|
|
708
|
+
const arw = tryRecursiveWatchAssets();
|
|
709
|
+
if (!arw) watchAssetsPerDir();
|
|
710
|
+
}
|
|
711
|
+
// Watch UI dist for live-reload and targeted search runtime rebuilds
|
|
712
|
+
if (fs.existsSync(UI_DIST_DIR)) {
|
|
713
|
+
console.log('[Watching]', prettyPath(UI_DIST_DIR), '(@canopy-iiif/app/ui dist)');
|
|
714
|
+
const urw = tryRecursiveWatchUiDist();
|
|
715
|
+
if (!urw) watchUiDistPerDir();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
module.exports = { dev };
|
|
720
|
+
|
|
721
|
+
if (require.main === module) dev();
|