@symbo.ls/smbls-utils 3.7.4 → 3.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -180,17 +180,139 @@ function resolveFileReferences(metadata, files) {
180
180
  }
181
181
  return result;
182
182
  }
183
- function getPageMetadata(data, pathname) {
183
+ function resolveDotPath(obj, path) {
184
+ if (!obj || !path) return void 0;
185
+ const parts = path.split(".");
186
+ let v = obj;
187
+ for (const p of parts) {
188
+ if (v == null || typeof v !== "object") return void 0;
189
+ v = v[p];
190
+ }
191
+ return v;
192
+ }
193
+ function resolveMetadataTemplates(metadata, data, ssrContext) {
194
+ const config = data.config || data.settings || {};
195
+ const polyglot = config.polyglot || data.polyglot;
196
+ const defaultLang = ssrContext?.lang || polyglot?.defaultLang || "en";
197
+ const translations = polyglot?.translations || {};
198
+ const langMap = translations[defaultLang] || {};
199
+ const state = ssrContext?.state || {};
200
+ const result = { ...metadata };
201
+ for (const [key, value] of Object.entries(result)) {
202
+ if (typeof value !== "string" || !value.includes("{{")) continue;
203
+ result[key] = value.replace(/\{\{\s*([^|{}]+?)\s*(?:\|\s*(\w+)\s*)?\}\}/g, (match, k, filter) => {
204
+ const trimmed = k.trim();
205
+ if (filter === "polyglot") {
206
+ return langMap[trimmed] ?? resolveDotPath(langMap, trimmed) ?? match;
207
+ }
208
+ if (filter === "getLocalStateLang") {
209
+ const resolved = resolveDotPath(state, trimmed + defaultLang);
210
+ return resolved ?? match;
211
+ }
212
+ const fromState = resolveDotPath(state, trimmed);
213
+ if (fromState !== void 0) return fromState;
214
+ return langMap[trimmed] ?? resolveDotPath(langMap, trimmed) ?? match;
215
+ });
216
+ }
217
+ return result;
218
+ }
219
+ function convertFunctionMetaToTemplates(metadata) {
220
+ const result = { ...metadata };
221
+ for (const [key, value] of Object.entries(result)) {
222
+ if (typeof value === "function") {
223
+ const src = value.toString();
224
+ const langMatch = src.match(/el\.call\(\s*["']getLocalStateLang["']\s*,\s*["']([^"']+)["']\s*\)/);
225
+ if (langMatch) {
226
+ result[key] = `{{ ${langMatch[1]} | getLocalStateLang }}`;
227
+ continue;
228
+ }
229
+ const polyMatch = src.match(/el\.call\(\s*["']polyglot["']\s*,\s*["']([^"']+)["']\s*\)/);
230
+ if (polyMatch) {
231
+ result[key] = `{{ ${polyMatch[1]} | polyglot }}`;
232
+ continue;
233
+ }
234
+ const stateMatch = src.match(/s\.(\w+(?:\.\w+)+)/);
235
+ if (stateMatch) {
236
+ result[key] = `{{ ${stateMatch[1]} }}`;
237
+ continue;
238
+ }
239
+ delete result[key];
240
+ } else if (typeof value === "string") {
241
+ const langMatch = value.match(/el\.call\(\s*["']getLocalStateLang["']\s*,\s*["']([^"']+)["']\s*\)/);
242
+ if (langMatch) {
243
+ result[key] = `{{ ${langMatch[1]} | getLocalStateLang }}`;
244
+ continue;
245
+ }
246
+ const polyMatch = value.match(/el\.call\(\s*["']polyglot["']\s*,\s*["']([^"']+)["']\s*\)/);
247
+ if (polyMatch) {
248
+ result[key] = `{{ ${polyMatch[1]} | polyglot }}`;
249
+ continue;
250
+ }
251
+ if (value.includes("=>") && value.includes("s.")) {
252
+ const stateMatch = value.match(/s\.(\w+(?:\.\w+)+)/);
253
+ if (stateMatch) {
254
+ result[key] = `{{ ${stateMatch[1]} }}`;
255
+ continue;
256
+ }
257
+ }
258
+ }
259
+ }
260
+ return result;
261
+ }
262
+ function getPageMetadata(data, pathname, ssrContext) {
184
263
  const currentPage = data.pages?.[pathname];
185
264
  const stateObject = (0, import_utils.isObject)(currentPage?.state) && currentPage?.state;
186
- let pageMetadata = currentPage?.metadata || currentPage?.helmet || stateObject || {};
265
+ const rawPageMeta = currentPage?.metadata || currentPage?.helmet || stateObject || {};
266
+ const pageMetadata = convertFunctionMetaToTemplates(rawPageMeta);
267
+ const pageExplicitKeys = new Set(Object.keys(pageMetadata));
187
268
  const appMetadata = data.app?.metadata || {};
269
+ const cleanAppMeta = convertFunctionMetaToTemplates(appMetadata);
270
+ let merged = {};
188
271
  if (data.integrations?.seo) {
189
- pageMetadata = { ...data.integrations.seo, ...appMetadata, ...pageMetadata };
190
- } else if (Object.keys(appMetadata).length) {
191
- pageMetadata = { ...appMetadata, ...pageMetadata };
272
+ merged = { ...data.integrations.seo, ...cleanAppMeta, ...pageMetadata };
273
+ } else if (Object.keys(cleanAppMeta).length) {
274
+ merged = { ...cleanAppMeta, ...pageMetadata };
275
+ } else {
276
+ merged = { ...pageMetadata };
277
+ }
278
+ if (!merged.title) merged.title = data.name + " / symbo.ls" || "Symbols demo";
279
+ merged = resolveMetadataTemplates(merged, data, ssrContext);
280
+ if (merged.title && (pageExplicitKeys.has("title") || !merged["og:title"])) {
281
+ if (!pageExplicitKeys.has("og:title")) {
282
+ merged["og:title"] = merged.title;
283
+ }
284
+ }
285
+ if (merged.description && (pageExplicitKeys.has("description") || !merged["og:description"])) {
286
+ if (!pageExplicitKeys.has("og:description")) {
287
+ merged["og:description"] = merged.description;
288
+ }
289
+ }
290
+ if (merged.title && !merged["twitter:title"]) {
291
+ merged["twitter:title"] = merged.title;
292
+ }
293
+ if (merged.description && !merged["twitter:description"]) {
294
+ merged["twitter:description"] = merged.description;
295
+ }
296
+ const routeForUrl = ssrContext?.actualPathname || pathname;
297
+ if (merged["og:url"] || merged.url) {
298
+ const baseUrl = (merged["og:url"] || merged.url || "").replace(/\/$/, "");
299
+ if (baseUrl && routeForUrl && routeForUrl !== "/") {
300
+ const cleanRoute = routeForUrl.startsWith("/") ? routeForUrl : "/" + routeForUrl;
301
+ merged["og:url"] = baseUrl + cleanRoute;
302
+ }
303
+ }
304
+ merged = resolveFileReferences(merged, data.files);
305
+ const siteName = data.name || data.app?.metadata?.title || data.app?.name || "";
306
+ for (const [key, value] of Object.entries(merged)) {
307
+ if (typeof value === "string" && value.includes("{{")) {
308
+ const cleaned = value.replace(/\{\{[^}]*\}\}/g, "").trim();
309
+ if (!cleaned) {
310
+ if (key === "title") merged[key] = siteName;
311
+ else delete merged[key];
312
+ } else {
313
+ merged[key] = cleaned;
314
+ }
315
+ }
192
316
  }
193
- if (!pageMetadata.title) pageMetadata.title = data.name + " / symbo.ls" || "Symbols demo";
194
- pageMetadata = resolveFileReferences(pageMetadata, data.files);
195
- return pageMetadata;
317
+ return merged;
196
318
  }
@@ -156,19 +156,141 @@ function resolveFileReferences(metadata, files) {
156
156
  }
157
157
  return result;
158
158
  }
159
- function getPageMetadata(data, pathname) {
159
+ function resolveDotPath(obj, path) {
160
+ if (!obj || !path) return void 0;
161
+ const parts = path.split(".");
162
+ let v = obj;
163
+ for (const p of parts) {
164
+ if (v == null || typeof v !== "object") return void 0;
165
+ v = v[p];
166
+ }
167
+ return v;
168
+ }
169
+ function resolveMetadataTemplates(metadata, data, ssrContext) {
170
+ const config = data.config || data.settings || {};
171
+ const polyglot = config.polyglot || data.polyglot;
172
+ const defaultLang = ssrContext?.lang || polyglot?.defaultLang || "en";
173
+ const translations = polyglot?.translations || {};
174
+ const langMap = translations[defaultLang] || {};
175
+ const state = ssrContext?.state || {};
176
+ const result = { ...metadata };
177
+ for (const [key, value] of Object.entries(result)) {
178
+ if (typeof value !== "string" || !value.includes("{{")) continue;
179
+ result[key] = value.replace(/\{\{\s*([^|{}]+?)\s*(?:\|\s*(\w+)\s*)?\}\}/g, (match, k, filter) => {
180
+ const trimmed = k.trim();
181
+ if (filter === "polyglot") {
182
+ return langMap[trimmed] ?? resolveDotPath(langMap, trimmed) ?? match;
183
+ }
184
+ if (filter === "getLocalStateLang") {
185
+ const resolved = resolveDotPath(state, trimmed + defaultLang);
186
+ return resolved ?? match;
187
+ }
188
+ const fromState = resolveDotPath(state, trimmed);
189
+ if (fromState !== void 0) return fromState;
190
+ return langMap[trimmed] ?? resolveDotPath(langMap, trimmed) ?? match;
191
+ });
192
+ }
193
+ return result;
194
+ }
195
+ function convertFunctionMetaToTemplates(metadata) {
196
+ const result = { ...metadata };
197
+ for (const [key, value] of Object.entries(result)) {
198
+ if (typeof value === "function") {
199
+ const src = value.toString();
200
+ const langMatch = src.match(/el\.call\(\s*["']getLocalStateLang["']\s*,\s*["']([^"']+)["']\s*\)/);
201
+ if (langMatch) {
202
+ result[key] = `{{ ${langMatch[1]} | getLocalStateLang }}`;
203
+ continue;
204
+ }
205
+ const polyMatch = src.match(/el\.call\(\s*["']polyglot["']\s*,\s*["']([^"']+)["']\s*\)/);
206
+ if (polyMatch) {
207
+ result[key] = `{{ ${polyMatch[1]} | polyglot }}`;
208
+ continue;
209
+ }
210
+ const stateMatch = src.match(/s\.(\w+(?:\.\w+)+)/);
211
+ if (stateMatch) {
212
+ result[key] = `{{ ${stateMatch[1]} }}`;
213
+ continue;
214
+ }
215
+ delete result[key];
216
+ } else if (typeof value === "string") {
217
+ const langMatch = value.match(/el\.call\(\s*["']getLocalStateLang["']\s*,\s*["']([^"']+)["']\s*\)/);
218
+ if (langMatch) {
219
+ result[key] = `{{ ${langMatch[1]} | getLocalStateLang }}`;
220
+ continue;
221
+ }
222
+ const polyMatch = value.match(/el\.call\(\s*["']polyglot["']\s*,\s*["']([^"']+)["']\s*\)/);
223
+ if (polyMatch) {
224
+ result[key] = `{{ ${polyMatch[1]} | polyglot }}`;
225
+ continue;
226
+ }
227
+ if (value.includes("=>") && value.includes("s.")) {
228
+ const stateMatch = value.match(/s\.(\w+(?:\.\w+)+)/);
229
+ if (stateMatch) {
230
+ result[key] = `{{ ${stateMatch[1]} }}`;
231
+ continue;
232
+ }
233
+ }
234
+ }
235
+ }
236
+ return result;
237
+ }
238
+ function getPageMetadata(data, pathname, ssrContext) {
160
239
  const currentPage = data.pages?.[pathname];
161
240
  const stateObject = isObject(currentPage?.state) && currentPage?.state;
162
- let pageMetadata = currentPage?.metadata || currentPage?.helmet || stateObject || {};
241
+ const rawPageMeta = currentPage?.metadata || currentPage?.helmet || stateObject || {};
242
+ const pageMetadata = convertFunctionMetaToTemplates(rawPageMeta);
243
+ const pageExplicitKeys = new Set(Object.keys(pageMetadata));
163
244
  const appMetadata = data.app?.metadata || {};
245
+ const cleanAppMeta = convertFunctionMetaToTemplates(appMetadata);
246
+ let merged = {};
164
247
  if (data.integrations?.seo) {
165
- pageMetadata = { ...data.integrations.seo, ...appMetadata, ...pageMetadata };
166
- } else if (Object.keys(appMetadata).length) {
167
- pageMetadata = { ...appMetadata, ...pageMetadata };
248
+ merged = { ...data.integrations.seo, ...cleanAppMeta, ...pageMetadata };
249
+ } else if (Object.keys(cleanAppMeta).length) {
250
+ merged = { ...cleanAppMeta, ...pageMetadata };
251
+ } else {
252
+ merged = { ...pageMetadata };
253
+ }
254
+ if (!merged.title) merged.title = data.name + " / symbo.ls" || "Symbols demo";
255
+ merged = resolveMetadataTemplates(merged, data, ssrContext);
256
+ if (merged.title && (pageExplicitKeys.has("title") || !merged["og:title"])) {
257
+ if (!pageExplicitKeys.has("og:title")) {
258
+ merged["og:title"] = merged.title;
259
+ }
260
+ }
261
+ if (merged.description && (pageExplicitKeys.has("description") || !merged["og:description"])) {
262
+ if (!pageExplicitKeys.has("og:description")) {
263
+ merged["og:description"] = merged.description;
264
+ }
265
+ }
266
+ if (merged.title && !merged["twitter:title"]) {
267
+ merged["twitter:title"] = merged.title;
268
+ }
269
+ if (merged.description && !merged["twitter:description"]) {
270
+ merged["twitter:description"] = merged.description;
271
+ }
272
+ const routeForUrl = ssrContext?.actualPathname || pathname;
273
+ if (merged["og:url"] || merged.url) {
274
+ const baseUrl = (merged["og:url"] || merged.url || "").replace(/\/$/, "");
275
+ if (baseUrl && routeForUrl && routeForUrl !== "/") {
276
+ const cleanRoute = routeForUrl.startsWith("/") ? routeForUrl : "/" + routeForUrl;
277
+ merged["og:url"] = baseUrl + cleanRoute;
278
+ }
279
+ }
280
+ merged = resolveFileReferences(merged, data.files);
281
+ const siteName = data.name || data.app?.metadata?.title || data.app?.name || "";
282
+ for (const [key, value] of Object.entries(merged)) {
283
+ if (typeof value === "string" && value.includes("{{")) {
284
+ const cleaned = value.replace(/\{\{[^}]*\}\}/g, "").trim();
285
+ if (!cleaned) {
286
+ if (key === "title") merged[key] = siteName;
287
+ else delete merged[key];
288
+ } else {
289
+ merged[key] = cleaned;
290
+ }
291
+ }
168
292
  }
169
- if (!pageMetadata.title) pageMetadata.title = data.name + " / symbo.ls" || "Symbols demo";
170
- pageMetadata = resolveFileReferences(pageMetadata, data.files);
171
- return pageMetadata;
293
+ return merged;
172
294
  }
173
295
  export {
174
296
  generateMetaTags,
@@ -735,19 +735,141 @@ var SmblsSmblsUtils = (() => {
735
735
  }
736
736
  return result;
737
737
  }
738
- function getPageMetadata(data, pathname) {
738
+ function resolveDotPath(obj, path) {
739
+ if (!obj || !path) return void 0;
740
+ const parts = path.split(".");
741
+ let v = obj;
742
+ for (const p of parts) {
743
+ if (v == null || typeof v !== "object") return void 0;
744
+ v = v[p];
745
+ }
746
+ return v;
747
+ }
748
+ function resolveMetadataTemplates(metadata, data, ssrContext) {
749
+ const config = data.config || data.settings || {};
750
+ const polyglot = config.polyglot || data.polyglot;
751
+ const defaultLang = ssrContext?.lang || polyglot?.defaultLang || "en";
752
+ const translations = polyglot?.translations || {};
753
+ const langMap = translations[defaultLang] || {};
754
+ const state = ssrContext?.state || {};
755
+ const result = { ...metadata };
756
+ for (const [key, value] of Object.entries(result)) {
757
+ if (typeof value !== "string" || !value.includes("{{")) continue;
758
+ result[key] = value.replace(/\{\{\s*([^|{}]+?)\s*(?:\|\s*(\w+)\s*)?\}\}/g, (match, k, filter) => {
759
+ const trimmed = k.trim();
760
+ if (filter === "polyglot") {
761
+ return langMap[trimmed] ?? resolveDotPath(langMap, trimmed) ?? match;
762
+ }
763
+ if (filter === "getLocalStateLang") {
764
+ const resolved = resolveDotPath(state, trimmed + defaultLang);
765
+ return resolved ?? match;
766
+ }
767
+ const fromState = resolveDotPath(state, trimmed);
768
+ if (fromState !== void 0) return fromState;
769
+ return langMap[trimmed] ?? resolveDotPath(langMap, trimmed) ?? match;
770
+ });
771
+ }
772
+ return result;
773
+ }
774
+ function convertFunctionMetaToTemplates(metadata) {
775
+ const result = { ...metadata };
776
+ for (const [key, value] of Object.entries(result)) {
777
+ if (typeof value === "function") {
778
+ const src = value.toString();
779
+ const langMatch = src.match(/el\.call\(\s*["']getLocalStateLang["']\s*,\s*["']([^"']+)["']\s*\)/);
780
+ if (langMatch) {
781
+ result[key] = `{{ ${langMatch[1]} | getLocalStateLang }}`;
782
+ continue;
783
+ }
784
+ const polyMatch = src.match(/el\.call\(\s*["']polyglot["']\s*,\s*["']([^"']+)["']\s*\)/);
785
+ if (polyMatch) {
786
+ result[key] = `{{ ${polyMatch[1]} | polyglot }}`;
787
+ continue;
788
+ }
789
+ const stateMatch = src.match(/s\.(\w+(?:\.\w+)+)/);
790
+ if (stateMatch) {
791
+ result[key] = `{{ ${stateMatch[1]} }}`;
792
+ continue;
793
+ }
794
+ delete result[key];
795
+ } else if (typeof value === "string") {
796
+ const langMatch = value.match(/el\.call\(\s*["']getLocalStateLang["']\s*,\s*["']([^"']+)["']\s*\)/);
797
+ if (langMatch) {
798
+ result[key] = `{{ ${langMatch[1]} | getLocalStateLang }}`;
799
+ continue;
800
+ }
801
+ const polyMatch = value.match(/el\.call\(\s*["']polyglot["']\s*,\s*["']([^"']+)["']\s*\)/);
802
+ if (polyMatch) {
803
+ result[key] = `{{ ${polyMatch[1]} | polyglot }}`;
804
+ continue;
805
+ }
806
+ if (value.includes("=>") && value.includes("s.")) {
807
+ const stateMatch = value.match(/s\.(\w+(?:\.\w+)+)/);
808
+ if (stateMatch) {
809
+ result[key] = `{{ ${stateMatch[1]} }}`;
810
+ continue;
811
+ }
812
+ }
813
+ }
814
+ }
815
+ return result;
816
+ }
817
+ function getPageMetadata(data, pathname, ssrContext) {
739
818
  const currentPage = data.pages?.[pathname];
740
819
  const stateObject = isObject(currentPage?.state) && currentPage?.state;
741
- let pageMetadata = currentPage?.metadata || currentPage?.helmet || stateObject || {};
820
+ const rawPageMeta = currentPage?.metadata || currentPage?.helmet || stateObject || {};
821
+ const pageMetadata = convertFunctionMetaToTemplates(rawPageMeta);
822
+ const pageExplicitKeys = new Set(Object.keys(pageMetadata));
742
823
  const appMetadata = data.app?.metadata || {};
824
+ const cleanAppMeta = convertFunctionMetaToTemplates(appMetadata);
825
+ let merged = {};
743
826
  if (data.integrations?.seo) {
744
- pageMetadata = { ...data.integrations.seo, ...appMetadata, ...pageMetadata };
745
- } else if (Object.keys(appMetadata).length) {
746
- pageMetadata = { ...appMetadata, ...pageMetadata };
827
+ merged = { ...data.integrations.seo, ...cleanAppMeta, ...pageMetadata };
828
+ } else if (Object.keys(cleanAppMeta).length) {
829
+ merged = { ...cleanAppMeta, ...pageMetadata };
830
+ } else {
831
+ merged = { ...pageMetadata };
832
+ }
833
+ if (!merged.title) merged.title = data.name + " / symbo.ls" || "Symbols demo";
834
+ merged = resolveMetadataTemplates(merged, data, ssrContext);
835
+ if (merged.title && (pageExplicitKeys.has("title") || !merged["og:title"])) {
836
+ if (!pageExplicitKeys.has("og:title")) {
837
+ merged["og:title"] = merged.title;
838
+ }
839
+ }
840
+ if (merged.description && (pageExplicitKeys.has("description") || !merged["og:description"])) {
841
+ if (!pageExplicitKeys.has("og:description")) {
842
+ merged["og:description"] = merged.description;
843
+ }
844
+ }
845
+ if (merged.title && !merged["twitter:title"]) {
846
+ merged["twitter:title"] = merged.title;
847
+ }
848
+ if (merged.description && !merged["twitter:description"]) {
849
+ merged["twitter:description"] = merged.description;
850
+ }
851
+ const routeForUrl = ssrContext?.actualPathname || pathname;
852
+ if (merged["og:url"] || merged.url) {
853
+ const baseUrl = (merged["og:url"] || merged.url || "").replace(/\/$/, "");
854
+ if (baseUrl && routeForUrl && routeForUrl !== "/") {
855
+ const cleanRoute = routeForUrl.startsWith("/") ? routeForUrl : "/" + routeForUrl;
856
+ merged["og:url"] = baseUrl + cleanRoute;
857
+ }
858
+ }
859
+ merged = resolveFileReferences(merged, data.files);
860
+ const siteName = data.name || data.app?.metadata?.title || data.app?.name || "";
861
+ for (const [key, value] of Object.entries(merged)) {
862
+ if (typeof value === "string" && value.includes("{{")) {
863
+ const cleaned = value.replace(/\{\{[^}]*\}\}/g, "").trim();
864
+ if (!cleaned) {
865
+ if (key === "title") merged[key] = siteName;
866
+ else delete merged[key];
867
+ } else {
868
+ merged[key] = cleaned;
869
+ }
870
+ }
747
871
  }
748
- if (!pageMetadata.title) pageMetadata.title = data.name + " / symbo.ls" || "Symbols demo";
749
- pageMetadata = resolveFileReferences(pageMetadata, data.files);
750
- return pageMetadata;
872
+ return merged;
751
873
  }
752
874
 
753
875
  // src/index.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/smbls-utils",
3
- "version": "3.7.4",
3
+ "version": "3.7.5",
4
4
  "author": "symbo.ls",
5
5
  "files": [
6
6
  "dist",
@@ -32,8 +32,8 @@
32
32
  },
33
33
  "license": "CC-BY-NC-4.0",
34
34
  "dependencies": {
35
- "@domql/element": "^3.7.4",
36
- "@domql/utils": "^3.7.4"
35
+ "@domql/element": "^3.7.5",
36
+ "@domql/utils": "^3.7.5"
37
37
  },
38
38
  "gitHead": "9fc1b79b41cdc725ca6b24aec64920a599634681",
39
39
  "browser": "./dist/esm/index.js",
package/src/metadata.js CHANGED
@@ -209,17 +209,195 @@ function resolveFileReferences (metadata, files) {
209
209
  return result
210
210
  }
211
211
 
212
- export function getPageMetadata (data, pathname) {
212
+ /**
213
+ * Resolve a dot-path like "item.title_ka" against an object.
214
+ */
215
+ function resolveDotPath (obj, path) {
216
+ if (!obj || !path) return undefined
217
+ const parts = path.split('.')
218
+ let v = obj
219
+ for (const p of parts) {
220
+ if (v == null || typeof v !== 'object') return undefined
221
+ v = v[p]
222
+ }
223
+ return v
224
+ }
225
+
226
+ /**
227
+ * Resolve {{ key | polyglot }} and {{ key | getLocalStateLang }} templates
228
+ * in metadata string values using project polyglot translations and SSR state.
229
+ */
230
+ function resolveMetadataTemplates (metadata, data, ssrContext) {
231
+ const config = data.config || data.settings || {}
232
+ const polyglot = config.polyglot || data.polyglot
233
+ const defaultLang = ssrContext?.lang || polyglot?.defaultLang || 'en'
234
+ const translations = polyglot?.translations || {}
235
+ const langMap = translations[defaultLang] || {}
236
+ const state = ssrContext?.state || {}
237
+
238
+ const result = { ...metadata }
239
+ for (const [key, value] of Object.entries(result)) {
240
+ if (typeof value !== 'string' || !value.includes('{{')) continue
241
+
242
+ result[key] = value.replace(/\{\{\s*([^|{}]+?)\s*(?:\|\s*(\w+)\s*)?\}\}/g, (match, k, filter) => {
243
+ const trimmed = k.trim()
244
+
245
+ if (filter === 'polyglot') {
246
+ return langMap[trimmed] ?? resolveDotPath(langMap, trimmed) ?? match
247
+ }
248
+
249
+ if (filter === 'getLocalStateLang') {
250
+ // key is like "item.title_" — append lang
251
+ const resolved = resolveDotPath(state, trimmed + defaultLang)
252
+ return resolved ?? match
253
+ }
254
+
255
+ // No filter — try state lookup, then polyglot
256
+ const fromState = resolveDotPath(state, trimmed)
257
+ if (fromState !== undefined) return fromState
258
+
259
+ return langMap[trimmed] ?? resolveDotPath(langMap, trimmed) ?? match
260
+ })
261
+ }
262
+ return result
263
+ }
264
+
265
+ /**
266
+ * Convert function-string metadata values to {{ }} templates.
267
+ * Handles common patterns from Symbols page definitions:
268
+ * (el, s) => s.item ? el.call("getLocalStateLang", "item.title_") : ""
269
+ * (el, s) => s.item ? s.item.image_url : ""
270
+ */
271
+ function convertFunctionMetaToTemplates (metadata) {
272
+ const result = { ...metadata }
273
+ for (const [key, value] of Object.entries(result)) {
274
+ if (typeof value === 'function') {
275
+ const src = value.toString()
276
+ // Pattern: el.call("getLocalStateLang", "item.title_")
277
+ const langMatch = src.match(/el\.call\(\s*["']getLocalStateLang["']\s*,\s*["']([^"']+)["']\s*\)/)
278
+ if (langMatch) {
279
+ result[key] = `{{ ${langMatch[1]} | getLocalStateLang }}`
280
+ continue
281
+ }
282
+ // Pattern: el.call("polyglot", "item.description")
283
+ const polyMatch = src.match(/el\.call\(\s*["']polyglot["']\s*,\s*["']([^"']+)["']\s*\)/)
284
+ if (polyMatch) {
285
+ result[key] = `{{ ${polyMatch[1]} | polyglot }}`
286
+ continue
287
+ }
288
+ // Pattern: s.item.field or s.item?.field
289
+ const stateMatch = src.match(/s\.(\w+(?:\.\w+)+)/)
290
+ if (stateMatch) {
291
+ result[key] = `{{ ${stateMatch[1]} }}`
292
+ continue
293
+ }
294
+ // Could not parse — drop function value
295
+ delete result[key]
296
+ } else if (typeof value === 'string') {
297
+ // Function-string (not yet destringified)
298
+ const langMatch = value.match(/el\.call\(\s*["']getLocalStateLang["']\s*,\s*["']([^"']+)["']\s*\)/)
299
+ if (langMatch) {
300
+ result[key] = `{{ ${langMatch[1]} | getLocalStateLang }}`
301
+ continue
302
+ }
303
+ const polyMatch = value.match(/el\.call\(\s*["']polyglot["']\s*,\s*["']([^"']+)["']\s*\)/)
304
+ if (polyMatch) {
305
+ result[key] = `{{ ${polyMatch[1]} | polyglot }}`
306
+ continue
307
+ }
308
+ // Function-string with s.item.field
309
+ if (value.includes('=>') && value.includes('s.')) {
310
+ const stateMatch = value.match(/s\.(\w+(?:\.\w+)+)/)
311
+ if (stateMatch) {
312
+ result[key] = `{{ ${stateMatch[1]} }}`
313
+ continue
314
+ }
315
+ }
316
+ }
317
+ }
318
+ return result
319
+ }
320
+
321
+ /**
322
+ * Extract page-level metadata from project data for a given pathname.
323
+ * @param {object} data - Full project data
324
+ * @param {string} pathname - Route path (e.g. '/blog/:id')
325
+ * @param {object} [ssrContext] - SSR context for resolving dynamic metadata
326
+ * @param {object} [ssrContext.state] - Page state with prefetched data
327
+ * @param {string} [ssrContext.lang] - Active language code (e.g. 'ka')
328
+ * @param {string} [ssrContext.actualPathname] - Actual URL pathname (e.g. '/blog/abc-123')
329
+ */
330
+ export function getPageMetadata (data, pathname, ssrContext) {
213
331
  const currentPage = data.pages?.[pathname]
214
332
  const stateObject = isObject(currentPage?.state) && currentPage?.state
215
- let pageMetadata = currentPage?.metadata || currentPage?.helmet || stateObject || {}
333
+ const rawPageMeta = currentPage?.metadata || currentPage?.helmet || stateObject || {}
334
+
335
+ // Convert function values to {{ }} templates instead of filtering them out
336
+ const pageMetadata = convertFunctionMetaToTemplates(rawPageMeta)
337
+
338
+ // Track which keys the page explicitly sets
339
+ const pageExplicitKeys = new Set(Object.keys(pageMetadata))
340
+
216
341
  const appMetadata = data.app?.metadata || {}
342
+ const cleanAppMeta = convertFunctionMetaToTemplates(appMetadata)
343
+
344
+ let merged = {}
217
345
  if (data.integrations?.seo) {
218
- pageMetadata = { ...data.integrations.seo, ...appMetadata, ...pageMetadata }
219
- } else if (Object.keys(appMetadata).length) {
220
- pageMetadata = { ...appMetadata, ...pageMetadata }
346
+ merged = { ...data.integrations.seo, ...cleanAppMeta, ...pageMetadata }
347
+ } else if (Object.keys(cleanAppMeta).length) {
348
+ merged = { ...cleanAppMeta, ...pageMetadata }
349
+ } else {
350
+ merged = { ...pageMetadata }
221
351
  }
222
- if (!pageMetadata.title) pageMetadata.title = data.name + ' / symbo.ls' || 'Symbols demo'
223
- pageMetadata = resolveFileReferences(pageMetadata, data.files)
224
- return pageMetadata
352
+
353
+ if (!merged.title) merged.title = data.name + ' / symbo.ls' || 'Symbols demo'
354
+
355
+ // Resolve {{ }} templates in metadata values (polyglot, state, getLocalStateLang)
356
+ merged = resolveMetadataTemplates(merged, data, ssrContext)
357
+
358
+ // Auto-cascade page-level title/description to OG/Twitter tags
359
+ if (merged.title && (pageExplicitKeys.has('title') || !merged['og:title'])) {
360
+ if (!pageExplicitKeys.has('og:title')) {
361
+ merged['og:title'] = merged.title
362
+ }
363
+ }
364
+ if (merged.description && (pageExplicitKeys.has('description') || !merged['og:description'])) {
365
+ if (!pageExplicitKeys.has('og:description')) {
366
+ merged['og:description'] = merged.description
367
+ }
368
+ }
369
+ if (merged.title && !merged['twitter:title']) {
370
+ merged['twitter:title'] = merged.title
371
+ }
372
+ if (merged.description && !merged['twitter:description']) {
373
+ merged['twitter:description'] = merged.description
374
+ }
375
+
376
+ // Make og:url route-aware — use actual pathname if available
377
+ const routeForUrl = ssrContext?.actualPathname || pathname
378
+ if (merged['og:url'] || merged.url) {
379
+ const baseUrl = (merged['og:url'] || merged.url || '').replace(/\/$/, '')
380
+ if (baseUrl && routeForUrl && routeForUrl !== '/') {
381
+ const cleanRoute = routeForUrl.startsWith('/') ? routeForUrl : '/' + routeForUrl
382
+ merged['og:url'] = baseUrl + cleanRoute
383
+ }
384
+ }
385
+
386
+ merged = resolveFileReferences(merged, data.files)
387
+
388
+ // Clean up any unresolved {{ }} templates (when prefetch fails or returns no data)
389
+ const siteName = data.name || data.app?.metadata?.title || data.app?.name || ''
390
+ for (const [key, value] of Object.entries(merged)) {
391
+ if (typeof value === 'string' && value.includes('{{')) {
392
+ const cleaned = value.replace(/\{\{[^}]*\}\}/g, '').trim()
393
+ if (!cleaned) {
394
+ if (key === 'title') merged[key] = siteName
395
+ else delete merged[key]
396
+ } else {
397
+ merged[key] = cleaned
398
+ }
399
+ }
400
+ }
401
+
402
+ return merged
225
403
  }