@symbo.ls/brender 3.7.4 → 3.7.6

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/dist/cjs/index.js CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  extractMetadata: () => import_metadata.extractMetadata,
25
25
  generateHeadHtml: () => import_metadata.generateHeadHtml,
26
26
  generateSitemap: () => import_sitemap.generateSitemap,
27
+ getAccumulatedEmotionCSS: () => import_render.getAccumulatedEmotionCSS,
27
28
  hydrate: () => import_hydrate.hydrate,
28
29
  injectPrefetchedState: () => import_prefetch.injectPrefetchedState,
29
30
  loadAndRenderAll: () => import_load.loadAndRenderAll,
@@ -34,6 +35,7 @@ __export(index_exports, {
34
35
  renderElement: () => import_render.renderElement,
35
36
  renderPage: () => import_render.renderPage,
36
37
  renderRoute: () => import_render.renderRoute,
38
+ replaceEmotionCSS: () => import_render.replaceEmotionCSS,
37
39
  resetGlobalCSSCache: () => import_render.resetGlobalCSSCache,
38
40
  resetKeys: () => import_keys.resetKeys
39
41
  });
@@ -58,6 +60,8 @@ var index_default = {
58
60
  renderRoute: import_render.renderRoute,
59
61
  renderPage: import_render.renderPage,
60
62
  resetGlobalCSSCache: import_render.resetGlobalCSSCache,
63
+ getAccumulatedEmotionCSS: import_render.getAccumulatedEmotionCSS,
64
+ replaceEmotionCSS: import_render.replaceEmotionCSS,
61
65
  extractMetadata: import_metadata.extractMetadata,
62
66
  generateHeadHtml: import_metadata.generateHeadHtml,
63
67
  collectBrNodes: import_hydrate.collectBrNodes,
@@ -99,15 +99,14 @@ const createSSRAdapter = async (dbConfig) => {
99
99
  if (adapter !== "supabase") return null;
100
100
  const supabaseUrl = url || projectId && `https://${projectId}.supabase.co`;
101
101
  if (!supabaseUrl || !key) return null;
102
- let clientFactory = createClient;
103
- if (!clientFactory) {
104
- try {
105
- const mod = await import("@supabase/supabase-js");
106
- clientFactory = mod.createClient;
107
- } catch {
108
- return null;
109
- }
102
+ let clientFactory;
103
+ try {
104
+ const mod = await import("@supabase/supabase-js");
105
+ clientFactory = mod.createClient;
106
+ } catch {
107
+ clientFactory = createClient;
110
108
  }
109
+ if (!clientFactory) return null;
111
110
  const client = clientFactory(supabaseUrl, key);
112
111
  return {
113
112
  rpc: ({ from, params }) => client.rpc(from, params),
@@ -161,7 +160,7 @@ const prefetchPageData = async (data, route = "/", options = {}) => {
161
160
  const pageDef = pages[route];
162
161
  if (!pageDef) return /* @__PURE__ */ new Map();
163
162
  const config = data.config || data.settings || {};
164
- const dbConfig = config.fetch || config.db || data.db;
163
+ const dbConfig = config.fetch || data.fetch || config.db || data.db;
165
164
  if (!dbConfig) return /* @__PURE__ */ new Map();
166
165
  const adapter = await createSSRAdapter(dbConfig);
167
166
  if (!adapter) return /* @__PURE__ */ new Map();
@@ -171,9 +170,13 @@ const prefetchPageData = async (data, route = "/", options = {}) => {
171
170
  const results = await Promise.allSettled(
172
171
  declarations.map(async ({ config: config2, stateKey, path }) => {
173
172
  const fetchedData = await executeSingle(adapter, config2);
174
- if (fetchedData !== null && stateKey) {
173
+ if (fetchedData !== null) {
175
174
  const existing = stateUpdates.get(path) || {};
176
- existing[stateKey] = fetchedData;
175
+ if (stateKey) {
176
+ existing[stateKey] = fetchedData;
177
+ } else if (isObject(fetchedData)) {
178
+ Object.assign(existing, fetchedData);
179
+ }
177
180
  stateUpdates.set(path, existing);
178
181
  }
179
182
  })
@@ -181,10 +184,10 @@ const prefetchPageData = async (data, route = "/", options = {}) => {
181
184
  return stateUpdates;
182
185
  };
183
186
  const fetchSSRTranslations = async (data) => {
184
- const config = data.config || data.settings || {};
185
- const polyglot = config.polyglot;
187
+ const config = data.config || {};
188
+ const polyglot = config.polyglot || data.polyglot;
186
189
  if (!polyglot?.fetch) return null;
187
- const dbConfig = config.fetch || config.db || data.db;
190
+ const dbConfig = config.fetch || data.fetch || config.db || data.db;
188
191
  if (!dbConfig) return null;
189
192
  const adapter = await createSSRAdapter(dbConfig);
190
193
  if (!adapter) return null;
@@ -27,10 +27,12 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
27
27
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
28
  var render_exports = {};
29
29
  __export(render_exports, {
30
+ getAccumulatedEmotionCSS: () => getAccumulatedEmotionCSS,
30
31
  render: () => render,
31
32
  renderElement: () => renderElement,
32
33
  renderPage: () => renderPage,
33
34
  renderRoute: () => renderRoute,
35
+ replaceEmotionCSS: () => replaceEmotionCSS,
34
36
  resetGlobalCSSCache: () => resetGlobalCSSCache
35
37
  });
36
38
  module.exports = __toCommonJS(render_exports);
@@ -46,6 +48,51 @@ var import_prefetch = require("./prefetch.js");
46
48
  var import_linkedom = require("linkedom");
47
49
  var import_create_instance = __toESM(require("@emotion/css/create-instance"), 1);
48
50
  const import_meta = {};
51
+ const ssrResolve = (map, key) => {
52
+ if (!map || !key) return void 0;
53
+ if (map[key] !== void 0) return map[key];
54
+ const parts = key.split(".");
55
+ let v = map;
56
+ for (const p of parts) {
57
+ if (v == null || typeof v !== "object") return void 0;
58
+ v = v[p];
59
+ }
60
+ return v;
61
+ };
62
+ const ssrTranslate = function(key, lang) {
63
+ if (!key) return "";
64
+ const ctx = this?.context;
65
+ const poly = ctx?.polyglot;
66
+ const activeLang = lang || poly?.defaultLang || "ka";
67
+ if (poly?.translations) {
68
+ const langMap = poly.translations[activeLang];
69
+ if (langMap) {
70
+ const val = ssrResolve(langMap, key);
71
+ if (val !== void 0) return val;
72
+ }
73
+ }
74
+ const root = this?.state?.root || ctx?.state?.root;
75
+ if (root?.translations) {
76
+ const langMap = root.translations[activeLang];
77
+ if (langMap) {
78
+ const val = ssrResolve(langMap, key);
79
+ if (val !== void 0) return val;
80
+ }
81
+ }
82
+ const defaultLang = poly?.defaultLang || "en";
83
+ if (defaultLang !== activeLang && poly?.translations) {
84
+ const fallback = poly.translations[defaultLang];
85
+ if (fallback) {
86
+ const val = ssrResolve(fallback, key);
87
+ if (val !== void 0) return val;
88
+ }
89
+ }
90
+ return key;
91
+ };
92
+ const ssrGetActiveLang = function() {
93
+ const ctx = this?.context;
94
+ return this?.state?.root?.lang || ctx?.polyglot?.defaultLang || "ka";
95
+ };
49
96
  const structuredCloneDeep = (obj, seen = /* @__PURE__ */ new WeakMap()) => {
50
97
  if (obj === null || typeof obj !== "object") return obj;
51
98
  if (seen.has(obj)) return seen.get(obj);
@@ -321,7 +368,16 @@ const UIKIT_STUBS = {
321
368
  Text: { tag: "span" }
322
369
  };
323
370
  const render = async (data, options = {}) => {
324
- const { route = "/", state: stateOverrides, context: contextOverrides, prefetch = false } = options;
371
+ const { route = "/", pathname, state: stateOverrides, context: contextOverrides, prefetch = false } = options;
372
+ const locationPath = pathname || route;
373
+ const _prevLocPrefetch = globalThis.location;
374
+ const _prevWinPrefetch = globalThis.window;
375
+ if (!globalThis.location || globalThis.location.pathname !== locationPath) {
376
+ globalThis.location = { pathname: locationPath, href: locationPath, search: "", hash: "", origin: "" };
377
+ }
378
+ if (!globalThis.window) {
379
+ globalThis.window = { location: globalThis.location };
380
+ }
325
381
  let prefetchedPages;
326
382
  if (prefetch) {
327
383
  try {
@@ -359,16 +415,26 @@ const render = async (data, options = {}) => {
359
415
  } catch {
360
416
  }
361
417
  }
418
+ if (_prevLocPrefetch !== void 0) globalThis.location = _prevLocPrefetch;
419
+ else delete globalThis.location;
420
+ if (_prevWinPrefetch !== void 0) globalThis.window = _prevWinPrefetch;
421
+ else delete globalThis.window;
362
422
  const { window, document } = (0, import_env.createEnv)();
363
423
  const body = document.body;
364
- window.location.pathname = route;
424
+ window.location.pathname = locationPath;
365
425
  const _prevDoc = globalThis.document;
366
426
  const _prevLoc = globalThis.location;
367
427
  globalThis.document = document;
368
428
  globalThis.location = window.location;
369
429
  const { createDomqlElement } = await bundleCreateDomql();
370
430
  const app = structuredCloneDeep(data.app || {});
371
- const config = data.config || data.settings || {};
431
+ const config = { ...data.config || {} };
432
+ if (data.polyglot && !config.polyglot) config.polyglot = data.polyglot;
433
+ if (data.fetch && !config.fetch) config.fetch = data.fetch;
434
+ if (data.router && !config.router) config.router = data.router;
435
+ for (const k of ["useReset", "useVariable", "useFontImport", "useIconSprite", "useSvgSprite", "useDefaultConfig", "useDocumentTheme"]) {
436
+ if (data[k] != null && config[k] == null) config[k] = data[k];
437
+ }
372
438
  const polyglotConfig = config.polyglot ? { ...config.polyglot } : void 0;
373
439
  if (ssrTranslations && polyglotConfig) {
374
440
  polyglotConfig.translations = {
@@ -401,7 +467,13 @@ const render = async (data, options = {}) => {
401
467
  components: structuredCloneDeep(data.components || {}),
402
468
  snippets: structuredCloneDeep(data.snippets || {}),
403
469
  pages: structuredCloneDeep(prefetchedPages || data.pages || {}),
404
- functions: data.functions || {},
470
+ functions: {
471
+ ...data.functions || {},
472
+ // SSR polyglot functions — enable {{ key | polyglot }} resolution during render
473
+ polyglot: ssrTranslate,
474
+ getActiveLang: ssrGetActiveLang,
475
+ getLang: ssrGetActiveLang
476
+ },
405
477
  methods: data.methods || {},
406
478
  designSystem: structuredCloneDeep(data.designSystem || {}),
407
479
  files: data.files || {},
@@ -426,7 +498,7 @@ const render = async (data, options = {}) => {
426
498
  await new Promise((r) => setTimeout(r, flushDelay));
427
499
  (0, import_keys.assignKeys)(body);
428
500
  const registry = (0, import_keys.mapKeysToElements)(element);
429
- const metadata = (0, import_metadata.extractMetadata)(data, route);
501
+ const metadata = (0, import_metadata.extractMetadata)(data, route, element, element?.state);
430
502
  const emotionCSS = [];
431
503
  const emotionInstance = ctx.emotion || element && element.context && element.context.emotion;
432
504
  if (emotionInstance && emotionInstance.cache) {
@@ -476,7 +548,7 @@ const render = async (data, options = {}) => {
476
548
  else delete globalThis.document;
477
549
  if (_prevLoc !== void 0) globalThis.location = _prevLoc;
478
550
  else delete globalThis.location;
479
- return { html, metadata, registry, element, emotionCSS, document, window, ssrTranslations };
551
+ return { html, metadata, registry, element, emotionCSS, document, window, ssrTranslations, prefetchedPages };
480
552
  };
481
553
  const renderElement = async (elementDef, options = {}) => {
482
554
  const { context = {} } = options;
@@ -660,63 +732,82 @@ ${frameRules}
660
732
  return _cachedGlobalCSS;
661
733
  }
662
734
  };
663
- let _cachedEmotionCSS = null;
735
+ let _accumulatedEmotionCSS = /* @__PURE__ */ new Set();
664
736
  const resetGlobalCSSCache = () => {
665
737
  _cachedGlobalCSS = null;
666
- _cachedEmotionCSS = null;
738
+ _accumulatedEmotionCSS = /* @__PURE__ */ new Set();
739
+ };
740
+ const getAccumulatedEmotionCSS = () => Array.from(_accumulatedEmotionCSS).join("\n");
741
+ const replaceEmotionCSS = (html, newCSS) => {
742
+ return html.replace(
743
+ /<style data-emotion="smbls">[\s\S]*?<\/style>/,
744
+ newCSS ? `<style data-emotion="smbls">
745
+ ${newCSS}
746
+ </style>` : ""
747
+ );
667
748
  };
668
749
  const renderRoute = async (data, options = {}) => {
669
- const { route = "/" } = options;
750
+ const { route = "/", pathname } = options;
751
+ const result = await render(data, { route, pathname, prefetch: true });
752
+ if (!result) return null;
670
753
  const ds = data.designSystem || {};
671
- const pageDef = (data.pages || {})[route];
672
- if (!pageDef) return null;
673
- const result = await renderElement(pageDef, {
674
- context: {
675
- components: data.components || {},
676
- snippets: data.snippets || {},
677
- designSystem: ds,
678
- state: data.state || {},
679
- functions: data.functions || {},
680
- methods: data.methods || {}
681
- }
682
- });
683
- const { document: cssDoc } = (0, import_linkedom.parseHTML)(`<html><head></head><body>${result.html}</body></html>`);
684
- let emotionInstance;
754
+ const globalCSS = await generateGlobalCSS(ds, data.config || data.settings);
755
+ let prefetchedState = null;
756
+ let activeLang = null;
685
757
  try {
686
- const { default: createInstance } = await import("@emotion/css/create-instance");
687
- emotionInstance = createInstance({ key: "smbls", container: cssDoc.head });
688
- } catch {
758
+ const el = result.element;
759
+ const polyglot = el?.context?.polyglot || data.polyglot || data.config?.polyglot;
760
+ activeLang = el?.state?.root?.lang || polyglot?.defaultLang || "en";
761
+ if (result.prefetchedPages && result.prefetchedPages[route]) {
762
+ const pageDef = result.prefetchedPages[route];
763
+ const collectStates = (def, result2 = {}) => {
764
+ if (!def || typeof def !== "object") return result2;
765
+ if (def.state && typeof def.state === "object") {
766
+ for (const [k, v] of Object.entries(def.state)) {
767
+ if (v !== void 0 && v !== null && typeof v !== "function") {
768
+ result2[k] = v;
769
+ }
770
+ }
771
+ }
772
+ for (const [key, child] of Object.entries(def)) {
773
+ if (key === "state" || key === "props" || key === "attr" || key === "on" || key === "define" || key === "__ref" || key.startsWith("__")) continue;
774
+ if (child && typeof child === "object" && !Array.isArray(child)) {
775
+ collectStates(child, result2);
776
+ }
777
+ }
778
+ return result2;
779
+ };
780
+ prefetchedState = collectStates(pageDef);
781
+ }
782
+ } catch (e) {
689
783
  }
690
- (0, import_hydrate.hydrate)(result.element, {
691
- root: cssDoc.body,
692
- renderEvents: false,
693
- events: false,
694
- emotion: emotionInstance,
695
- designSystem: ds
696
- });
697
- const globalCSS = await generateGlobalCSS(ds, data.config || data.settings);
698
784
  return {
699
- html: cssDoc.body.innerHTML,
700
- css: extractCSS(result.element, ds),
785
+ html: result.html,
786
+ css: result.emotionCSS ? result.emotionCSS.join("\n") : "",
701
787
  globalCSS,
702
788
  resetCss: globalCSS.resetRules || generateResetCSS(ds.reset),
703
789
  fontLinks: generateFontLinks(ds),
704
- metadata: (0, import_metadata.extractMetadata)(data, route),
705
- brKeyCount: Object.keys(result.registry).length
790
+ metadata: result.metadata || (0, import_metadata.extractMetadata)(data, route),
791
+ brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
792
+ ssrTranslations: result.ssrTranslations,
793
+ prefetchedState,
794
+ activeLang
706
795
  };
707
796
  };
708
797
  const renderPage = async (data, route = "/", options = {}) => {
709
- const { lang, themeColor, isr, prefetch = true } = options;
798
+ const { lang, themeColor, isr, hydrate: hydrate2 = true, prefetch = true } = options;
710
799
  const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || "en";
711
800
  const result = await render(data, { route, prefetch });
712
801
  if (!result) return null;
713
802
  const metadata = { ...result.metadata };
714
803
  if (themeColor) metadata["theme-color"] = themeColor;
715
804
  const headTags = (0, import_metadata.generateHeadHtml)(metadata);
716
- if (!_cachedEmotionCSS && result.emotionCSS && result.emotionCSS.length) {
717
- _cachedEmotionCSS = result.emotionCSS.join("\n");
805
+ if (result.emotionCSS && result.emotionCSS.length) {
806
+ for (const rule of result.emotionCSS) {
807
+ if (rule) _accumulatedEmotionCSS.add(rule);
808
+ }
718
809
  }
719
- const emotionCSS = _cachedEmotionCSS || (result.emotionCSS || []).join("\n");
810
+ const emotionCSS = Array.from(_accumulatedEmotionCSS).join("\n");
720
811
  const ds = data.designSystem || {};
721
812
  const globalCSS = await generateGlobalCSS(ds, data.config || data.settings);
722
813
  const fontLinks = generateFontLinks(ds);
@@ -725,7 +816,32 @@ const renderPage = async (data, route = "/", options = {}) => {
725
816
  if (isr && isr.clientScript) {
726
817
  const depth = route === "/" ? 0 : route.replace(/^\/|\/$/g, "").split("/").length;
727
818
  const prefix = depth > 0 ? "../".repeat(depth) : "./";
728
- isrBody = `<script type="module">
819
+ if (hydrate2) {
820
+ let translationSeed = "";
821
+ if (result.ssrTranslations) {
822
+ const polyglotCfg2 = data.polyglot || data.config?.polyglot;
823
+ const storagePrefix = polyglotCfg2?.storagePrefix || "";
824
+ const storageLangKey = polyglotCfg2?.storageLangKey || "";
825
+ const seedEntries = [];
826
+ for (const lang2 in result.ssrTranslations) {
827
+ const map = result.ssrTranslations[lang2];
828
+ if (map && typeof map === "object") {
829
+ seedEntries.push(`localStorage.setItem(${JSON.stringify(storagePrefix + lang2)},${JSON.stringify(JSON.stringify(map))})`);
830
+ }
831
+ }
832
+ if (storageLangKey) {
833
+ const defaultLang = polyglotCfg2?.defaultLang || "en";
834
+ seedEntries.push(`if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`);
835
+ }
836
+ if (seedEntries.length) {
837
+ translationSeed = `<script>try{${seedEntries.join(";")}}catch(e){}<\/script>
838
+ `;
839
+ }
840
+ }
841
+ isrBody = `${translationSeed}<script>window.__BRENDER__ = true<\/script>
842
+ <script type="module" src="${prefix}${isr.clientScript}"><\/script>`;
843
+ } else {
844
+ isrBody = `<script type="module">
729
845
  {
730
846
  const brEls = document.querySelectorAll('body > :not(script):not(style)')
731
847
  const observer = new MutationObserver((mutations) => {
@@ -743,9 +859,11 @@ const renderPage = async (data, route = "/", options = {}) => {
743
859
  }
744
860
  <\/script>
745
861
  <script type="module" src="${prefix}${isr.clientScript}"><\/script>`;
862
+ }
746
863
  }
747
- const config = data.config || data.settings || {};
748
- const polyglotCfg = config.polyglot;
864
+ const headConfig = { ...data.config || {} };
865
+ if (data.polyglot && !headConfig.polyglot) headConfig.polyglot = data.polyglot;
866
+ const polyglotCfg = headConfig.polyglot;
749
867
  let resolvedHeadTags = headTags;
750
868
  if (polyglotCfg) {
751
869
  const defaultLang = polyglotCfg.defaultLang || "en";
package/dist/esm/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createEnv } from "./env.js";
2
2
  import { resetKeys, assignKeys, mapKeysToElements } from "./keys.js";
3
3
  import { loadProject, loadAndRenderAll } from "./load.js";
4
- import { render, renderElement, renderRoute, renderPage, resetGlobalCSSCache } from "./render.js";
4
+ import { render, renderElement, renderRoute, renderPage, resetGlobalCSSCache, getAccumulatedEmotionCSS, replaceEmotionCSS } from "./render.js";
5
5
  import { extractMetadata, generateHeadHtml } from "./metadata.js";
6
6
  import { collectBrNodes, hydrate } from "./hydrate.js";
7
7
  import { generateSitemap } from "./sitemap.js";
@@ -18,6 +18,8 @@ var index_default = {
18
18
  renderRoute,
19
19
  renderPage,
20
20
  resetGlobalCSSCache,
21
+ getAccumulatedEmotionCSS,
22
+ replaceEmotionCSS,
21
23
  extractMetadata,
22
24
  generateHeadHtml,
23
25
  collectBrNodes,
@@ -34,6 +36,7 @@ export {
34
36
  extractMetadata,
35
37
  generateHeadHtml,
36
38
  generateSitemap,
39
+ getAccumulatedEmotionCSS,
37
40
  hydrate,
38
41
  injectPrefetchedState,
39
42
  loadAndRenderAll,
@@ -44,6 +47,7 @@ export {
44
47
  renderElement,
45
48
  renderPage,
46
49
  renderRoute,
50
+ replaceEmotionCSS,
47
51
  resetGlobalCSSCache,
48
52
  resetKeys
49
53
  };
@@ -65,15 +65,14 @@ const createSSRAdapter = async (dbConfig) => {
65
65
  if (adapter !== "supabase") return null;
66
66
  const supabaseUrl = url || projectId && `https://${projectId}.supabase.co`;
67
67
  if (!supabaseUrl || !key) return null;
68
- let clientFactory = createClient;
69
- if (!clientFactory) {
70
- try {
71
- const mod = await import("@supabase/supabase-js");
72
- clientFactory = mod.createClient;
73
- } catch {
74
- return null;
75
- }
68
+ let clientFactory;
69
+ try {
70
+ const mod = await import("@supabase/supabase-js");
71
+ clientFactory = mod.createClient;
72
+ } catch {
73
+ clientFactory = createClient;
76
74
  }
75
+ if (!clientFactory) return null;
77
76
  const client = clientFactory(supabaseUrl, key);
78
77
  return {
79
78
  rpc: ({ from, params }) => client.rpc(from, params),
@@ -127,7 +126,7 @@ const prefetchPageData = async (data, route = "/", options = {}) => {
127
126
  const pageDef = pages[route];
128
127
  if (!pageDef) return /* @__PURE__ */ new Map();
129
128
  const config = data.config || data.settings || {};
130
- const dbConfig = config.fetch || config.db || data.db;
129
+ const dbConfig = config.fetch || data.fetch || config.db || data.db;
131
130
  if (!dbConfig) return /* @__PURE__ */ new Map();
132
131
  const adapter = await createSSRAdapter(dbConfig);
133
132
  if (!adapter) return /* @__PURE__ */ new Map();
@@ -137,9 +136,13 @@ const prefetchPageData = async (data, route = "/", options = {}) => {
137
136
  const results = await Promise.allSettled(
138
137
  declarations.map(async ({ config: config2, stateKey, path }) => {
139
138
  const fetchedData = await executeSingle(adapter, config2);
140
- if (fetchedData !== null && stateKey) {
139
+ if (fetchedData !== null) {
141
140
  const existing = stateUpdates.get(path) || {};
142
- existing[stateKey] = fetchedData;
141
+ if (stateKey) {
142
+ existing[stateKey] = fetchedData;
143
+ } else if (isObject(fetchedData)) {
144
+ Object.assign(existing, fetchedData);
145
+ }
143
146
  stateUpdates.set(path, existing);
144
147
  }
145
148
  })
@@ -147,10 +150,10 @@ const prefetchPageData = async (data, route = "/", options = {}) => {
147
150
  return stateUpdates;
148
151
  };
149
152
  const fetchSSRTranslations = async (data) => {
150
- const config = data.config || data.settings || {};
151
- const polyglot = config.polyglot;
153
+ const config = data.config || {};
154
+ const polyglot = config.polyglot || data.polyglot;
152
155
  if (!polyglot?.fetch) return null;
153
- const dbConfig = config.fetch || config.db || data.db;
156
+ const dbConfig = config.fetch || data.fetch || config.db || data.db;
154
157
  if (!dbConfig) return null;
155
158
  const adapter = await createSSRAdapter(dbConfig);
156
159
  if (!adapter) return null;
@@ -9,6 +9,51 @@ import { hydrate } from "./hydrate.js";
9
9
  import { prefetchPageData, injectPrefetchedState, fetchSSRTranslations } from "./prefetch.js";
10
10
  import { parseHTML } from "linkedom";
11
11
  import createEmotionInstance from "@emotion/css/create-instance";
12
+ const ssrResolve = (map, key) => {
13
+ if (!map || !key) return void 0;
14
+ if (map[key] !== void 0) return map[key];
15
+ const parts = key.split(".");
16
+ let v = map;
17
+ for (const p of parts) {
18
+ if (v == null || typeof v !== "object") return void 0;
19
+ v = v[p];
20
+ }
21
+ return v;
22
+ };
23
+ const ssrTranslate = function(key, lang) {
24
+ if (!key) return "";
25
+ const ctx = this?.context;
26
+ const poly = ctx?.polyglot;
27
+ const activeLang = lang || poly?.defaultLang || "ka";
28
+ if (poly?.translations) {
29
+ const langMap = poly.translations[activeLang];
30
+ if (langMap) {
31
+ const val = ssrResolve(langMap, key);
32
+ if (val !== void 0) return val;
33
+ }
34
+ }
35
+ const root = this?.state?.root || ctx?.state?.root;
36
+ if (root?.translations) {
37
+ const langMap = root.translations[activeLang];
38
+ if (langMap) {
39
+ const val = ssrResolve(langMap, key);
40
+ if (val !== void 0) return val;
41
+ }
42
+ }
43
+ const defaultLang = poly?.defaultLang || "en";
44
+ if (defaultLang !== activeLang && poly?.translations) {
45
+ const fallback = poly.translations[defaultLang];
46
+ if (fallback) {
47
+ const val = ssrResolve(fallback, key);
48
+ if (val !== void 0) return val;
49
+ }
50
+ }
51
+ return key;
52
+ };
53
+ const ssrGetActiveLang = function() {
54
+ const ctx = this?.context;
55
+ return this?.state?.root?.lang || ctx?.polyglot?.defaultLang || "ka";
56
+ };
12
57
  const structuredCloneDeep = (obj, seen = /* @__PURE__ */ new WeakMap()) => {
13
58
  if (obj === null || typeof obj !== "object") return obj;
14
59
  if (seen.has(obj)) return seen.get(obj);
@@ -284,7 +329,16 @@ const UIKIT_STUBS = {
284
329
  Text: { tag: "span" }
285
330
  };
286
331
  const render = async (data, options = {}) => {
287
- const { route = "/", state: stateOverrides, context: contextOverrides, prefetch = false } = options;
332
+ const { route = "/", pathname, state: stateOverrides, context: contextOverrides, prefetch = false } = options;
333
+ const locationPath = pathname || route;
334
+ const _prevLocPrefetch = globalThis.location;
335
+ const _prevWinPrefetch = globalThis.window;
336
+ if (!globalThis.location || globalThis.location.pathname !== locationPath) {
337
+ globalThis.location = { pathname: locationPath, href: locationPath, search: "", hash: "", origin: "" };
338
+ }
339
+ if (!globalThis.window) {
340
+ globalThis.window = { location: globalThis.location };
341
+ }
288
342
  let prefetchedPages;
289
343
  if (prefetch) {
290
344
  try {
@@ -322,16 +376,26 @@ const render = async (data, options = {}) => {
322
376
  } catch {
323
377
  }
324
378
  }
379
+ if (_prevLocPrefetch !== void 0) globalThis.location = _prevLocPrefetch;
380
+ else delete globalThis.location;
381
+ if (_prevWinPrefetch !== void 0) globalThis.window = _prevWinPrefetch;
382
+ else delete globalThis.window;
325
383
  const { window, document } = createEnv();
326
384
  const body = document.body;
327
- window.location.pathname = route;
385
+ window.location.pathname = locationPath;
328
386
  const _prevDoc = globalThis.document;
329
387
  const _prevLoc = globalThis.location;
330
388
  globalThis.document = document;
331
389
  globalThis.location = window.location;
332
390
  const { createDomqlElement } = await bundleCreateDomql();
333
391
  const app = structuredCloneDeep(data.app || {});
334
- const config = data.config || data.settings || {};
392
+ const config = { ...data.config || {} };
393
+ if (data.polyglot && !config.polyglot) config.polyglot = data.polyglot;
394
+ if (data.fetch && !config.fetch) config.fetch = data.fetch;
395
+ if (data.router && !config.router) config.router = data.router;
396
+ for (const k of ["useReset", "useVariable", "useFontImport", "useIconSprite", "useSvgSprite", "useDefaultConfig", "useDocumentTheme"]) {
397
+ if (data[k] != null && config[k] == null) config[k] = data[k];
398
+ }
335
399
  const polyglotConfig = config.polyglot ? { ...config.polyglot } : void 0;
336
400
  if (ssrTranslations && polyglotConfig) {
337
401
  polyglotConfig.translations = {
@@ -364,7 +428,13 @@ const render = async (data, options = {}) => {
364
428
  components: structuredCloneDeep(data.components || {}),
365
429
  snippets: structuredCloneDeep(data.snippets || {}),
366
430
  pages: structuredCloneDeep(prefetchedPages || data.pages || {}),
367
- functions: data.functions || {},
431
+ functions: {
432
+ ...data.functions || {},
433
+ // SSR polyglot functions — enable {{ key | polyglot }} resolution during render
434
+ polyglot: ssrTranslate,
435
+ getActiveLang: ssrGetActiveLang,
436
+ getLang: ssrGetActiveLang
437
+ },
368
438
  methods: data.methods || {},
369
439
  designSystem: structuredCloneDeep(data.designSystem || {}),
370
440
  files: data.files || {},
@@ -389,7 +459,7 @@ const render = async (data, options = {}) => {
389
459
  await new Promise((r) => setTimeout(r, flushDelay));
390
460
  assignKeys(body);
391
461
  const registry = mapKeysToElements(element);
392
- const metadata = extractMetadata(data, route);
462
+ const metadata = extractMetadata(data, route, element, element?.state);
393
463
  const emotionCSS = [];
394
464
  const emotionInstance = ctx.emotion || element && element.context && element.context.emotion;
395
465
  if (emotionInstance && emotionInstance.cache) {
@@ -439,7 +509,7 @@ const render = async (data, options = {}) => {
439
509
  else delete globalThis.document;
440
510
  if (_prevLoc !== void 0) globalThis.location = _prevLoc;
441
511
  else delete globalThis.location;
442
- return { html, metadata, registry, element, emotionCSS, document, window, ssrTranslations };
512
+ return { html, metadata, registry, element, emotionCSS, document, window, ssrTranslations, prefetchedPages };
443
513
  };
444
514
  const renderElement = async (elementDef, options = {}) => {
445
515
  const { context = {} } = options;
@@ -623,63 +693,82 @@ ${frameRules}
623
693
  return _cachedGlobalCSS;
624
694
  }
625
695
  };
626
- let _cachedEmotionCSS = null;
696
+ let _accumulatedEmotionCSS = /* @__PURE__ */ new Set();
627
697
  const resetGlobalCSSCache = () => {
628
698
  _cachedGlobalCSS = null;
629
- _cachedEmotionCSS = null;
699
+ _accumulatedEmotionCSS = /* @__PURE__ */ new Set();
700
+ };
701
+ const getAccumulatedEmotionCSS = () => Array.from(_accumulatedEmotionCSS).join("\n");
702
+ const replaceEmotionCSS = (html, newCSS) => {
703
+ return html.replace(
704
+ /<style data-emotion="smbls">[\s\S]*?<\/style>/,
705
+ newCSS ? `<style data-emotion="smbls">
706
+ ${newCSS}
707
+ </style>` : ""
708
+ );
630
709
  };
631
710
  const renderRoute = async (data, options = {}) => {
632
- const { route = "/" } = options;
711
+ const { route = "/", pathname } = options;
712
+ const result = await render(data, { route, pathname, prefetch: true });
713
+ if (!result) return null;
633
714
  const ds = data.designSystem || {};
634
- const pageDef = (data.pages || {})[route];
635
- if (!pageDef) return null;
636
- const result = await renderElement(pageDef, {
637
- context: {
638
- components: data.components || {},
639
- snippets: data.snippets || {},
640
- designSystem: ds,
641
- state: data.state || {},
642
- functions: data.functions || {},
643
- methods: data.methods || {}
644
- }
645
- });
646
- const { document: cssDoc } = parseHTML(`<html><head></head><body>${result.html}</body></html>`);
647
- let emotionInstance;
715
+ const globalCSS = await generateGlobalCSS(ds, data.config || data.settings);
716
+ let prefetchedState = null;
717
+ let activeLang = null;
648
718
  try {
649
- const { default: createInstance } = await import("@emotion/css/create-instance");
650
- emotionInstance = createInstance({ key: "smbls", container: cssDoc.head });
651
- } catch {
719
+ const el = result.element;
720
+ const polyglot = el?.context?.polyglot || data.polyglot || data.config?.polyglot;
721
+ activeLang = el?.state?.root?.lang || polyglot?.defaultLang || "en";
722
+ if (result.prefetchedPages && result.prefetchedPages[route]) {
723
+ const pageDef = result.prefetchedPages[route];
724
+ const collectStates = (def, result2 = {}) => {
725
+ if (!def || typeof def !== "object") return result2;
726
+ if (def.state && typeof def.state === "object") {
727
+ for (const [k, v] of Object.entries(def.state)) {
728
+ if (v !== void 0 && v !== null && typeof v !== "function") {
729
+ result2[k] = v;
730
+ }
731
+ }
732
+ }
733
+ for (const [key, child] of Object.entries(def)) {
734
+ if (key === "state" || key === "props" || key === "attr" || key === "on" || key === "define" || key === "__ref" || key.startsWith("__")) continue;
735
+ if (child && typeof child === "object" && !Array.isArray(child)) {
736
+ collectStates(child, result2);
737
+ }
738
+ }
739
+ return result2;
740
+ };
741
+ prefetchedState = collectStates(pageDef);
742
+ }
743
+ } catch (e) {
652
744
  }
653
- hydrate(result.element, {
654
- root: cssDoc.body,
655
- renderEvents: false,
656
- events: false,
657
- emotion: emotionInstance,
658
- designSystem: ds
659
- });
660
- const globalCSS = await generateGlobalCSS(ds, data.config || data.settings);
661
745
  return {
662
- html: cssDoc.body.innerHTML,
663
- css: extractCSS(result.element, ds),
746
+ html: result.html,
747
+ css: result.emotionCSS ? result.emotionCSS.join("\n") : "",
664
748
  globalCSS,
665
749
  resetCss: globalCSS.resetRules || generateResetCSS(ds.reset),
666
750
  fontLinks: generateFontLinks(ds),
667
- metadata: extractMetadata(data, route),
668
- brKeyCount: Object.keys(result.registry).length
751
+ metadata: result.metadata || extractMetadata(data, route),
752
+ brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
753
+ ssrTranslations: result.ssrTranslations,
754
+ prefetchedState,
755
+ activeLang
669
756
  };
670
757
  };
671
758
  const renderPage = async (data, route = "/", options = {}) => {
672
- const { lang, themeColor, isr, prefetch = true } = options;
759
+ const { lang, themeColor, isr, hydrate: hydrate2 = true, prefetch = true } = options;
673
760
  const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || "en";
674
761
  const result = await render(data, { route, prefetch });
675
762
  if (!result) return null;
676
763
  const metadata = { ...result.metadata };
677
764
  if (themeColor) metadata["theme-color"] = themeColor;
678
765
  const headTags = generateHeadHtml(metadata);
679
- if (!_cachedEmotionCSS && result.emotionCSS && result.emotionCSS.length) {
680
- _cachedEmotionCSS = result.emotionCSS.join("\n");
766
+ if (result.emotionCSS && result.emotionCSS.length) {
767
+ for (const rule of result.emotionCSS) {
768
+ if (rule) _accumulatedEmotionCSS.add(rule);
769
+ }
681
770
  }
682
- const emotionCSS = _cachedEmotionCSS || (result.emotionCSS || []).join("\n");
771
+ const emotionCSS = Array.from(_accumulatedEmotionCSS).join("\n");
683
772
  const ds = data.designSystem || {};
684
773
  const globalCSS = await generateGlobalCSS(ds, data.config || data.settings);
685
774
  const fontLinks = generateFontLinks(ds);
@@ -688,7 +777,32 @@ const renderPage = async (data, route = "/", options = {}) => {
688
777
  if (isr && isr.clientScript) {
689
778
  const depth = route === "/" ? 0 : route.replace(/^\/|\/$/g, "").split("/").length;
690
779
  const prefix = depth > 0 ? "../".repeat(depth) : "./";
691
- isrBody = `<script type="module">
780
+ if (hydrate2) {
781
+ let translationSeed = "";
782
+ if (result.ssrTranslations) {
783
+ const polyglotCfg2 = data.polyglot || data.config?.polyglot;
784
+ const storagePrefix = polyglotCfg2?.storagePrefix || "";
785
+ const storageLangKey = polyglotCfg2?.storageLangKey || "";
786
+ const seedEntries = [];
787
+ for (const lang2 in result.ssrTranslations) {
788
+ const map = result.ssrTranslations[lang2];
789
+ if (map && typeof map === "object") {
790
+ seedEntries.push(`localStorage.setItem(${JSON.stringify(storagePrefix + lang2)},${JSON.stringify(JSON.stringify(map))})`);
791
+ }
792
+ }
793
+ if (storageLangKey) {
794
+ const defaultLang = polyglotCfg2?.defaultLang || "en";
795
+ seedEntries.push(`if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`);
796
+ }
797
+ if (seedEntries.length) {
798
+ translationSeed = `<script>try{${seedEntries.join(";")}}catch(e){}<\/script>
799
+ `;
800
+ }
801
+ }
802
+ isrBody = `${translationSeed}<script>window.__BRENDER__ = true<\/script>
803
+ <script type="module" src="${prefix}${isr.clientScript}"><\/script>`;
804
+ } else {
805
+ isrBody = `<script type="module">
692
806
  {
693
807
  const brEls = document.querySelectorAll('body > :not(script):not(style)')
694
808
  const observer = new MutationObserver((mutations) => {
@@ -706,9 +820,11 @@ const renderPage = async (data, route = "/", options = {}) => {
706
820
  }
707
821
  <\/script>
708
822
  <script type="module" src="${prefix}${isr.clientScript}"><\/script>`;
823
+ }
709
824
  }
710
- const config = data.config || data.settings || {};
711
- const polyglotCfg = config.polyglot;
825
+ const headConfig = { ...data.config || {} };
826
+ if (data.polyglot && !headConfig.polyglot) headConfig.polyglot = data.polyglot;
827
+ const polyglotCfg = headConfig.polyglot;
712
828
  let resolvedHeadTags = headTags;
713
829
  if (polyglotCfg) {
714
830
  const defaultLang = polyglotCfg.defaultLang || "en";
@@ -1189,9 +1305,11 @@ const generateFontLinks = (ds) => {
1189
1305
  ].join("\n");
1190
1306
  };
1191
1307
  export {
1308
+ getAccumulatedEmotionCSS,
1192
1309
  render,
1193
1310
  renderElement,
1194
1311
  renderPage,
1195
1312
  renderRoute,
1313
+ replaceEmotionCSS,
1196
1314
  resetGlobalCSSCache
1197
1315
  };
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createEnv } from './env.js'
2
2
  import { resetKeys, assignKeys, mapKeysToElements } from './keys.js'
3
3
  import { loadProject, loadAndRenderAll } from './load.js'
4
- import { render, renderElement, renderRoute, renderPage, resetGlobalCSSCache } from './render.js'
4
+ import { render, renderElement, renderRoute, renderPage, resetGlobalCSSCache, getAccumulatedEmotionCSS, replaceEmotionCSS } from './render.js'
5
5
  import { extractMetadata, generateHeadHtml } from './metadata.js'
6
6
  import { collectBrNodes, hydrate } from './hydrate.js'
7
7
  import { generateSitemap } from './sitemap.js'
@@ -19,6 +19,8 @@ export {
19
19
  renderRoute,
20
20
  renderPage,
21
21
  resetGlobalCSSCache,
22
+ getAccumulatedEmotionCSS,
23
+ replaceEmotionCSS,
22
24
  extractMetadata,
23
25
  generateHeadHtml,
24
26
  collectBrNodes,
@@ -40,6 +42,8 @@ export default {
40
42
  renderRoute,
41
43
  renderPage,
42
44
  resetGlobalCSSCache,
45
+ getAccumulatedEmotionCSS,
46
+ replaceEmotionCSS,
43
47
  extractMetadata,
44
48
  generateHeadHtml,
45
49
  collectBrNodes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/brender",
3
- "version": "3.7.4",
3
+ "version": "3.7.6",
4
4
  "license": "CC-BY-NC-4.0",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",
@@ -36,7 +36,7 @@
36
36
  "dev:rita": "node examples/serve-rita.js"
37
37
  },
38
38
  "dependencies": {
39
- "@symbo.ls/helmet": "^3.7.4",
39
+ "@symbo.ls/helmet": "^3.7.6",
40
40
  "linkedom": "^0.16.8"
41
41
  },
42
42
  "devDependencies": {
package/prefetch.js CHANGED
@@ -118,15 +118,17 @@ const createSSRAdapter = async (dbConfig) => {
118
118
  const supabaseUrl = url || (projectId && `https://${projectId}.supabase.co`)
119
119
  if (!supabaseUrl || !key) return null
120
120
 
121
- let clientFactory = createClient
122
- if (!clientFactory) {
123
- try {
124
- const mod = await import('@supabase/supabase-js')
125
- clientFactory = mod.createClient
126
- } catch {
127
- return null
128
- }
121
+ // Always import @supabase/supabase-js for SSR — the serialized createClient
122
+ // from project data is a no-op placeholder that won't produce a real client.
123
+ let clientFactory
124
+ try {
125
+ const mod = await import('@supabase/supabase-js')
126
+ clientFactory = mod.createClient
127
+ } catch {
128
+ // Fall back to provided createClient only if import fails
129
+ clientFactory = createClient
129
130
  }
131
+ if (!clientFactory) return null
130
132
 
131
133
  const client = clientFactory(supabaseUrl, key)
132
134
 
@@ -199,7 +201,7 @@ export const prefetchPageData = async (data, route = '/', options = {}) => {
199
201
  if (!pageDef) return new Map()
200
202
 
201
203
  const config = data.config || data.settings || {}
202
- const dbConfig = config.fetch || config.db || data.db
204
+ const dbConfig = config.fetch || data.fetch || config.db || data.db
203
205
  if (!dbConfig) return new Map()
204
206
 
205
207
  const adapter = await createSSRAdapter(dbConfig)
@@ -214,9 +216,15 @@ export const prefetchPageData = async (data, route = '/', options = {}) => {
214
216
  const results = await Promise.allSettled(
215
217
  declarations.map(async ({ config, stateKey, path }) => {
216
218
  const fetchedData = await executeSingle(adapter, config)
217
- if (fetchedData !== null && stateKey) {
219
+ if (fetchedData !== null) {
218
220
  const existing = stateUpdates.get(path) || {}
219
- existing[stateKey] = fetchedData
221
+ if (stateKey) {
222
+ // Named: store under the `as` key
223
+ existing[stateKey] = fetchedData
224
+ } else if (isObject(fetchedData)) {
225
+ // No `as` key + transform returned an object: spread into state
226
+ Object.assign(existing, fetchedData)
227
+ }
220
228
  stateUpdates.set(path, existing)
221
229
  }
222
230
  })
@@ -240,11 +248,12 @@ export const prefetchPageData = async (data, route = '/', options = {}) => {
240
248
  * @returns {Promise<object|null>} Translation map keyed by language, or null on failure
241
249
  */
242
250
  export const fetchSSRTranslations = async (data) => {
243
- const config = data.config || data.settings || {}
244
- const polyglot = config.polyglot
251
+ // Config fields may be nested under data.config or spread at the top level
252
+ const config = data.config || {}
253
+ const polyglot = config.polyglot || data.polyglot
245
254
  if (!polyglot?.fetch) return null
246
255
 
247
- const dbConfig = config.fetch || config.db || data.db
256
+ const dbConfig = config.fetch || data.fetch || config.db || data.db
248
257
  if (!dbConfig) return null
249
258
 
250
259
  const adapter = await createSSRAdapter(dbConfig)
package/render.js CHANGED
@@ -10,6 +10,64 @@ import { prefetchPageData, injectPrefetchedState, fetchSSRTranslations } from '.
10
10
  import { parseHTML } from 'linkedom'
11
11
  import createEmotionInstance from '@emotion/css/create-instance'
12
12
 
13
+ // Lightweight SSR polyglot functions — resolve translations from context
14
+ // without needing the full polyglot plugin runtime
15
+ const ssrResolve = (map, key) => {
16
+ if (!map || !key) return undefined
17
+ if (map[key] !== undefined) return map[key]
18
+ // Try nested dot-path (e.g. "ui.main.latest")
19
+ const parts = key.split('.')
20
+ let v = map
21
+ for (const p of parts) {
22
+ if (v == null || typeof v !== 'object') return undefined
23
+ v = v[p]
24
+ }
25
+ return v
26
+ }
27
+
28
+ const ssrTranslate = function (key, lang) {
29
+ if (!key) return ''
30
+ const ctx = this?.context
31
+ const poly = ctx?.polyglot
32
+ const activeLang = lang || poly?.defaultLang || 'ka'
33
+
34
+ // Static translations from context.polyglot.translations
35
+ if (poly?.translations) {
36
+ const langMap = poly.translations[activeLang]
37
+ if (langMap) {
38
+ const val = ssrResolve(langMap, key)
39
+ if (val !== undefined) return val
40
+ }
41
+ }
42
+
43
+ // Server-loaded translations in root state
44
+ const root = this?.state?.root || ctx?.state?.root
45
+ if (root?.translations) {
46
+ const langMap = root.translations[activeLang]
47
+ if (langMap) {
48
+ const val = ssrResolve(langMap, key)
49
+ if (val !== undefined) return val
50
+ }
51
+ }
52
+
53
+ // Fallback to default lang
54
+ const defaultLang = poly?.defaultLang || 'en'
55
+ if (defaultLang !== activeLang && poly?.translations) {
56
+ const fallback = poly.translations[defaultLang]
57
+ if (fallback) {
58
+ const val = ssrResolve(fallback, key)
59
+ if (val !== undefined) return val
60
+ }
61
+ }
62
+
63
+ return key
64
+ }
65
+
66
+ const ssrGetActiveLang = function () {
67
+ const ctx = this?.context
68
+ return this?.state?.root?.lang || ctx?.polyglot?.defaultLang || 'ka'
69
+ }
70
+
13
71
  // Deep clone that preserves functions and avoids circular refs
14
72
  const structuredCloneDeep = (obj, seen = new WeakMap()) => {
15
73
  if (obj === null || typeof obj !== 'object') return obj
@@ -307,12 +365,24 @@ const UIKIT_STUBS = {
307
365
  * @returns {Promise<{ html: string, metadata: object, registry: object, element: object }>}
308
366
  */
309
367
  export const render = async (data, options = {}) => {
310
- const { route = '/', state: stateOverrides, context: contextOverrides, prefetch = false } = options
368
+ const { route = '/', pathname, state: stateOverrides, context: contextOverrides, prefetch = false } = options
369
+ // pathname is the actual URL path (e.g. /podcast/abc-123), route is the page pattern (e.g. /podcast/:id)
370
+ const locationPath = pathname || route
311
371
 
312
372
  // ── SSR data prefetching ──
313
373
  // When prefetch is enabled, walk the page definition to find fetch
314
374
  // declarations, execute them against the DB adapter, and inject
315
375
  // the results into element state before rendering.
376
+ // Set up globalThis.location early so fetch params that reference
377
+ // window.location.pathname (e.g. to extract :id from URL) work during prefetch.
378
+ const _prevLocPrefetch = globalThis.location
379
+ const _prevWinPrefetch = globalThis.window
380
+ if (!globalThis.location || globalThis.location.pathname !== locationPath) {
381
+ globalThis.location = { pathname: locationPath, href: locationPath, search: '', hash: '', origin: '' }
382
+ }
383
+ if (!globalThis.window) {
384
+ globalThis.window = { location: globalThis.location }
385
+ }
316
386
  let prefetchedPages
317
387
  if (prefetch) {
318
388
  try {
@@ -355,11 +425,17 @@ export const render = async (data, options = {}) => {
355
425
  } catch {}
356
426
  }
357
427
 
428
+ // Restore location/window before createEnv sets them properly
429
+ if (_prevLocPrefetch !== undefined) globalThis.location = _prevLocPrefetch
430
+ else delete globalThis.location
431
+ if (_prevWinPrefetch !== undefined) globalThis.window = _prevWinPrefetch
432
+ else delete globalThis.window
433
+
358
434
  const { window, document } = createEnv()
359
435
  const body = document.body
360
436
 
361
437
  // Set route on location so the router picks it up
362
- window.location.pathname = route
438
+ window.location.pathname = locationPath
363
439
 
364
440
  // Set globalThis.document/location so the bundled smbls code
365
441
  // (which uses `window = globalThis`) can access them during SSR.
@@ -375,7 +451,15 @@ export const render = async (data, options = {}) => {
375
451
 
376
452
  const app = structuredCloneDeep(data.app || {})
377
453
 
378
- const config = data.config || data.settings || {}
454
+ // Config fields may be nested under data.config or spread at the top level
455
+ // (frank spreads config.js exports at the top level of the JSON)
456
+ const config = { ...(data.config || {}) }
457
+ if (data.polyglot && !config.polyglot) config.polyglot = data.polyglot
458
+ if (data.fetch && !config.fetch) config.fetch = data.fetch
459
+ if (data.router && !config.router) config.router = data.router
460
+ for (const k of ['useReset', 'useVariable', 'useFontImport', 'useIconSprite', 'useSvgSprite', 'useDefaultConfig', 'useDocumentTheme']) {
461
+ if (data[k] != null && config[k] == null) config[k] = data[k]
462
+ }
379
463
 
380
464
  // Inject SSR translations into polyglot config and root state
381
465
  const polyglotConfig = config.polyglot ? { ...config.polyglot } : undefined
@@ -418,7 +502,13 @@ export const render = async (data, options = {}) => {
418
502
  components: structuredCloneDeep(data.components || {}),
419
503
  snippets: structuredCloneDeep(data.snippets || {}),
420
504
  pages: structuredCloneDeep(prefetchedPages || data.pages || {}),
421
- functions: data.functions || {},
505
+ functions: {
506
+ ...(data.functions || {}),
507
+ // SSR polyglot functions — enable {{ key | polyglot }} resolution during render
508
+ polyglot: ssrTranslate,
509
+ getActiveLang: ssrGetActiveLang,
510
+ getLang: ssrGetActiveLang
511
+ },
422
512
  methods: data.methods || {},
423
513
  designSystem: structuredCloneDeep(data.designSystem || {}),
424
514
  files: data.files || {},
@@ -455,7 +545,9 @@ export const render = async (data, options = {}) => {
455
545
  const registry = mapKeysToElements(element)
456
546
 
457
547
  // Extract metadata for the rendered route
458
- const metadata = extractMetadata(data, route)
548
+ // Pass the rendered element and its state so function-valued metadata
549
+ // (e.g. detail page titles from fetched data) can be resolved
550
+ const metadata = extractMetadata(data, route, element, element?.state)
459
551
 
460
552
  // Extract emotion-generated CSS
461
553
  // Emotion uses insertRule() (CSSOM) which doesn't populate textContent in linkedom.
@@ -521,7 +613,7 @@ export const render = async (data, options = {}) => {
521
613
  if (_prevLoc !== undefined) globalThis.location = _prevLoc
522
614
  else delete globalThis.location
523
615
 
524
- return { html, metadata, registry, element, emotionCSS, document, window, ssrTranslations }
616
+ return { html, metadata, registry, element, emotionCSS, document, window, ssrTranslations, prefetchedPages }
525
617
  }
526
618
 
527
619
  /**
@@ -768,16 +860,34 @@ const generateGlobalCSS = async (ds, config) => {
768
860
  }
769
861
  }
770
862
 
771
- // Emotion CSS is generated by a singleton cache in the bundled smbls module.
772
- // After the first render, emotion's cache.inserted marks all classes as done,
773
- // so subsequent renders don't re-insert CSS. We capture the emotion CSS from
774
- // the first render and reuse it for all pages (all pages share the same components).
775
- let _cachedEmotionCSS = null
863
+ // Accumulate emotion CSS across all page renders.
864
+ // Each page may generate unique CSS classes (e.g. page-specific components/styles).
865
+ // Emotion's singleton cache marks classes as "inserted" after the first render,
866
+ // so subsequent renders only produce NEW classes not seen before.
867
+ // We merge all CSS rules across renders to ensure every page has complete styles.
868
+ let _accumulatedEmotionCSS = new Set()
776
869
 
777
870
  /**
778
871
  * Reset the cached global CSS and emotion CSS (useful when rendering multiple projects).
779
872
  */
780
- export const resetGlobalCSSCache = () => { _cachedGlobalCSS = null; _cachedEmotionCSS = null }
873
+ export const resetGlobalCSSCache = () => { _cachedGlobalCSS = null; _accumulatedEmotionCSS = new Set() }
874
+
875
+ /**
876
+ * Returns the complete accumulated emotion CSS from all renders so far.
877
+ * Call this after rendering ALL pages to get the full CSS needed by every page.
878
+ */
879
+ export const getAccumulatedEmotionCSS = () => Array.from(_accumulatedEmotionCSS).join('\n')
880
+
881
+ /**
882
+ * Replace the emotion CSS in a rendered HTML page with updated CSS.
883
+ * Used in two-pass rendering: render all pages first, then inject complete CSS.
884
+ */
885
+ export const replaceEmotionCSS = (html, newCSS) => {
886
+ return html.replace(
887
+ /<style data-emotion="smbls">[\s\S]*?<\/style>/,
888
+ newCSS ? `<style data-emotion="smbls">\n${newCSS}\n</style>` : ''
889
+ )
890
+ }
781
891
 
782
892
  // ── Route-level SSR ───────────────────────────────────────────────────────────
783
893
 
@@ -792,49 +902,61 @@ export const resetGlobalCSSCache = () => { _cachedGlobalCSS = null; _cachedEmoti
792
902
  * @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
793
903
  */
794
904
  export const renderRoute = async (data, options = {}) => {
795
- const { route = '/' } = options
796
- const ds = data.designSystem || {}
797
- const pageDef = (data.pages || {})[route]
798
- if (!pageDef) return null
799
-
800
- const result = await renderElement(pageDef, {
801
- context: {
802
- components: data.components || {},
803
- snippets: data.snippets || {},
804
- designSystem: ds,
805
- state: data.state || {},
806
- functions: data.functions || {},
807
- methods: data.methods || {}
808
- }
809
- })
905
+ const { route = '/', pathname } = options
810
906
 
811
- // Hydrate with emotion CSS classes on nodes
812
- const { document: cssDoc } = parseHTML(`<html><head></head><body>${result.html}</body></html>`)
813
- let emotionInstance
814
- try {
815
- const { default: createInstance } = await import('@emotion/css/create-instance')
816
- emotionInstance = createInstance({ key: 'smbls', container: cssDoc.head })
817
- } catch {}
818
-
819
- hydrate(result.element, {
820
- root: cssDoc.body,
821
- renderEvents: false,
822
- events: false,
823
- emotion: emotionInstance,
824
- designSystem: ds
825
- })
907
+ // Use the full render pipeline which handles polyglot, prefetch, emotion, etc.
908
+ // Pass pathname so the virtual DOM location reflects the actual URL (for dynamic routes)
909
+ const result = await render(data, { route, pathname, prefetch: true })
910
+ if (!result) return null
826
911
 
827
- // Generate global CSS (variables, reset, keyframes) via scratch pipeline
912
+ const ds = data.designSystem || {}
828
913
  const globalCSS = await generateGlobalCSS(ds, data.config || data.settings)
829
914
 
915
+ // Extract prefetched state and language for metadata resolution
916
+ let prefetchedState = null
917
+ let activeLang = null
918
+ try {
919
+ const el = result.element
920
+ const polyglot = el?.context?.polyglot || data.polyglot || data.config?.polyglot
921
+ activeLang = el?.state?.root?.lang || polyglot?.defaultLang || 'en'
922
+
923
+ // Get prefetched data from the injected page definitions (pre-DOMQL proxy)
924
+ if (result.prefetchedPages && result.prefetchedPages[route]) {
925
+ const pageDef = result.prefetchedPages[route]
926
+ // Collect all state entries from the page definition tree
927
+ const collectStates = (def, result = {}) => {
928
+ if (!def || typeof def !== 'object') return result
929
+ if (def.state && typeof def.state === 'object') {
930
+ for (const [k, v] of Object.entries(def.state)) {
931
+ if (v !== undefined && v !== null && typeof v !== 'function') {
932
+ result[k] = v
933
+ }
934
+ }
935
+ }
936
+ // Recurse into all child definitions (not just those with state)
937
+ for (const [key, child] of Object.entries(def)) {
938
+ if (key === 'state' || key === 'props' || key === 'attr' || key === 'on' || key === 'define' || key === '__ref' || key.startsWith('__')) continue
939
+ if (child && typeof child === 'object' && !Array.isArray(child)) {
940
+ collectStates(child, result)
941
+ }
942
+ }
943
+ return result
944
+ }
945
+ prefetchedState = collectStates(pageDef)
946
+ }
947
+ } catch (e) { /* ignore */ }
948
+
830
949
  return {
831
- html: cssDoc.body.innerHTML,
832
- css: extractCSS(result.element, ds),
950
+ html: result.html,
951
+ css: result.emotionCSS ? result.emotionCSS.join('\n') : '',
833
952
  globalCSS,
834
953
  resetCss: globalCSS.resetRules || generateResetCSS(ds.reset),
835
954
  fontLinks: generateFontLinks(ds),
836
- metadata: extractMetadata(data, route),
837
- brKeyCount: Object.keys(result.registry).length
955
+ metadata: result.metadata || extractMetadata(data, route),
956
+ brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
957
+ ssrTranslations: result.ssrTranslations,
958
+ prefetchedState,
959
+ activeLang
838
960
  }
839
961
  }
840
962
 
@@ -853,11 +975,12 @@ export const renderRoute = async (data, options = {}) => {
853
975
  * @param {string} [options.lang='en'] - HTML lang attribute
854
976
  * @param {string} [options.themeColor] - theme-color meta
855
977
  * @param {object} [options.isr] - ISR options with clientScript path
978
+ * @param {boolean} [options.hydrate=true] - Use true hydration (attach to existing DOM) instead of full SPA re-render
856
979
  * @param {boolean} [options.prefetch=true] - Whether to prefetch data via DB adapter
857
980
  * @returns {Promise<{ html: string, route: string, brKeyCount: number }>}
858
981
  */
859
982
  export const renderPage = async (data, route = '/', options = {}) => {
860
- const { lang, themeColor, isr, prefetch = true } = options
983
+ const { lang, themeColor, isr, hydrate = true, prefetch = true } = options
861
984
 
862
985
  // Detect lang from project config, app metadata, or default
863
986
  const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || 'en'
@@ -870,13 +993,16 @@ export const renderPage = async (data, route = '/', options = {}) => {
870
993
  if (themeColor) metadata['theme-color'] = themeColor
871
994
  const headTags = generateHeadHtml(metadata)
872
995
 
873
- // Extract CSS: emotion-generated styles from the full pipeline.
874
- // Emotion uses a singleton cache in the bundled module after the first render,
875
- // subsequent renders don't re-insert CSS. Cache the first result and reuse.
876
- if (!_cachedEmotionCSS && result.emotionCSS && result.emotionCSS.length) {
877
- _cachedEmotionCSS = result.emotionCSS.join('\n')
996
+ // Accumulate emotion CSS from each page render.
997
+ // Each page may introduce unique CSS classes not seen on previous pages.
998
+ // Emotion's singleton cache only emits NEW classes per render, so we
999
+ // collect all rules across renders to build the complete stylesheet.
1000
+ if (result.emotionCSS && result.emotionCSS.length) {
1001
+ for (const rule of result.emotionCSS) {
1002
+ if (rule) _accumulatedEmotionCSS.add(rule)
1003
+ }
878
1004
  }
879
- const emotionCSS = _cachedEmotionCSS || (result.emotionCSS || []).join('\n')
1005
+ const emotionCSS = Array.from(_accumulatedEmotionCSS).join('\n')
880
1006
 
881
1007
  // Generate global CSS (variables, reset, keyframes) via scratch pipeline
882
1008
  const ds = data.designSystem || {}
@@ -893,9 +1019,40 @@ export const renderPage = async (data, route = '/', options = {}) => {
893
1019
  // Calculate relative path from route directory to root
894
1020
  const depth = route === '/' ? 0 : route.replace(/^\/|\/$/g, '').split('/').length
895
1021
  const prefix = depth > 0 ? '../'.repeat(depth) : './'
896
- // Inline handoff script: when the SPA adds its root element to <body>,
897
- // remove the pre-rendered brender content for a seamless transition.
898
- isrBody = `<script type="module">
1022
+
1023
+ if (hydrate) {
1024
+ // True hydration: signal the SPA to adopt existing DOM nodes
1025
+ // instead of creating new ones. The SPA detects __BRENDER__ flag
1026
+ // and uses onlyResolveExtends + node adoption.
1027
+ //
1028
+ // Seed client-side polyglot with SSR translations so that
1029
+ // el.call('polyglot', key) resolves immediately during hydration
1030
+ // instead of showing raw keys until the async fetch completes.
1031
+ let translationSeed = ''
1032
+ if (result.ssrTranslations) {
1033
+ const polyglotCfg = data.polyglot || data.config?.polyglot
1034
+ const storagePrefix = polyglotCfg?.storagePrefix || ''
1035
+ const storageLangKey = polyglotCfg?.storageLangKey || ''
1036
+ const seedEntries = []
1037
+ for (const lang in result.ssrTranslations) {
1038
+ const map = result.ssrTranslations[lang]
1039
+ if (map && typeof map === 'object') {
1040
+ seedEntries.push(`localStorage.setItem(${JSON.stringify(storagePrefix + lang)},${JSON.stringify(JSON.stringify(map))})`)
1041
+ }
1042
+ }
1043
+ if (storageLangKey) {
1044
+ const defaultLang = polyglotCfg?.defaultLang || 'en'
1045
+ seedEntries.push(`if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`)
1046
+ }
1047
+ if (seedEntries.length) {
1048
+ translationSeed = `<script>try{${seedEntries.join(';')}}catch(e){}</script>\n`
1049
+ }
1050
+ }
1051
+ isrBody = `${translationSeed}<script>window.__BRENDER__ = true</script>
1052
+ <script type="module" src="${prefix}${isr.clientScript}"></script>`
1053
+ } else {
1054
+ // Legacy swap mode: SPA creates new DOM, MutationObserver removes brender nodes
1055
+ isrBody = `<script type="module">
899
1056
  {
900
1057
  const brEls = document.querySelectorAll('body > :not(script):not(style)')
901
1058
  const observer = new MutationObserver((mutations) => {
@@ -913,11 +1070,13 @@ export const renderPage = async (data, route = '/', options = {}) => {
913
1070
  }
914
1071
  </script>
915
1072
  <script type="module" src="${prefix}${isr.clientScript}"></script>`
1073
+ }
916
1074
  }
917
1075
 
918
1076
  // Resolve any {{ key | polyglot }} templates in head tags (title, meta, etc.)
919
- const config = data.config || data.settings || {}
920
- const polyglotCfg = config.polyglot
1077
+ const headConfig = { ...(data.config || {}) }
1078
+ if (data.polyglot && !headConfig.polyglot) headConfig.polyglot = data.polyglot
1079
+ const polyglotCfg = headConfig.polyglot
921
1080
  let resolvedHeadTags = headTags
922
1081
  if (polyglotCfg) {
923
1082
  const defaultLang = polyglotCfg.defaultLang || 'en'