@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/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
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
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
+ }