jsgui3-server 0.0.150 → 0.0.152

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 (86) hide show
  1. package/.github/instructions/copilot.instructions.md +1 -0
  2. package/AGENTS.md +2 -0
  3. package/README.md +89 -13
  4. package/admin-ui/v1/controls/admin_shell.js +702 -669
  5. package/admin-ui/v1/server.js +14 -1
  6. package/docs/api-reference.md +504 -306
  7. package/docs/books/creating-a-new-admin-ui/README.md +20 -20
  8. package/docs/books/website-design/01-introduction.md +73 -0
  9. package/docs/books/website-design/02-current-state.md +195 -0
  10. package/docs/books/website-design/03-base-class.md +181 -0
  11. package/docs/books/website-design/04-webpage.md +307 -0
  12. package/docs/books/website-design/05-website.md +456 -0
  13. package/docs/books/website-design/06-pages-storage.md +170 -0
  14. package/docs/books/website-design/07-api-layer.md +285 -0
  15. package/docs/books/website-design/08-server-integration.md +271 -0
  16. package/docs/books/website-design/09-cross-agent-review.md +190 -0
  17. package/docs/books/website-design/10-open-questions.md +196 -0
  18. package/docs/books/website-design/11-converged-recommendation.md +205 -0
  19. package/docs/books/website-design/12-content-model.md +395 -0
  20. package/docs/books/website-design/13-webpage-module-spec.md +404 -0
  21. package/docs/books/website-design/14-website-module-spec.md +541 -0
  22. package/docs/books/website-design/15-multi-repo-plan.md +275 -0
  23. package/docs/books/website-design/16-minimal-first.md +203 -0
  24. package/docs/books/website-design/17-implementation-report-codex.md +81 -0
  25. package/docs/books/website-design/README.md +43 -0
  26. package/docs/comprehensive-documentation.md +220 -220
  27. package/docs/configuration-reference.md +281 -204
  28. package/docs/middleware-guide.md +236 -0
  29. package/docs/proposals/jsgui3-website-and-webpage-design-jsgui3-server-support.md +257 -0
  30. package/docs/proposals/jsgui3-website-and-webpage-design-review.md +73 -0
  31. package/docs/proposals/jsgui3-website-and-webpage-design.md +732 -0
  32. package/docs/swagger.md +316 -0
  33. package/docs/system-architecture.md +24 -18
  34. package/examples/controls/1) window/server.js +6 -1
  35. package/examples/controls/21) mvvm and declarative api/check.js +94 -0
  36. package/examples/controls/21) mvvm and declarative api/check_output.txt +25 -0
  37. package/examples/controls/21) mvvm and declarative api/check_output_2.txt +27 -0
  38. package/examples/controls/21) mvvm and declarative api/client.js +241 -0
  39. declarative api/e2e-screenshot-1-name-change.png +0 -0
  40. declarative api/e2e-screenshot-2-toggled.png +0 -0
  41. declarative api/e2e-screenshot-3-final.png +0 -0
  42. declarative api/e2e-screenshot-final.png +0 -0
  43. package/examples/controls/21) mvvm and declarative api/e2e-test.js +175 -0
  44. package/examples/controls/21) mvvm and declarative api/out.html +1 -0
  45. package/examples/controls/21) mvvm and declarative api/page_out.html +1 -0
  46. package/examples/controls/21) mvvm and declarative api/server.js +18 -0
  47. package/examples/data-views/01) query-endpoint/server.js +61 -0
  48. package/labs/website-design/001-base-class-overhead/check.js +162 -0
  49. package/labs/website-design/002-pages-storage/check.js +244 -0
  50. package/labs/website-design/002-pages-storage/results.txt +0 -0
  51. package/labs/website-design/003-type-detection/check.js +193 -0
  52. package/labs/website-design/003-type-detection/results.txt +0 -0
  53. package/labs/website-design/004-two-stage-validation/check.js +314 -0
  54. package/labs/website-design/004-two-stage-validation/results.txt +0 -0
  55. package/labs/website-design/005-normalize-input/check.js +303 -0
  56. package/labs/website-design/006-serve-website-spike/check.js +290 -0
  57. package/labs/website-design/README.md +34 -0
  58. package/labs/website-design/manifest.json +68 -0
  59. package/labs/website-design/run-all.js +60 -0
  60. package/middleware/compression.js +217 -0
  61. package/middleware/index.js +15 -0
  62. package/middleware/json-body.js +126 -0
  63. package/module.js +3 -0
  64. package/openapi.js +474 -0
  65. package/package.json +11 -8
  66. package/publishers/Publishers.js +6 -5
  67. package/publishers/http-function-publisher.js +135 -126
  68. package/publishers/http-webpage-publisher.js +89 -11
  69. package/publishers/query-publisher.js +116 -0
  70. package/publishers/swagger-publisher.js +203 -0
  71. package/publishers/swagger-ui.js +578 -0
  72. package/resources/adapters/array-adapter.js +143 -0
  73. package/resources/query-resource.js +131 -0
  74. package/serve-factory.js +756 -18
  75. package/server.js +502 -123
  76. package/tests/README.md +23 -1
  77. package/tests/admin-ui-jsgui-controls.test.js +16 -1
  78. package/tests/helpers/playwright-e2e-harness.js +326 -0
  79. package/tests/openapi.test.js +319 -0
  80. package/tests/playwright-smoke.test.js +134 -0
  81. package/tests/publish-enhancements.test.js +673 -0
  82. package/tests/query-publisher.test.js +430 -0
  83. package/tests/quick-json-body-test.js +169 -0
  84. package/tests/serve.test.js +425 -122
  85. package/tests/swagger-publisher.test.js +1076 -0
  86. package/tests/test-runner.js +1 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Lab 004: Two-Stage Validation
3
+ * Book Chapter: 11-converged-recommendation §11.4
4
+ *
5
+ * Question: Is construction-time lightweight + publish-time strict validation practical?
6
+ *
7
+ * Run: node labs/website-design/004-two-stage-validation/check.js
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ let pass = 0, fail = 0;
13
+ function check(label, ok) {
14
+ if (ok) { console.log(` ✅ ${label}`); pass++; }
15
+ else { console.log(` ❌ ${label}`); fail++; }
16
+ }
17
+
18
+ console.log('\n═══ 004: Two-Stage Validation ═══\n');
19
+
20
+ const { Evented_Class } = require('jsgui3-html');
21
+
22
+ // ── Prototype Webpage with two-stage validation ────────
23
+ class Webpage extends Evented_Class {
24
+ constructor(spec = {}) {
25
+ super();
26
+
27
+ // Stage 1: lightweight validation (fast, permissive)
28
+ if (spec.path != null && typeof spec.path !== 'string') {
29
+ throw new TypeError(`Webpage path must be a string, got ${typeof spec.path}`);
30
+ }
31
+ if (spec.content !== undefined) {
32
+ const t = typeof spec.content;
33
+ if (t !== 'function' && t !== 'object') {
34
+ throw new TypeError(`Webpage content must be a constructor or instance, got ${t}`);
35
+ }
36
+ }
37
+
38
+ // Normalize path
39
+ if (typeof spec.path === 'string') {
40
+ this.path = spec.path.startsWith('/') ? spec.path : '/' + spec.path;
41
+ } else {
42
+ this.path = undefined;
43
+ }
44
+
45
+ this.name = spec.name || undefined;
46
+ this.title = spec.title || undefined;
47
+ this.content = spec.content || undefined;
48
+ this.meta = spec.meta || {};
49
+ this.render_mode = spec.render_mode || undefined;
50
+ this.scripts = spec.scripts || [];
51
+ this.stylesheets = spec.stylesheets || [];
52
+ this._finalized = false;
53
+ }
54
+
55
+ get has_content() { return this.content != null; }
56
+ get is_dynamic() { return typeof this.content === 'function'; }
57
+ get finalized() { return this._finalized; }
58
+
59
+ finalize() {
60
+ if (this._finalized) return this;
61
+
62
+ // Stage 2: strict validation (thorough, publish-time)
63
+ const errors = [];
64
+
65
+ if (!this.path) {
66
+ errors.push('path is required for finalization');
67
+ }
68
+
69
+ if (!this.has_content) {
70
+ errors.push('content is required for finalization');
71
+ }
72
+
73
+ if (this.render_mode !== undefined &&
74
+ this.render_mode !== 'static' &&
75
+ this.render_mode !== 'dynamic') {
76
+ errors.push(`render_mode must be 'static' or 'dynamic', got '${this.render_mode}'`);
77
+ }
78
+
79
+ if (errors.length > 0) {
80
+ throw new Error(`Webpage finalization failed:\n - ${errors.join('\n - ')}`);
81
+ }
82
+
83
+ this._finalized = true;
84
+ this.raise('finalized');
85
+ return this;
86
+ }
87
+
88
+ toJSON() {
89
+ return {
90
+ name: this.name, title: this.title, path: this.path,
91
+ has_content: this.has_content, is_dynamic: this.is_dynamic,
92
+ finalized: this._finalized, meta: this.meta
93
+ };
94
+ }
95
+ }
96
+
97
+ // ── Prototype Website with finalize-time route validation ──
98
+ class Website extends Evented_Class {
99
+ constructor(spec = {}) {
100
+ super();
101
+ this.name = spec.name || undefined;
102
+ this.meta = spec.meta || {};
103
+ this._pages = new Map();
104
+ }
105
+
106
+ add_page(page) {
107
+ if (!(page instanceof Webpage)) {
108
+ page = new Webpage(page);
109
+ }
110
+ if (this._pages.has(page.path)) {
111
+ throw new Error(`Duplicate page path: ${page.path}`);
112
+ }
113
+ this._pages.set(page.path, page);
114
+ return page;
115
+ }
116
+
117
+ get_page(path) { return this._pages.get(path); }
118
+ has_page(path) { return this._pages.has(path); }
119
+ get routes() { return [...this._pages.keys()]; }
120
+ get size() { return this._pages.size; }
121
+
122
+ finalize() {
123
+ const errors = [];
124
+
125
+ if (this._pages.size === 0) {
126
+ errors.push('Website has no pages');
127
+ }
128
+
129
+ // Finalize each page
130
+ for (const [path, page] of this._pages) {
131
+ try {
132
+ page.finalize();
133
+ } catch (e) {
134
+ errors.push(`Page ${path}: ${e.message}`);
135
+ }
136
+ }
137
+
138
+ if (errors.length > 0) {
139
+ throw new Error(`Website finalization failed:\n - ${errors.join('\n - ')}`);
140
+ }
141
+
142
+ this.raise('finalized');
143
+ return this;
144
+ }
145
+ }
146
+
147
+ // ── TEST 1: Stage 1 — construction-time validation ─────
148
+ console.log('TEST 1: Stage 1 — construction catches type errors');
149
+ console.log('────────────────────────────────');
150
+
151
+ // Bad path type
152
+ let threw = false;
153
+ try { new Webpage({ path: 123 }); } catch (e) { threw = true; }
154
+ check('Bad path type (number) throws', threw);
155
+
156
+ threw = false;
157
+ try { new Webpage({ path: null }); } catch (e) { threw = true; }
158
+ check('Null path does NOT throw (null !== string, but is falsy)', !threw);
159
+
160
+ // Bad content type
161
+ threw = false;
162
+ try { new Webpage({ content: 'not a class' }); } catch (e) { threw = true; }
163
+ check('Bad content type (string) throws', threw);
164
+
165
+ threw = false;
166
+ try { new Webpage({ content: 42 }); } catch (e) { threw = true; }
167
+ check('Bad content type (number) throws', threw);
168
+
169
+ // Good types pass
170
+ threw = false;
171
+ try { new Webpage({ path: '/ok', content: function MyCtrl() { } }); } catch (e) { threw = true; }
172
+ check('Function content passes', !threw);
173
+
174
+ threw = false;
175
+ try { new Webpage({ path: '/ok', content: { render: () => '<div/>' } }); } catch (e) { threw = true; }
176
+ check('Object content passes', !threw);
177
+
178
+ // ── TEST 2: Stage 1 — permissiveness ──────────────────
179
+ console.log('\nTEST 2: Stage 1 — permissive construction');
180
+ console.log('────────────────────────────────');
181
+
182
+ threw = false;
183
+ try { new Webpage({}); } catch (e) { threw = true; }
184
+ check('Empty spec is OK (no required fields at construction)', !threw);
185
+
186
+ threw = false;
187
+ try { new Webpage({ path: '/about' }); } catch (e) { threw = true; }
188
+ check('Path without content at construction is OK', !threw);
189
+
190
+ threw = false;
191
+ try { new Webpage({ content: function () { } }); } catch (e) { threw = true; }
192
+ check('Content without path at construction is OK', !threw);
193
+
194
+ // ── TEST 3: Path normalization ─────────────────────────
195
+ console.log('\nTEST 3: Path normalization');
196
+ console.log('────────────────────────────────');
197
+
198
+ check('Leading slash preserved', new Webpage({ path: '/about' }).path === '/about');
199
+ check('Missing leading slash added', new Webpage({ path: 'about' }).path === '/about');
200
+ check('Root path works', new Webpage({ path: '/' }).path === '/');
201
+
202
+ // ── TEST 4: Stage 2 — finalize-time strict validation ──
203
+ console.log('\nTEST 4: Stage 2 — finalize catches missing fields');
204
+ console.log('────────────────────────────────');
205
+
206
+ // Missing path
207
+ threw = false;
208
+ try { new Webpage({ content: function () { } }).finalize(); } catch (e) { threw = true; }
209
+ check('Finalize: missing path throws', threw);
210
+
211
+ // Missing content
212
+ threw = false;
213
+ try { new Webpage({ path: '/' }).finalize(); } catch (e) { threw = true; }
214
+ check('Finalize: missing content throws', threw);
215
+
216
+ // Bad render_mode
217
+ threw = false;
218
+ try { new Webpage({ path: '/', content: function () { }, render_mode: 'invalid' }).finalize(); } catch (e) { threw = true; }
219
+ check('Finalize: invalid render_mode throws', threw);
220
+
221
+ // Valid render_modes pass
222
+ threw = false;
223
+ try { new Webpage({ path: '/', content: function () { }, render_mode: 'static' }).finalize(); } catch (e) { threw = true; }
224
+ check('Finalize: render_mode "static" passes', !threw);
225
+
226
+ threw = false;
227
+ try { new Webpage({ path: '/', content: function () { }, render_mode: 'dynamic' }).finalize(); } catch (e) { threw = true; }
228
+ check('Finalize: render_mode "dynamic" passes', !threw);
229
+
230
+ // Complete page finalizes
231
+ const completePage = new Webpage({ path: '/', title: 'Home', content: function Home() { } });
232
+ threw = false;
233
+ try { completePage.finalize(); } catch (e) { threw = true; }
234
+ check('Finalize: complete page passes', !threw);
235
+ check('Finalize: page is now finalized', completePage.finalized === true);
236
+
237
+ // Double finalize is idempotent
238
+ threw = false;
239
+ try { completePage.finalize(); } catch (e) { threw = true; }
240
+ check('Finalize: double finalize is safe', !threw);
241
+
242
+ // ── TEST 5: Incremental composition ────────────────────
243
+ console.log('\nTEST 5: Incremental composition workflow');
244
+ console.log('────────────────────────────────');
245
+
246
+ const page = new Webpage({});
247
+ check('Step 1: empty page created', page.path === undefined);
248
+
249
+ page.path = '/incremental';
250
+ check('Step 2: path set later', page.path === '/incremental');
251
+
252
+ page.content = function IncrementalCtrl() { };
253
+ check('Step 3: content set later', page.has_content === true);
254
+
255
+ page.title = 'Incremental Page';
256
+ threw = false;
257
+ try { page.finalize(); } catch (e) { threw = true; }
258
+ check('Step 4: finalize succeeds after all fields set', !threw);
259
+
260
+ // ── TEST 6: Finalize event ─────────────────────────────
261
+ console.log('\nTEST 6: Finalize lifecycle event');
262
+ console.log('────────────────────────────────');
263
+
264
+ const eventPage = new Webpage({ path: '/events', content: function () { } });
265
+ let finalized = false;
266
+ eventPage.on('finalized', () => { finalized = true; });
267
+ eventPage.finalize();
268
+ check('Finalize raises "finalized" event', finalized);
269
+
270
+ // ── TEST 7: Website finalization cascades ──────────────
271
+ console.log('\nTEST 7: Website finalization cascades to pages');
272
+ console.log('────────────────────────────────');
273
+
274
+ const site = new Website({ name: 'Test' });
275
+ site.add_page({ path: '/', title: 'Home', content: function () { } });
276
+ site.add_page({ path: '/about', title: 'About', content: function () { } });
277
+
278
+ threw = false;
279
+ try { site.finalize(); } catch (e) { threw = true; }
280
+ check('Website finalize: cascades to all pages', !threw);
281
+ check('Website finalize: page / is finalized', site.get_page('/').finalized);
282
+ check('Website finalize: page /about is finalized', site.get_page('/about').finalized);
283
+
284
+ // Website with an invalid page
285
+ const badSite = new Website({ name: 'Bad' });
286
+ badSite.add_page({ path: '/ok', content: function () { } });
287
+ badSite.add_page({ path: '/missing-content' }); // no content
288
+
289
+ threw = false;
290
+ let errMsg = '';
291
+ try { badSite.finalize(); } catch (e) { threw = true; errMsg = e.message; }
292
+ check('Website finalize: catches invalid page', threw);
293
+ check('Website finalize: error mentions the bad page', errMsg.includes('/missing-content'));
294
+
295
+ // Empty website
296
+ const emptySite = new Website({ name: 'Empty' });
297
+ threw = false;
298
+ try { emptySite.finalize(); } catch (e) { threw = true; }
299
+ check('Website finalize: empty site throws', threw);
300
+
301
+ // ── VERDICT ────────────────────────────────────────────
302
+ console.log('\n═══════════════════════════════════════════');
303
+ console.log(`RESULTS: ${pass} passed, ${fail} failed`);
304
+
305
+ console.log('\n📊 SUMMARY:');
306
+ console.log(' Stage 1 (construction): catches type errors, allows incomplete objects');
307
+ console.log(' Stage 2 (finalize): catches missing fields, bad render_mode, duplicates');
308
+ console.log(' Incremental composition: build piece by piece, finalize when ready');
309
+ console.log(' Cascading: Website.finalize() validates all pages');
310
+ console.log('\n✅ VERDICT: Two-stage validation is practical and ergonomic.');
311
+ console.log(' → Ch.11 §11.4 recommendation confirmed');
312
+ console.log('═══════════════════════════════════════════\n');
313
+
314
+ process.exit(fail > 0 ? 1 : 0);
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Lab 005: Input Normalization
3
+ * Book Chapter: 08-server-integration + 11-converged-recommendation
4
+ *
5
+ * Question: Can all Server.serve() input shapes normalize to one manifest?
6
+ *
7
+ * Run: node labs/website-design/005-normalize-input/check.js
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ let pass = 0, fail = 0;
13
+ function check(label, ok) {
14
+ if (ok) { console.log(` ✅ ${label}`); pass++; }
15
+ else { console.log(` ❌ ${label}`); fail++; }
16
+ }
17
+
18
+ console.log('\n═══ 005: Input Normalization ═══\n');
19
+
20
+ const { Evented_Class } = require('jsgui3-html');
21
+
22
+ // ── Prototype classes (from experiment 004) ────────────
23
+ class Webpage extends Evented_Class {
24
+ constructor(spec = {}) {
25
+ super();
26
+ if (spec.path != null && typeof spec.path !== 'string') {
27
+ throw new TypeError(`path must be a string, got ${typeof spec.path}`);
28
+ }
29
+ this.path = typeof spec.path === 'string'
30
+ ? (spec.path.startsWith('/') ? spec.path : '/' + spec.path)
31
+ : undefined;
32
+ this.name = spec.name || undefined;
33
+ this.title = spec.title || undefined;
34
+ this.content = spec.content || undefined;
35
+ this.meta = spec.meta || {};
36
+ this.render_mode = spec.render_mode || undefined;
37
+ }
38
+ get has_content() { return this.content != null; }
39
+ get is_dynamic() { return typeof this.content === 'function'; }
40
+ toJSON() {
41
+ return {
42
+ path: this.path, name: this.name, title: this.title,
43
+ has_content: this.has_content, is_dynamic: this.is_dynamic, meta: this.meta
44
+ };
45
+ }
46
+ }
47
+
48
+ class Website extends Evented_Class {
49
+ constructor(spec = {}) {
50
+ super();
51
+ this.name = spec.name || undefined;
52
+ this.meta = spec.meta || {};
53
+ this._pages = new Map();
54
+ this._api = new Map();
55
+ }
56
+ add_page(page) {
57
+ if (!(page instanceof Webpage)) page = new Webpage(page);
58
+ if (this._pages.has(page.path)) throw new Error(`Duplicate: ${page.path}`);
59
+ this._pages.set(page.path, page);
60
+ return page;
61
+ }
62
+ get_page(path) { return this._pages.get(path); }
63
+ has_page(path) { return this._pages.has(path); }
64
+ get routes() { return [...this._pages.keys()]; }
65
+ get pages() { return [...this._pages.values()]; }
66
+
67
+ add_endpoint(name, handler, meta = {}) {
68
+ this._api.set(name, { name, handler, ...meta });
69
+ }
70
+ get api_endpoints() { return [...this._api.values()]; }
71
+
72
+ toJSON() {
73
+ return {
74
+ name: this.name,
75
+ pages: [...this._pages.values()].map(p => p.toJSON()),
76
+ api: [...this._api.entries()].map(([name, ep]) => ({ name, method: ep.method || 'GET', path: ep.path }))
77
+ };
78
+ }
79
+ }
80
+
81
+ // ── The normalizer ─────────────────────────────────────
82
+
83
+ /**
84
+ * Normalized manifest — the universal internal representation.
85
+ *
86
+ * All Server.serve() input shapes get converted to this before
87
+ * being handed to publishers.
88
+ */
89
+ function normalize_serve_input(input) {
90
+ const manifest = {
91
+ pages: [], // Array of { path, title, content, meta, render_mode }
92
+ api: [], // Array of { name, method, path, handler }
93
+ meta: {},
94
+ _source: null // what kind of input produced this
95
+ };
96
+
97
+ // Case 1: Control constructor — Server.serve(MyCtrl)
98
+ if (typeof input === 'function') {
99
+ manifest.pages.push({
100
+ path: '/',
101
+ title: input.name || 'Home',
102
+ content: input,
103
+ meta: {},
104
+ render_mode: 'dynamic'
105
+ });
106
+ manifest._source = 'control';
107
+ return manifest;
108
+ }
109
+
110
+ // Case 2: Webpage instance — Server.serve(new Webpage(...))
111
+ if (input instanceof Webpage) {
112
+ manifest.pages.push({
113
+ path: input.path || '/',
114
+ title: input.title,
115
+ content: input.content,
116
+ meta: input.meta,
117
+ render_mode: input.render_mode
118
+ });
119
+ manifest._source = 'webpage';
120
+ return manifest;
121
+ }
122
+
123
+ // Case 3: Website instance — Server.serve(new Website(...))
124
+ if (input instanceof Website) {
125
+ manifest.pages = input.pages.map(p => ({
126
+ path: p.path,
127
+ title: p.title,
128
+ content: p.content,
129
+ meta: p.meta,
130
+ render_mode: p.render_mode
131
+ }));
132
+ manifest.api = input.api_endpoints.map(ep => ({
133
+ name: ep.name,
134
+ method: ep.method || 'GET',
135
+ path: ep.path || `/api/${ep.name}`,
136
+ handler: ep.handler
137
+ }));
138
+ manifest.meta = input.meta;
139
+ manifest._source = 'website';
140
+ return manifest;
141
+ }
142
+
143
+ // Case 4: Plain object — Server.serve({ pages: {...}, api: {...} })
144
+ if (input && typeof input === 'object') {
145
+ // Pages from object spec
146
+ if (input.pages) {
147
+ for (const [route, pageSpec] of Object.entries(input.pages)) {
148
+ const spec = typeof pageSpec === 'function'
149
+ ? { path: route, content: pageSpec, title: pageSpec.name }
150
+ : { path: route, ...pageSpec };
151
+ manifest.pages.push({
152
+ path: spec.path || route,
153
+ title: spec.title,
154
+ content: spec.content,
155
+ meta: spec.meta || {},
156
+ render_mode: spec.render_mode
157
+ });
158
+ }
159
+ }
160
+
161
+ // Single page shorthand
162
+ if (input.page) {
163
+ const p = input.page;
164
+ manifest.pages.push({
165
+ path: p.path || '/',
166
+ title: p.title,
167
+ content: p.content || p.Ctrl,
168
+ meta: p.meta || {},
169
+ render_mode: p.render_mode
170
+ });
171
+ }
172
+
173
+ // API
174
+ if (input.api) {
175
+ for (const [name, handler] of Object.entries(input.api)) {
176
+ manifest.api.push({
177
+ name,
178
+ method: 'GET',
179
+ path: `/api/${name}`,
180
+ handler: typeof handler === 'function' ? handler : handler.handler
181
+ });
182
+ }
183
+ }
184
+
185
+ manifest._source = 'plain-object';
186
+ return manifest;
187
+ }
188
+
189
+ throw new Error(`Cannot normalize input: ${typeof input}`);
190
+ }
191
+
192
+ // ── TEST 1: Control constructor ────────────────────────
193
+ console.log('TEST 1: Control constructor input');
194
+ console.log('────────────────────────────────');
195
+
196
+ function HomeCtrl() { }
197
+ const m1 = normalize_serve_input(HomeCtrl);
198
+
199
+ check('Source: control', m1._source === 'control');
200
+ check('One page at /', m1.pages.length === 1 && m1.pages[0].path === '/');
201
+ check('Content is the constructor', m1.pages[0].content === HomeCtrl);
202
+ check('Title from constructor name', m1.pages[0].title === 'HomeCtrl');
203
+ check('No API endpoints', m1.api.length === 0);
204
+
205
+ // ── TEST 2: Webpage instance ───────────────────────────
206
+ console.log('\nTEST 2: Webpage instance input');
207
+ console.log('────────────────────────────────');
208
+
209
+ const wp = new Webpage({ path: '/about', title: 'About', content: function AboutCtrl() { } });
210
+ const m2 = normalize_serve_input(wp);
211
+
212
+ check('Source: webpage', m2._source === 'webpage');
213
+ check('One page at /about', m2.pages.length === 1 && m2.pages[0].path === '/about');
214
+ check('Title preserved', m2.pages[0].title === 'About');
215
+
216
+ // ── TEST 3: Website instance ───────────────────────────
217
+ console.log('\nTEST 3: Website instance input');
218
+ console.log('────────────────────────────────');
219
+
220
+ const site = new Website({ name: 'My App' });
221
+ site.add_page({ path: '/', title: 'Home', content: function () { } });
222
+ site.add_page({ path: '/about', title: 'About', content: function () { } });
223
+ site.add_endpoint('get-users', () => [], { method: 'GET', path: '/api/users' });
224
+
225
+ const m3 = normalize_serve_input(site);
226
+
227
+ check('Source: website', m3._source === 'website');
228
+ check('Two pages', m3.pages.length === 2);
229
+ check('Page paths correct', m3.pages[0].path === '/' && m3.pages[1].path === '/about');
230
+ check('One API endpoint', m3.api.length === 1);
231
+ check('API method preserved', m3.api[0].method === 'GET');
232
+ check('API path preserved', m3.api[0].path === '/api/users');
233
+
234
+ // ── TEST 4: Plain object ──────────────────────────────
235
+ console.log('\nTEST 4: Plain object input');
236
+ console.log('────────────────────────────────');
237
+
238
+ function BlogCtrl() { }
239
+ function DocsCtrl() { }
240
+
241
+ const m4 = normalize_serve_input({
242
+ pages: {
243
+ '/': { title: 'Blog', content: BlogCtrl },
244
+ '/docs': DocsCtrl // shorthand: just a constructor
245
+ },
246
+ api: {
247
+ 'get-posts': () => [],
248
+ 'get-tags': () => []
249
+ }
250
+ });
251
+
252
+ check('Source: plain-object', m4._source === 'plain-object');
253
+ check('Two pages', m4.pages.length === 2);
254
+ check('Page / has title', m4.pages[0].title === 'Blog');
255
+ check('Page /docs from constructor shorthand', m4.pages[1].content === DocsCtrl);
256
+ check('Two API endpoints', m4.api.length === 2);
257
+ check('API paths auto-generated', m4.api[0].path === '/api/get-posts');
258
+
259
+ // ── TEST 5: Structural consistency ─────────────────────
260
+ console.log('\nTEST 5: All manifests have same structure');
261
+ console.log('────────────────────────────────');
262
+
263
+ const manifests = [m1, m2, m3, m4];
264
+
265
+ for (const m of manifests) {
266
+ check(`${m._source}: has pages array`, Array.isArray(m.pages));
267
+ check(`${m._source}: has api array`, Array.isArray(m.api));
268
+ check(`${m._source}: has meta object`, typeof m.meta === 'object');
269
+ // Every page has the same shape
270
+ for (const p of m.pages) {
271
+ check(`${m._source}: page has path`, typeof p.path === 'string');
272
+ check(`${m._source}: page has content`, p.content != null);
273
+ }
274
+ }
275
+
276
+ // ── TEST 6: Deterministic serialization ────────────────
277
+ console.log('\nTEST 6: Manifest serialization');
278
+ console.log('────────────────────────────────');
279
+
280
+ const serializeManifest = (m) => JSON.stringify({
281
+ pages: m.pages.map(p => ({ path: p.path, title: p.title, has_content: p.content != null })),
282
+ api: m.api.map(a => ({ name: a.name, method: a.method, path: a.path })),
283
+ source: m._source
284
+ });
285
+
286
+ // Same input should produce identical JSON
287
+ const s1 = serializeManifest(normalize_serve_input(HomeCtrl));
288
+ const s2 = serializeManifest(normalize_serve_input(HomeCtrl));
289
+ check('Same input produces identical manifest JSON', s1 === s2);
290
+
291
+ // ── VERDICT ────────────────────────────────────────────
292
+ console.log('\n═══════════════════════════════════════════');
293
+ console.log(`RESULTS: ${pass} passed, ${fail} failed`);
294
+
295
+ console.log('\n📊 SUMMARY:');
296
+ console.log(' All four input shapes normalize to { pages[], api[], meta }');
297
+ console.log(' Publishers only need to understand ONE shape');
298
+ console.log(' No input-specific branching after normalization');
299
+ console.log('\n✅ VERDICT: Input normalization works cleanly.');
300
+ console.log(' → Ch.8 + Ch.11 normalization approach confirmed');
301
+ console.log('═══════════════════════════════════════════\n');
302
+
303
+ process.exit(fail > 0 ? 1 : 0);