@symbo.ls/mermaid 3.14.0

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 ADDED
@@ -0,0 +1,411 @@
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
+ export const DEFAULT_TEMPLATE_HTML = `<!doctype html>
41
+ <html>
42
+ <head>
43
+ '\${{{ META }}}'
44
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
45
+ <meta charset="UTF-8" />
46
+ '\${{{ IMPORTMAP }}}'
47
+ </head>
48
+ <body>
49
+ <div data-ssr>'\${{{ BODY }}}'</div>
50
+ <script>
51
+ window.process = { env: { NODE_ENV: '\${{{ NODE_ENV }}}' } };
52
+ </script>
53
+ <script>
54
+ '\${{{ SMBLS_IIFE }}}';
55
+ </script>
56
+ <script>
57
+ '\${{{ BUNDLE }}}';
58
+ </script>
59
+ '\${{{ EDITOR }}}'
60
+ </body>
61
+ </html>`;
62
+
63
+ /**
64
+ * Walk an object tree and convert function-like strings to funcql schemas.
65
+ * Used in environments where eval/Function is unavailable (CF Workers).
66
+ * Non-transpilable functions are replaced with null.
67
+ */
68
+ export function transpileFunctionStrings(obj, target = Array.isArray(obj) ? [] : {}) {
69
+ for (const prop in obj) {
70
+ if (!Object.prototype.hasOwnProperty.call(obj, prop)) continue;
71
+ const value = obj[prop];
72
+ if (typeof value === 'string') {
73
+ const t = value.trim();
74
+ const isFn =
75
+ (t.startsWith('(') && t.includes('=>')) ||
76
+ t.startsWith('function') ||
77
+ t.startsWith('async ');
78
+ if (isFn) {
79
+ const result = transpile(value);
80
+ target[prop] = result ? result.schema : null;
81
+ } else {
82
+ target[prop] = value;
83
+ }
84
+ } else if (Array.isArray(value)) {
85
+ target[prop] = value.map((item) => {
86
+ if (typeof item === 'string') {
87
+ const t = item.trim();
88
+ const isFn =
89
+ (t.startsWith('(') && t.includes('=>')) ||
90
+ t.startsWith('function') ||
91
+ t.startsWith('async ');
92
+ if (isFn) {
93
+ const r = transpile(item);
94
+ return r ? r.schema : null;
95
+ }
96
+ }
97
+ if (item && typeof item === 'object') return transpileFunctionStrings(item);
98
+ return item;
99
+ });
100
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
101
+ target[prop] = transpileFunctionStrings(value);
102
+ } else {
103
+ target[prop] = value;
104
+ }
105
+ }
106
+ return target;
107
+ }
108
+
109
+ /**
110
+ * Render project data into a full HTML document string.
111
+ *
112
+ * @param {object} data — project JSON
113
+ * @param {object} options
114
+ * @param {string} options.appkey — appkey identifier (for metadata, comments)
115
+ * @param {string} options.pathname — request pathname
116
+ * @param {string} [options.channel='production'] — 'production' | 'staging' | 'development'
117
+ * @param {boolean} [options.isProd=false] — controls SEO indexing (custom domains only)
118
+ * @param {object} [options.req] — full request object (Express-like) for getBundleScript context
119
+ * @param {object} [options.query] — query params (when no req available)
120
+ * @param {string} [options.userId] — auth user id, injects window.__SYMBOLS_AUTH__
121
+ * @param {string} [options.smblsIife] — preloaded smbls IIFE source (overrides default loader)
122
+ * @param {string} [options.editorBundle] — preview-editor bundle to inject (non-prod only)
123
+ * @param {string} [options.analyzedScript] — analytics tracking <script> to inject
124
+ * @param {string} [options.indexHtml] — override index.html template
125
+ * @param {object} [options.ssrCache] — pre-computed SSR cache (skips runtime brender)
126
+ * @param {object} [options.ProjectDataService] — used by getBundleScript for shared-lib merging
127
+ * @returns {Promise<{ html: string, status: number, contentType: string }>}
128
+ */
129
+ export async function renderProjectHtml(data, options = {}) {
130
+ const {
131
+ appkey,
132
+ pathname = '/',
133
+ channel = 'production',
134
+ isProd = false,
135
+ req,
136
+ query,
137
+ userId,
138
+ smblsIife: smblsIifeOverride,
139
+ editorBundle,
140
+ analyzedScript = '',
141
+ indexHtml: indexHtmlOverride,
142
+ ssrCache,
143
+ ProjectDataService,
144
+ } = options;
145
+
146
+ if (!data) {
147
+ return { html: '', status: 404, contentType: 'text/html' };
148
+ }
149
+
150
+ // Inject owner/key from appkey when missing (used by metadata fallback)
151
+ if (typeof appkey === 'string' && appkey.includes('/')) {
152
+ const [owner, key] = appkey.split('/');
153
+ if (!data.key) data.key = key;
154
+ if (!data.owner) data.owner = owner;
155
+ }
156
+
157
+ const route = normalizeRoute(pathname);
158
+ const resolved = resolvePageRoute(data.pages, pathname);
159
+ const shouldBrender = channel === 'production' || channel === 'staging';
160
+
161
+ // ── Brender / SSR ─────────────────────────────────────────────────────────
162
+ let prerenderedBody = '';
163
+ let brenderStyles = '';
164
+ let brenderResult = null;
165
+ let destringifiedData = null;
166
+ let hydrationScript = '';
167
+
168
+ if (ssrCache && shouldBrender) {
169
+ prerenderedBody = ssrCache.html || '';
170
+ const styleParts = [];
171
+ if (ssrCache.fontLinks) styleParts.push(ssrCache.fontLinks);
172
+ const globalStyles = [ssrCache.globalCSS || '', ssrCache.resetCss || '']
173
+ .filter(Boolean)
174
+ .join('\n');
175
+ if (globalStyles) styleParts.push(`<style>${globalStyles}</style>`);
176
+ if (ssrCache.css) styleParts.push(`<style data-emotion="smbls">${ssrCache.css}</style>`);
177
+ brenderStyles = styleParts.join('\n');
178
+
179
+ const hydrationParts = [];
180
+ if (ssrCache.ssrTranslations) {
181
+ const config = data.config || data.settings || {};
182
+ const polyglotCfg = config.polyglot || data.polyglot;
183
+ const storagePrefix = polyglotCfg?.storagePrefix || '';
184
+ const storageLangKey = polyglotCfg?.storageLangKey || '';
185
+ const seedEntries = [];
186
+ for (const lang in ssrCache.ssrTranslations) {
187
+ const map = ssrCache.ssrTranslations[lang];
188
+ if (map && typeof map === 'object') {
189
+ seedEntries.push(
190
+ `localStorage.setItem(${JSON.stringify(storagePrefix + lang)},${JSON.stringify(JSON.stringify(map))})`,
191
+ );
192
+ }
193
+ }
194
+ if (storageLangKey) {
195
+ const defaultLang = polyglotCfg?.defaultLang || 'en';
196
+ seedEntries.push(
197
+ `if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))` +
198
+ `localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`,
199
+ );
200
+ }
201
+ if (seedEntries.length) {
202
+ hydrationParts.push(`<script>try{${seedEntries.join(';')}}catch(e){}</script>`);
203
+ }
204
+ }
205
+ hydrationParts.push('<script>window.__BRENDER__=true</script>');
206
+ hydrationScript = hydrationParts.join('\n');
207
+
208
+ brenderResult = {
209
+ metadata: ssrCache.metadata,
210
+ prefetchedState: ssrCache.prefetchedState,
211
+ activeLang: ssrCache.activeLang,
212
+ };
213
+ } else if (renderRoute && shouldBrender) {
214
+ try {
215
+ const renderPath = resolved ? resolved.route : route;
216
+ log.debug(`[MERMAID] Running brender for route: ${renderPath}`);
217
+
218
+ // Detect eval availability (blocked in CF Workers).
219
+ let canDestringify = false;
220
+ try {
221
+ // eslint-disable-next-line no-eval
222
+ (0, eval)('1');
223
+ canDestringify = true;
224
+ } catch {
225
+ /* eval blocked */
226
+ }
227
+
228
+ destringifiedData = canDestringify
229
+ ? deepDestringifyFunctions(data)
230
+ : transpileFunctionStrings(data);
231
+
232
+ brenderResult = await renderRoute(destringifiedData, {
233
+ route: renderPath,
234
+ pathname: route,
235
+ });
236
+
237
+ if (brenderResult) {
238
+ prerenderedBody = brenderResult.html || '';
239
+ const parts = [];
240
+ if (brenderResult.fontLinks) parts.push(brenderResult.fontLinks);
241
+
242
+ const globalCSS = brenderResult.globalCSS || {};
243
+ const globalStyles = [
244
+ globalCSS.rootRule || '',
245
+ brenderResult.resetCss || '',
246
+ globalCSS.keyframeRules || '',
247
+ ]
248
+ .filter(Boolean)
249
+ .join('\n');
250
+ if (globalStyles) parts.push(`<style>${globalStyles}</style>`);
251
+ if (brenderResult.css)
252
+ parts.push(`<style data-emotion="smbls">${brenderResult.css}</style>`);
253
+ brenderStyles = parts.join('\n');
254
+
255
+ // Hydration signals
256
+ const hydrationParts = [];
257
+
258
+ if (brenderResult.ssrTranslations) {
259
+ const config = destringifiedData?.config || {};
260
+ const polyglotCfg = config.polyglot || destringifiedData?.polyglot;
261
+ const storagePrefix = polyglotCfg?.storagePrefix || '';
262
+ const storageLangKey = polyglotCfg?.storageLangKey || '';
263
+ const seedEntries = [];
264
+ for (const lang in brenderResult.ssrTranslations) {
265
+ const map = brenderResult.ssrTranslations[lang];
266
+ if (map && typeof map === 'object') {
267
+ seedEntries.push(
268
+ `localStorage.setItem(${JSON.stringify(storagePrefix + lang)},${JSON.stringify(JSON.stringify(map))})`,
269
+ );
270
+ }
271
+ }
272
+ if (storageLangKey) {
273
+ const defaultLang = polyglotCfg?.defaultLang || 'en';
274
+ seedEntries.push(
275
+ `if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))` +
276
+ `localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`,
277
+ );
278
+ }
279
+ if (seedEntries.length) {
280
+ hydrationParts.push(`<script>try{${seedEntries.join(';')}}catch(e){}</script>`);
281
+ }
282
+ }
283
+
284
+ // Embed BR registry for true DOM-adoption hydration (Express path only;
285
+ // CF worker variant skipped historically). Only when destringifyable.
286
+ if (
287
+ canDestringify &&
288
+ brenderResult.brRegistry &&
289
+ Object.keys(brenderResult.brRegistry).length
290
+ ) {
291
+ hydrationParts.push(
292
+ `<script>window.__BR_REGISTRY__=${JSON.stringify(brenderResult.brRegistry)}</script>`,
293
+ );
294
+ }
295
+
296
+ hydrationParts.push('<script>window.__BRENDER__=true</script>');
297
+ hydrationScript = hydrationParts.join('\n');
298
+
299
+ log.debug(
300
+ `[MERMAID] Brender completed: body=${prerenderedBody.length}b, registry=${Object.keys(brenderResult.brRegistry || {}).length}`,
301
+ );
302
+ }
303
+ } catch (brenderError) {
304
+ log.warn(`[MERMAID] Brender failed, falling back to client render: ${brenderError.message}`);
305
+ }
306
+ }
307
+
308
+ // ── Bundle script ─────────────────────────────────────────────────────────
309
+ const bundle = getBundleScript(appkey, data, req, { ProjectDataService });
310
+
311
+ // ── Importmap ─────────────────────────────────────────────────────────────
312
+ const importmap = getImportMapScript(data, data?.settings?.cdnProvider || 'skypack');
313
+
314
+ // ── HTML template ─────────────────────────────────────────────────────────
315
+ const indexHtmlFromFiles = data?.files && data.files['index.html'];
316
+ const indexHtml = indexHtmlOverride || indexHtmlFromFiles?.code || DEFAULT_TEMPLATE_HTML;
317
+
318
+ // ── Auth context ──────────────────────────────────────────────────────────
319
+ const authScript = userId
320
+ ? `<script>window.__SYMBOLS_AUTH__=${JSON.stringify({ userId })}</script>`
321
+ : '';
322
+
323
+ // ── Metadata ──────────────────────────────────────────────────────────────
324
+ const prefetchedState = brenderResult?.prefetchedState;
325
+ const config = data.config || data.settings || {};
326
+ const polyglot = config.polyglot || data.polyglot;
327
+ const activeLang = brenderResult?.activeLang || polyglot?.defaultLang || 'en';
328
+ const ssrContext =
329
+ prefetchedState && Object.keys(prefetchedState).length
330
+ ? { state: prefetchedState, lang: activeLang, actualPathname: pathname }
331
+ : { actualPathname: pathname };
332
+
333
+ const pageMetadata =
334
+ brenderResult?.metadata && Object.keys(brenderResult.metadata).length
335
+ ? brenderResult.metadata
336
+ : getPageMetadata(data, resolved ? resolved.route : route, ssrContext, {
337
+ isProduction: isProd,
338
+ channel,
339
+ });
340
+ const metaTags = generateMetaTags(pageMetadata, isProd);
341
+
342
+ // ── smbls IIFE ────────────────────────────────────────────────────────────
343
+ const smblsIife = smblsIifeOverride || getSmblsIife();
344
+ if (!smblsIife) {
345
+ log.error('[MERMAID] smbls IIFE bundle not available');
346
+ return {
347
+ html: 'Internal Server Error: smbls bundle not found',
348
+ status: 500,
349
+ contentType: 'text/plain',
350
+ };
351
+ }
352
+
353
+ // ── Editor + analytics injection ──────────────────────────────────────────
354
+ const shouldInjectEditor = editorBundle && !isProd;
355
+ const editorScript = shouldInjectEditor
356
+ ? `<script defer type="text/javascript">${editorBundle}</script>`
357
+ : '';
358
+
359
+ const envComment = isProd
360
+ ? `<!-- built with symbols.app -->\n`
361
+ : `<!-- symbols | ${appkey} | ${channel} | ${shouldBrender ? 'ssr' : 'csr'} | noindex -->\n`;
362
+
363
+ const html = replaceVariablesInFileSync(
364
+ envComment + indexHtml,
365
+ {
366
+ META: metaTags + '\n' + brenderStyles + '\n' + hydrationScript + '\n' + authScript,
367
+ BUNDLE: bundle,
368
+ BODY: prerenderedBody,
369
+ SMBLS_IIFE: smblsIife,
370
+ NODE_ENV: "'" + (process.env.NODE_ENV || 'development') + "'",
371
+ IMPORTMAP: importmap,
372
+ EDITOR: editorScript + analyzedScript,
373
+ },
374
+ { allowScript: true },
375
+ );
376
+
377
+ return { html, status: 200, contentType: 'text/html' };
378
+ }
379
+
380
+ /**
381
+ * Render the "project not published" 404 fallback page.
382
+ */
383
+ export function renderNotPublishedHtml(appkey, channel = 'production') {
384
+ const envLabel = channel !== 'production' ? ` (${channel})` : '';
385
+ return `<!doctype html>
386
+ <html>
387
+ <head>
388
+ <meta charset="UTF-8">
389
+ <meta name="viewport" content="width=device-width, initial-scale=1">
390
+ <meta name="robots" content="noindex">
391
+ <title>Not Published</title>
392
+ <style>
393
+ * { margin: 0; padding: 0; box-sizing: border-box; }
394
+ 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; }
395
+ .container { text-align: center; max-width: 480px; padding: 2rem; }
396
+ h1 { font-size: 1.25rem; font-weight: 500; margin-bottom: 0.75rem; }
397
+ p { color: #888; font-size: 0.875rem; line-height: 1.6; }
398
+ a { color: #888; text-decoration: underline; text-underline-offset: 2px; }
399
+ a:hover { color: #fafafa; }
400
+ .env { display: inline-block; font-size: 0.75rem; color: #555; background: #1a1a1a; padding: 0.2rem 0.5rem; border-radius: 4px; margin-top: 1rem; }
401
+ </style>
402
+ </head>
403
+ <body>
404
+ <div class="container">
405
+ <h1>This project hasn't been published yet${envLabel}</h1>
406
+ <p>Run <code>smbls publish</code> to deploy your project, or visit <a href="https://symbols.app">symbols.app</a> to get started.</p>
407
+ <span class="env">${appkey}${envLabel}</span>
408
+ </div>
409
+ </body>
410
+ </html>`;
411
+ }
package/src/route.js ADDED
@@ -0,0 +1,80 @@
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;
package/src/server.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Express factory for the mermaid runner.
3
+ *
4
+ * Self-sustaining: pass `jsonPath` to serve a project from a local JSON file
5
+ * with no infrastructure dependencies. Or inject a `getProjectData` callback
6
+ * to source data from KV / API / database / anywhere.
7
+ *
8
+ * import { createMermaidApp } from '@symbo.ls/mermaid';
9
+ *
10
+ * // From a JSON file:
11
+ * const app = createMermaidApp({ jsonPath: './app.json' });
12
+ *
13
+ * // From a custom data source (e.g. KV worker):
14
+ * const app = createMermaidApp({
15
+ * async getProjectData(req, { appkey }) {
16
+ * return { data: await myKv.fetch(appkey), channel: 'production' };
17
+ * },
18
+ * });
19
+ *
20
+ * app.listen(3333);
21
+ */
22
+
23
+ import path from 'path';
24
+ import fs from 'fs';
25
+ import express from 'express';
26
+ import cors from 'cors';
27
+ import log from './utils/logger.js';
28
+ import { STATIC_EXT } from './route.js';
29
+ import { renderProjectHtml, renderNotPublishedHtml, generateSitemap } from './render.js';
30
+
31
+ /**
32
+ * Load project data from a local JSON file (or app.json inside a directory).
33
+ */
34
+ export function loadJsonData(jsonPath) {
35
+ const resolved = path.resolve(jsonPath);
36
+ let filePath = resolved;
37
+
38
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
39
+ filePath = path.join(resolved, 'app.json');
40
+ }
41
+
42
+ if (!fs.existsSync(filePath)) {
43
+ throw new Error(`JSON data file not found: ${filePath}`);
44
+ }
45
+
46
+ const raw = fs.readFileSync(filePath, 'utf8');
47
+ const data = JSON.parse(raw);
48
+ log.info(`[MERMAID] Loaded project data from ${filePath}`);
49
+ return data;
50
+ }
51
+
52
+ function normalizeAppkey(rawAppkey, req) {
53
+ if (typeof rawAppkey !== 'string') return rawAppkey;
54
+
55
+ // When renderUrl includes its own query, split and merge nested params
56
+ if (rawAppkey.includes('?') && req) {
57
+ const [baseAppkey, nestedQuery] = rawAppkey.split('?');
58
+ rawAppkey = baseAppkey.replace(/\/$/, '');
59
+
60
+ try {
61
+ const nestedParams = new URLSearchParams(nestedQuery);
62
+ for (const [key, value] of nestedParams.entries()) {
63
+ if (req.query[key] === undefined) req.query[key] = value;
64
+ }
65
+ } catch (err) {
66
+ log.warn(`[MERMAID] Failed to parse nested query in renderUrl: ${err.message}`);
67
+ }
68
+ }
69
+
70
+ // Strip scheme, port, leading www., trailing slash
71
+ return rawAppkey
72
+ .replace(/^https?:\/\//, '')
73
+ .split(':')[0]
74
+ .replace(/^www\./, '')
75
+ .replace(/\/$/, '');
76
+ }
77
+
78
+ /**
79
+ * Create an Express app that serves Symbols projects.
80
+ *
81
+ * @param {object} options
82
+ * @param {string} [options.jsonPath] — local JSON file (or directory containing app.json)
83
+ * @param {string} [options.appkeyOverride] — force a specific appkey for all requests
84
+ * @param {string} [options.channel='production'] — channel for JSON-mode rendering
85
+ * @param {Function} [options.getProjectData] — async (req, ctx) → { data, appkey?, channel?, isProd? }
86
+ * @param {boolean} [options.cors=true] — enable CORS middleware
87
+ * @param {object} [options.editor] — { bundle: string } to inject preview-editor for non-prod
88
+ * @returns {express.Application}
89
+ */
90
+ export function createMermaidApp(options = {}) {
91
+ const {
92
+ jsonPath,
93
+ appkeyOverride,
94
+ channel: defaultChannel = 'production',
95
+ getProjectData,
96
+ cors: enableCors = true,
97
+ editor,
98
+ } = options;
99
+
100
+ // Pre-load JSON data if a path is provided
101
+ let jsonData = null;
102
+ if (jsonPath) jsonData = loadJsonData(jsonPath);
103
+
104
+ const app = express();
105
+
106
+ if (enableCors) app.use(cors({ origin: '*', methods: 'GET, POST' }));
107
+ app.use(express.json());
108
+
109
+ app.get('/health', (_req, res) =>
110
+ res.send({ alive: true, service: 'mermaid', version: '3.14.0' }),
111
+ );
112
+
113
+ app.get('*', async (req, res) => {
114
+ try {
115
+ const pathname = req.originalUrl.split('?')[0];
116
+
117
+ // Skip static asset requests
118
+ if (STATIC_EXT.test(pathname)) {
119
+ if (pathname === '/favicon.ico') return res.status(204).end();
120
+ return res.status(404).send('Not Found');
121
+ }
122
+
123
+ // Resolve appkey from override / renderUrl / Host header
124
+ let rawAppkey = appkeyOverride || req.query.renderUrl || req.headers.host;
125
+ rawAppkey = normalizeAppkey(rawAppkey, req);
126
+
127
+ let data = null;
128
+ let appkey = rawAppkey;
129
+ let channel = defaultChannel;
130
+ let isProd = false;
131
+
132
+ if (jsonData) {
133
+ data = jsonData;
134
+ log.debug('[MERMAID] Using local JSON data');
135
+ } else if (getProjectData) {
136
+ const result = await getProjectData(req, { appkey: rawAppkey });
137
+ if (result) {
138
+ data = result.data;
139
+ if (result.appkey) appkey = result.appkey;
140
+ if (result.channel) channel = result.channel;
141
+ if (typeof result.isProd === 'boolean') isProd = result.isProd;
142
+ }
143
+ }
144
+
145
+ if (!appkey) {
146
+ return res.send(
147
+ 'Hello, this is Mermaid (Symbols runner). Read developer documentation at https://symbols.app/developers',
148
+ );
149
+ }
150
+
151
+ if (!data) {
152
+ log.warn(`[MERMAID] Project not found for appkey: ${appkey}`);
153
+ return res.status(404).type('html').send(renderNotPublishedHtml(appkey, channel));
154
+ }
155
+
156
+ // Sitemap
157
+ if (pathname === '/sitemap.xml') {
158
+ if (generateSitemap && data.pages && Object.keys(data.pages).length) {
159
+ const baseUrl = `https://${appkey}`;
160
+ const sitemap = generateSitemap(baseUrl, data.pages);
161
+ return res.type('application/xml').send(sitemap);
162
+ }
163
+ return res.status(404).send('No sitemap available');
164
+ }
165
+
166
+ const userId = req.headers['x-user-id'] || '';
167
+
168
+ const result = await renderProjectHtml(data, {
169
+ appkey,
170
+ pathname,
171
+ channel,
172
+ isProd,
173
+ req,
174
+ userId,
175
+ editorBundle: editor?.bundle,
176
+ });
177
+
178
+ return res
179
+ .status(result.status)
180
+ .set({ host: 'https://' + appkey + '/' })
181
+ .type(result.contentType === 'text/html' ? 'html' : result.contentType)
182
+ .send(result.html);
183
+ } catch (error) {
184
+ log.error(`[MERMAID] Error in request: ${error}`);
185
+ return res.status(500).send('Internal Server Error');
186
+ }
187
+ });
188
+
189
+ return app;
190
+ }