@symbo.ls/mermaid 3.14.4 → 3.14.5

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/src/render.js DELETED
@@ -1,502 +0,0 @@
1
- /**
2
- * Pure project → HTML renderer.
3
- *
4
- * Runtime-agnostic: takes project JSON + render options and returns a
5
- * complete HTML document. Used by both the Express server (./server.js)
6
- * and Cloudflare Worker handlers (in @symbo.ls/mermaid-server).
7
- *
8
- * Optional brender SSR is loaded lazily and silently skipped if missing.
9
- */
10
-
11
- import log from './utils/logger.js';
12
- import replaceVariablesInFileSync from './utils/vartranformer.js';
13
- import { getBundleScript, getSmblsIife } from './bundle.js';
14
- import { deepDestringifyFunctions } from './utils/deepDestringify.js';
15
- import { transpile } from './utils/funcql-transpile.js';
16
- import { normalizeRoute, resolvePageRoute } from './route.js';
17
- import { generateMetaTags, getPageMetadata, getImportMapScript } from '@symbo.ls/utils';
18
-
19
- // ── Optional brender SSR ────────────────────────────────────────────────────
20
- const BRENDER_ENABLED = process.env.BRENDER !== 'false';
21
- let renderRoute = null;
22
- let generateSitemap = null;
23
- let prefetchPageData = null;
24
- if (BRENDER_ENABLED) {
25
- try {
26
- const brender = await import('@symbo.ls/brender');
27
- renderRoute = brender.renderRoute;
28
- generateSitemap = brender.generateSitemap;
29
- prefetchPageData = brender.prefetchPageData;
30
- } catch (err) {
31
- log.debug(`[MERMAID] Brender not available: ${err.message}`);
32
- }
33
- }
34
-
35
- export { generateSitemap, prefetchPageData };
36
-
37
- // ── Default HTML template ───────────────────────────────────────────────────
38
- // Inlined so the plugin works in any runtime (Node, CF Workers, browser).
39
- // Mirrors src/template.html — keep in sync.
40
- //
41
- // ROOT_ATTRS + HEAD_BOOT eliminate the FOUC that used to happen between
42
- // HTML-parse-finish and the end-of-body bundle IIFE running (~1s on a
43
- // 2.5 MB bundle). For projects with a concrete `globalTheme` (e.g. 'light'),
44
- // SSR bakes `<html data-theme="light">` so first paint is on the right
45
- // theme. For 'auto' projects, the inline HEAD_BOOT script resolves URL
46
- // override / localStorage / matchMedia synchronously before any CSS
47
- // applies. The end-of-body IIFE in bundle.js is kept as an idempotent
48
- // safety net.
49
- export const DEFAULT_TEMPLATE_HTML = `<!doctype html>
50
- <html'\${{{ ROOT_ATTRS }}}'>
51
- <head>
52
- <meta charset="UTF-8" />
53
- '\${{{ HEAD_BOOT }}}'
54
- '\${{{ META }}}'
55
- <meta name="viewport" content="width=device-width, initial-scale=1" />
56
- '\${{{ IMPORTMAP }}}'
57
- </head>
58
- <body>
59
- <div data-ssr>'\${{{ BODY }}}'</div>
60
- <script>
61
- window.process = { env: { NODE_ENV: '\${{{ NODE_ENV }}}' } };
62
- </script>
63
- <script>
64
- '\${{{ SMBLS_IIFE }}}';
65
- </script>
66
- <script>
67
- '\${{{ BUNDLE }}}';
68
- </script>
69
- '\${{{ EDITOR }}}'
70
- </body>
71
- </html>`;
72
-
73
- /**
74
- * Walk an object tree and convert function-like strings to funcql schemas.
75
- * Used in environments where eval/Function is unavailable (CF Workers).
76
- * Non-transpilable functions are replaced with null.
77
- */
78
- export function transpileFunctionStrings(obj, target = Array.isArray(obj) ? [] : {}) {
79
- for (const prop in obj) {
80
- if (!Object.prototype.hasOwnProperty.call(obj, prop)) continue;
81
- const value = obj[prop];
82
- if (typeof value === 'string') {
83
- const t = value.trim();
84
- const isFn =
85
- (t.startsWith('(') && t.includes('=>')) ||
86
- t.startsWith('function') ||
87
- t.startsWith('async ');
88
- if (isFn) {
89
- const result = transpile(value);
90
- target[prop] = result ? result.schema : null;
91
- } else {
92
- target[prop] = value;
93
- }
94
- } else if (Array.isArray(value)) {
95
- target[prop] = value.map((item) => {
96
- if (typeof item === 'string') {
97
- const t = item.trim();
98
- const isFn =
99
- (t.startsWith('(') && t.includes('=>')) ||
100
- t.startsWith('function') ||
101
- t.startsWith('async ');
102
- if (isFn) {
103
- const r = transpile(item);
104
- return r ? r.schema : null;
105
- }
106
- }
107
- if (item && typeof item === 'object') return transpileFunctionStrings(item);
108
- return item;
109
- });
110
- } else if (value && typeof value === 'object' && !Array.isArray(value)) {
111
- target[prop] = transpileFunctionStrings(value);
112
- } else {
113
- target[prop] = value;
114
- }
115
- }
116
- return target;
117
- }
118
-
119
- /**
120
- * Render project data into a full HTML document string.
121
- *
122
- * @param {object} data — project JSON
123
- * @param {object} options
124
- * @param {string} options.appkey — appkey identifier (for metadata, comments)
125
- * @param {string} options.pathname — request pathname
126
- * @param {string} [options.channel='production'] — sysEnv label (production|upcoming|next|...);
127
- * informational, used for metadata. NOT the same axis as userEnv — sysEnv is which
128
- * deployment served the request, userEnv is which version of project content.
129
- * @param {string} [options.userEnv='production'] — projectEnv (production|staging|development|...);
130
- * drives the SSR-vs-CSR decision and the `<!-- symbols | … -->` comment. Defaults
131
- * to channel for back-compat with callers that haven't been updated.
132
- * @param {boolean} [options.isProd=false] — controls SEO indexing (custom domains only)
133
- * @param {object} [options.req] — full request object (Express-like) for getBundleScript context
134
- * @param {object} [options.query] — query params (when no req available)
135
- * @param {string} [options.userId] — auth user id, injects window.__SYMBOLS_AUTH__
136
- * @param {string} [options.smblsIife] — preloaded smbls IIFE source (overrides default loader)
137
- * @param {string} [options.editorBundle] — preview-editor bundle to inject (non-prod only)
138
- * @param {string} [options.analyzedScript] — analytics tracking <script> to inject
139
- * @param {string} [options.indexHtml] — override index.html template
140
- * @param {object} [options.ssrCache] — pre-computed SSR cache (skips runtime brender)
141
- * @param {object} [options.ProjectDataService] — used by getBundleScript for shared-lib merging
142
- * @returns {Promise<{ html: string, status: number, contentType: string }>}
143
- */
144
- export async function renderProjectHtml(data, options = {}) {
145
- const {
146
- appkey,
147
- pathname = '/',
148
- channel = 'production',
149
- userEnv = channel,
150
- isProd = false,
151
- req,
152
- query,
153
- userId,
154
- smblsIife: smblsIifeOverride,
155
- editorBundle,
156
- analyzedScript = '',
157
- indexHtml: indexHtmlOverride,
158
- ssrCache,
159
- ProjectDataService,
160
- } = options;
161
-
162
- if (!data) {
163
- return { html: '', status: 404, contentType: 'text/html' };
164
- }
165
-
166
- // Inject owner/key from appkey when missing (used by metadata fallback)
167
- if (typeof appkey === 'string' && appkey.includes('/')) {
168
- const [owner, key] = appkey.split('/');
169
- if (!data.key) data.key = key;
170
- if (!data.owner) data.owner = owner;
171
- }
172
-
173
- const route = normalizeRoute(pathname);
174
- const resolved = resolvePageRoute(data.pages, pathname);
175
- // SSR is a projectEnv decision — render published (production) and pre-published
176
- // (staging) slots server-side; render dev slot client-side. Was previously
177
- // checking `channel` (sysEnv), which gave wrong answers on canonical hosts
178
- // like *.upcoming.symbo.ls (sysEnv=upcoming, userEnv=production → was CSR
179
- // when it should have been SSR).
180
- const shouldBrender = userEnv === 'production' || userEnv === 'staging';
181
-
182
- // ── Brender / SSR ─────────────────────────────────────────────────────────
183
- let prerenderedBody = '';
184
- let brenderStyles = '';
185
- let brenderResult = null;
186
- let destringifiedData = null;
187
- let hydrationScript = '';
188
-
189
- if (ssrCache && shouldBrender) {
190
- prerenderedBody = ssrCache.html || '';
191
- const styleParts = [];
192
- if (ssrCache.fontLinks) styleParts.push(ssrCache.fontLinks);
193
- const globalStyles = [ssrCache.globalCSS || '', ssrCache.resetCss || '']
194
- .filter(Boolean)
195
- .join('\n');
196
- if (globalStyles) styleParts.push(`<style>${globalStyles}</style>`);
197
- if (ssrCache.css) styleParts.push(`<style data-emotion="smbls">${ssrCache.css}</style>`);
198
- brenderStyles = styleParts.join('\n');
199
-
200
- const hydrationParts = [];
201
- if (ssrCache.ssrTranslations) {
202
- const config = data.config || data.settings || {};
203
- const polyglotCfg = config.polyglot || data.polyglot;
204
- const storagePrefix = polyglotCfg?.storagePrefix || '';
205
- const storageLangKey = polyglotCfg?.storageLangKey || '';
206
- const seedEntries = [];
207
- for (const lang in ssrCache.ssrTranslations) {
208
- const map = ssrCache.ssrTranslations[lang];
209
- if (map && typeof map === 'object') {
210
- seedEntries.push(
211
- `localStorage.setItem(${JSON.stringify(storagePrefix + lang)},${JSON.stringify(JSON.stringify(map))})`,
212
- );
213
- }
214
- }
215
- if (storageLangKey) {
216
- const defaultLang = polyglotCfg?.defaultLang || 'en';
217
- seedEntries.push(
218
- `if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))` +
219
- `localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`,
220
- );
221
- }
222
- if (seedEntries.length) {
223
- hydrationParts.push(`<script>try{${seedEntries.join(';')}}catch(e){}</script>`);
224
- }
225
- }
226
- hydrationParts.push('<script>window.__BRENDER__=true</script>');
227
- hydrationScript = hydrationParts.join('\n');
228
-
229
- brenderResult = {
230
- metadata: ssrCache.metadata,
231
- prefetchedState: ssrCache.prefetchedState,
232
- activeLang: ssrCache.activeLang,
233
- };
234
- } else if (renderRoute && shouldBrender) {
235
- try {
236
- const renderPath = resolved ? resolved.route : route;
237
- log.debug(`[MERMAID] Running brender for route: ${renderPath}`);
238
-
239
- // Detect eval availability (blocked in CF Workers).
240
- let canDestringify = false;
241
- try {
242
- // eslint-disable-next-line no-eval
243
- (0, eval)('1');
244
- canDestringify = true;
245
- } catch {
246
- /* eval blocked */
247
- }
248
-
249
- destringifiedData = canDestringify
250
- ? deepDestringifyFunctions(data)
251
- : transpileFunctionStrings(data);
252
-
253
- brenderResult = await renderRoute(destringifiedData, {
254
- route: renderPath,
255
- pathname: route,
256
- });
257
-
258
- if (brenderResult) {
259
- prerenderedBody = brenderResult.html || '';
260
- const parts = [];
261
- if (brenderResult.fontLinks) parts.push(brenderResult.fontLinks);
262
-
263
- const globalCSS = brenderResult.globalCSS || {};
264
- const globalStyles = [
265
- globalCSS.rootRule || '',
266
- brenderResult.resetCss || '',
267
- globalCSS.keyframeRules || '',
268
- ]
269
- .filter(Boolean)
270
- .join('\n');
271
- if (globalStyles) parts.push(`<style>${globalStyles}</style>`);
272
- if (brenderResult.css)
273
- parts.push(`<style data-emotion="smbls">${brenderResult.css}</style>`);
274
- brenderStyles = parts.join('\n');
275
-
276
- // Hydration signals
277
- const hydrationParts = [];
278
-
279
- if (brenderResult.ssrTranslations) {
280
- const config = destringifiedData?.config || {};
281
- const polyglotCfg = config.polyglot || destringifiedData?.polyglot;
282
- const storagePrefix = polyglotCfg?.storagePrefix || '';
283
- const storageLangKey = polyglotCfg?.storageLangKey || '';
284
- const seedEntries = [];
285
- for (const lang in brenderResult.ssrTranslations) {
286
- const map = brenderResult.ssrTranslations[lang];
287
- if (map && typeof map === 'object') {
288
- seedEntries.push(
289
- `localStorage.setItem(${JSON.stringify(storagePrefix + lang)},${JSON.stringify(JSON.stringify(map))})`,
290
- );
291
- }
292
- }
293
- if (storageLangKey) {
294
- const defaultLang = polyglotCfg?.defaultLang || 'en';
295
- seedEntries.push(
296
- `if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))` +
297
- `localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`,
298
- );
299
- }
300
- if (seedEntries.length) {
301
- hydrationParts.push(`<script>try{${seedEntries.join(';')}}catch(e){}</script>`);
302
- }
303
- }
304
-
305
- // Embed BR registry for true DOM-adoption hydration (Express path only;
306
- // CF worker variant skipped historically). Only when destringifyable.
307
- if (
308
- canDestringify &&
309
- brenderResult.brRegistry &&
310
- Object.keys(brenderResult.brRegistry).length
311
- ) {
312
- hydrationParts.push(
313
- `<script>window.__BR_REGISTRY__=${JSON.stringify(brenderResult.brRegistry)}</script>`,
314
- );
315
- }
316
-
317
- hydrationParts.push('<script>window.__BRENDER__=true</script>');
318
- hydrationScript = hydrationParts.join('\n');
319
-
320
- log.debug(
321
- `[MERMAID] Brender completed: body=${prerenderedBody.length}b, registry=${Object.keys(brenderResult.brRegistry || {}).length}`,
322
- );
323
- }
324
- } catch (brenderError) {
325
- log.warn(`[MERMAID] Brender failed, falling back to client render: ${brenderError.message}`);
326
- }
327
- }
328
-
329
- // ── Bundle script ─────────────────────────────────────────────────────────
330
- const bundle = getBundleScript(appkey, data, req, { ProjectDataService });
331
-
332
- // ── Importmap ─────────────────────────────────────────────────────────────
333
- // esm.sh is the default — it resolves transitive deps correctly and matches
334
- // the `context.packageManager = 'esm.sh'` set by bundle.js at runtime.
335
- // Skypack frequently 500s on transitive deps (e.g. lit-html via ninja-keys).
336
- const importmap = getImportMapScript(data, data?.settings?.cdnProvider || 'esmsh');
337
-
338
- // ── HTML template ─────────────────────────────────────────────────────────
339
- const indexHtmlFromFiles = data?.files && data.files['index.html'];
340
- const indexHtml = indexHtmlOverride || indexHtmlFromFiles?.code || DEFAULT_TEMPLATE_HTML;
341
-
342
- // ── Auth context ──────────────────────────────────────────────────────────
343
- const authScript = userId
344
- ? `<script>window.__SYMBOLS_AUTH__=${JSON.stringify({ userId })}</script>`
345
- : '';
346
-
347
- // ── Metadata ──────────────────────────────────────────────────────────────
348
- const prefetchedState = brenderResult?.prefetchedState;
349
- const config = data.config || data.settings || {};
350
- const polyglot = config.polyglot || data.polyglot;
351
- const activeLang = brenderResult?.activeLang || polyglot?.defaultLang || 'en';
352
- const ssrContext =
353
- prefetchedState && Object.keys(prefetchedState).length
354
- ? { state: prefetchedState, lang: activeLang, actualPathname: pathname }
355
- : { actualPathname: pathname };
356
-
357
- const pageMetadata =
358
- brenderResult?.metadata && Object.keys(brenderResult.metadata).length
359
- ? brenderResult.metadata
360
- : getPageMetadata(data, resolved ? resolved.route : route, ssrContext, {
361
- isProduction: isProd,
362
- channel,
363
- });
364
- const metaTags = generateMetaTags(pageMetadata, isProd);
365
-
366
- // ── smbls IIFE ────────────────────────────────────────────────────────────
367
- const smblsIife = smblsIifeOverride || getSmblsIife();
368
- if (!smblsIife) {
369
- log.error('[MERMAID] smbls IIFE bundle not available');
370
- return {
371
- html: 'Internal Server Error: smbls bundle not found',
372
- status: 500,
373
- contentType: 'text/plain',
374
- };
375
- }
376
-
377
- // ── Editor + analytics injection ──────────────────────────────────────────
378
- const shouldInjectEditor = editorBundle && !isProd;
379
- const editorScript = shouldInjectEditor
380
- ? `<script defer type="text/javascript">${editorBundle}</script>`
381
- : '';
382
-
383
- // Comment label is the projectEnv (which version of the content is being
384
- // served). Was previously `${channel}` (sysEnv) which mislabelled canonical
385
- // *.upcoming.symbo.ls hosts as "upcoming" when the actual projectEnv is
386
- // "production".
387
- const envComment = isProd
388
- ? `<!-- built with symbols.app -->\n`
389
- : `<!-- symbols | ${appkey} | ${userEnv} | ${shouldBrender ? 'ssr' : 'csr'} | noindex -->\n`;
390
-
391
- // ── Theme: zero-flash boot ─────────────────────────────────────────────────
392
- // Eliminates the dark→light (or light→dark) flash that used to happen
393
- // between first paint and the end-of-body bundle IIFE setting data-theme.
394
- // Two layers:
395
- // 1. Concrete `globalTheme` ('light'|'dark'|<custom>) → bake `data-theme`
396
- // onto <html> server-side. Zero JS, perfect first paint.
397
- // 2. URL ?globalTheme= override / localStorage user choice / 'auto' OS
398
- // pref → small inline <head> script resolves synchronously before
399
- // any CSS applies. Browser renders first paint with the resolved
400
- // data-theme, no flash.
401
- // Layer 3 (existing end-of-body IIFE in bundle.js) is the back-compat
402
- // safety net for cases the head script didn't reach.
403
- const useDocumentTheme = data.useDocumentTheme !== false;
404
- const concreteTheme =
405
- data.globalTheme && data.globalTheme !== 'auto' ? String(data.globalTheme) : null;
406
- const themeStorageKey = data.themeStorageKey ? String(data.themeStorageKey) : '';
407
-
408
- // SSR-baked attribute on <html>. Only when the project has a concrete
409
- // theme — for 'auto' we leave <html> bare so the head script (or
410
- // CSS @media) can decide. Custom schemes (e.g. 'ocean') flow through.
411
- const rootAttrs =
412
- useDocumentTheme && concreteTheme ? ' data-theme="' + concreteTheme + '"' : '';
413
-
414
- // Inline head boot. Runs synchronously during HTML parse, before any
415
- // CSS applies. Order matches resolveAndApplyTheme in
416
- // smbls/packages/smbls/src/index.js:
417
- // 1. ?globalTheme= URL param (preview/editor iframes push themes here)
418
- // 2. localStorage[themeStorageKey] (persisted user toggle)
419
- // 3. concreteTheme baked from project config (for 'auto' projects this
420
- // is null and we fall to step 4)
421
- // 4. matchMedia(prefers-color-scheme: dark) — OS fallback
422
- // Skipped entirely when useDocumentTheme is false.
423
- const headBoot = useDocumentTheme
424
- ? '<script>(function(){try{var t=null;' +
425
- "try{t=new URLSearchParams(location.search).get('globalTheme')}catch(e){}" +
426
- (themeStorageKey
427
- ? 'if(!t){try{t=localStorage.getItem(' +
428
- JSON.stringify(themeStorageKey) +
429
- ')}catch(e){}}'
430
- : '') +
431
- "if(!t||t==='auto'){" +
432
- (concreteTheme
433
- ? 't=' + JSON.stringify(concreteTheme) + ';'
434
- : "try{t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}catch(e){}") +
435
- '}' +
436
- "if(t&&t!=='auto')document.documentElement.setAttribute('data-theme',t);" +
437
- '}catch(e){}})();</script>\n <meta name="color-scheme" content="light dark">'
438
- : '';
439
-
440
- const html = replaceVariablesInFileSync(
441
- envComment + indexHtml,
442
- {
443
- ROOT_ATTRS: rootAttrs,
444
- HEAD_BOOT: headBoot,
445
- META: metaTags + '\n' + brenderStyles + '\n' + hydrationScript + '\n' + authScript,
446
- BUNDLE: bundle,
447
- BODY: prerenderedBody,
448
- SMBLS_IIFE: smblsIife,
449
- NODE_ENV: "'" + (process.env.NODE_ENV || 'development') + "'",
450
- IMPORTMAP: importmap,
451
- EDITOR: editorScript + analyzedScript,
452
- },
453
- { allowScript: true },
454
- );
455
-
456
- return { html, status: 200, contentType: 'text/html' };
457
- }
458
-
459
- /**
460
- * Render the "project not published" 404 fallback page. When `reason` is
461
- * 'not_public', renders a tailored "site not yet public" message —
462
- * specifically for paid custom domains pointing at a project whose
463
- * visibility is still private.
464
- */
465
- export function renderNotPublishedHtml(appkey, channel = 'production', reason = null) {
466
- const envLabel = channel !== 'production' ? ` (${channel})` : '';
467
- const isNotPublic = reason === 'not_public';
468
- const title = isNotPublic ? 'Site Not Yet Public' : 'Not Published';
469
- const heading = isNotPublic
470
- ? `This site isn't public yet${envLabel}`
471
- : `This project hasn't been published yet${envLabel}`;
472
- const body = isNotPublic
473
- ? `The owner has connected this domain but the project's visibility is set to private. Once they switch it to public in the workspace settings, the site will appear here automatically.`
474
- : `Run <code>smbls publish</code> to deploy your project, or visit <a href="https://symbols.app">symbols.app</a> to get started.`;
475
- return `<!doctype html>
476
- <html>
477
- <head>
478
- <meta charset="UTF-8">
479
- <meta name="viewport" content="width=device-width, initial-scale=1">
480
- <meta name="robots" content="noindex">
481
- <title>${title}</title>
482
- <style>
483
- * { margin: 0; padding: 0; box-sizing: border-box; }
484
- body { min-height: 100vh; display: flex; align-items: center; justify-content: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0a0a0a; color: #fafafa; }
485
- .container { text-align: center; max-width: 520px; padding: 2rem; }
486
- h1 { font-size: 1.25rem; font-weight: 500; margin-bottom: 0.75rem; }
487
- p { color: #888; font-size: 0.875rem; line-height: 1.6; }
488
- code { background: #1a1a1a; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.8rem; color: #ccc; }
489
- a { color: #888; text-decoration: underline; text-underline-offset: 2px; }
490
- a:hover { color: #fafafa; }
491
- .env { display: inline-block; font-size: 0.75rem; color: #555; background: #1a1a1a; padding: 0.2rem 0.5rem; border-radius: 4px; margin-top: 1rem; }
492
- </style>
493
- </head>
494
- <body>
495
- <div class="container">
496
- <h1>${heading}</h1>
497
- <p>${body}</p>
498
- <span class="env">${appkey}${envLabel}</span>
499
- </div>
500
- </body>
501
- </html>`;
502
- }
package/src/route.js DELETED
@@ -1,80 +0,0 @@
1
- /**
2
- * Route helpers — pathname → page resolution.
3
- *
4
- * Pure functions, runtime-agnostic. Used by both the Express server
5
- * and Cloudflare Worker variants of mermaid.
6
- */
7
-
8
- /**
9
- * Normalize a pathname for page matching:
10
- * - strip trailing slash (except root '/')
11
- * - ensure leading slash
12
- */
13
- export function normalizeRoute(pathname) {
14
- let route = pathname || '/';
15
- if (!route.startsWith('/')) route = '/' + route;
16
- if (route.length > 1 && route.endsWith('/')) route = route.slice(0, -1);
17
- return route;
18
- }
19
-
20
- /**
21
- * Extract :params from a dynamic route pattern.
22
- * extractRouteParams('/podcast/:id', '/podcast/abc') → { id: 'abc' }
23
- */
24
- export function extractRouteParams(pattern, pathname) {
25
- const patternParts = pattern.split('/');
26
- const pathParts = pathname.split('/');
27
- const params = {};
28
- for (let i = 0; i < patternParts.length; i++) {
29
- if (patternParts[i].startsWith(':')) {
30
- params[patternParts[i].slice(1)] = pathParts[i];
31
- }
32
- }
33
- return params;
34
- }
35
-
36
- /**
37
- * Resolve a route against available pages.
38
- * Tries exact match first, then dynamic routes (with :params), then '/' fallback.
39
- * Returns { route, fallback, params? } or null if no pages exist.
40
- */
41
- export function resolvePageRoute(pages, pathname) {
42
- if (!pages || !Object.keys(pages).length) return null;
43
-
44
- const route = normalizeRoute(pathname);
45
-
46
- // Exact match
47
- if (pages[route]) return { route, fallback: false };
48
-
49
- // Dynamic route match (e.g. /podcast/:id matches /podcast/abc-123)
50
- const segments = route.split('/');
51
- for (const pageRoute of Object.keys(pages)) {
52
- if (!pageRoute.includes(':')) continue;
53
- const pageSegments = pageRoute.split('/');
54
- if (pageSegments.length !== segments.length) continue;
55
- let match = true;
56
- for (let i = 0; i < pageSegments.length; i++) {
57
- if (pageSegments[i].startsWith(':')) continue;
58
- if (pageSegments[i] !== segments[i]) {
59
- match = false;
60
- break;
61
- }
62
- }
63
- if (match) {
64
- return {
65
- route: pageRoute,
66
- fallback: false,
67
- params: extractRouteParams(pageRoute, route),
68
- };
69
- }
70
- }
71
-
72
- // Fallback to root
73
- if (route !== '/' && pages['/']) return { route: '/', fallback: true };
74
-
75
- return null;
76
- }
77
-
78
- // Static asset extensions — skip SSR for these
79
- export const STATIC_EXT =
80
- /\.(js|mjs|css|map|png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf|eot|otf|json|xml|txt|webmanifest|mp4|webm|mp3|ogg|wav|pdf)$/i;