@magneticjs/server 0.1.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.
- package/README.md +65 -0
- package/package.json +20 -0
- package/src/assets.ts +190 -0
- package/src/error-boundary.ts +63 -0
- package/src/file-router.ts +196 -0
- package/src/index.ts +23 -0
- package/src/jsx-runtime.ts +244 -0
- package/src/middleware.ts +174 -0
- package/src/router.ts +250 -0
- package/src/ssr.ts +232 -0
package/src/ssr.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// @magnetic/server — SSR (Server-Side Rendering)
|
|
2
|
+
// Converts DomNode tree → HTML string for first paint + SEO
|
|
3
|
+
|
|
4
|
+
import type { DomNode } from './jsx-runtime.ts';
|
|
5
|
+
|
|
6
|
+
// ── HTML escaping ───────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const ESC: Record<string, string> = {
|
|
9
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function esc(s: string): string {
|
|
13
|
+
return s.replace(/[&<>"']/g, (c) => ESC[c]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ── Void elements (self-closing, no children) ───────────────────────
|
|
17
|
+
|
|
18
|
+
const VOID = new Set([
|
|
19
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
20
|
+
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
// ── Head extraction ─────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface ExtractedHead {
|
|
26
|
+
/** DomNode tree with magnetic:head nodes removed */
|
|
27
|
+
body: DomNode;
|
|
28
|
+
/** Collected head elements from all <Head> components */
|
|
29
|
+
headNodes: DomNode[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Walks the DomNode tree, extracts all `magnetic:head` nodes,
|
|
34
|
+
* and returns the cleaned body + collected head elements.
|
|
35
|
+
*/
|
|
36
|
+
export function extractHead(root: DomNode): ExtractedHead {
|
|
37
|
+
const headNodes: DomNode[] = [];
|
|
38
|
+
|
|
39
|
+
function walk(node: DomNode): DomNode | null {
|
|
40
|
+
// This IS a head node — collect its children and remove from body
|
|
41
|
+
if (node.tag === 'magnetic:head') {
|
|
42
|
+
if (node.children) headNodes.push(...node.children);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Recurse into children
|
|
47
|
+
if (node.children) {
|
|
48
|
+
const filtered: DomNode[] = [];
|
|
49
|
+
for (const child of node.children) {
|
|
50
|
+
const result = walk(child);
|
|
51
|
+
if (result) filtered.push(result);
|
|
52
|
+
}
|
|
53
|
+
return { ...node, children: filtered.length ? filtered : undefined };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return node;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const body = walk(root) || root;
|
|
60
|
+
return { body, headNodes };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── DomNode → HTML ──────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Renders a DomNode tree to an HTML string.
|
|
67
|
+
* Events become `data-a_<event>` attributes (Magnetic's event delegation).
|
|
68
|
+
* Keys become `data-key` attributes for client-side reconciliation.
|
|
69
|
+
*/
|
|
70
|
+
export function renderToHTML(node: DomNode): string {
|
|
71
|
+
// Skip magnetic:head nodes in HTML output
|
|
72
|
+
if (node.tag === 'magnetic:head') return '';
|
|
73
|
+
|
|
74
|
+
let html = `<${node.tag}`;
|
|
75
|
+
|
|
76
|
+
// Key → data-key
|
|
77
|
+
if (node.key) {
|
|
78
|
+
html += ` data-key="${esc(node.key)}"`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Attributes
|
|
82
|
+
if (node.attrs) {
|
|
83
|
+
for (const [k, v] of Object.entries(node.attrs)) {
|
|
84
|
+
if (v === '') {
|
|
85
|
+
html += ` ${k}`;
|
|
86
|
+
} else {
|
|
87
|
+
html += ` ${k}="${esc(v)}"`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Events → data-a_ attributes
|
|
93
|
+
if (node.events) {
|
|
94
|
+
for (const [event, action] of Object.entries(node.events)) {
|
|
95
|
+
html += ` data-a_${event}="${esc(action)}"`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Void element — self-close
|
|
100
|
+
if (VOID.has(node.tag)) {
|
|
101
|
+
return html + ' />';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
html += '>';
|
|
105
|
+
|
|
106
|
+
// Text content
|
|
107
|
+
if (node.text != null) {
|
|
108
|
+
html += esc(node.text);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Children
|
|
112
|
+
if (node.children) {
|
|
113
|
+
for (const child of node.children) {
|
|
114
|
+
html += renderToHTML(child);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
html += `</${node.tag}>`;
|
|
119
|
+
return html;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Full page render ────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export interface PageOptions {
|
|
125
|
+
/** The root DomNode from your App component */
|
|
126
|
+
root: DomNode;
|
|
127
|
+
/** CSS file paths to include */
|
|
128
|
+
styles?: string[];
|
|
129
|
+
/** JS file paths to include */
|
|
130
|
+
scripts?: string[];
|
|
131
|
+
/** Inline CSS to inject */
|
|
132
|
+
inlineCSS?: string;
|
|
133
|
+
/** Inline JS to inject at end of body */
|
|
134
|
+
inlineJS?: string;
|
|
135
|
+
/** SSE endpoint for Magnetic client to connect to */
|
|
136
|
+
sseUrl?: string;
|
|
137
|
+
/** Mount selector for Magnetic client */
|
|
138
|
+
mountSelector?: string;
|
|
139
|
+
/** WASM transport URL (optional) */
|
|
140
|
+
wasmUrl?: string;
|
|
141
|
+
/** HTML lang attribute */
|
|
142
|
+
lang?: string;
|
|
143
|
+
/** Fallback title if no <Head><title> found */
|
|
144
|
+
title?: string;
|
|
145
|
+
/** Fallback meta description */
|
|
146
|
+
description?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Renders a complete HTML document with SSR'd body,
|
|
151
|
+
* extracted <Head> elements, and Magnetic client bootstrap.
|
|
152
|
+
*/
|
|
153
|
+
export function renderPage(options: PageOptions): string {
|
|
154
|
+
const {
|
|
155
|
+
root,
|
|
156
|
+
styles = [],
|
|
157
|
+
scripts = [],
|
|
158
|
+
inlineCSS,
|
|
159
|
+
inlineJS,
|
|
160
|
+
sseUrl = '/sse',
|
|
161
|
+
mountSelector = '#app',
|
|
162
|
+
wasmUrl,
|
|
163
|
+
lang = 'en',
|
|
164
|
+
title,
|
|
165
|
+
description,
|
|
166
|
+
} = options;
|
|
167
|
+
|
|
168
|
+
// Extract <Head> elements from component tree
|
|
169
|
+
const { body, headNodes } = extractHead(root);
|
|
170
|
+
|
|
171
|
+
// Render head elements to HTML
|
|
172
|
+
let headHTML = '';
|
|
173
|
+
let hasTitle = false;
|
|
174
|
+
|
|
175
|
+
for (const node of headNodes) {
|
|
176
|
+
if (node.tag === 'title') hasTitle = true;
|
|
177
|
+
headHTML += renderToHTML(node);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Fallbacks
|
|
181
|
+
if (!hasTitle && title) {
|
|
182
|
+
headHTML += `<title>${esc(title)}</title>`;
|
|
183
|
+
}
|
|
184
|
+
if (description) {
|
|
185
|
+
headHTML += `<meta name="description" content="${esc(description)}" />`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Stylesheets
|
|
189
|
+
for (const href of styles) {
|
|
190
|
+
headHTML += `<link rel="stylesheet" href="${esc(href)}" />`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Inline CSS
|
|
194
|
+
if (inlineCSS) {
|
|
195
|
+
headHTML += `<style>${inlineCSS}</style>`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Render body content
|
|
199
|
+
const bodyHTML = renderToHTML(body);
|
|
200
|
+
|
|
201
|
+
// Magnetic client bootstrap script
|
|
202
|
+
let bootstrap = `Magnetic.connect("${sseUrl}", "${mountSelector}");`;
|
|
203
|
+
if (wasmUrl) {
|
|
204
|
+
bootstrap += `\nMagnetic.loadWasm("${wasmUrl}");`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Script tags
|
|
208
|
+
let scriptsHTML = '';
|
|
209
|
+
for (const src of scripts) {
|
|
210
|
+
scriptsHTML += `<script src="${esc(src)}"></script>`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (inlineJS) {
|
|
214
|
+
scriptsHTML += `<script>${inlineJS}</script>`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return `<!DOCTYPE html>
|
|
218
|
+
<html lang="${lang}">
|
|
219
|
+
<head>
|
|
220
|
+
<meta charset="utf-8" />
|
|
221
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
222
|
+
${headHTML}
|
|
223
|
+
</head>
|
|
224
|
+
<body>
|
|
225
|
+
<div id="${mountSelector.replace('#', '')}">${bodyHTML}</div>
|
|
226
|
+
${scriptsHTML}
|
|
227
|
+
<script>
|
|
228
|
+
${bootstrap}
|
|
229
|
+
</script>
|
|
230
|
+
</body>
|
|
231
|
+
</html>`;
|
|
232
|
+
}
|