@symbo.ls/mermaid 3.14.1 → 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.1",
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.",
@@ -29,7 +29,7 @@
29
29
  "mermaid": "./bin/start.js"
30
30
  },
31
31
  "dependencies": {
32
- "@symbo.ls/utils": "^3.14.0",
32
+ "@symbo.ls/utils": "^3.14.2",
33
33
  "cors": "^2.8.5",
34
34
  "express": "^4.21.2"
35
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,8 +54,10 @@ 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
62
  /*
52
63
  * Determine liveSync setting.
@@ -93,13 +104,13 @@ export function getBundleScript(appKey, data, req, { ProjectDataService } = {})
93
104
  (data && (data._id || data.projectId || data.id)) ||
94
105
  null;
95
106
  const settingsStr = isSymboLs
96
- ? JSON.stringify({
107
+ ? escapeJsonForScript(JSON.stringify({
97
108
  ...data.settings,
98
109
  liveSync: !shouldDisableLiveSync,
99
110
  inspect: true,
100
111
  verbose: !shouldDisableLiveSync,
101
112
  projectId,
102
- })
113
+ }))
103
114
  : 'null';
104
115
 
105
116
  // Generate the client script - same pattern as templates/start.js runFromJson
@@ -124,23 +135,55 @@ export function getBundleScript(appKey, data, req, { ProjectDataService } = {})
124
135
  var settings = ${settingsStr};
125
136
  if (settings) context.editor = settings;
126
137
 
127
- // When brender pre-rendered this page, adopt the existing DOM nodes
128
- // instead of tearing them down. The [data-ssr] wrapper is moved out of the
129
- // way so pre-rendered children land at document.body level — matching the
130
- // level at which DOMQL creates its own elements. The __BRENDER__ flag and
131
- // __BR_REGISTRY__ map were injected into <head> by the server.
132
138
  // Theme initialization: set data-theme before app renders to avoid FOUC.
133
- // 'auto' means "follow OS preference" — resolve through matchMedia and
134
- // emit the concrete light/dark value. Writing the literal "auto" string
135
- // would leave [data-theme="dark"] / [data-theme="light"] selectors
136
- // unmatched + the page would render with system-default colors instead
137
- // of the chosen theme. Matches resolveAndApplyTheme in smbls/src/index.js.
138
- if (context.useDocumentTheme !== false) {
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
+ ) {
139
168
  (function() {
140
169
  try {
141
170
  var t = null;
142
- var key = context.themeStorageKey;
143
- if (key) { try { t = localStorage.getItem(key) } catch(e) {} }
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.
144
187
  if (!t || t === 'auto') {
145
188
  t = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
146
189
  }
@@ -149,6 +192,11 @@ export function getBundleScript(appKey, data, req, { ProjectDataService } = {})
149
192
  })();
150
193
  }
151
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.
152
200
  var ssrShell = document.querySelector('[data-ssr]');
153
201
  if (window.__BRENDER__ && ssrShell) {
154
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 = '';
@@ -359,13 +380,68 @@ export async function renderProjectHtml(data, options = {}) {
359
380
  ? `<script defer type="text/javascript">${editorBundle}</script>`
360
381
  : '';
361
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".
362
387
  const envComment = isProd
363
388
  ? `<!-- built with symbols.app -->\n`
364
- : `<!-- 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
+ : '';
365
439
 
366
440
  const html = replaceVariablesInFileSync(
367
441
  envComment + indexHtml,
368
442
  {
443
+ ROOT_ATTRS: rootAttrs,
444
+ HEAD_BOOT: headBoot,
369
445
  META: metaTags + '\n' + brenderStyles + '\n' + hydrationScript + '\n' + authScript,
370
446
  BUNDLE: bundle,
371
447
  BODY: prerenderedBody,
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>