@domql/brender 3.2.7
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/README.md +282 -0
- package/dist/cjs/env.js +57 -0
- package/dist/cjs/hydrate.js +472 -0
- package/dist/cjs/index.js +58 -0
- package/dist/cjs/keys.js +62 -0
- package/dist/cjs/load.js +82 -0
- package/dist/cjs/metadata.js +102 -0
- package/dist/cjs/render.js +341 -0
- package/dist/esm/env.js +38 -0
- package/dist/esm/hydrate.js +453 -0
- package/dist/esm/index.js +39 -0
- package/dist/esm/keys.js +43 -0
- package/dist/esm/load.js +63 -0
- package/dist/esm/metadata.js +83 -0
- package/dist/esm/render.js +311 -0
- package/env.js +43 -0
- package/hydrate.js +388 -0
- package/index.js +40 -0
- package/keys.js +54 -0
- package/load.js +81 -0
- package/metadata.js +117 -0
- package/package.json +48 -0
- package/render.js +386 -0
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@domql/brender",
|
|
3
|
+
"version": "3.2.7",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"module": "./dist/esm/index.js",
|
|
7
|
+
"main": "./dist/cjs/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/esm/index.js",
|
|
11
|
+
"require": "./dist/cjs/index.js",
|
|
12
|
+
"default": "./dist/esm/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./hydrate": {
|
|
15
|
+
"import": "./hydrate.js",
|
|
16
|
+
"default": "./hydrate.js"
|
|
17
|
+
},
|
|
18
|
+
"./load": {
|
|
19
|
+
"import": "./load.js",
|
|
20
|
+
"default": "./load.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"source": "index.js",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"*.js"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"copy:package:cjs": "cp ../../build/package-cjs.json dist/cjs/package.json",
|
|
30
|
+
"build:esm": "cross-env NODE_ENV=$NODE_ENV esbuild *.js --target=es2020 --format=esm --outdir=dist/esm --define:process.env.NODE_ENV=process.env.NODE_ENV",
|
|
31
|
+
"build:cjs": "cross-env NODE_ENV=$NODE_ENV esbuild *.js --target=node18 --format=cjs --outdir=dist/cjs --define:process.env.NODE_ENV=process.env.NODE_ENV",
|
|
32
|
+
"build": "node ../../build/build.js",
|
|
33
|
+
"prepublish": "npm run build && npm run copy:package:cjs",
|
|
34
|
+
"render:survey": "node scripts/render.js survey",
|
|
35
|
+
"render:rita": "node scripts/render.js rita",
|
|
36
|
+
"render:all": "npm run render:survey && npm run render:rita",
|
|
37
|
+
"liquidate:survey": "node scripts/liquidate.js survey",
|
|
38
|
+
"liquidate:rita": "node scripts/liquidate.js rita",
|
|
39
|
+
"dev:rita": "node examples/serve-rita.js"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"linkedom": "^0.16.8"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@babel/core": "^7.26.0"
|
|
46
|
+
},
|
|
47
|
+
"sideEffects": false
|
|
48
|
+
}
|
package/render.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { createEnv } from './env.js'
|
|
2
|
+
import { resetKeys, assignKeys, mapKeysToElements } from './keys.js'
|
|
3
|
+
import { extractMetadata, generateHeadHtml } from './metadata.js'
|
|
4
|
+
import { hydrate } from './hydrate.js'
|
|
5
|
+
import { parseHTML } from 'linkedom'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders a Symbols/DomQL project to HTML on the server.
|
|
9
|
+
*
|
|
10
|
+
* Accepts project data as a plain object (matching what ProjectDataService provides)
|
|
11
|
+
* or as a pre-loaded smbls context. Runs DomQL in a linkedom virtual DOM,
|
|
12
|
+
* assigns data-br keys for hydration, and extracts page metadata for SEO.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} data - Project data object with: pages, components, designSystem,
|
|
15
|
+
* state, functions, methods, snippets, files, app, config/settings
|
|
16
|
+
* @param {object} [options]
|
|
17
|
+
* @param {string} [options.route='/'] - The route/page to render
|
|
18
|
+
* @param {object} [options.state] - State overrides
|
|
19
|
+
* @param {object} [options.context] - Additional context overrides
|
|
20
|
+
* @returns {Promise<{ html: string, metadata: object, registry: object, element: object }>}
|
|
21
|
+
*/
|
|
22
|
+
export const render = async (data, options = {}) => {
|
|
23
|
+
const { route = '/', state: stateOverrides, context: contextOverrides } = options
|
|
24
|
+
|
|
25
|
+
const { window, document } = createEnv()
|
|
26
|
+
const body = document.body
|
|
27
|
+
|
|
28
|
+
// Set route on location so the router picks it up
|
|
29
|
+
window.location.pathname = route
|
|
30
|
+
|
|
31
|
+
// Lazily import smbls createDomqlElement — this avoids requiring
|
|
32
|
+
// the whole smbls package at module load time
|
|
33
|
+
// Import from source directly — the smbls package doesn't export this subpath
|
|
34
|
+
const smblsSrc = new URL('../../packages/smbls/src/createDomql.js', import.meta.url)
|
|
35
|
+
const { createDomqlElement } = await import(smblsSrc.href)
|
|
36
|
+
|
|
37
|
+
const app = data.app || {}
|
|
38
|
+
|
|
39
|
+
const ctx = {
|
|
40
|
+
state: { ...data.state, ...(stateOverrides || {}) },
|
|
41
|
+
dependencies: data.dependencies || {},
|
|
42
|
+
components: data.components || {},
|
|
43
|
+
snippets: data.snippets || {},
|
|
44
|
+
pages: data.pages || {},
|
|
45
|
+
functions: data.functions || {},
|
|
46
|
+
methods: data.methods || {},
|
|
47
|
+
designSystem: data.designSystem || {},
|
|
48
|
+
files: data.files || {},
|
|
49
|
+
...(data.config || data.settings || {}),
|
|
50
|
+
// Virtual DOM environment
|
|
51
|
+
document,
|
|
52
|
+
window,
|
|
53
|
+
parent: { node: body },
|
|
54
|
+
// Caller overrides
|
|
55
|
+
...(contextOverrides || {})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
resetKeys()
|
|
59
|
+
|
|
60
|
+
const element = await createDomqlElement(app, ctx)
|
|
61
|
+
|
|
62
|
+
// Assign data-br keys for hydration
|
|
63
|
+
assignKeys(body)
|
|
64
|
+
|
|
65
|
+
const registry = mapKeysToElements(element)
|
|
66
|
+
|
|
67
|
+
// Extract metadata for the rendered route
|
|
68
|
+
const metadata = extractMetadata(data, route)
|
|
69
|
+
|
|
70
|
+
const html = body.innerHTML
|
|
71
|
+
|
|
72
|
+
return { html, metadata, registry, element }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Renders a single DomQL element definition to HTML.
|
|
77
|
+
* Useful for rendering individual components without a full project.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} elementDef - DomQL element definition
|
|
80
|
+
* @param {object} [options]
|
|
81
|
+
* @param {object} [options.context] - DomQL context (components, designSystem, etc.)
|
|
82
|
+
* @returns {Promise<{ html: string, registry: object, element: object }>}
|
|
83
|
+
*/
|
|
84
|
+
export const renderElement = async (elementDef, options = {}) => {
|
|
85
|
+
const { context = {} } = options
|
|
86
|
+
|
|
87
|
+
const { window, document } = createEnv()
|
|
88
|
+
const body = document.body
|
|
89
|
+
|
|
90
|
+
const { create } = await import('@domql/element')
|
|
91
|
+
|
|
92
|
+
resetKeys()
|
|
93
|
+
|
|
94
|
+
const element = create(elementDef, { node: body }, 'root', {
|
|
95
|
+
context: { document, window, ...context }
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
assignKeys(body)
|
|
99
|
+
const registry = mapKeysToElements(element)
|
|
100
|
+
const html = body.innerHTML
|
|
101
|
+
|
|
102
|
+
return { html, registry, element }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Route-level SSR ───────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Renders a single route and returns body HTML + CSS separately.
|
|
109
|
+
* Designed for integration with an existing server router that manages
|
|
110
|
+
* its own <head>, template, and bundle injection.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} data - Full project data
|
|
113
|
+
* @param {object} [options]
|
|
114
|
+
* @param {string} [options.route='/'] - Route to render
|
|
115
|
+
* @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
|
|
116
|
+
*/
|
|
117
|
+
export const renderRoute = async (data, options = {}) => {
|
|
118
|
+
const { route = '/' } = options
|
|
119
|
+
const ds = data.designSystem || {}
|
|
120
|
+
const pageDef = (data.pages || {})[route]
|
|
121
|
+
if (!pageDef) return null
|
|
122
|
+
|
|
123
|
+
const result = await renderElement(pageDef, {
|
|
124
|
+
context: {
|
|
125
|
+
components: data.components || {},
|
|
126
|
+
snippets: data.snippets || {},
|
|
127
|
+
designSystem: ds,
|
|
128
|
+
state: data.state || {},
|
|
129
|
+
functions: data.functions || {},
|
|
130
|
+
methods: data.methods || {}
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Hydrate with emotion → CSS classes on nodes
|
|
135
|
+
const { document: cssDoc } = parseHTML(`<html><head></head><body>${result.html}</body></html>`)
|
|
136
|
+
let emotionInstance
|
|
137
|
+
try {
|
|
138
|
+
const { default: createInstance } = await import('@emotion/css/create-instance')
|
|
139
|
+
emotionInstance = createInstance({ key: 'smbls', container: cssDoc.head })
|
|
140
|
+
} catch {}
|
|
141
|
+
|
|
142
|
+
hydrate(result.element, {
|
|
143
|
+
root: cssDoc.body,
|
|
144
|
+
renderEvents: false,
|
|
145
|
+
events: false,
|
|
146
|
+
emotion: emotionInstance,
|
|
147
|
+
designSystem: ds
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
html: cssDoc.body.innerHTML,
|
|
152
|
+
css: extractCSS(result.element, ds),
|
|
153
|
+
resetCss: generateResetCSS(ds.reset),
|
|
154
|
+
fontLinks: generateFontLinks(ds),
|
|
155
|
+
metadata: extractMetadata(data, route),
|
|
156
|
+
brKeyCount: Object.keys(result.registry).length
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Full page SSR ─────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Renders a complete HTML page for a route — ready to serve.
|
|
164
|
+
* Includes head, metadata, fonts, reset CSS, component CSS, and body.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} data - Full project data (from loadProject)
|
|
167
|
+
* @param {string} route - Route to render (e.g. '/', '/about')
|
|
168
|
+
* @param {object} [options]
|
|
169
|
+
* @param {string} [options.lang='en'] - HTML lang attribute
|
|
170
|
+
* @param {string} [options.themeColor] - theme-color meta
|
|
171
|
+
* @returns {Promise<{ html: string, route: string, brKeyCount: number }>}
|
|
172
|
+
*/
|
|
173
|
+
export const renderPage = async (data, route = '/', options = {}) => {
|
|
174
|
+
const { lang = 'en', themeColor } = options
|
|
175
|
+
|
|
176
|
+
const result = await renderRoute(data, { route })
|
|
177
|
+
if (!result) return null
|
|
178
|
+
|
|
179
|
+
const metadata = { ...result.metadata }
|
|
180
|
+
if (themeColor) metadata['theme-color'] = themeColor
|
|
181
|
+
const headTags = generateHeadHtml(metadata)
|
|
182
|
+
|
|
183
|
+
const html = `<!DOCTYPE html>
|
|
184
|
+
<html lang="${lang}">
|
|
185
|
+
<head>
|
|
186
|
+
${headTags}
|
|
187
|
+
${result.fontLinks}
|
|
188
|
+
<style>${result.resetCss}</style>
|
|
189
|
+
<style data-emotion="smbls">
|
|
190
|
+
${result.css}
|
|
191
|
+
</style>
|
|
192
|
+
</head>
|
|
193
|
+
<body>
|
|
194
|
+
${result.html}
|
|
195
|
+
</body>
|
|
196
|
+
</html>`
|
|
197
|
+
|
|
198
|
+
return { html, route, brKeyCount: result.brKeyCount }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── CSS helpers ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
const CSS_COLOR_PROPS = new Set([
|
|
204
|
+
'color', 'background', 'backgroundColor', 'borderColor',
|
|
205
|
+
'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor',
|
|
206
|
+
'outlineColor', 'fill', 'stroke'
|
|
207
|
+
])
|
|
208
|
+
|
|
209
|
+
const NON_CSS_PROPS = new Set([
|
|
210
|
+
'href', 'src', 'alt', 'title', 'id', 'name', 'type', 'value', 'placeholder',
|
|
211
|
+
'target', 'rel', 'loading', 'srcset', 'sizes', 'media', 'role', 'tabindex',
|
|
212
|
+
'for', 'action', 'method', 'enctype', 'autocomplete', 'autofocus',
|
|
213
|
+
'theme', '__element', 'update'
|
|
214
|
+
])
|
|
215
|
+
|
|
216
|
+
const camelToKebab = (str) => str.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
|
|
217
|
+
|
|
218
|
+
const resolveShorthand = (key, val) => {
|
|
219
|
+
if (key === 'flexAlign' && typeof val === 'string') {
|
|
220
|
+
const [alignItems, justifyContent] = val.split(' ')
|
|
221
|
+
return { display: 'flex', 'align-items': alignItems, 'justify-content': justifyContent }
|
|
222
|
+
}
|
|
223
|
+
if (key === 'gridAlign' && typeof val === 'string') {
|
|
224
|
+
const [alignItems, justifyContent] = val.split(' ')
|
|
225
|
+
return { display: 'grid', 'align-items': alignItems, 'justify-content': justifyContent }
|
|
226
|
+
}
|
|
227
|
+
if (key === 'round' && val) {
|
|
228
|
+
return { 'border-radius': typeof val === 'number' ? val + 'px' : val }
|
|
229
|
+
}
|
|
230
|
+
if (key === 'boxSize' && val) {
|
|
231
|
+
return { width: val, height: val }
|
|
232
|
+
}
|
|
233
|
+
return null
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const resolveInnerProps = (obj, colorMap) => {
|
|
237
|
+
const result = {}
|
|
238
|
+
for (const k in obj) {
|
|
239
|
+
const v = obj[k]
|
|
240
|
+
const expanded = resolveShorthand(k, v)
|
|
241
|
+
if (expanded) { Object.assign(result, expanded); continue }
|
|
242
|
+
if (typeof v !== 'string' && typeof v !== 'number') continue
|
|
243
|
+
result[camelToKebab(k)] = CSS_COLOR_PROPS.has(k) && colorMap[v] ? colorMap[v] : v
|
|
244
|
+
}
|
|
245
|
+
return result
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const buildCSSFromProps = (props, colorMap, mediaMap) => {
|
|
249
|
+
const base = {}
|
|
250
|
+
const mediaRules = {}
|
|
251
|
+
const pseudoRules = {}
|
|
252
|
+
|
|
253
|
+
for (const key in props) {
|
|
254
|
+
const val = props[key]
|
|
255
|
+
|
|
256
|
+
if (key.charCodeAt(0) === 64 && typeof val === 'object') {
|
|
257
|
+
const bp = mediaMap?.[key.slice(1)]
|
|
258
|
+
if (bp) {
|
|
259
|
+
const inner = resolveInnerProps(val, colorMap)
|
|
260
|
+
if (Object.keys(inner).length) mediaRules[bp] = inner
|
|
261
|
+
}
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (key.charCodeAt(0) === 58 && typeof val === 'object') {
|
|
266
|
+
const inner = resolveInnerProps(val, colorMap)
|
|
267
|
+
if (Object.keys(inner).length) pseudoRules[key] = inner
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (typeof val !== 'string' && typeof val !== 'number') continue
|
|
272
|
+
if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
|
|
273
|
+
if (NON_CSS_PROPS.has(key)) continue
|
|
274
|
+
|
|
275
|
+
const expanded = resolveShorthand(key, val)
|
|
276
|
+
if (expanded) { Object.assign(base, expanded); continue }
|
|
277
|
+
|
|
278
|
+
base[camelToKebab(key)] = CSS_COLOR_PROPS.has(key) && colorMap[val] ? colorMap[val] : val
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { base, mediaRules, pseudoRules }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const renderCSSRule = (selector, { base, mediaRules, pseudoRules }) => {
|
|
285
|
+
const lines = []
|
|
286
|
+
const baseDecls = Object.entries(base).map(([k, v]) => `${k}: ${v}`).join('; ')
|
|
287
|
+
if (baseDecls) lines.push(`${selector} { ${baseDecls}; }`)
|
|
288
|
+
|
|
289
|
+
for (const [pseudo, p] of Object.entries(pseudoRules)) {
|
|
290
|
+
const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
|
|
291
|
+
if (decls) lines.push(`${selector}${pseudo} { ${decls}; }`)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (const [query, p] of Object.entries(mediaRules)) {
|
|
295
|
+
const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
|
|
296
|
+
const mq = query.startsWith('@') ? query : `@media ${query}`
|
|
297
|
+
if (decls) lines.push(`${mq} { ${selector} { ${decls}; } }`)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return lines.join('\n')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const extractCSS = (element, ds) => {
|
|
304
|
+
const colorMap = ds?.color || {}
|
|
305
|
+
const mediaMap = ds?.media || {}
|
|
306
|
+
const animations = ds?.animation || {}
|
|
307
|
+
const rules = []
|
|
308
|
+
const seen = new Set()
|
|
309
|
+
const usedAnimations = new Set()
|
|
310
|
+
|
|
311
|
+
const walk = (el) => {
|
|
312
|
+
if (!el || !el.__ref) return
|
|
313
|
+
const { props } = el
|
|
314
|
+
if (props && el.node) {
|
|
315
|
+
const cls = el.node.getAttribute?.('class')
|
|
316
|
+
if (cls && !seen.has(cls)) {
|
|
317
|
+
seen.add(cls)
|
|
318
|
+
const cssResult = buildCSSFromProps(props, colorMap, mediaMap)
|
|
319
|
+
const has = Object.keys(cssResult.base).length || Object.keys(cssResult.mediaRules).length || Object.keys(cssResult.pseudoRules).length
|
|
320
|
+
if (has) rules.push(renderCSSRule('.' + cls.split(' ')[0], cssResult))
|
|
321
|
+
|
|
322
|
+
const anim = props.animation || props.animationName
|
|
323
|
+
if (typeof anim === 'string') {
|
|
324
|
+
const name = anim.split(' ')[0]
|
|
325
|
+
if (animations[name]) usedAnimations.add(name)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (el.__ref.__children) {
|
|
330
|
+
for (const ck of el.__ref.__children) {
|
|
331
|
+
if (el[ck]?.__ref) walk(el[ck])
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
walk(element)
|
|
336
|
+
|
|
337
|
+
const keyframes = []
|
|
338
|
+
for (const name of usedAnimations) {
|
|
339
|
+
const frames = animations[name]
|
|
340
|
+
const frameRules = Object.entries(frames).map(([step, p]) => {
|
|
341
|
+
const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join('; ')
|
|
342
|
+
return ` ${step} { ${decls}; }`
|
|
343
|
+
}).join('\n')
|
|
344
|
+
keyframes.push(`@keyframes ${name} {\n${frameRules}\n}`)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return [...keyframes, ...rules].join('\n')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const generateResetCSS = (reset) => {
|
|
351
|
+
if (!reset) return ''
|
|
352
|
+
const rules = []
|
|
353
|
+
for (const [selector, props] of Object.entries(reset)) {
|
|
354
|
+
const decls = Object.entries(props)
|
|
355
|
+
.map(([k, v]) => `${camelToKebab(k)}: ${v}`)
|
|
356
|
+
.join('; ')
|
|
357
|
+
if (decls) rules.push(`${selector} { ${decls}; }`)
|
|
358
|
+
}
|
|
359
|
+
return rules.join('\n')
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const generateFontLinks = (ds) => {
|
|
363
|
+
if (!ds) return ''
|
|
364
|
+
const families = ds.font_family || ds.fontFamily || {}
|
|
365
|
+
const fontNames = new Set()
|
|
366
|
+
|
|
367
|
+
// Collect font family names from the design system
|
|
368
|
+
for (const val of Object.values(families)) {
|
|
369
|
+
const match = val.match(/'([^']+)'/)
|
|
370
|
+
if (match) fontNames.add(match[1])
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (!fontNames.size) return ''
|
|
374
|
+
|
|
375
|
+
// Build Google Fonts URL
|
|
376
|
+
const params = [...fontNames].map(name => {
|
|
377
|
+
const slug = name.replace(/\s+/g, '+')
|
|
378
|
+
return `family=${slug}:wght@300;400;500;600;700`
|
|
379
|
+
}).join('&')
|
|
380
|
+
|
|
381
|
+
return [
|
|
382
|
+
'<link rel="preconnect" href="https://fonts.googleapis.com">',
|
|
383
|
+
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
|
|
384
|
+
`<link href="https://fonts.googleapis.com/css2?${params}&display=swap" rel="stylesheet">`
|
|
385
|
+
].join('\n')
|
|
386
|
+
}
|