aberdeen 1.17.1 → 2.0.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 (139) hide show
  1. package/dist/src/aberdeen.d.ts +928 -944
  2. package/dist/src/aberdeen.js +1693 -1705
  3. package/dist/src/aberdeen.js.map +3 -3
  4. package/dist/src/index.d.ts +12 -0
  5. package/dist/src/index.js +11 -0
  6. package/dist/src/index.js.map +10 -0
  7. package/dist/src/prediction.d.ts +7 -4
  8. package/dist/src/prediction.js +18 -23
  9. package/dist/src/prediction.js.map +3 -3
  10. package/dist/src/route.d.ts +112 -99
  11. package/dist/src/route.js +254 -254
  12. package/dist/src/route.js.map +3 -3
  13. package/dist/src/server-dom.d.ts +158 -0
  14. package/dist/src/server-dom.js +458 -0
  15. package/dist/src/server-dom.js.map +10 -0
  16. package/dist/src/server.d.ts +55 -0
  17. package/dist/src/server.js +25 -0
  18. package/dist/src/server.js.map +10 -0
  19. package/dist/src/transitions.js +7 -4
  20. package/dist/src/transitions.js.map +3 -3
  21. package/dist/tests/environment.d.ts +24 -0
  22. package/dist/tests/{fakedom.js → environment.js} +239 -288
  23. package/dist/tests/environment.js.map +11 -0
  24. package/dist/tests/helpers.d.ts +15 -3
  25. package/dist/tests/helpers.js +277 -306
  26. package/dist/tests/helpers.js.map +6 -5
  27. package/dist-docs/Tutorial/index.html +30 -30
  28. package/dist-docs/aberdeen/Aberdeen/index.html +350 -0
  29. package/dist-docs/aberdeen/InitOptions/index.html +4 -0
  30. package/dist-docs/aberdeen/KeyToString/index.html +2 -0
  31. package/dist-docs/aberdeen/PromiseProxy/index.html +6 -6
  32. package/dist-docs/aberdeen/SortKeyType/index.html +2 -0
  33. package/dist-docs/aberdeen/TargetType/index.html +2 -0
  34. package/dist-docs/aberdeen/ValueRef/index.html +2 -0
  35. package/dist-docs/aberdeen/WindowLike/index.html +5 -0
  36. package/dist-docs/aberdeen/{clone → createA}/index.html +10 -5
  37. package/dist-docs/aberdeen/index.html +13 -54
  38. package/dist-docs/assets/aberdeen/aberdeen.d.ts +928 -944
  39. package/dist-docs/assets/aberdeen/aberdeen.js +1693 -1705
  40. package/dist-docs/assets/aberdeen/aberdeen.js.map +3 -3
  41. package/dist-docs/assets/aberdeen/index.d.ts +12 -0
  42. package/dist-docs/assets/aberdeen/index.js +11 -0
  43. package/dist-docs/assets/aberdeen/index.js.map +10 -0
  44. package/dist-docs/assets/aberdeen/prediction.d.ts +7 -4
  45. package/dist-docs/assets/aberdeen/prediction.js +18 -23
  46. package/dist-docs/assets/aberdeen/prediction.js.map +3 -3
  47. package/dist-docs/assets/aberdeen/route.d.ts +112 -99
  48. package/dist-docs/assets/aberdeen/route.js +254 -254
  49. package/dist-docs/assets/aberdeen/route.js.map +3 -3
  50. package/dist-docs/assets/aberdeen/server-dom.d.ts +158 -0
  51. package/dist-docs/assets/aberdeen/server-dom.js +458 -0
  52. package/dist-docs/assets/aberdeen/server-dom.js.map +10 -0
  53. package/dist-docs/assets/aberdeen/server.d.ts +55 -0
  54. package/dist-docs/assets/aberdeen/server.js +25 -0
  55. package/dist-docs/assets/aberdeen/server.js.map +10 -0
  56. package/dist-docs/assets/aberdeen/transitions.js +7 -4
  57. package/dist-docs/assets/aberdeen/transitions.js.map +3 -3
  58. package/dist-docs/assets/navigation.js +1 -1
  59. package/dist-docs/assets/search.js +1 -1
  60. package/dist-docs/dispatcher/Dispatcher/index.html +5 -6
  61. package/dist-docs/dispatcher/MATCH_FAILED/index.html +2 -2
  62. package/dist-docs/dispatcher/MATCH_REST/index.html +2 -2
  63. package/dist-docs/dispatcher/index.html +2 -2
  64. package/dist-docs/hierarchy.html +1 -1
  65. package/dist-docs/index-1/index.html +2 -0
  66. package/dist-docs/index.html +2 -2
  67. package/dist-docs/media/CHANGELOG.md +10 -1
  68. package/dist-docs/modules.html +1 -1
  69. package/dist-docs/prediction/applyCanon/index.html +4 -3
  70. package/dist-docs/prediction/applyPrediction/index.html +4 -3
  71. package/dist-docs/prediction/index.html +2 -2
  72. package/dist-docs/route/Route/index.html +9 -9
  73. package/dist-docs/route/RouteApi/index.html +60 -0
  74. package/dist-docs/route/index.html +6 -15
  75. package/dist-docs/route/init/index.html +5 -0
  76. package/dist-docs/server/RenderToStringOptions/index.html +6 -0
  77. package/dist-docs/server/RenderToStringResult/index.html +6 -0
  78. package/dist-docs/server/index.html +4 -0
  79. package/dist-docs/server/renderToString/index.html +6 -0
  80. package/dist-docs/sitemap.xml +51 -155
  81. package/dist-docs/transitions/grow/index.html +2 -2
  82. package/dist-docs/transitions/index.html +2 -2
  83. package/dist-docs/transitions/shrink/index.html +2 -2
  84. package/package.json +17 -7
  85. package/skill/SKILL.md +28 -28
  86. package/skill/aberdeen.md +471 -3448
  87. package/skill/dispatcher.md +6 -16
  88. package/skill/prediction.md +17 -5
  89. package/skill/route.md +95 -77
  90. package/skill/transitions.md +3 -3
  91. package/src/aberdeen.ts +1224 -741
  92. package/src/index.ts +13 -0
  93. package/src/prediction.ts +25 -24
  94. package/src/route.ts +192 -142
  95. package/src/server-dom.ts +508 -0
  96. package/src/server.ts +74 -0
  97. package/src/transitions.ts +9 -3
  98. package/dist/tests/fakedom.d.ts +0 -9
  99. package/dist/tests/fakedom.js.map +0 -10
  100. package/dist-docs/aberdeen/A/index.html +0 -115
  101. package/dist-docs/aberdeen/CUSTOM_DUMP/index.html +0 -7
  102. package/dist-docs/aberdeen/NO_COPY/index.html +0 -2
  103. package/dist-docs/aberdeen/OPAQUE/index.html +0 -12
  104. package/dist-docs/aberdeen/clean/index.html +0 -11
  105. package/dist-docs/aberdeen/copy/index.html +0 -22
  106. package/dist-docs/aberdeen/count/index.html +0 -7
  107. package/dist-docs/aberdeen/cssVars/index.html +0 -11
  108. package/dist-docs/aberdeen/darkMode/index.html +0 -9
  109. package/dist-docs/aberdeen/default/index.html +0 -341
  110. package/dist-docs/aberdeen/derive/index.html +0 -18
  111. package/dist-docs/aberdeen/disableCreateDestroy/index.html +0 -6
  112. package/dist-docs/aberdeen/dump/index.html +0 -11
  113. package/dist-docs/aberdeen/insertCss/index.html +0 -30
  114. package/dist-docs/aberdeen/insertGlobalCss/index.html +0 -19
  115. package/dist-docs/aberdeen/invertString/index.html +0 -13
  116. package/dist-docs/aberdeen/isEmpty/index.html +0 -12
  117. package/dist-docs/aberdeen/map/index.html +0 -26
  118. package/dist-docs/aberdeen/merge/index.html +0 -11
  119. package/dist-docs/aberdeen/mount/index.html +0 -17
  120. package/dist-docs/aberdeen/multiMap/index.html +0 -28
  121. package/dist-docs/aberdeen/onEach/index.html +0 -19
  122. package/dist-docs/aberdeen/partition/index.html +0 -36
  123. package/dist-docs/aberdeen/peek/index.html +0 -40
  124. package/dist-docs/aberdeen/proxy/index.html +0 -35
  125. package/dist-docs/aberdeen/ref/index.html +0 -13
  126. package/dist-docs/aberdeen/runQueue/index.html +0 -13
  127. package/dist-docs/aberdeen/setErrorHandler/index.html +0 -16
  128. package/dist-docs/aberdeen/setSpacingCssVars/index.html +0 -8
  129. package/dist-docs/aberdeen/unmountAll/index.html +0 -5
  130. package/dist-docs/aberdeen/unproxy/index.html +0 -11
  131. package/dist-docs/route/back/index.html +0 -4
  132. package/dist-docs/route/current/index.html +0 -2
  133. package/dist-docs/route/go/index.html +0 -14
  134. package/dist-docs/route/interceptLinks/index.html +0 -8
  135. package/dist-docs/route/matchCurrent/index.html +0 -10
  136. package/dist-docs/route/persistScroll/index.html +0 -6
  137. package/dist-docs/route/push/index.html +0 -6
  138. package/dist-docs/route/setLog/index.html +0 -3
  139. package/dist-docs/route/up/index.html +0 -5
@@ -0,0 +1,508 @@
1
+ /**
2
+ * A minimal, dependency-free DOM implementation that Aberdeen can render into when no
3
+ * real browser DOM is available (server-side rendering, automated tests).
4
+ *
5
+ * It implements just enough of the `Node`/`Element`/`Text` interfaces for Aberdeen's
6
+ * renderer, plus two serializers:
7
+ * - {@link serializeHtml} produces real HTML (used for SSR output).
8
+ * - `Element.toString()` produces a terse, test-oriented representation (used by the
9
+ * test suite's `assertBody`).
10
+ *
11
+ * The same classes back both the test fake-DOM (`tests/fakedom.ts`) and the public
12
+ * `aberdeen/server` entry point, so there is a single source of truth for DOM behaviour.
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Instrumentation (used by the test suite to count DOM operations)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ let newCount: number = 0, changeCount: number = 0;
20
+ export const resetCounts = function(): void { newCount = changeCount = 0; };
21
+ export const getCounts = function(): { new: number, changed: number } {
22
+ return { new: newCount, changed: changeCount };
23
+ };
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // DOM node classes
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export class Node {
30
+ parentNode: Element | null = null;
31
+ childNodes: Node[] = [];
32
+ /** The document that created this node; its `defaultView` is the owning window. */
33
+ ownerDocument: any = null;
34
+
35
+ get nextSibling(): Node | undefined {
36
+ return this.getSibling(+1);
37
+ }
38
+
39
+ get previousSibling(): Node | undefined {
40
+ return this.getSibling(-1);
41
+ }
42
+
43
+ getSibling(delta: number): Node | undefined {
44
+ if (!this.parentNode) return undefined;
45
+ let siblings = this.parentNode.childNodes;
46
+ let idx = siblings.indexOf(this);
47
+ if (idx < 0) throw new Error("not part of siblings!?");
48
+ return siblings[idx + delta];
49
+ }
50
+ }
51
+
52
+ type ClassList = {
53
+ add: (...names: string[]) => void;
54
+ remove: (...names: string[]) => void;
55
+ toggle: (name: string, force?: boolean) => void;
56
+ contains: (name: string) => boolean;
57
+ };
58
+
59
+ export class Element extends Node {
60
+ tagName: string;
61
+ namespaceURI?: string;
62
+ _style: Record<string, string> = {};
63
+ attrs: Record<string, string> = {};
64
+ events: Record<string, Set<Function>> = {};
65
+ _classList?: ClassList;
66
+ _scrollTop?: number;
67
+ _scrollLeft?: number;
68
+
69
+ constructor(tag: string, namespaceURI?: string) {
70
+ super();
71
+ this.tagName = tag;
72
+ this.namespaceURI = namespaceURI;
73
+ this.childNodes = [];
74
+ newCount++;
75
+ }
76
+
77
+ appendChild(node: Node): void {
78
+ this.insertBefore(node, null);
79
+ }
80
+
81
+ insertBefore(node: Node, ref: Node | null): void {
82
+ if (node.parentNode) node.parentNode.removeChild(node);
83
+ else changeCount++;
84
+ node.parentNode = this;
85
+ if (ref) {
86
+ let idx = this.childNodes.indexOf(ref);
87
+ if (idx < 0) throw new Error("non-existing ref node");
88
+ this.childNodes.splice(idx, 0, node);
89
+ } else {
90
+ this.childNodes.push(node);
91
+ }
92
+ }
93
+
94
+ removeChild(node: Node): void {
95
+ let idx = this.childNodes.indexOf(node);
96
+ if (idx < 0) throw new Error("no such child");
97
+ this.childNodes.splice(idx, 1);
98
+ node.parentNode = null;
99
+ changeCount++;
100
+ }
101
+
102
+ remove(): void {
103
+ this.parentNode?.removeChild(this);
104
+ }
105
+
106
+ replaceChild(newNode: Node, oldNode: Node): void {
107
+ this.insertBefore(newNode, oldNode);
108
+ this.removeChild(oldNode);
109
+ }
110
+
111
+ setAttribute(k: string, v: any): void {
112
+ if (v == null || v === '') delete this.attrs[k];
113
+ else this.attrs[k] = '' + v;
114
+ changeCount++;
115
+ }
116
+
117
+ getAttribute(k: string): string | undefined {
118
+ return this.attrs[k];
119
+ }
120
+
121
+ removeAttribute(k: string): void {
122
+ delete this.attrs[k];
123
+ changeCount++;
124
+ }
125
+
126
+ get classList(): ClassList {
127
+ return this._classList || (this._classList = {
128
+ add: (...names: string[]) => {
129
+ let set = this._getClassSet();
130
+ for(const name of names) set.add(name);
131
+ this._setClassSet(set);
132
+ },
133
+ remove: (...names: string[]) => {
134
+ let set = this._getClassSet();
135
+ for(const name of names) set.delete(name);
136
+ this._setClassSet(set);
137
+ },
138
+ toggle: (name: string, force?: boolean) => {
139
+ if (force === true || (force !== false && !this._getClassSet().has(name))) this._classList!.add(name);
140
+ else this._classList!.remove(name);
141
+ },
142
+ contains: (name: string) => this._getClassSet().has(name),
143
+ });
144
+ }
145
+
146
+ get parentElement(): Element | undefined {
147
+ if (this.parentNode instanceof Element) return this.parentNode;
148
+ return undefined;
149
+ }
150
+
151
+ _getClassSet(): Set<string> {
152
+ return new Set(this.attrs.class ? this.attrs.class.split(' ') : []);
153
+ }
154
+
155
+ _setClassSet(map: Set<string>): void {
156
+ this.setAttribute('class', Array.from(map).sort().join(' ') || undefined);
157
+ }
158
+
159
+ get firstChild(): Node | undefined {
160
+ return this.childNodes[0];
161
+ }
162
+
163
+ get lastChild(): Node | undefined {
164
+ return this.childNodes[this.childNodes.length - 1];
165
+ }
166
+
167
+ set textContent(text: string) {
168
+ this.childNodes = [new TextNode(text)];
169
+ }
170
+
171
+ get textContent() {
172
+ let text = '';
173
+ for(let child of this.childNodes) {
174
+ if (child instanceof TextNode) {
175
+ text += child.textContent;
176
+ }
177
+ }
178
+ return text;
179
+ }
180
+
181
+ querySelectorAll(selector: string): Element[] {
182
+ const results: Element[] = [];
183
+ this._querySelectorAllRecursive(selector, results);
184
+ return results;
185
+ }
186
+
187
+ private _querySelectorAllRecursive(selector: string, results: Element[]): void {
188
+ for (const child of this.childNodes) {
189
+ if (child instanceof Element) {
190
+ if (child.tagName === selector) {
191
+ results.push(child);
192
+ }
193
+ child._querySelectorAllRecursive(selector, results);
194
+ }
195
+ }
196
+ }
197
+
198
+ set innerHTML(html: string) {
199
+ this.childNodes = [];
200
+ if (html) {
201
+ let n = new Element('fake-emulated-html');
202
+ n.textContent = html;
203
+ this.appendChild(n);
204
+ }
205
+ }
206
+
207
+ set style(val: string) {
208
+ if (val !== '') throw new Error("non-empty style string cannot be emulated");
209
+ this._style = {};
210
+ changeCount++;
211
+ }
212
+
213
+ get style(): Record<string, string> {
214
+ for(let k in this._style) {
215
+ if (this._style[k] === '') delete this._style[k];
216
+ }
217
+ return this._style;
218
+ }
219
+
220
+ set className(v: string) {
221
+ this.attrs.class = v;
222
+ changeCount++;
223
+ }
224
+
225
+ get offsetHeight(): number {
226
+ return 20;
227
+ }
228
+
229
+ get offsetWidth(): number {
230
+ return 200;
231
+ }
232
+
233
+ get scrollTop(): number {
234
+ return this._scrollTop || 0;
235
+ }
236
+
237
+ set scrollTop(value: number) {
238
+ this._scrollTop = value;
239
+ }
240
+
241
+ get scrollLeft(): number {
242
+ return this._scrollLeft || 0;
243
+ }
244
+
245
+ set scrollLeft(value: number) {
246
+ this._scrollLeft = value;
247
+ }
248
+
249
+ /**
250
+ * Terse, test-oriented serialization (NOT valid HTML). Shows attributes (`=`),
251
+ * DOM properties (`->`), styles (`:`) and nested children. Used by `assertBody`.
252
+ */
253
+ toString(): string {
254
+ let arr: string[] = [];
255
+ for(let [symbol, items, toKebab] of [['=', this.attrs, false], ['->', this, false], [':', this.style, true]] as [string, Record<string, any>, boolean][]) {
256
+ for(let key of Object.keys(items).sort()) {
257
+ const outputKey = toKebab ? key.replace(/[A-Z]/g, l => `-${l.toLowerCase()}`) : key;
258
+ const prefix = outputKey + symbol;
259
+ if (key[0] === '_' || IGNORE_OUTPUT.has(prefix)) continue;
260
+ let value = '' + items[key];
261
+ if (value.indexOf(" ") >= 0 || value.indexOf("}") >= 0 || !value.length) value = JSON.stringify(value);
262
+ arr.push(prefix + value);
263
+ }
264
+ }
265
+ for(let child of this.childNodes) arr.push(child.toString());
266
+
267
+ const cls = this.attrs['class'] ? "." + this.attrs['class'].replace(/ /g, '.') : '';
268
+ return this.tagName + cls + (arr.length ? `{${arr.join(' ')}}` : '');
269
+ }
270
+
271
+ addEventListener(name: string, func: Function): void {
272
+ this.events[name] = this.events[name] || new Set();
273
+ this.events[name].add(func);
274
+ changeCount++;
275
+ }
276
+
277
+ removeEventListener(name: string, func: Function): void {
278
+ if (this.events[name]) {
279
+ if (this.events[name].delete(func)) {
280
+ changeCount++;
281
+ }
282
+ }
283
+ }
284
+
285
+ event(info: string | { type: string; [key: string]: any }): void {
286
+ if (typeof info === 'string') info = {type: info};
287
+ let type = info.type;
288
+ info.target = this;
289
+ info.preventDefault = function() {};
290
+ info.stopPropagation = function() { info.stopped = true; };
291
+ let node: Element | null = this;
292
+ while(node && !info.stopped) {
293
+ let funcs = node.events[type];
294
+ if (funcs) {
295
+ for(let func of funcs) {
296
+ func.call(node, info);
297
+ }
298
+ }
299
+ node = node.parentNode;
300
+ }
301
+ }
302
+
303
+ getElementById(id: string): Element | undefined {
304
+ if (this.attrs.id === id) return this;
305
+ for(let child of this.childNodes) {
306
+ if (child instanceof Element) {
307
+ let el = child.getElementById(id);
308
+ if (el) return el;
309
+ }
310
+ }
311
+ return undefined;
312
+ }
313
+ }
314
+
315
+ const IGNORE_OUTPUT = new Set("tagName-> attrs-> events-> childNodes-> parentNode-> class= namespaceURI-> ownerDocument->".split(" "));
316
+
317
+ export class TextNode extends Node {
318
+ textContent: string;
319
+
320
+ constructor(textContent: string) {
321
+ super();
322
+ this.textContent = '' + textContent;
323
+ newCount++;
324
+ }
325
+
326
+ toString(): string {
327
+ return JSON.stringify(this.textContent);
328
+ }
329
+
330
+ remove() {
331
+ this.parentNode?.removeChild(this);
332
+ }
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Document factory
337
+ // ---------------------------------------------------------------------------
338
+
339
+ export interface ServerDocument {
340
+ createElement(tag: string): Element;
341
+ createElementNS(namespaceURI: string, tag: string): Element;
342
+ createTextNode(text: string): TextNode;
343
+ head: Element;
344
+ body: Element;
345
+ /** The owning window, set by {@link createWindow}. */
346
+ defaultView: any;
347
+ }
348
+
349
+ const XHTML_NS = 'http://www.w3.org/1999/xhtml';
350
+
351
+ /** Create a fresh, isolated document. Use one per SSR request to avoid cross-request state. */
352
+ export function createDocument(): ServerDocument {
353
+ const doc: ServerDocument = {
354
+ createElement: (tag: string) => own(new Element(tag, XHTML_NS)),
355
+ createElementNS: (namespaceURI: string, tag: string) => own(new Element(tag, namespaceURI)),
356
+ createTextNode: (text: string) => own(new TextNode(text)),
357
+ head: new Element('head', XHTML_NS),
358
+ body: new Element('body', XHTML_NS),
359
+ defaultView: null,
360
+ };
361
+ function own<T extends Node>(node: T): T { node.ownerDocument = doc; return node; }
362
+ own(doc.head);
363
+ own(doc.body);
364
+ return doc;
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // HTML serialization
369
+ // ---------------------------------------------------------------------------
370
+
371
+ const VOID_ELEMENTS = new Set("area base br col embed hr img input link meta param source track wbr".split(" "));
372
+ // Elements whose text content must NOT be HTML-escaped.
373
+ const RAW_TEXT_ELEMENTS = new Set(["script", "style"]);
374
+
375
+ function escapeText(s: string): string {
376
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
377
+ }
378
+
379
+ function escapeAttr(s: string): string {
380
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
381
+ }
382
+
383
+ function styleToString(style: Record<string, string>): string {
384
+ let out = '';
385
+ for (const key of Object.keys(style)) {
386
+ const value = style[key];
387
+ if (value === '') continue;
388
+ const prop = key.replace(/[A-Z]/g, l => `-${l.toLowerCase()}`);
389
+ out += `${prop}:${value};`;
390
+ }
391
+ return out;
392
+ }
393
+
394
+ /**
395
+ * Serialize a node (and its descendants) to an HTML string.
396
+ *
397
+ * Notes/limitations:
398
+ * - Event listeners are dropped (they are client-only by design).
399
+ * - DOM properties set directly on elements (e.g. `input.value` via `value:` arg) are not
400
+ * reflected as attributes and therefore do not appear in the output.
401
+ */
402
+ export function serializeHtml(node: Node): string {
403
+ if (node instanceof TextNode) return escapeText(node.textContent);
404
+ if (!(node instanceof Element)) return '';
405
+
406
+ const tag = node.tagName;
407
+ // Wrapper produced by `innerHTML` / the `html:` arg — emit its content verbatim.
408
+ if (tag === 'fake-emulated-html') return node.textContent;
409
+
410
+ let html = '<' + tag;
411
+ for (const key of Object.keys(node.attrs)) {
412
+ html += ` ${key}="${escapeAttr(node.attrs[key])}"`;
413
+ }
414
+ const styleStr = styleToString(node.style);
415
+ if (styleStr) html += ` style="${escapeAttr(styleStr)}"`;
416
+ html += '>';
417
+
418
+ if (VOID_ELEMENTS.has(tag)) return html;
419
+
420
+ if (RAW_TEXT_ELEMENTS.has(tag)) {
421
+ html += node.textContent;
422
+ } else {
423
+ for (const child of node.childNodes) html += serializeHtml(child);
424
+ }
425
+ return html + '</' + tag + '>';
426
+ }
427
+
428
+ /** Serialize the children of a container element to an HTML string (the container tag itself is omitted). */
429
+ export function serializeChildren(node: Element): string {
430
+ let html = '';
431
+ for (const child of node.childNodes) html += serializeHtml(child);
432
+ return html;
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Minimal injectable window / location / history (sufficient for SSR routing)
437
+ // ---------------------------------------------------------------------------
438
+
439
+ export class ServerLocation {
440
+ private _href: string = 'http://localhost/';
441
+ constructor(href: string = 'http://localhost/') {
442
+ // Goes through the setter, which absolutizes path-only URLs (e.g. Express `req.originalUrl`).
443
+ this.href = href;
444
+ }
445
+
446
+ get href(): string { return this._href; }
447
+ set href(value: string) {
448
+ this._href = value.startsWith('/')
449
+ ? `${new URL(this._href).origin}${value}`
450
+ : value;
451
+ }
452
+ get origin(): string { return new URL(this._href).origin; }
453
+ get host(): string { return new URL(this._href).host; }
454
+ get pathname(): string { return new URL(this._href).pathname; }
455
+ set pathname(v: string) { const u = new URL(this._href); u.pathname = v; this._href = u.href; }
456
+ get search(): string { return new URL(this._href).search; }
457
+ set search(v: string) { const u = new URL(this._href); u.search = v; this._href = u.href; }
458
+ get hash(): string { return new URL(this._href).hash; }
459
+ set hash(v: string) { const u = new URL(this._href); u.hash = v; this._href = u.href; }
460
+ }
461
+
462
+ export class ServerHistory {
463
+ state: any = null;
464
+ constructor(private location: ServerLocation) {}
465
+ pushState(state: any, _title: string, url: string): void { this.state = state; this.location.href = url; }
466
+ replaceState(state: any, _title: string, url: string): void { this.state = state; this.location.href = url; }
467
+ go(_delta: number): void { /* no navigation during SSR */ }
468
+ back(): void {}
469
+ forward(): void {}
470
+ }
471
+
472
+ export interface ServerWindow {
473
+ document: ServerDocument;
474
+ Node: typeof Node;
475
+ Element: typeof Element;
476
+ location: ServerLocation;
477
+ history: ServerHistory;
478
+ addEventListener(type: string, listener: Function): void;
479
+ removeEventListener(type: string, listener: Function): void;
480
+ dispatchEvent(event: any): void;
481
+ matchMedia(query: string): { matches: boolean; media: string; addEventListener(): void; removeEventListener(): void };
482
+ }
483
+
484
+ /**
485
+ * Create a fresh, isolated window (carrying its own document and the `Node`/`Element`
486
+ * constructors) bound to `url`. Use one per SSR request. Satisfies Aberdeen's `WindowLike`.
487
+ */
488
+ export function createWindow(url: string = 'http://localhost/'): ServerWindow {
489
+ const location = new ServerLocation(url);
490
+ const history = new ServerHistory(location);
491
+ const listeners: Record<string, Set<Function>> = {};
492
+ const document = createDocument();
493
+ const win: ServerWindow = {
494
+ document,
495
+ Node,
496
+ Element,
497
+ location,
498
+ history,
499
+ addEventListener(type, listener) { (listeners[type] ||= new Set()).add(listener); },
500
+ removeEventListener(type, listener) { listeners[type]?.delete(listener); },
501
+ dispatchEvent(event) { listeners[event.type]?.forEach(l => l.call(this, event)); },
502
+ matchMedia(query) {
503
+ return { matches: false, media: query, addEventListener() {}, removeEventListener() {} };
504
+ },
505
+ };
506
+ document.defaultView = win;
507
+ return win;
508
+ }
package/src/server.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Server-side rendering entry point for Aberdeen (`aberdeen/server`).
3
+ *
4
+ * It renders an Aberdeen component to an HTML string against a fresh, isolated instance (via
5
+ * {@link createA}) bound to a dependency-free server DOM. Because each call gets its own instance
6
+ * and window, concurrent renders never share reactive state.
7
+ *
8
+ * This produces the initial, synchronous render — ideal for fast first paint, link-preview
9
+ * bots and crawlers that don't run JavaScript. Reactive updates driven by asynchronously
10
+ * arriving data are not awaited; the output reflects the component's initial state (including
11
+ * any loading placeholders). Orchestrating async data and instance lifecycles is left to the
12
+ * caller (the higher level in the stack).
13
+ *
14
+ * @example Express
15
+ * ```ts
16
+ * import { renderToString } from 'aberdeen/server';
17
+ * import { init as initRoute } from 'aberdeen/route';
18
+ *
19
+ * app.get('*', (req, res) => {
20
+ * const { html, head } = renderToString((A) => {
21
+ * const route = initRoute(A); // optional: routing bound to this request
22
+ * App(A, route);
23
+ * }, { url: req.originalUrl });
24
+ * res.send(`<!doctype html><html><head>${head}</head>` +
25
+ * `<body><div id="app">${html}</div><script type="module" src="/client.js"></script></body></html>`);
26
+ * });
27
+ * ```
28
+ */
29
+ import { createA } from "./aberdeen.js";
30
+ import { createWindow, serializeChildren } from "./server-dom.js";
31
+ import type { ServerWindow } from "./server-dom.js";
32
+
33
+ export { createDocument, createWindow, serializeHtml, serializeChildren } from "./server-dom.js";
34
+ export type { ServerDocument, ServerWindow } from "./server-dom.js";
35
+
36
+ /** Options for {@link renderToString}. */
37
+ export interface RenderToStringOptions {
38
+ /** The request URL used to populate `window.location` so routing renders the right page. Defaults to `http://localhost/`. */
39
+ url?: string;
40
+ /** Use an existing server window instead of creating a fresh one (advanced). */
41
+ window?: ServerWindow;
42
+ }
43
+
44
+ /** The result of {@link renderToString}. */
45
+ export interface RenderToStringResult {
46
+ /** The rendered HTML of the mounted content (the children of the internal root container). */
47
+ html: string;
48
+ /** The serialized `<head>` content Aberdeen produced for this render (the `<style>` tag from `cssVars`/{@link aberdeen!Aberdeen.insertCss}), ready to drop into your document's `<head>`. */
49
+ head: string;
50
+ }
51
+
52
+ /**
53
+ * Render an Aberdeen component function to an HTML string.
54
+ *
55
+ * @param render Builds the DOM. It receives the fresh Aberdeen instance `A` for this render;
56
+ * use it (and pass it to your components) so everything renders against this isolated instance.
57
+ * @param options See {@link RenderToStringOptions}.
58
+ * @returns The rendered `html` and `head`. See {@link RenderToStringResult}.
59
+ */
60
+ export function renderToString(render: (A: ReturnType<typeof createA>) => void, options: RenderToStringOptions = {}): RenderToStringResult {
61
+ const win = options.window ?? createWindow(options.url);
62
+ // A fresh, isolated instance per render. `transitions: false` keeps SSR output stable
63
+ // (no transient `create` classes baked into the HTML).
64
+ const A = createA(win as any, { transitions: false });
65
+
66
+ const root = win.document.createElement("div");
67
+ A.mount(root as any, () => render(A));
68
+ A.runQueue();
69
+
70
+ return {
71
+ html: serializeChildren(root as any),
72
+ head: serializeChildren(win.document.head as any),
73
+ };
74
+ }
@@ -1,9 +1,15 @@
1
1
  const FADE_TIME = 400;
2
2
  const GROW_SHRINK_TRANSITION = `margin ${FADE_TIME}ms ease-out, transform ${FADE_TIME}ms ease-out`;
3
3
 
4
+ // Resolve `getComputedStyle`/`setTimeout` from the element's own window (the one Aberdeen was
5
+ // initialised with) rather than the global, so this works under any injected DOM.
6
+ function windowOf(el: any): any {
7
+ return el?.ownerDocument?.defaultView ?? globalThis;
8
+ }
9
+
4
10
  function getGrowShrinkProps(el: HTMLElement): Partial<CSSStyleDeclaration> {
5
11
  if (el.parentElement) {
6
- const parentStyle = getComputedStyle(el.parentElement);
12
+ const parentStyle = windowOf(el).getComputedStyle(el.parentElement);
7
13
  const isHorizontal =
8
14
  parentStyle.display === "flex" &&
9
15
  (parentStyle.flexDirection || "").startsWith("row");
@@ -39,7 +45,7 @@ export async function grow(el: HTMLElement) {
39
45
 
40
46
  el.style.transition = GROW_SHRINK_TRANSITION;
41
47
  for (const prop in props) el.style[prop] = "";
42
- setTimeout(() => {
48
+ windowOf(el).setTimeout(() => {
43
49
  // Disable transitions.
44
50
  el.style.transition = "";
45
51
  }, FADE_TIME);
@@ -62,7 +68,7 @@ export async function shrink(el: HTMLElement) {
62
68
  Object.assign(el.style, props);
63
69
 
64
70
  // Remove the element after the transition is done.
65
- setTimeout(() => {
71
+ windowOf(el).setTimeout(() => {
66
72
  el.remove();
67
73
  }, FADE_TIME);
68
74
  }
@@ -1,9 +0,0 @@
1
- export declare const resetCounts: () => void;
2
- export declare const getCounts: () => {
3
- new: number;
4
- changed: number;
5
- };
6
- export declare const clearBody: () => void;
7
- export declare const resetBrowserState: () => void;
8
- export declare const passTime: (ms?: number) => Promise<void>;
9
- export declare function setMediaQuery(query: string, matches: boolean): void;