@symbo.ls/mermaid 3.14.1 → 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/CHANGELOG.md +9 -0
- package/dist/esm/bin/start.js +2 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/src/bundle.js +97 -0
- package/dist/esm/src/render.js +61 -0
- package/dist/esm/src/route.js +1 -0
- package/dist/esm/src/server.js +1 -0
- package/dist/esm/src/utils/deepDestringify.js +1 -0
- package/dist/esm/src/utils/funcql-transpile.js +1 -0
- package/dist/esm/src/utils/logger.js +1 -0
- package/dist/esm/src/utils/vartranformer.js +1 -0
- package/package.json +63 -18
- package/index.js +0 -21
- package/src/bundle.js +0 -169
- package/src/render.js +0 -426
- package/src/route.js +0 -80
- package/src/server.js +0 -190
- package/src/template.html +0 -22
- package/src/utils/deepDestringify.js +0 -149
- package/src/utils/funcql-transpile.js +0 -346
- package/src/utils/logger.js +0 -131
- package/src/utils/vartranformer.js +0 -30
package/src/render.js
DELETED
|
@@ -1,426 +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
|
-
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
|
-
// esm.sh is the default — it resolves transitive deps correctly and matches
|
|
313
|
-
// the `context.packageManager = 'esm.sh'` set by bundle.js at runtime.
|
|
314
|
-
// Skypack frequently 500s on transitive deps (e.g. lit-html via ninja-keys).
|
|
315
|
-
const importmap = getImportMapScript(data, data?.settings?.cdnProvider || 'esmsh');
|
|
316
|
-
|
|
317
|
-
// ── HTML template ─────────────────────────────────────────────────────────
|
|
318
|
-
const indexHtmlFromFiles = data?.files && data.files['index.html'];
|
|
319
|
-
const indexHtml = indexHtmlOverride || indexHtmlFromFiles?.code || DEFAULT_TEMPLATE_HTML;
|
|
320
|
-
|
|
321
|
-
// ── Auth context ──────────────────────────────────────────────────────────
|
|
322
|
-
const authScript = userId
|
|
323
|
-
? `<script>window.__SYMBOLS_AUTH__=${JSON.stringify({ userId })}</script>`
|
|
324
|
-
: '';
|
|
325
|
-
|
|
326
|
-
// ── Metadata ──────────────────────────────────────────────────────────────
|
|
327
|
-
const prefetchedState = brenderResult?.prefetchedState;
|
|
328
|
-
const config = data.config || data.settings || {};
|
|
329
|
-
const polyglot = config.polyglot || data.polyglot;
|
|
330
|
-
const activeLang = brenderResult?.activeLang || polyglot?.defaultLang || 'en';
|
|
331
|
-
const ssrContext =
|
|
332
|
-
prefetchedState && Object.keys(prefetchedState).length
|
|
333
|
-
? { state: prefetchedState, lang: activeLang, actualPathname: pathname }
|
|
334
|
-
: { actualPathname: pathname };
|
|
335
|
-
|
|
336
|
-
const pageMetadata =
|
|
337
|
-
brenderResult?.metadata && Object.keys(brenderResult.metadata).length
|
|
338
|
-
? brenderResult.metadata
|
|
339
|
-
: getPageMetadata(data, resolved ? resolved.route : route, ssrContext, {
|
|
340
|
-
isProduction: isProd,
|
|
341
|
-
channel,
|
|
342
|
-
});
|
|
343
|
-
const metaTags = generateMetaTags(pageMetadata, isProd);
|
|
344
|
-
|
|
345
|
-
// ── smbls IIFE ────────────────────────────────────────────────────────────
|
|
346
|
-
const smblsIife = smblsIifeOverride || getSmblsIife();
|
|
347
|
-
if (!smblsIife) {
|
|
348
|
-
log.error('[MERMAID] smbls IIFE bundle not available');
|
|
349
|
-
return {
|
|
350
|
-
html: 'Internal Server Error: smbls bundle not found',
|
|
351
|
-
status: 500,
|
|
352
|
-
contentType: 'text/plain',
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// ── Editor + analytics injection ──────────────────────────────────────────
|
|
357
|
-
const shouldInjectEditor = editorBundle && !isProd;
|
|
358
|
-
const editorScript = shouldInjectEditor
|
|
359
|
-
? `<script defer type="text/javascript">${editorBundle}</script>`
|
|
360
|
-
: '';
|
|
361
|
-
|
|
362
|
-
const envComment = isProd
|
|
363
|
-
? `<!-- built with symbols.app -->\n`
|
|
364
|
-
: `<!-- symbols | ${appkey} | ${channel} | ${shouldBrender ? 'ssr' : 'csr'} | noindex -->\n`;
|
|
365
|
-
|
|
366
|
-
const html = replaceVariablesInFileSync(
|
|
367
|
-
envComment + indexHtml,
|
|
368
|
-
{
|
|
369
|
-
META: metaTags + '\n' + brenderStyles + '\n' + hydrationScript + '\n' + authScript,
|
|
370
|
-
BUNDLE: bundle,
|
|
371
|
-
BODY: prerenderedBody,
|
|
372
|
-
SMBLS_IIFE: smblsIife,
|
|
373
|
-
NODE_ENV: "'" + (process.env.NODE_ENV || 'development') + "'",
|
|
374
|
-
IMPORTMAP: importmap,
|
|
375
|
-
EDITOR: editorScript + analyzedScript,
|
|
376
|
-
},
|
|
377
|
-
{ allowScript: true },
|
|
378
|
-
);
|
|
379
|
-
|
|
380
|
-
return { html, status: 200, contentType: 'text/html' };
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Render the "project not published" 404 fallback page. When `reason` is
|
|
385
|
-
* 'not_public', renders a tailored "site not yet public" message —
|
|
386
|
-
* specifically for paid custom domains pointing at a project whose
|
|
387
|
-
* visibility is still private.
|
|
388
|
-
*/
|
|
389
|
-
export function renderNotPublishedHtml(appkey, channel = 'production', reason = null) {
|
|
390
|
-
const envLabel = channel !== 'production' ? ` (${channel})` : '';
|
|
391
|
-
const isNotPublic = reason === 'not_public';
|
|
392
|
-
const title = isNotPublic ? 'Site Not Yet Public' : 'Not Published';
|
|
393
|
-
const heading = isNotPublic
|
|
394
|
-
? `This site isn't public yet${envLabel}`
|
|
395
|
-
: `This project hasn't been published yet${envLabel}`;
|
|
396
|
-
const body = isNotPublic
|
|
397
|
-
? `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.`
|
|
398
|
-
: `Run <code>smbls publish</code> to deploy your project, or visit <a href="https://symbols.app">symbols.app</a> to get started.`;
|
|
399
|
-
return `<!doctype html>
|
|
400
|
-
<html>
|
|
401
|
-
<head>
|
|
402
|
-
<meta charset="UTF-8">
|
|
403
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
404
|
-
<meta name="robots" content="noindex">
|
|
405
|
-
<title>${title}</title>
|
|
406
|
-
<style>
|
|
407
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
408
|
-
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; }
|
|
409
|
-
.container { text-align: center; max-width: 520px; padding: 2rem; }
|
|
410
|
-
h1 { font-size: 1.25rem; font-weight: 500; margin-bottom: 0.75rem; }
|
|
411
|
-
p { color: #888; font-size: 0.875rem; line-height: 1.6; }
|
|
412
|
-
code { background: #1a1a1a; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.8rem; color: #ccc; }
|
|
413
|
-
a { color: #888; text-decoration: underline; text-underline-offset: 2px; }
|
|
414
|
-
a:hover { color: #fafafa; }
|
|
415
|
-
.env { display: inline-block; font-size: 0.75rem; color: #555; background: #1a1a1a; padding: 0.2rem 0.5rem; border-radius: 4px; margin-top: 1rem; }
|
|
416
|
-
</style>
|
|
417
|
-
</head>
|
|
418
|
-
<body>
|
|
419
|
-
<div class="container">
|
|
420
|
-
<h1>${heading}</h1>
|
|
421
|
-
<p>${body}</p>
|
|
422
|
-
<span class="env">${appkey}${envLabel}</span>
|
|
423
|
-
</div>
|
|
424
|
-
</body>
|
|
425
|
-
</html>`;
|
|
426
|
-
}
|
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;
|
package/src/server.js
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
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
|
-
}
|