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.
- package/.github/instructions/copilot.instructions.md +1 -0
- package/AGENTS.md +2 -0
- package/README.md +89 -13
- package/admin-ui/v1/controls/admin_shell.js +702 -669
- package/admin-ui/v1/server.js +14 -1
- package/docs/api-reference.md +504 -306
- package/docs/books/creating-a-new-admin-ui/README.md +20 -20
- package/docs/books/website-design/01-introduction.md +73 -0
- package/docs/books/website-design/02-current-state.md +195 -0
- package/docs/books/website-design/03-base-class.md +181 -0
- package/docs/books/website-design/04-webpage.md +307 -0
- package/docs/books/website-design/05-website.md +456 -0
- package/docs/books/website-design/06-pages-storage.md +170 -0
- package/docs/books/website-design/07-api-layer.md +285 -0
- package/docs/books/website-design/08-server-integration.md +271 -0
- package/docs/books/website-design/09-cross-agent-review.md +190 -0
- package/docs/books/website-design/10-open-questions.md +196 -0
- package/docs/books/website-design/11-converged-recommendation.md +205 -0
- package/docs/books/website-design/12-content-model.md +395 -0
- package/docs/books/website-design/13-webpage-module-spec.md +404 -0
- package/docs/books/website-design/14-website-module-spec.md +541 -0
- package/docs/books/website-design/15-multi-repo-plan.md +275 -0
- package/docs/books/website-design/16-minimal-first.md +203 -0
- package/docs/books/website-design/17-implementation-report-codex.md +81 -0
- package/docs/books/website-design/README.md +43 -0
- package/docs/comprehensive-documentation.md +220 -220
- package/docs/configuration-reference.md +281 -204
- package/docs/middleware-guide.md +236 -0
- package/docs/proposals/jsgui3-website-and-webpage-design-jsgui3-server-support.md +257 -0
- package/docs/proposals/jsgui3-website-and-webpage-design-review.md +73 -0
- package/docs/proposals/jsgui3-website-and-webpage-design.md +732 -0
- package/docs/swagger.md +316 -0
- package/docs/system-architecture.md +24 -18
- package/examples/controls/1) window/server.js +6 -1
- package/examples/controls/21) mvvm and declarative api/check.js +94 -0
- package/examples/controls/21) mvvm and declarative api/check_output.txt +25 -0
- package/examples/controls/21) mvvm and declarative api/check_output_2.txt +27 -0
- package/examples/controls/21) mvvm and declarative api/client.js +241 -0
- declarative api/e2e-screenshot-1-name-change.png +0 -0
- declarative api/e2e-screenshot-2-toggled.png +0 -0
- declarative api/e2e-screenshot-3-final.png +0 -0
- declarative api/e2e-screenshot-final.png +0 -0
- package/examples/controls/21) mvvm and declarative api/e2e-test.js +175 -0
- package/examples/controls/21) mvvm and declarative api/out.html +1 -0
- package/examples/controls/21) mvvm and declarative api/page_out.html +1 -0
- package/examples/controls/21) mvvm and declarative api/server.js +18 -0
- package/examples/data-views/01) query-endpoint/server.js +61 -0
- package/labs/website-design/001-base-class-overhead/check.js +162 -0
- package/labs/website-design/002-pages-storage/check.js +244 -0
- package/labs/website-design/002-pages-storage/results.txt +0 -0
- package/labs/website-design/003-type-detection/check.js +193 -0
- package/labs/website-design/003-type-detection/results.txt +0 -0
- package/labs/website-design/004-two-stage-validation/check.js +314 -0
- package/labs/website-design/004-two-stage-validation/results.txt +0 -0
- package/labs/website-design/005-normalize-input/check.js +303 -0
- package/labs/website-design/006-serve-website-spike/check.js +290 -0
- package/labs/website-design/README.md +34 -0
- package/labs/website-design/manifest.json +68 -0
- package/labs/website-design/run-all.js +60 -0
- package/middleware/compression.js +217 -0
- package/middleware/index.js +15 -0
- package/middleware/json-body.js +126 -0
- package/module.js +3 -0
- package/openapi.js +474 -0
- package/package.json +11 -8
- package/publishers/Publishers.js +6 -5
- package/publishers/http-function-publisher.js +135 -126
- package/publishers/http-webpage-publisher.js +89 -11
- package/publishers/query-publisher.js +116 -0
- package/publishers/swagger-publisher.js +203 -0
- package/publishers/swagger-ui.js +578 -0
- package/resources/adapters/array-adapter.js +143 -0
- package/resources/query-resource.js +131 -0
- package/serve-factory.js +756 -18
- package/server.js +502 -123
- package/tests/README.md +23 -1
- package/tests/admin-ui-jsgui-controls.test.js +16 -1
- package/tests/helpers/playwright-e2e-harness.js +326 -0
- package/tests/openapi.test.js +319 -0
- package/tests/playwright-smoke.test.js +134 -0
- package/tests/publish-enhancements.test.js +673 -0
- package/tests/query-publisher.test.js +430 -0
- package/tests/quick-json-body-test.js +169 -0
- package/tests/serve.test.js +425 -122
- package/tests/swagger-publisher.test.js +1076 -0
- 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);
|
|
Binary file
|
|
@@ -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);
|