@symbo.ls/utils 3.2.3 → 3.14.0

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.
Files changed (122) hide show
  1. package/LICENSE +25 -12
  2. package/README.md +106 -0
  3. package/array.js +162 -0
  4. package/cache.js +7 -0
  5. package/cdn.js +83 -0
  6. package/component.js +25 -0
  7. package/cookie.js +71 -0
  8. package/{src/detectHeight.js → detectHeight.js} +3 -3
  9. package/dist/cjs/array.js +168 -0
  10. package/dist/cjs/browser.js +0 -2
  11. package/dist/cjs/cache.js +26 -0
  12. package/dist/cjs/cdn.js +85 -0
  13. package/dist/cjs/component.js +45 -0
  14. package/dist/cjs/cookie.js +75 -0
  15. package/dist/cjs/date.js +1 -3
  16. package/dist/cjs/detectHeight.js +4 -6
  17. package/dist/cjs/element.js +136 -0
  18. package/dist/cjs/env.js +43 -0
  19. package/dist/cjs/events.js +39 -0
  20. package/dist/cjs/extends.js +434 -0
  21. package/dist/cjs/fibonacci.js +1 -3
  22. package/dist/cjs/files.js +1 -3
  23. package/dist/cjs/function.js +114 -0
  24. package/dist/cjs/globals.js +30 -0
  25. package/dist/cjs/if.js +48 -0
  26. package/dist/cjs/index.js +38 -425
  27. package/dist/cjs/key.js +38 -0
  28. package/dist/cjs/keys.js +201 -0
  29. package/dist/cjs/load.js +5 -7
  30. package/dist/cjs/log.js +34 -0
  31. package/dist/cjs/metadata.js +310 -0
  32. package/dist/cjs/methods.js +599 -0
  33. package/dist/cjs/node.js +35 -0
  34. package/dist/cjs/object.js +634 -0
  35. package/dist/cjs/projectKeys.js +35 -0
  36. package/dist/cjs/props.js +282 -0
  37. package/dist/cjs/scaling.js +5 -169
  38. package/dist/cjs/scope.js +44 -0
  39. package/dist/cjs/sharedLibraries.js +146 -0
  40. package/dist/cjs/state.js +173 -0
  41. package/dist/cjs/string-extra.js +72 -0
  42. package/dist/cjs/string.js +183 -0
  43. package/dist/cjs/style.js +1 -3
  44. package/dist/cjs/tags.js +220 -0
  45. package/dist/cjs/triggerEvent.js +112 -0
  46. package/dist/cjs/types.js +74 -0
  47. package/dist/cjs/update.js +42 -0
  48. package/dist/esm/array.js +148 -0
  49. package/dist/esm/browser.js +14 -0
  50. package/dist/esm/cache.js +6 -0
  51. package/dist/esm/cdn.js +65 -0
  52. package/dist/esm/component.js +25 -0
  53. package/dist/esm/cookie.js +55 -0
  54. package/dist/esm/date.js +11 -0
  55. package/dist/esm/detectHeight.js +42 -0
  56. package/dist/esm/element.js +116 -0
  57. package/dist/esm/env.js +23 -0
  58. package/dist/esm/events.js +19 -0
  59. package/dist/esm/extends.js +414 -0
  60. package/dist/esm/fibonacci.js +10 -0
  61. package/dist/esm/files.js +4 -0
  62. package/dist/esm/function.js +94 -0
  63. package/dist/esm/globals.js +10 -0
  64. package/dist/esm/if.js +28 -0
  65. package/dist/esm/index.js +37 -0
  66. package/dist/esm/key.js +18 -0
  67. package/dist/esm/keys.js +181 -0
  68. package/dist/esm/load.js +151 -0
  69. package/dist/esm/log.js +14 -0
  70. package/dist/esm/metadata.js +290 -0
  71. package/dist/esm/methods.js +569 -0
  72. package/dist/esm/node.js +15 -0
  73. package/dist/esm/object.js +621 -0
  74. package/dist/esm/projectKeys.js +16 -0
  75. package/dist/esm/props.js +262 -0
  76. package/dist/esm/scaling.js +15 -0
  77. package/dist/esm/scope.js +24 -0
  78. package/dist/esm/sharedLibraries.js +126 -0
  79. package/dist/esm/state.js +163 -0
  80. package/dist/esm/string-extra.js +52 -0
  81. package/dist/esm/string.js +163 -0
  82. package/dist/esm/style.js +20 -0
  83. package/dist/esm/tags.js +200 -0
  84. package/dist/esm/triggerEvent.js +92 -0
  85. package/dist/esm/types.js +54 -0
  86. package/dist/esm/update.js +22 -0
  87. package/dist/iife/index.js +5818 -0
  88. package/element.js +148 -0
  89. package/env.js +15 -0
  90. package/events.js +19 -0
  91. package/extends.js +519 -0
  92. package/function.js +145 -0
  93. package/globals.js +8 -0
  94. package/if.js +30 -0
  95. package/index.js +41 -0
  96. package/key.js +20 -0
  97. package/keys.js +176 -0
  98. package/log.js +12 -0
  99. package/metadata.js +392 -0
  100. package/methods.js +636 -0
  101. package/node.js +37 -0
  102. package/object.js +803 -0
  103. package/package.json +38 -22
  104. package/projectKeys.js +30 -0
  105. package/props.js +327 -0
  106. package/{src/scaling.js → scaling.js} +1 -1
  107. package/scope.js +32 -0
  108. package/sharedLibraries.js +184 -0
  109. package/state.js +203 -0
  110. package/{src/index.js → string-extra.js} +11 -17
  111. package/string.js +206 -0
  112. package/tags.js +176 -0
  113. package/triggerEvent.js +102 -0
  114. package/types.js +55 -0
  115. package/update.js +27 -0
  116. package/dist/cjs/package.json +0 -4
  117. /package/{src/browser.js → browser.js} +0 -0
  118. /package/{src/date.js → date.js} +0 -0
  119. /package/{src/fibonacci.js → fibonacci.js} +0 -0
  120. /package/{src/files.js → files.js} +0 -0
  121. /package/{src/load.js → load.js} +0 -0
  122. /package/{src/style.js → style.js} +0 -0
package/metadata.js ADDED
@@ -0,0 +1,392 @@
1
+ 'use strict'
2
+
3
+ import { isObject } from './types.js'
4
+
5
+ const escapeHtml = (text) => {
6
+ if (text === null || text === undefined) return ''
7
+ const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
8
+ return text.toString().replace(/[&<>"']/g, (m) => map[m])
9
+ }
10
+
11
+ const buildAttrs = (obj) =>
12
+ Object.entries(obj || {})
13
+ .filter(([_, v]) => v !== undefined)
14
+ .map(([k, v]) => `${k}="${escapeHtml(v)}"`)
15
+ .join(' ')
16
+
17
+ /**
18
+ * Generates HTML meta tags from a flat metadata object.
19
+ * Works on both server (SSR head injection) and client (dynamic meta updates).
20
+ */
21
+ export const generateMetaTags = (metadata, isProduction) => {
22
+ // For non-production (preview/staging), inject noindex into metadata
23
+ // but still generate full OG/Twitter tags for sharing previews
24
+ if (!isProduction) {
25
+ metadata = { ...metadata, robots: 'noindex' }
26
+ }
27
+
28
+ const tags = Object.entries(metadata).reduce(
29
+ (acc, [key, value]) => {
30
+ if (!value && value !== 0 && value !== false) return acc
31
+
32
+ if (key === 'title') {
33
+ acc.push(`<title>${escapeHtml(value)}</title>`)
34
+ return acc
35
+ }
36
+
37
+ if (key === 'canonical') {
38
+ acc.push(`<link rel="canonical" href="${escapeHtml(value)}">`)
39
+ return acc
40
+ }
41
+
42
+ if (key === 'manifest') {
43
+ acc.push(`<link rel="manifest" href="${escapeHtml(value)}">`)
44
+ return acc
45
+ }
46
+
47
+ if (key === 'favicon' || key === 'favicons' || key === 'icon' || key === 'icons') {
48
+ const items = Array.isArray(value) ? value : [value]
49
+ items.forEach((item) => {
50
+ if (typeof item === 'string') {
51
+ acc.push(`<link rel="icon" href="${escapeHtml(item)}">`)
52
+ } else if (typeof item === 'object') {
53
+ const attrs = buildAttrs(item)
54
+ if (!/rel=/.test(attrs)) {
55
+ acc.push(`<link rel="icon" ${attrs}>`)
56
+ } else {
57
+ acc.push(`<link ${attrs}>`)
58
+ }
59
+ }
60
+ })
61
+ return acc
62
+ }
63
+
64
+ if (key === 'alternate') {
65
+ const alternates = Array.isArray(value) ? value : [value]
66
+ alternates.forEach((alt) => {
67
+ if (typeof alt === 'object') {
68
+ const attrs = Object.entries(alt)
69
+ .filter(([_, attrValue]) => attrValue !== undefined)
70
+ .map(([attrKey, attrValue]) => `${attrKey}="${escapeHtml(attrValue)}"`)
71
+ .join(' ')
72
+ acc.push(`<link rel="alternate" ${attrs}>`)
73
+ }
74
+ })
75
+ return acc
76
+ }
77
+
78
+ const processMetaTag = (tagKey, tagValue, attrType = 'name') => {
79
+ if (
80
+ typeof tagValue === 'string' ||
81
+ typeof tagValue === 'number' ||
82
+ typeof tagValue === 'boolean'
83
+ ) {
84
+ acc.push(`<meta ${attrType}="${escapeHtml(tagKey)}" content="${escapeHtml(tagValue)}">`)
85
+ } else if (Array.isArray(tagValue)) {
86
+ tagValue.forEach((item) => {
87
+ if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') {
88
+ acc.push(`<meta ${attrType}="${escapeHtml(tagKey)}" content="${escapeHtml(item)}">`)
89
+ } else if (typeof item === 'object') {
90
+ const attrs = Object.entries(item)
91
+ .filter(([_, attrValue]) => attrValue !== undefined)
92
+ .map(([attrKey, attrValue]) => `${attrKey}="${escapeHtml(attrValue)}"`)
93
+ .join(' ')
94
+ acc.push(`<meta ${attrs}>`)
95
+ }
96
+ })
97
+ } else if (typeof tagValue === 'object') {
98
+ const attrs = Object.entries(tagValue)
99
+ .filter(([_, attrValue]) => attrValue !== undefined)
100
+ .map(([attrKey, attrValue]) => `${attrKey}="${escapeHtml(attrValue)}"`)
101
+ .join(' ')
102
+ acc.push(`<meta ${attrs}>`)
103
+ }
104
+ }
105
+
106
+ if (key.startsWith('http-equiv:')) {
107
+ const httpEquivKey = key.replace('http-equiv:', '')
108
+ processMetaTag(httpEquivKey, value, 'http-equiv')
109
+ return acc
110
+ }
111
+
112
+ if (key.startsWith('itemprop:')) {
113
+ const itempropKey = key.replace('itemprop:', '')
114
+ processMetaTag(itempropKey, value, 'itemprop')
115
+ return acc
116
+ }
117
+
118
+ const prefixes = [
119
+ 'og:', 'twitter:', 'fb:', 'article:', 'profile:', 'book:',
120
+ 'business:', 'music:', 'product:', 'video:', 'DC:', 'DCTERMS:'
121
+ ]
122
+ const prefix = prefixes.find((p) => key.startsWith(p))
123
+ if (prefix) {
124
+ const tagKey = key.replace(prefix, '')
125
+ processMetaTag(
126
+ `${prefix.replace(':', '')}:${tagKey}`,
127
+ value,
128
+ prefix === 'twitter:' || prefix === 'DC:' || prefix === 'DCTERMS:' ? 'name' : 'property'
129
+ )
130
+ return acc
131
+ }
132
+
133
+ if (key.startsWith('apple:') || key.startsWith('msapplication:')) {
134
+ const pfx = key.startsWith('apple:') ? 'apple-' : 'msapplication-'
135
+ const tagKey = key.replace(/^apple:|^msapplication:/, '')
136
+ processMetaTag(`${pfx}${tagKey}`, value, 'name')
137
+ return acc
138
+ }
139
+
140
+ processMetaTag(key, value)
141
+ return acc
142
+ },
143
+ [
144
+ '<meta charset="UTF-8">',
145
+ '<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">'
146
+ ]
147
+ )
148
+
149
+ return tags.join('\n')
150
+ }
151
+
152
+ /**
153
+ * Extract page-level metadata from project data for a given pathname.
154
+ * Merges global SEO (data.integrations.seo) with page-level metadata/helmet/state.
155
+ */
156
+ /**
157
+ * Check if a string looks like a bare filename (not an absolute path or URL).
158
+ * These are references to project files from `data.files`.
159
+ */
160
+ const isBareFilename = (val) =>
161
+ typeof val === 'string' && val.length > 0 && !val.startsWith('/') && !val.startsWith('http')
162
+
163
+ /**
164
+ * Resolve bare filename references in metadata values against `data.files`.
165
+ * If a metadata value is a plain filename (e.g. "logo.png") and a matching
166
+ * entry exists in `data.files`, replace it with the file's `src` URL.
167
+ */
168
+ function resolveFileReferences (metadata, files) {
169
+ if (!files || typeof files !== 'object') return metadata
170
+
171
+ const resolve = (val) => {
172
+ if (!isBareFilename(val)) return val
173
+ const fileEntry = files[val]
174
+ if (fileEntry?.src) return fileEntry.src
175
+ return val
176
+ }
177
+
178
+ const result = { ...metadata }
179
+ for (const [key, value] of Object.entries(result)) {
180
+ if (typeof value === 'string') {
181
+ result[key] = resolve(value)
182
+ } else if (Array.isArray(value)) {
183
+ result[key] = value.map((item) =>
184
+ typeof item === 'string' ? resolve(item) : item
185
+ )
186
+ }
187
+ }
188
+ return result
189
+ }
190
+
191
+ /**
192
+ * Resolve a dot-path like "item.title_ka" against an object.
193
+ */
194
+ function resolveDotPath (obj, path) {
195
+ if (!obj || !path) return undefined
196
+ const parts = path.split('.')
197
+ let v = obj
198
+ for (const p of parts) {
199
+ if (v == null || typeof v !== 'object') return undefined
200
+ v = v[p]
201
+ }
202
+ return v
203
+ }
204
+
205
+ /**
206
+ * Resolve {{ key | polyglot }} and {{ key | getLocalStateLang }} templates
207
+ * in metadata string values using project polyglot translations and SSR state.
208
+ */
209
+ function resolveMetadataTemplates (metadata, data, ssrContext) {
210
+ const config = data.config || data.settings || {}
211
+ const polyglot = config.polyglot || data.polyglot
212
+ const defaultLang = ssrContext?.lang || polyglot?.defaultLang || 'en'
213
+ const translations = polyglot?.translations || {}
214
+ const langMap = translations[defaultLang] || {}
215
+ const state = ssrContext?.state || {}
216
+
217
+ const result = { ...metadata }
218
+ for (const [key, value] of Object.entries(result)) {
219
+ if (typeof value !== 'string' || !value.includes('{{')) continue
220
+
221
+ result[key] = value.replace(/\{\{\s*([^|{}]+?)\s*(?:\|\s*(\w+)\s*)?\}\}/g, (match, k, filter) => {
222
+ const trimmed = k.trim()
223
+
224
+ if (filter === 'polyglot') {
225
+ return langMap[trimmed] ?? resolveDotPath(langMap, trimmed) ?? match
226
+ }
227
+
228
+ if (filter === 'getLocalStateLang') {
229
+ // key is like "item.title_" — append lang
230
+ const resolved = resolveDotPath(state, trimmed + defaultLang)
231
+ return resolved ?? match
232
+ }
233
+
234
+ // No filter — try state lookup, then polyglot
235
+ const fromState = resolveDotPath(state, trimmed)
236
+ if (fromState !== undefined) return fromState
237
+
238
+ return langMap[trimmed] ?? resolveDotPath(langMap, trimmed) ?? match
239
+ })
240
+ }
241
+ return result
242
+ }
243
+
244
+ /**
245
+ * Convert function-string metadata values to {{ }} templates.
246
+ * Handles common patterns from Symbols page definitions:
247
+ * (el, s) => s.item ? el.call("getLocalStateLang", "item.title_") : ""
248
+ * (el, s) => s.item ? s.item.image_url : ""
249
+ */
250
+ function convertFunctionMetaToTemplates (metadata) {
251
+ const result = { ...metadata }
252
+ for (const [key, value] of Object.entries(result)) {
253
+ if (typeof value === 'function') {
254
+ const src = value.toString()
255
+ // Pattern: el.call("getLocalStateLang", "item.title_")
256
+ const langMatch = src.match(/el\.call\(\s*["']getLocalStateLang["']\s*,\s*["']([^"']+)["']\s*\)/)
257
+ if (langMatch) {
258
+ result[key] = `{{ ${langMatch[1]} | getLocalStateLang }}`
259
+ continue
260
+ }
261
+ // Pattern: el.call("polyglot", "item.description")
262
+ const polyMatch = src.match(/el\.call\(\s*["']polyglot["']\s*,\s*["']([^"']+)["']\s*\)/)
263
+ if (polyMatch) {
264
+ result[key] = `{{ ${polyMatch[1]} | polyglot }}`
265
+ continue
266
+ }
267
+ // Pattern: s.item.field or s.item?.field
268
+ const stateMatch = src.match(/s\.(\w+(?:\.\w+)+)/)
269
+ if (stateMatch) {
270
+ result[key] = `{{ ${stateMatch[1]} }}`
271
+ continue
272
+ }
273
+ // Could not parse — drop function value
274
+ delete result[key]
275
+ } else if (typeof value === 'string') {
276
+ // Function-string (not yet destringified)
277
+ const langMatch = value.match(/el\.call\(\s*["']getLocalStateLang["']\s*,\s*["']([^"']+)["']\s*\)/)
278
+ if (langMatch) {
279
+ result[key] = `{{ ${langMatch[1]} | getLocalStateLang }}`
280
+ continue
281
+ }
282
+ const polyMatch = value.match(/el\.call\(\s*["']polyglot["']\s*,\s*["']([^"']+)["']\s*\)/)
283
+ if (polyMatch) {
284
+ result[key] = `{{ ${polyMatch[1]} | polyglot }}`
285
+ continue
286
+ }
287
+ // Function-string with s.item.field
288
+ if (value.includes('=>') && value.includes('s.')) {
289
+ const stateMatch = value.match(/s\.(\w+(?:\.\w+)+)/)
290
+ if (stateMatch) {
291
+ result[key] = `{{ ${stateMatch[1]} }}`
292
+ continue
293
+ }
294
+ }
295
+ }
296
+ }
297
+ return result
298
+ }
299
+
300
+ /**
301
+ * Extract page-level metadata from project data for a given pathname.
302
+ * @param {object} data - Full project data
303
+ * @param {string} pathname - Route path (e.g. '/blog/:id')
304
+ * @param {object} [ssrContext] - SSR context for resolving dynamic metadata
305
+ * @param {object} [ssrContext.state] - Page state with prefetched data
306
+ * @param {string} [ssrContext.lang] - Active language code (e.g. 'ka')
307
+ * @param {string} [ssrContext.actualPathname] - Actual URL pathname (e.g. '/blog/abc-123')
308
+ */
309
+ export function getPageMetadata (data, pathname, ssrContext, { isProduction = false, channel = 'production' } = {}) {
310
+ const currentPage = data.pages?.[pathname]
311
+ const stateObject = isObject(currentPage?.state) && currentPage?.state
312
+ const rawPageMeta = currentPage?.metadata || currentPage?.helmet || stateObject || {}
313
+
314
+ // Convert function values to {{ }} templates instead of filtering them out
315
+ const pageMetadata = convertFunctionMetaToTemplates(rawPageMeta)
316
+
317
+ // Track which keys the page explicitly sets
318
+ const pageExplicitKeys = new Set(Object.keys(pageMetadata))
319
+
320
+ const appMetadata = data.app?.metadata || {}
321
+ const cleanAppMeta = convertFunctionMetaToTemplates(appMetadata)
322
+
323
+ let merged = {}
324
+ if (data.integrations?.seo) {
325
+ merged = { ...data.integrations.seo, ...cleanAppMeta, ...pageMetadata }
326
+ } else if (Object.keys(cleanAppMeta).length) {
327
+ merged = { ...cleanAppMeta, ...pageMetadata }
328
+ } else {
329
+ merged = { ...pageMetadata }
330
+ }
331
+
332
+ if (!merged.title) {
333
+ const label = data.name || data.key
334
+ if (label) {
335
+ if (isProduction) merged.title = label
336
+ else if (channel === 'development') merged.title = `${label} / dev:symbo.ls`
337
+ else if (channel === 'staging') merged.title = `${label} / staging:symbo.ls`
338
+ else merged.title = `${label} / symbo.ls`
339
+ } else {
340
+ merged.title = 'Symbols demo'
341
+ }
342
+ }
343
+
344
+ // Resolve {{ }} templates in metadata values (polyglot, state, getLocalStateLang)
345
+ merged = resolveMetadataTemplates(merged, data, ssrContext)
346
+
347
+ // Auto-cascade page-level title/description to OG/Twitter tags
348
+ if (merged.title && (pageExplicitKeys.has('title') || !merged['og:title'])) {
349
+ if (!pageExplicitKeys.has('og:title')) {
350
+ merged['og:title'] = merged.title
351
+ }
352
+ }
353
+ if (merged.description && (pageExplicitKeys.has('description') || !merged['og:description'])) {
354
+ if (!pageExplicitKeys.has('og:description')) {
355
+ merged['og:description'] = merged.description
356
+ }
357
+ }
358
+ if (merged.title && !merged['twitter:title']) {
359
+ merged['twitter:title'] = merged.title
360
+ }
361
+ if (merged.description && !merged['twitter:description']) {
362
+ merged['twitter:description'] = merged.description
363
+ }
364
+
365
+ // Make og:url route-aware — use actual pathname if available
366
+ const routeForUrl = ssrContext?.actualPathname || pathname
367
+ if (merged['og:url'] || merged.url) {
368
+ const baseUrl = (merged['og:url'] || merged.url || '').replace(/\/$/, '')
369
+ if (baseUrl && routeForUrl && routeForUrl !== '/') {
370
+ const cleanRoute = routeForUrl.startsWith('/') ? routeForUrl : '/' + routeForUrl
371
+ merged['og:url'] = baseUrl + cleanRoute
372
+ }
373
+ }
374
+
375
+ merged = resolveFileReferences(merged, data.files)
376
+
377
+ // Clean up any unresolved {{ }} templates (when prefetch fails or returns no data)
378
+ const siteName = data.name || data.app?.metadata?.title || data.app?.name || ''
379
+ for (const [key, value] of Object.entries(merged)) {
380
+ if (typeof value === 'string' && value.includes('{{')) {
381
+ const cleaned = value.replace(/\{\{[^}]*\}\}/g, '').trim()
382
+ if (!cleaned) {
383
+ if (key === 'title') merged[key] = siteName
384
+ else delete merged[key]
385
+ } else {
386
+ merged[key] = cleaned
387
+ }
388
+ }
389
+ }
390
+
391
+ return merged
392
+ }