@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
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
const render = async (data, options = {}) => {
|
|
7
|
+
const { route = "/", state: stateOverrides, context: contextOverrides } = options;
|
|
8
|
+
const { window, document } = createEnv();
|
|
9
|
+
const body = document.body;
|
|
10
|
+
window.location.pathname = route;
|
|
11
|
+
const smblsSrc = new URL("../../packages/smbls/src/createDomql.js", import.meta.url);
|
|
12
|
+
const { createDomqlElement } = await import(smblsSrc.href);
|
|
13
|
+
const app = data.app || {};
|
|
14
|
+
const ctx = {
|
|
15
|
+
state: { ...data.state, ...stateOverrides || {} },
|
|
16
|
+
dependencies: data.dependencies || {},
|
|
17
|
+
components: data.components || {},
|
|
18
|
+
snippets: data.snippets || {},
|
|
19
|
+
pages: data.pages || {},
|
|
20
|
+
functions: data.functions || {},
|
|
21
|
+
methods: data.methods || {},
|
|
22
|
+
designSystem: data.designSystem || {},
|
|
23
|
+
files: data.files || {},
|
|
24
|
+
...data.config || data.settings || {},
|
|
25
|
+
// Virtual DOM environment
|
|
26
|
+
document,
|
|
27
|
+
window,
|
|
28
|
+
parent: { node: body },
|
|
29
|
+
// Caller overrides
|
|
30
|
+
...contextOverrides || {}
|
|
31
|
+
};
|
|
32
|
+
resetKeys();
|
|
33
|
+
const element = await createDomqlElement(app, ctx);
|
|
34
|
+
assignKeys(body);
|
|
35
|
+
const registry = mapKeysToElements(element);
|
|
36
|
+
const metadata = extractMetadata(data, route);
|
|
37
|
+
const html = body.innerHTML;
|
|
38
|
+
return { html, metadata, registry, element };
|
|
39
|
+
};
|
|
40
|
+
const renderElement = async (elementDef, options = {}) => {
|
|
41
|
+
const { context = {} } = options;
|
|
42
|
+
const { window, document } = createEnv();
|
|
43
|
+
const body = document.body;
|
|
44
|
+
const { create } = await import("@domql/element");
|
|
45
|
+
resetKeys();
|
|
46
|
+
const element = create(elementDef, { node: body }, "root", {
|
|
47
|
+
context: { document, window, ...context }
|
|
48
|
+
});
|
|
49
|
+
assignKeys(body);
|
|
50
|
+
const registry = mapKeysToElements(element);
|
|
51
|
+
const html = body.innerHTML;
|
|
52
|
+
return { html, registry, element };
|
|
53
|
+
};
|
|
54
|
+
const renderRoute = async (data, options = {}) => {
|
|
55
|
+
const { route = "/" } = options;
|
|
56
|
+
const ds = data.designSystem || {};
|
|
57
|
+
const pageDef = (data.pages || {})[route];
|
|
58
|
+
if (!pageDef) return null;
|
|
59
|
+
const result = await renderElement(pageDef, {
|
|
60
|
+
context: {
|
|
61
|
+
components: data.components || {},
|
|
62
|
+
snippets: data.snippets || {},
|
|
63
|
+
designSystem: ds,
|
|
64
|
+
state: data.state || {},
|
|
65
|
+
functions: data.functions || {},
|
|
66
|
+
methods: data.methods || {}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
const { document: cssDoc } = parseHTML(`<html><head></head><body>${result.html}</body></html>`);
|
|
70
|
+
let emotionInstance;
|
|
71
|
+
try {
|
|
72
|
+
const { default: createInstance } = await import("@emotion/css/create-instance");
|
|
73
|
+
emotionInstance = createInstance({ key: "smbls", container: cssDoc.head });
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
hydrate(result.element, {
|
|
77
|
+
root: cssDoc.body,
|
|
78
|
+
renderEvents: false,
|
|
79
|
+
events: false,
|
|
80
|
+
emotion: emotionInstance,
|
|
81
|
+
designSystem: ds
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
html: cssDoc.body.innerHTML,
|
|
85
|
+
css: extractCSS(result.element, ds),
|
|
86
|
+
resetCss: generateResetCSS(ds.reset),
|
|
87
|
+
fontLinks: generateFontLinks(ds),
|
|
88
|
+
metadata: extractMetadata(data, route),
|
|
89
|
+
brKeyCount: Object.keys(result.registry).length
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const renderPage = async (data, route = "/", options = {}) => {
|
|
93
|
+
const { lang = "en", themeColor } = options;
|
|
94
|
+
const result = await renderRoute(data, { route });
|
|
95
|
+
if (!result) return null;
|
|
96
|
+
const metadata = { ...result.metadata };
|
|
97
|
+
if (themeColor) metadata["theme-color"] = themeColor;
|
|
98
|
+
const headTags = generateHeadHtml(metadata);
|
|
99
|
+
const html = `<!DOCTYPE html>
|
|
100
|
+
<html lang="${lang}">
|
|
101
|
+
<head>
|
|
102
|
+
${headTags}
|
|
103
|
+
${result.fontLinks}
|
|
104
|
+
<style>${result.resetCss}</style>
|
|
105
|
+
<style data-emotion="smbls">
|
|
106
|
+
${result.css}
|
|
107
|
+
</style>
|
|
108
|
+
</head>
|
|
109
|
+
<body>
|
|
110
|
+
${result.html}
|
|
111
|
+
</body>
|
|
112
|
+
</html>`;
|
|
113
|
+
return { html, route, brKeyCount: result.brKeyCount };
|
|
114
|
+
};
|
|
115
|
+
const CSS_COLOR_PROPS = /* @__PURE__ */ new Set([
|
|
116
|
+
"color",
|
|
117
|
+
"background",
|
|
118
|
+
"backgroundColor",
|
|
119
|
+
"borderColor",
|
|
120
|
+
"borderTopColor",
|
|
121
|
+
"borderRightColor",
|
|
122
|
+
"borderBottomColor",
|
|
123
|
+
"borderLeftColor",
|
|
124
|
+
"outlineColor",
|
|
125
|
+
"fill",
|
|
126
|
+
"stroke"
|
|
127
|
+
]);
|
|
128
|
+
const NON_CSS_PROPS = /* @__PURE__ */ new Set([
|
|
129
|
+
"href",
|
|
130
|
+
"src",
|
|
131
|
+
"alt",
|
|
132
|
+
"title",
|
|
133
|
+
"id",
|
|
134
|
+
"name",
|
|
135
|
+
"type",
|
|
136
|
+
"value",
|
|
137
|
+
"placeholder",
|
|
138
|
+
"target",
|
|
139
|
+
"rel",
|
|
140
|
+
"loading",
|
|
141
|
+
"srcset",
|
|
142
|
+
"sizes",
|
|
143
|
+
"media",
|
|
144
|
+
"role",
|
|
145
|
+
"tabindex",
|
|
146
|
+
"for",
|
|
147
|
+
"action",
|
|
148
|
+
"method",
|
|
149
|
+
"enctype",
|
|
150
|
+
"autocomplete",
|
|
151
|
+
"autofocus",
|
|
152
|
+
"theme",
|
|
153
|
+
"__element",
|
|
154
|
+
"update"
|
|
155
|
+
]);
|
|
156
|
+
const camelToKebab = (str) => str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
157
|
+
const resolveShorthand = (key, val) => {
|
|
158
|
+
if (key === "flexAlign" && typeof val === "string") {
|
|
159
|
+
const [alignItems, justifyContent] = val.split(" ");
|
|
160
|
+
return { display: "flex", "align-items": alignItems, "justify-content": justifyContent };
|
|
161
|
+
}
|
|
162
|
+
if (key === "gridAlign" && typeof val === "string") {
|
|
163
|
+
const [alignItems, justifyContent] = val.split(" ");
|
|
164
|
+
return { display: "grid", "align-items": alignItems, "justify-content": justifyContent };
|
|
165
|
+
}
|
|
166
|
+
if (key === "round" && val) {
|
|
167
|
+
return { "border-radius": typeof val === "number" ? val + "px" : val };
|
|
168
|
+
}
|
|
169
|
+
if (key === "boxSize" && val) {
|
|
170
|
+
return { width: val, height: val };
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
};
|
|
174
|
+
const resolveInnerProps = (obj, colorMap) => {
|
|
175
|
+
const result = {};
|
|
176
|
+
for (const k in obj) {
|
|
177
|
+
const v = obj[k];
|
|
178
|
+
const expanded = resolveShorthand(k, v);
|
|
179
|
+
if (expanded) {
|
|
180
|
+
Object.assign(result, expanded);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (typeof v !== "string" && typeof v !== "number") continue;
|
|
184
|
+
result[camelToKebab(k)] = CSS_COLOR_PROPS.has(k) && colorMap[v] ? colorMap[v] : v;
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
};
|
|
188
|
+
const buildCSSFromProps = (props, colorMap, mediaMap) => {
|
|
189
|
+
const base = {};
|
|
190
|
+
const mediaRules = {};
|
|
191
|
+
const pseudoRules = {};
|
|
192
|
+
for (const key in props) {
|
|
193
|
+
const val = props[key];
|
|
194
|
+
if (key.charCodeAt(0) === 64 && typeof val === "object") {
|
|
195
|
+
const bp = mediaMap?.[key.slice(1)];
|
|
196
|
+
if (bp) {
|
|
197
|
+
const inner = resolveInnerProps(val, colorMap);
|
|
198
|
+
if (Object.keys(inner).length) mediaRules[bp] = inner;
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (key.charCodeAt(0) === 58 && typeof val === "object") {
|
|
203
|
+
const inner = resolveInnerProps(val, colorMap);
|
|
204
|
+
if (Object.keys(inner).length) pseudoRules[key] = inner;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (typeof val !== "string" && typeof val !== "number") continue;
|
|
208
|
+
if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue;
|
|
209
|
+
if (NON_CSS_PROPS.has(key)) continue;
|
|
210
|
+
const expanded = resolveShorthand(key, val);
|
|
211
|
+
if (expanded) {
|
|
212
|
+
Object.assign(base, expanded);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
base[camelToKebab(key)] = CSS_COLOR_PROPS.has(key) && colorMap[val] ? colorMap[val] : val;
|
|
216
|
+
}
|
|
217
|
+
return { base, mediaRules, pseudoRules };
|
|
218
|
+
};
|
|
219
|
+
const renderCSSRule = (selector, { base, mediaRules, pseudoRules }) => {
|
|
220
|
+
const lines = [];
|
|
221
|
+
const baseDecls = Object.entries(base).map(([k, v]) => `${k}: ${v}`).join("; ");
|
|
222
|
+
if (baseDecls) lines.push(`${selector} { ${baseDecls}; }`);
|
|
223
|
+
for (const [pseudo, p] of Object.entries(pseudoRules)) {
|
|
224
|
+
const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join("; ");
|
|
225
|
+
if (decls) lines.push(`${selector}${pseudo} { ${decls}; }`);
|
|
226
|
+
}
|
|
227
|
+
for (const [query, p] of Object.entries(mediaRules)) {
|
|
228
|
+
const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join("; ");
|
|
229
|
+
const mq = query.startsWith("@") ? query : `@media ${query}`;
|
|
230
|
+
if (decls) lines.push(`${mq} { ${selector} { ${decls}; } }`);
|
|
231
|
+
}
|
|
232
|
+
return lines.join("\n");
|
|
233
|
+
};
|
|
234
|
+
const extractCSS = (element, ds) => {
|
|
235
|
+
const colorMap = ds?.color || {};
|
|
236
|
+
const mediaMap = ds?.media || {};
|
|
237
|
+
const animations = ds?.animation || {};
|
|
238
|
+
const rules = [];
|
|
239
|
+
const seen = /* @__PURE__ */ new Set();
|
|
240
|
+
const usedAnimations = /* @__PURE__ */ new Set();
|
|
241
|
+
const walk = (el) => {
|
|
242
|
+
if (!el || !el.__ref) return;
|
|
243
|
+
const { props } = el;
|
|
244
|
+
if (props && el.node) {
|
|
245
|
+
const cls = el.node.getAttribute?.("class");
|
|
246
|
+
if (cls && !seen.has(cls)) {
|
|
247
|
+
seen.add(cls);
|
|
248
|
+
const cssResult = buildCSSFromProps(props, colorMap, mediaMap);
|
|
249
|
+
const has = Object.keys(cssResult.base).length || Object.keys(cssResult.mediaRules).length || Object.keys(cssResult.pseudoRules).length;
|
|
250
|
+
if (has) rules.push(renderCSSRule("." + cls.split(" ")[0], cssResult));
|
|
251
|
+
const anim = props.animation || props.animationName;
|
|
252
|
+
if (typeof anim === "string") {
|
|
253
|
+
const name = anim.split(" ")[0];
|
|
254
|
+
if (animations[name]) usedAnimations.add(name);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (el.__ref.__children) {
|
|
259
|
+
for (const ck of el.__ref.__children) {
|
|
260
|
+
if (el[ck]?.__ref) walk(el[ck]);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
walk(element);
|
|
265
|
+
const keyframes = [];
|
|
266
|
+
for (const name of usedAnimations) {
|
|
267
|
+
const frames = animations[name];
|
|
268
|
+
const frameRules = Object.entries(frames).map(([step, p]) => {
|
|
269
|
+
const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join("; ");
|
|
270
|
+
return ` ${step} { ${decls}; }`;
|
|
271
|
+
}).join("\n");
|
|
272
|
+
keyframes.push(`@keyframes ${name} {
|
|
273
|
+
${frameRules}
|
|
274
|
+
}`);
|
|
275
|
+
}
|
|
276
|
+
return [...keyframes, ...rules].join("\n");
|
|
277
|
+
};
|
|
278
|
+
const generateResetCSS = (reset) => {
|
|
279
|
+
if (!reset) return "";
|
|
280
|
+
const rules = [];
|
|
281
|
+
for (const [selector, props] of Object.entries(reset)) {
|
|
282
|
+
const decls = Object.entries(props).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join("; ");
|
|
283
|
+
if (decls) rules.push(`${selector} { ${decls}; }`);
|
|
284
|
+
}
|
|
285
|
+
return rules.join("\n");
|
|
286
|
+
};
|
|
287
|
+
const generateFontLinks = (ds) => {
|
|
288
|
+
if (!ds) return "";
|
|
289
|
+
const families = ds.font_family || ds.fontFamily || {};
|
|
290
|
+
const fontNames = /* @__PURE__ */ new Set();
|
|
291
|
+
for (const val of Object.values(families)) {
|
|
292
|
+
const match = val.match(/'([^']+)'/);
|
|
293
|
+
if (match) fontNames.add(match[1]);
|
|
294
|
+
}
|
|
295
|
+
if (!fontNames.size) return "";
|
|
296
|
+
const params = [...fontNames].map((name) => {
|
|
297
|
+
const slug = name.replace(/\s+/g, "+");
|
|
298
|
+
return `family=${slug}:wght@300;400;500;600;700`;
|
|
299
|
+
}).join("&");
|
|
300
|
+
return [
|
|
301
|
+
'<link rel="preconnect" href="https://fonts.googleapis.com">',
|
|
302
|
+
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
|
|
303
|
+
`<link href="https://fonts.googleapis.com/css2?${params}&display=swap" rel="stylesheet">`
|
|
304
|
+
].join("\n");
|
|
305
|
+
};
|
|
306
|
+
export {
|
|
307
|
+
render,
|
|
308
|
+
renderElement,
|
|
309
|
+
renderPage,
|
|
310
|
+
renderRoute
|
|
311
|
+
};
|
package/env.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { parseHTML } from 'linkedom'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a virtual DOM environment for server-side rendering.
|
|
5
|
+
* Returns window and document that DomQL can use as context.
|
|
6
|
+
*/
|
|
7
|
+
export const createEnv = (html = '<!DOCTYPE html><html><head></head><body></body></html>') => {
|
|
8
|
+
const { window, document } = parseHTML(html)
|
|
9
|
+
|
|
10
|
+
// Stub APIs that DomQL/smbls may call during rendering
|
|
11
|
+
if (!window.requestAnimationFrame) {
|
|
12
|
+
window.requestAnimationFrame = (fn) => setTimeout(fn, 0)
|
|
13
|
+
}
|
|
14
|
+
if (!window.cancelAnimationFrame) {
|
|
15
|
+
window.cancelAnimationFrame = (id) => clearTimeout(id)
|
|
16
|
+
}
|
|
17
|
+
if (!window.history) {
|
|
18
|
+
window.history = {
|
|
19
|
+
pushState: () => {},
|
|
20
|
+
replaceState: () => {},
|
|
21
|
+
state: null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (!window.location) {
|
|
25
|
+
window.location = { pathname: '/', search: '', hash: '', origin: 'http://localhost' }
|
|
26
|
+
}
|
|
27
|
+
if (!window.URL) {
|
|
28
|
+
window.URL = URL
|
|
29
|
+
}
|
|
30
|
+
if (!window.scrollTo) {
|
|
31
|
+
window.scrollTo = () => {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Expose linkedom constructors on globalThis so @domql/utils isDOMNode
|
|
35
|
+
// can use instanceof checks (it reads from globalThis.Node, etc.)
|
|
36
|
+
globalThis.window = window
|
|
37
|
+
globalThis.document = document
|
|
38
|
+
globalThis.Node = window.Node || globalThis.Node
|
|
39
|
+
globalThis.HTMLElement = window.HTMLElement || globalThis.HTMLElement
|
|
40
|
+
globalThis.Window = window.constructor
|
|
41
|
+
|
|
42
|
+
return { window, document }
|
|
43
|
+
}
|