@symbo.ls/mermaid 3.14.0 → 3.14.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/mermaid",
3
- "version": "3.14.0",
3
+ "version": "3.14.4",
4
4
  "type": "module",
5
5
  "license": "CC-BY-NC-4.0",
6
6
  "description": "Self-sustaining server runner for Symbols projects from JSON. Renders DOMQL projects to HTML with optional brender SSR.",
@@ -17,7 +17,11 @@
17
17
  "./utils/logger": "./src/utils/logger.js",
18
18
  "./template.html": "./src/template.html"
19
19
  },
20
- "files": ["index.js", "src", "README.md"],
20
+ "files": [
21
+ "index.js",
22
+ "src",
23
+ "README.md"
24
+ ],
21
25
  "scripts": {
22
26
  "start": "node bin/start.js"
23
27
  },
@@ -25,7 +29,7 @@
25
29
  "mermaid": "./bin/start.js"
26
30
  },
27
31
  "dependencies": {
28
- "@symbo.ls/utils": "^3.14.0",
32
+ "@symbo.ls/utils": "^3.14.2",
29
33
  "cors": "^2.8.5",
30
34
  "express": "^4.21.2"
31
35
  },
package/src/bundle.js CHANGED
@@ -6,6 +6,15 @@ import smblsIife from 'smbls/iife-string';
6
6
 
7
7
  log.debug(`[BUNDLE] smbls IIFE loaded from smbls/iife-string (${smblsIife.length} bytes)`);
8
8
 
9
+ // FRAMEWORK-6: any `<` in JSON that gets inlined into a `<script>` block can
10
+ // terminate the wrapper (`</script>`) or trigger HTML-comment / CDATA modes
11
+ // (`<!--`, `<![CDATA[`) inside the script-data state. Replacing every `<`
12
+ // with the JS unicode escape `<` neutralizes HTML interpretation while
13
+ // preserving the runtime string value — `<` decodes to `<` at JS parse
14
+ // time, so the embedded object is identical to the original. Standard
15
+ // mitigation: see OWASP §RULE-3.1, React's renderToString, Rails json_escape.
16
+ const escapeJsonForScript = (s) => s.replace(/</g, '\\u003c');
17
+
9
18
  export function getSmblsIife() {
10
19
  return smblsIife;
11
20
  }
@@ -45,17 +54,63 @@ export function getBundleScript(appKey, data, req, { ProjectDataService } = {})
45
54
  log.error(`[BUNDLE] Failed to ensure merged data: ${mergeError.message}`);
46
55
  }
47
56
 
48
- // Serialize the project data as JSON (same as templates __mockedData approach)
49
- const jsonData = JSON.stringify(data);
57
+ // Serialize the project data as JSON (same as templates __mockedData approach).
58
+ // escapeJsonForScript: see FRAMEWORK-6 — any `</script>` (or `<!--`) inside a
59
+ // string value would break out of the inline `<script>` wrapper.
60
+ const jsonData = escapeJsonForScript(JSON.stringify(data));
50
61
 
51
- // Determine liveSync setting
52
- const release = req?.query?.release || false;
53
- const version = req?.query?.version;
54
- const isSymboLs = typeof appKey === 'string' && appKey.endsWith('symbo.ls');
62
+ /*
63
+ * Determine liveSync setting.
64
+ *
65
+ * Realtime is enabled on Symbols-managed dev hosts (any *.symbo.ls env,
66
+ * *.preview.symbols.app, and localhost / *.localhost for local dev) and
67
+ * disabled on custom domains so we don't leak the realtime backend to
68
+ * third-party hosts. Post-§45 the appkey is a bare `owner/key` slug, so
69
+ * gating on `appKey.endsWith('symbo.ls')` would always evaluate false
70
+ * (rendering all browsers blind). The signal we want is the request host.
71
+ */
72
+ // Resolve incoming host across Express (req.headers.host),
73
+ // Cloudflare Workers / Fetch (req.headers is a Headers instance with .get),
74
+ // and plain options bags ({ host }). Falls back to req.url parsing so a
75
+ // missing Host header still degrades to a URL-based read instead of ''.
76
+ const rawHost =
77
+ (req?.headers && typeof req.headers.get === 'function' && req.headers.get('host')) ||
78
+ req?.headers?.host ||
79
+ req?.host ||
80
+ (req?.url && (() => { try { return new URL(req.url).host; } catch { return ''; } })()) ||
81
+ '';
82
+ const host = String(rawHost).toLowerCase().split(':')[0];
83
+ // Express exposes parsed query on req.query; CF Workers expose req.url only —
84
+ // pull `release` / `version` from a parsed URL when the structured form is
85
+ // absent so feature flags work in both runtimes.
86
+ const queryFrom = (() => {
87
+ if (req?.query) return req.query;
88
+ try { return Object.fromEntries(new URL(req?.url || '', 'http://x').searchParams); } catch { return {}; }
89
+ })();
90
+ const release = queryFrom.release || false;
91
+ const version = queryFrom.version;
92
+ const isSymboLs =
93
+ host.endsWith('.symbo.ls') ||
94
+ host.endsWith('.preview.symbols.app') ||
95
+ host === 'localhost' ||
96
+ host.endsWith('.localhost');
55
97
  const shouldDisableLiveSync = Boolean(release) || Boolean(version) || !isSymboLs;
56
98
 
99
+ // Inject the persisted `_id` so the realtime client can hand the socket
100
+ // server a concrete projectId (mongo _id) on handshake. Without this, the
101
+ // server falls back to a `/v1/projects/by-key/...` lookup that 404s on the
102
+ // current API and drops the connection mid-join.
103
+ const projectId =
104
+ (data && (data._id || data.projectId || data.id)) ||
105
+ null;
57
106
  const settingsStr = isSymboLs
58
- ? JSON.stringify({ ...data.settings, liveSync: !shouldDisableLiveSync, inspect: true })
107
+ ? escapeJsonForScript(JSON.stringify({
108
+ ...data.settings,
109
+ liveSync: !shouldDisableLiveSync,
110
+ inspect: true,
111
+ verbose: !shouldDisableLiveSync,
112
+ projectId,
113
+ }))
59
114
  : 'null';
60
115
 
61
116
  // Generate the client script - same pattern as templates/start.js runFromJson
@@ -80,24 +135,68 @@ export function getBundleScript(appKey, data, req, { ProjectDataService } = {})
80
135
  var settings = ${settingsStr};
81
136
  if (settings) context.editor = settings;
82
137
 
83
- // When brender pre-rendered this page, adopt the existing DOM nodes
84
- // instead of tearing them down. The [data-ssr] wrapper is moved out of the
85
- // way so pre-rendered children land at document.body level — matching the
86
- // level at which DOMQL creates its own elements. The __BRENDER__ flag and
87
- // __BR_REGISTRY__ map were injected into <head> by the server.
88
- // Theme initialization: set data-theme before app renders to avoid FOUC
89
- if (context.useDocumentTheme !== false) {
138
+ // Theme initialization: set data-theme before app renders to avoid FOUC.
139
+ // Mirrors the precedence in resolveAndApplyTheme (packages/smbls/src/index.js):
140
+ // 1. URL param ?globalTheme=<name> — embedded-iframe override
141
+ // 2. localStorage[themeStorageKey] — persisted user choice
142
+ // 3. context.globalTheme — project's configured value
143
+ // 4. matchMedia(prefers-color-scheme: dark) — OS fallback (when 'auto')
144
+ //
145
+ // Writing the literal 'auto' as data-theme would leave the
146
+ // [data-theme="dark"] / [data-theme="light"] CSS selectors unmatched and
147
+ // the page would render with system defaults instead of the chosen
148
+ // theme — so 'auto' is always resolved to a concrete light/dark value.
149
+ //
150
+ // PRIOR BUG: this script previously ignored context.globalTheme entirely
151
+ // and went straight to matchMedia after localStorage. A project with
152
+ // globalTheme: light in config.js would render in OS dark on a
153
+ // dark-mode device because the inline boot ran first and locked in
154
+ // dark before resolveAndApplyTheme could correct it.
155
+ //
156
+ // IDEMPOTENT GUARD: the SSR-side render in @symbo.ls/mermaid/render.js
157
+ // now bakes data-theme onto <html> for concrete-theme projects and
158
+ // emits an inline <head> script that runs synchronously before paint.
159
+ // When that path runs, data-theme is already set by the time the bundle
160
+ // executes — skip this block to avoid re-resolving and overwriting
161
+ // (which would race with URL/localStorage values the head script
162
+ // already honored). Kept as a safety net for older renders / cases
163
+ // where the head script couldn't run.
164
+ if (
165
+ context.useDocumentTheme !== false &&
166
+ !document.documentElement.hasAttribute('data-theme')
167
+ ) {
90
168
  (function() {
91
169
  try {
92
170
  var t = null;
93
- var key = context.themeStorageKey;
94
- if (key) { try { t = localStorage.getItem(key) } catch(e) {} }
95
- if (!t) t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
171
+ // 1. URL param wins (used by preview/canvas iframe embedding).
172
+ try {
173
+ var p = new URLSearchParams(window.location.search).get('globalTheme');
174
+ if (p) t = p;
175
+ } catch(e) {}
176
+ // 2. localStorage — only consulted when no forcing source above.
177
+ if (!t) {
178
+ var key = context.themeStorageKey;
179
+ if (key) { try { t = localStorage.getItem(key) } catch(e) {} }
180
+ }
181
+ // 3. Project's configured globalTheme (config.js export). Honors an
182
+ // explicit 'light'/'dark'/<scheme> instead of falling through to OS.
183
+ if (!t && context.globalTheme && context.globalTheme !== 'auto') {
184
+ t = context.globalTheme;
185
+ }
186
+ // 4. OS preference fallback — only when nothing concrete chose.
187
+ if (!t || t === 'auto') {
188
+ t = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
189
+ }
96
190
  if (t) document.documentElement.setAttribute('data-theme', t);
97
191
  } catch(e) {}
98
192
  })();
99
193
  }
100
194
 
195
+ // When brender pre-rendered this page, adopt the existing DOM nodes
196
+ // instead of tearing them down. The [data-ssr] wrapper is moved out of
197
+ // the way so pre-rendered children land at document.body level — matching
198
+ // the level at which DOMQL creates its own elements. The __BRENDER__ flag
199
+ // and __BR_REGISTRY__ map were injected into <head> by the server.
101
200
  var ssrShell = document.querySelector('[data-ssr]');
102
201
  if (window.__BRENDER__ && ssrShell) {
103
202
  // Move pre-rendered children to body before DOMQL skeleton creation
package/src/render.js CHANGED
@@ -37,12 +37,22 @@ export { generateSitemap, prefetchPageData };
37
37
  // ── Default HTML template ───────────────────────────────────────────────────
38
38
  // Inlined so the plugin works in any runtime (Node, CF Workers, browser).
39
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.
40
49
  export const DEFAULT_TEMPLATE_HTML = `<!doctype html>
41
- <html>
50
+ <html'\${{{ ROOT_ATTRS }}}'>
42
51
  <head>
52
+ <meta charset="UTF-8" />
53
+ '\${{{ HEAD_BOOT }}}'
43
54
  '\${{{ META }}}'
44
55
  <meta name="viewport" content="width=device-width, initial-scale=1" />
45
- <meta charset="UTF-8" />
46
56
  '\${{{ IMPORTMAP }}}'
47
57
  </head>
48
58
  <body>
@@ -113,7 +123,12 @@ export function transpileFunctionStrings(obj, target = Array.isArray(obj) ? [] :
113
123
  * @param {object} options
114
124
  * @param {string} options.appkey — appkey identifier (for metadata, comments)
115
125
  * @param {string} options.pathname — request pathname
116
- * @param {string} [options.channel='production'] — 'production' | 'staging' | 'development'
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.
117
132
  * @param {boolean} [options.isProd=false] — controls SEO indexing (custom domains only)
118
133
  * @param {object} [options.req] — full request object (Express-like) for getBundleScript context
119
134
  * @param {object} [options.query] — query params (when no req available)
@@ -131,6 +146,7 @@ export async function renderProjectHtml(data, options = {}) {
131
146
  appkey,
132
147
  pathname = '/',
133
148
  channel = 'production',
149
+ userEnv = channel,
134
150
  isProd = false,
135
151
  req,
136
152
  query,
@@ -156,7 +172,12 @@ export async function renderProjectHtml(data, options = {}) {
156
172
 
157
173
  const route = normalizeRoute(pathname);
158
174
  const resolved = resolvePageRoute(data.pages, pathname);
159
- const shouldBrender = channel === 'production' || channel === 'staging';
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';
160
181
 
161
182
  // ── Brender / SSR ─────────────────────────────────────────────────────────
162
183
  let prerenderedBody = '';
@@ -309,7 +330,10 @@ export async function renderProjectHtml(data, options = {}) {
309
330
  const bundle = getBundleScript(appkey, data, req, { ProjectDataService });
310
331
 
311
332
  // ── Importmap ─────────────────────────────────────────────────────────────
312
- const importmap = getImportMapScript(data, data?.settings?.cdnProvider || 'skypack');
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');
313
337
 
314
338
  // ── HTML template ─────────────────────────────────────────────────────────
315
339
  const indexHtmlFromFiles = data?.files && data.files['index.html'];
@@ -356,13 +380,68 @@ export async function renderProjectHtml(data, options = {}) {
356
380
  ? `<script defer type="text/javascript">${editorBundle}</script>`
357
381
  : '';
358
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".
359
387
  const envComment = isProd
360
388
  ? `<!-- built with symbols.app -->\n`
361
- : `<!-- symbols | ${appkey} | ${channel} | ${shouldBrender ? 'ssr' : 'csr'} | noindex -->\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
+ : '';
362
439
 
363
440
  const html = replaceVariablesInFileSync(
364
441
  envComment + indexHtml,
365
442
  {
443
+ ROOT_ATTRS: rootAttrs,
444
+ HEAD_BOOT: headBoot,
366
445
  META: metaTags + '\n' + brenderStyles + '\n' + hydrationScript + '\n' + authScript,
367
446
  BUNDLE: bundle,
368
447
  BODY: prerenderedBody,
@@ -378,23 +457,35 @@ export async function renderProjectHtml(data, options = {}) {
378
457
  }
379
458
 
380
459
  /**
381
- * Render the "project not published" 404 fallback page.
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.
382
464
  */
383
- export function renderNotPublishedHtml(appkey, channel = 'production') {
465
+ export function renderNotPublishedHtml(appkey, channel = 'production', reason = null) {
384
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.`;
385
475
  return `<!doctype html>
386
476
  <html>
387
477
  <head>
388
478
  <meta charset="UTF-8">
389
479
  <meta name="viewport" content="width=device-width, initial-scale=1">
390
480
  <meta name="robots" content="noindex">
391
- <title>Not Published</title>
481
+ <title>${title}</title>
392
482
  <style>
393
483
  * { margin: 0; padding: 0; box-sizing: border-box; }
394
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; }
395
- .container { text-align: center; max-width: 480px; padding: 2rem; }
485
+ .container { text-align: center; max-width: 520px; padding: 2rem; }
396
486
  h1 { font-size: 1.25rem; font-weight: 500; margin-bottom: 0.75rem; }
397
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; }
398
489
  a { color: #888; text-decoration: underline; text-underline-offset: 2px; }
399
490
  a:hover { color: #fafafa; }
400
491
  .env { display: inline-block; font-size: 0.75rem; color: #555; background: #1a1a1a; padding: 0.2rem 0.5rem; border-radius: 4px; margin-top: 1rem; }
@@ -402,8 +493,8 @@ export function renderNotPublishedHtml(appkey, channel = 'production') {
402
493
  </head>
403
494
  <body>
404
495
  <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>
496
+ <h1>${heading}</h1>
497
+ <p>${body}</p>
407
498
  <span class="env">${appkey}${envLabel}</span>
408
499
  </div>
409
500
  </body>
package/src/template.html CHANGED
@@ -1,9 +1,10 @@
1
1
  <!doctype html>
2
- <html>
2
+ <html'${{{ ROOT_ATTRS }}}'>
3
3
  <head>
4
+ <meta charset="UTF-8" />
5
+ '${{{ HEAD_BOOT }}}'
4
6
  '${{{ META }}}'
5
7
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <meta charset="UTF-8" />
7
8
  '${{{ IMPORTMAP }}}'
8
9
  </head>
9
10
  <body>