redweb 0.9.0 → 0.10.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 (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +138 -23
  3. package/docs/LIVE_HTML.md +313 -0
  4. package/examples/live-html/cards.css +36 -0
  5. package/examples/live-html/cards.html +11 -0
  6. package/examples/live-html/cards.js +91 -0
  7. package/examples/live-html/cards.ts +35 -0
  8. package/examples/live-html/chatroom.css +156 -0
  9. package/examples/live-html/chatroom.js +268 -0
  10. package/examples/live-html/chatroom.ts +217 -0
  11. package/examples/live-html/components.css +7 -0
  12. package/examples/live-html/components.js +113 -0
  13. package/examples/live-html/components.ts +41 -0
  14. package/examples/live-html/counter.css +24 -0
  15. package/examples/live-html/counter.html +10 -0
  16. package/examples/live-html/counter.js +73 -0
  17. package/examples/live-html/counter.ts +21 -0
  18. package/examples/live-html/tsconfig.json +16 -0
  19. package/index.d.ts +219 -1
  20. package/index.js +18 -1
  21. package/package.json +14 -3
  22. package/src/htmx/Html.js +133 -0
  23. package/src/htmx/HtmlRenderer.js +88 -0
  24. package/src/htmx/HtmlSyntax.js +168 -0
  25. package/src/htmx/LiveHtmlServer.js +91 -0
  26. package/src/htmx/LivePage.js +232 -0
  27. package/src/htmx/PageAssetLoader.js +34 -0
  28. package/src/htmx/PageManager.js +435 -0
  29. package/src/htmx/StaticExporter.js +78 -0
  30. package/src/htmx/StaticSite.js +182 -0
  31. package/src/htmx/TemplateRenderer.js +231 -0
  32. package/src/htmx/browserRuntime.js +97 -0
  33. package/src/htmx/index.js +10 -0
  34. package/src/htmx/metadata.js +349 -0
  35. package/src/htmx/sourceRoot.js +28 -0
  36. package/src/htmx/start.js +17 -0
  37. package/src/htmx/synchronous.js +9 -0
  38. package/src/http/BaseHttpServer.js +0 -35
  39. package/src/ws/BaseSocketServer.js +4 -0
  40. package/src/htmx/HtmxRenderer.js +0 -73
  41. package/src/htmx/RedWebHtmxComponent.js +0 -11
package/index.d.ts CHANGED
@@ -2,6 +2,7 @@ declare module 'redweb' {
2
2
  import { Application } from 'express';
3
3
  import { CorsOptions } from 'cors';
4
4
  import { Server as NodeHttpServer } from 'http';
5
+ import { Server as NodeHttpsServer } from 'https';
5
6
  import { WebSocket, ServerOptions } from 'ws';
6
7
  import { Buffer } from 'buffer';
7
8
  import { EventEmitter } from 'events';
@@ -21,7 +22,6 @@ declare module 'redweb' {
21
22
  ssl?: { key: string; cert: string };
22
23
  server?: Application;
23
24
  corsOptions?: CorsOptions | false;
24
- enableHtmxRendering?: boolean;
25
25
  exposeErrors?: boolean;
26
26
  logger?: RedWebLogger | null;
27
27
  }
@@ -402,6 +402,224 @@ declare module 'redweb' {
402
402
  shutdown(): Promise<void>;
403
403
  }
404
404
 
405
+ /** ─────────────────── LIVE HTML ─────────────────── */
406
+
407
+ const htmlFragmentBrand: unique symbol;
408
+ const htmlAttributeBrand: unique symbol;
409
+ const htmlUrlBrand: unique symbol;
410
+
411
+ export interface HtmlFragment {
412
+ readonly [htmlFragmentBrand]: true;
413
+ toString(): string;
414
+ }
415
+
416
+ export interface HtmlAttribute {
417
+ readonly [htmlAttributeBrand]: true;
418
+ }
419
+
420
+ export interface HtmlUrl {
421
+ readonly [htmlUrlBrand]: true;
422
+ }
423
+
424
+ export interface LivePageRequest {
425
+ readonly path: string;
426
+ readonly url: string;
427
+ readonly method: string;
428
+ readonly headers: import('http').IncomingHttpHeaders;
429
+ readonly params: Readonly<Record<string, string>>;
430
+ readonly query: Readonly<Record<string, unknown>>;
431
+ readonly body: unknown;
432
+ get(name: string): string | undefined;
433
+ }
434
+
435
+ export interface LivePageRequestContext {
436
+ request: LivePageRequest;
437
+ params: Readonly<Record<string, string>>;
438
+ query: Readonly<Record<string, unknown>>;
439
+ body: unknown;
440
+ principal?: string | number | bigint | boolean;
441
+ signal: AbortSignal;
442
+ }
443
+
444
+ export interface LivePageConnectionContext {
445
+ socket: RedWebSocket;
446
+ signal?: AbortSignal;
447
+ principal?: string | number | bigint | boolean;
448
+ }
449
+
450
+ export abstract class LivePage {
451
+ protected readonly _connections: Set<RedWebSocket>;
452
+ loading?(context: LivePageRequestContext): void | Promise<void>;
453
+ render?(context: LivePageRequestContext): string | HtmlFragment | Promise<string | HtmlFragment>;
454
+ connected?(context: LivePageConnectionContext): void | Promise<void>;
455
+ disconnected?(context: { socket: RedWebSocket }): void | Promise<void>;
456
+ disposed?(): void | Promise<void>;
457
+ dispose(): Promise<boolean>;
458
+ }
459
+
460
+ export interface PageOptions {
461
+ template?: string;
462
+ css?: string | readonly string[];
463
+ scope?: 'connection' | 'shared';
464
+ shared?: boolean;
465
+ live?: boolean;
466
+ head?: PageHead;
467
+ cache?: PageCache;
468
+ layout?: PageLayout;
469
+ }
470
+
471
+ export type PageLayout = (content: HtmlFragment, context: LivePageRequestContext) => HtmlFragment;
472
+
473
+ export interface PageHead {
474
+ title?: string;
475
+ description?: string;
476
+ canonical?: string;
477
+ image?: string;
478
+ robots?: string;
479
+ }
480
+
481
+ export interface PageCache {
482
+ maxAge?: number;
483
+ staleWhileRevalidate?: number;
484
+ immutable?: boolean;
485
+ }
486
+
487
+ export interface StateOptions {
488
+ writable?: boolean;
489
+ }
490
+
491
+ export interface LiveStateDecorator {
492
+ (target: object, propertyKey: string): void;
493
+ <This, Value>(value: undefined, context: ClassFieldDecoratorContext<This, Value>):
494
+ (this: This, initialValue: Value) => Value;
495
+ }
496
+
497
+ export interface LiveActionDecorator {
498
+ (target: object, propertyKey: string, descriptor: PropertyDescriptor): void | PropertyDescriptor;
499
+ <This, Value extends (this: This, ...args: any[]) => any>(
500
+ value: Value,
501
+ context: ClassMethodDecoratorContext<This, Value>
502
+ ): Value;
503
+ }
504
+
505
+ export interface LiveViewDecorator {
506
+ (target: object, propertyKey: string, descriptor: PropertyDescriptor): void | PropertyDescriptor;
507
+ <This, Value extends (this: This, item: any, index: number) => HtmlFragment>(
508
+ value: Value,
509
+ context: ClassMethodDecoratorContext<This, Value>
510
+ ): Value;
511
+ }
512
+
513
+ export function page(path: string, options?: PageOptions): ClassDecorator;
514
+ export function component(): ClassDecorator;
515
+ export function component<Props = void>(render: (properties: Props) => HtmlFragment):
516
+ (properties: Props) => HtmlFragment;
517
+ export function state(options?: StateOptions): LiveStateDecorator;
518
+ export function action(): LiveActionDecorator;
519
+ export function view(stateName: string): LiveViewDecorator;
520
+ export function html(strings: TemplateStringsArray, ...values: unknown[]): HtmlFragment;
521
+ export function attribute(value: string | number | bigint | boolean): HtmlAttribute;
522
+ export function url(value: string): HtmlUrl;
523
+ export function each<Item>(items: readonly Item[], render: (item: Item, index: number) => HtmlFragment): HtmlFragment;
524
+ export function codeBlock(code: unknown, options?: {
525
+ language?: string;
526
+ label?: string;
527
+ highlight?: (source: string, language: string) => HtmlFragment;
528
+ }): HtmlFragment;
529
+
530
+ export type LivePageClass = new () => object;
531
+
532
+ export interface LiveHtmlServerOptions extends Omit<RedWebOptions, 'enableHtmxRendering'> {
533
+ pages: readonly LivePageClass[];
534
+ templateRoot?: string;
535
+ livePaths?: {
536
+ socket?: string;
537
+ client?: string;
538
+ runtime?: string;
539
+ css?: string;
540
+ };
541
+ sessionTtlMs?: number;
542
+ maxSessions?: number;
543
+ maxConcurrentRenders?: number;
544
+ shutdownTimeoutMs?: number;
545
+ heartbeat?: HeartbeatOptions;
546
+ authenticate?(request: import('http').IncomingMessage | import('express').Request):
547
+ string | number | bigint | boolean | false | null | undefined |
548
+ Promise<string | number | bigint | boolean | false | null | undefined>;
549
+ origins?: string[] | ((origin: string | undefined, request: import('http').IncomingMessage) => boolean | Promise<boolean>);
550
+ }
551
+
552
+ export class LiveHtmlServer {
553
+ app: Application;
554
+ server: NodeHttpServer | NodeHttpsServer;
555
+ http: HttpServer | HttpsServer;
556
+ sockets: SocketServer | null;
557
+ constructor(options: LiveHtmlServerOptions);
558
+ shutdown(): Promise<void>;
559
+ }
560
+
561
+ export function start(
562
+ pageOrPages: LivePageClass | readonly LivePageClass[],
563
+ options?: Omit<LiveHtmlServerOptions, 'pages'>
564
+ ): LiveHtmlServer;
565
+
566
+ export interface StaticExportOptions {
567
+ outDir: string;
568
+ templateRoot?: string;
569
+ logger?: RedWebLogger | null;
570
+ }
571
+
572
+ export interface StaticExportResult {
573
+ readonly pages: readonly string[];
574
+ readonly assets: readonly string[];
575
+ }
576
+
577
+ export function exportStatic(
578
+ pageOrPages: LivePageClass | readonly LivePageClass[],
579
+ options: StaticExportOptions
580
+ ): Promise<StaticExportResult>;
581
+
582
+ export interface SiteOptions {
583
+ origin?: string;
584
+ css?: string | readonly string[];
585
+ head?: PageHead;
586
+ cache?: PageCache;
587
+ layout?: PageLayout;
588
+ }
589
+
590
+ export interface SitePageOptions extends Omit<PageOptions, 'live'> {
591
+ live?: false;
592
+ }
593
+
594
+ export interface SiteExportOptions extends StaticExportOptions {
595
+ publicDir?: string;
596
+ }
597
+
598
+ export interface StaticSite {
599
+ page(path: string, options?: SitePageOptions): ClassDecorator;
600
+ export(
601
+ pageOrPages: LivePageClass | readonly LivePageClass[],
602
+ options: SiteExportOptions
603
+ ): Promise<StaticExportResult>;
604
+ }
605
+
606
+ export function defineSite(options?: SiteOptions): StaticSite;
607
+
608
+ export class HtmlRenderer {
609
+ static template(filePath: string, rootDir: string): string;
610
+ static stylesheet(filePath: string, rootDir: string): string;
611
+ static render(source: string, page: object, options?: { live?: boolean }): string;
612
+ static collection(page: object, name: string, value: unknown): string;
613
+ static statePayload(name: string, value: unknown, page?: object): { name: string; value: string; html: boolean };
614
+ static head(metadata?: PageHead): string;
615
+ static document(
616
+ markup: string,
617
+ config?: (Record<string, unknown> & { runtimePath: string }) | null,
618
+ stylesheets?: string[],
619
+ metadata?: PageHead
620
+ ): string;
621
+ }
622
+
405
623
  /** ─────────────────── CONSTANTS ─────────────────── */
406
624
 
407
625
  export const METHODS: {
package/index.js CHANGED
@@ -16,6 +16,7 @@ const {
16
16
  const { BaseHandler } = require('./src/ws/BaseHandler');
17
17
  const HttpServer = require('./src/http/HttpServer');
18
18
  const HttpsServer = require('./src/http/HttpsServer');
19
+ const { action, attribute, codeBlock, component, defineSite, each, exportStatic, html, HtmlRenderer, LiveHtmlServer, LivePage, page, start, state, url, view } = require('./src/htmx');
19
20
  module.exports = {
20
21
  HttpServer,
21
22
  HttpsServer,
@@ -34,5 +35,21 @@ module.exports = {
34
35
  SOCKET_OPTIONS,
35
36
  HTTP_OPTIONS,
36
37
  ENCODINGS,
37
- METHODS
38
+ METHODS,
39
+ action,
40
+ attribute,
41
+ codeBlock,
42
+ component,
43
+ defineSite,
44
+ each,
45
+ exportStatic,
46
+ html,
47
+ HtmlRenderer,
48
+ LiveHtmlServer,
49
+ LivePage,
50
+ page,
51
+ start,
52
+ state,
53
+ url,
54
+ view
38
55
  };
package/package.json CHANGED
@@ -1,17 +1,26 @@
1
1
  {
2
2
  "name": "redweb",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "A way to quickly set up an express server",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
7
  "scripts": {
8
- "pretest": "node scripts/generate-protocol-types.js --check && tsc -p tests/types/tsconfig.json",
8
+ "pretest": "node scripts/build-live-html-examples.js --check && node scripts/generate-protocol-types.js --check && tsc -p tests/types/tsconfig.json && tsc -p tests/types/tsconfig.standard.json",
9
+ "prepack": "node scripts/build-live-html-examples.js --check",
10
+ "example:counter": "node examples/live-html/counter.js",
11
+ "example:chatroom": "node examples/live-html/chatroom.js",
12
+ "example:cards": "node examples/live-html/cards.js",
13
+ "example:components": "node examples/live-html/components.js",
9
14
  "generate:protocol-types": "node scripts/generate-protocol-types.js",
10
15
  "verify:overhead": "node scripts/verify-disabled-overhead.js",
11
16
  "verify:soak": "node --expose-gc scripts/verify-soak.js",
12
17
  "verify:memory": "node scripts/verify-memory-overhead.js",
13
18
  "verify:load": "node scripts/verify-load.js",
14
19
  "verify:recovery": "node --expose-gc scripts/verify-recovery.js",
20
+ "verify:live-html": "node scripts/build-live-html-examples.js --check && npx jest tests/integration/live-html.integration.test.js --runInBand --coverage=false",
21
+ "verify:live-html:browser": "node scripts/build-live-html-examples.js --check && node scripts/verify-live-html-browser.js",
22
+ "verify:live-html:load": "node scripts/build-live-html-examples.js --check && node --expose-gc scripts/verify-live-html-load.js",
23
+ "verify:live-html:package": "node scripts/verify-live-html-package.js",
15
24
  "test": "npx jest"
16
25
  },
17
26
  "files": [
@@ -20,7 +29,8 @@
20
29
  "client.d.ts",
21
30
  "CHANGELOG.md",
22
31
  "index.d.ts",
23
- "docs"
32
+ "docs",
33
+ "examples/live-html"
24
34
  ],
25
35
  "keywords": [],
26
36
  "author": "",
@@ -35,6 +45,7 @@
35
45
  "@types/ws": "^8.18.1",
36
46
  "cors": "^2.8.5",
37
47
  "express": "^4.22.2",
48
+ "redweb-client": "^0.1.0",
38
49
  "ws": "^8.21.3"
39
50
  },
40
51
  "devDependencies": {
@@ -0,0 +1,133 @@
1
+ const HTML_FRAGMENT = Symbol('redweb.htmlFragment');
2
+ const HTML_ATTRIBUTE = Symbol('redweb.htmlAttribute');
3
+ const HTML_URL = Symbol('redweb.htmlUrl');
4
+ const HTML_RENDERERS = new WeakMap();
5
+ const URL_ATTRIBUTES = new Set(['action', 'background', 'cite', 'data', 'formaction', 'href', 'manifest', 'ping', 'poster', 'src', 'xlink:href']);
6
+ const FORBIDDEN_ATTRIBUTES = new Set(['srcdoc', 'srcset', 'style']);
7
+ const { interpolationContext } = require('./HtmlSyntax');
8
+ const synchronous = require('./synchronous');
9
+
10
+ function escapeHtml(value) {
11
+ return String(value ?? '')
12
+ .replaceAll('&', '&amp;')
13
+ .replaceAll('<', '&lt;')
14
+ .replaceAll('>', '&gt;')
15
+ .replaceAll('"', '&quot;')
16
+ .replaceAll("'", '&#39;');
17
+ }
18
+
19
+ function isHtml(value) {
20
+ if (Array.isArray(value)) return value.every(isHtml);
21
+ return Boolean(value?.[HTML_FRAGMENT]) || Boolean(value && typeof value === 'object' && HTML_RENDERERS.has(value));
22
+ }
23
+
24
+ function markHtml(value, toString) {
25
+ HTML_RENDERERS.set(value, toString);
26
+ return value;
27
+ }
28
+
29
+ function trustedHtml(value) {
30
+ const fragment = {};
31
+ markHtml(fragment, () => String(value));
32
+ return Object.freeze(fragment);
33
+ }
34
+
35
+ function trustedValue(brand, value) {
36
+ return Object.freeze({ [brand]: true, value: String(value) });
37
+ }
38
+
39
+ function attribute(value) {
40
+ if (!['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
41
+ throw new TypeError('attribute() requires a string, number, bigint, or boolean.');
42
+ }
43
+ return trustedValue(HTML_ATTRIBUTE, value);
44
+ }
45
+
46
+ function safeUrl(value) {
47
+ if (typeof value !== 'string' || !value || value !== value.trim() || /[\u0000-\u001f\u007f]/.test(value)) {
48
+ throw new TypeError('url() requires a non-empty URL without surrounding whitespace or control characters.');
49
+ }
50
+ if (value.startsWith('//') || value.includes('\\')) throw new TypeError('url() does not allow protocol-relative or backslash URLs.');
51
+ const protocol = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(value)?.[0].toLowerCase();
52
+ if (protocol && !['http:', 'https:', 'mailto:', 'tel:'].includes(protocol)) {
53
+ throw new TypeError(`url() does not allow the ${protocol} protocol.`);
54
+ }
55
+ return trustedValue(HTML_URL, value);
56
+ }
57
+
58
+ function renderValue(value) {
59
+ if (Array.isArray(value)) {
60
+ if (!isHtml(value)) throw new TypeError('Only arrays of HtmlFragment values can be rendered as HTML.');
61
+ return value.map(renderValue).join('');
62
+ }
63
+ if (HTML_RENDERERS.has(value)) return HTML_RENDERERS.get(value).call(value);
64
+ return isHtml(value) ? value.toString() : escapeHtml(value);
65
+ }
66
+
67
+ function renderInterpolation(source, value) {
68
+ const context = interpolationContext(source);
69
+ if (context.kind === 'attribute') {
70
+ const name = context.name;
71
+ if (name.startsWith('on') || FORBIDDEN_ATTRIBUTES.has(name)) {
72
+ throw new TypeError(`Dynamic ${name} attributes are not allowed.`);
73
+ }
74
+ if (URL_ATTRIBUTES.has(name)) {
75
+ if (!value?.[HTML_URL]) {
76
+ if (value?.[HTML_ATTRIBUTE]) throw new TypeError(`The ${name} attribute requires url().`);
77
+ value = safeUrl(value);
78
+ }
79
+ } else if (!value?.[HTML_ATTRIBUTE]) {
80
+ if (value?.[HTML_URL]) throw new TypeError(`The ${name} attribute requires attribute().`);
81
+ if (isHtml(value)) throw new TypeError(`The ${name} attribute requires a primitive value.`);
82
+ value = attribute(value);
83
+ }
84
+ return escapeHtml(value.value);
85
+ }
86
+ if (context.kind !== 'text') throw new TypeError('html interpolations are only allowed in element text.');
87
+ if (value?.[HTML_ATTRIBUTE] || value?.[HTML_URL]) {
88
+ throw new TypeError('attribute() and url() values may only be used in matching quoted attributes.');
89
+ }
90
+ return renderValue(value);
91
+ }
92
+
93
+ function html(strings, ...values) {
94
+ if (!Array.isArray(strings) || !Object.prototype.hasOwnProperty.call(strings, 'raw')) {
95
+ throw new TypeError('html must be used as a tagged template literal.');
96
+ }
97
+ const rendered = strings.reduce((result, part, index) => {
98
+ const next = result + part;
99
+ if (index >= values.length) return next;
100
+ return next + renderInterpolation(next, values[index]);
101
+ }, '');
102
+ return Object.freeze({ [HTML_FRAGMENT]: true, toString: () => rendered });
103
+ }
104
+
105
+ function each(items, render) {
106
+ if (!Array.isArray(items)) throw new TypeError('each() requires an array.');
107
+ if (typeof render !== 'function') throw new TypeError('each() requires a render function.');
108
+ const fragments = items.map((item, index) => render(item, index));
109
+ if (!fragments.every(isHtml)) throw new TypeError('each() render functions must return html fragments.');
110
+ return Object.freeze({ [HTML_FRAGMENT]: true, toString: () => fragments.map(renderValue).join('') });
111
+ }
112
+
113
+ function codeBlock(code, options = {}) {
114
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
115
+ throw new TypeError('codeBlock() options must be an object.');
116
+ }
117
+ const { language = 'text', label = language, highlight } = options;
118
+ if (typeof language !== 'string' || !/^[A-Za-z0-9_+-]{1,32}$/.test(language)) {
119
+ throw new TypeError('codeBlock() language must be a safe name of at most 32 characters.');
120
+ }
121
+ if (typeof label !== 'string') throw new TypeError('codeBlock() label must be a string.');
122
+ if (highlight !== undefined && typeof highlight !== 'function') throw new TypeError('codeBlock() highlight must be a function.');
123
+ const caption = label ? html`<figcaption>${label}</figcaption>` : html``;
124
+ let content = isHtml(code) ? code : String(code ?? '');
125
+ if (highlight) {
126
+ if (isHtml(code)) throw new TypeError('codeBlock() cannot highlight an HtmlFragment.');
127
+ content = synchronous(highlight(content, language), 'codeBlock() highlight must render synchronously.');
128
+ if (!isHtml(content)) throw new TypeError('codeBlock() highlight must return an HtmlFragment.');
129
+ }
130
+ return html`<figure class="redweb-code">${caption}<pre><code class="${attribute(`language-${language}`)}">${content}</code></pre></figure>`;
131
+ }
132
+
133
+ module.exports = { attribute, codeBlock, each, escapeHtml, html, isHtml, markHtml, renderValue, safeUrl, trustedHtml };
@@ -0,0 +1,88 @@
1
+ const { escapeHtml, isHtml, renderValue } = require('./Html');
2
+ const PageAssetLoader = require('./PageAssetLoader');
3
+ const TemplateRenderer = require('./TemplateRenderer');
4
+ const { getStateConfig, getViewMetadata } = require('./metadata');
5
+
6
+ function serializeJson(value) {
7
+ return JSON.stringify(value).replaceAll('<', '\\u003c');
8
+ }
9
+
10
+ function meta(attribute, name, content) {
11
+ return `<meta ${attribute}="${escapeHtml(name)}" content="${escapeHtml(content)}">`;
12
+ }
13
+
14
+ class HtmlRenderer {
15
+ static file(filePath, rootDir, kind) {
16
+ return new PageAssetLoader().load(filePath, rootDir, kind).content;
17
+ }
18
+
19
+ static template(filePath, rootDir) {
20
+ return HtmlRenderer.file(filePath, rootDir, 'template');
21
+ }
22
+
23
+ static stylesheet(filePath, rootDir) {
24
+ return HtmlRenderer.file(filePath, rootDir, 'stylesheet');
25
+ }
26
+
27
+ static render(source, page, options = {}) {
28
+ if (typeof source !== 'string') throw new TypeError('Page markup must be a string.');
29
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Render options must be an object.');
30
+ const { live = true } = options;
31
+ if (typeof live !== 'boolean') throw new TypeError('Render live must be a boolean.');
32
+ return new TemplateRenderer(source, page, HtmlRenderer.collection, live).render();
33
+ }
34
+
35
+ static collection(page, name, value) {
36
+ if (!getStateConfig(page.constructor, name)) throw new Error(`Page collection "${name}" is missing @state metadata.`);
37
+ if (!Array.isArray(value)) throw new TypeError(`Page collection "${name}" must be an array.`);
38
+ const view = getViewMetadata(page.constructor, name);
39
+ if (!view) throw new Error(`Page collection "${name}" is missing @view metadata.`);
40
+ if (page[view.method] !== view.implementation) throw new Error(`View for page collection "${name}" was replaced.`);
41
+ return value.map((item, index) => {
42
+ const rendered = view.implementation.call(page, item, index);
43
+ if (!isHtml(rendered)) throw new TypeError(`View for page collection "${name}" must return html.`);
44
+ return renderValue(rendered);
45
+ }).join('');
46
+ }
47
+
48
+ static statePayload(name, value, page) {
49
+ if (page && getViewMetadata(page.constructor, name)) {
50
+ return { name, value: HtmlRenderer.collection(page, name, value), html: true };
51
+ }
52
+ return { name, value: isHtml(value) ? renderValue(value) : String(value ?? ''), html: isHtml(value) };
53
+ }
54
+
55
+ static head(metadata = {}) {
56
+ const tags = [];
57
+ if (metadata.title) tags.push(`<title>${escapeHtml(metadata.title)}</title>`, meta('property', 'og:title', metadata.title), meta('name', 'twitter:title', metadata.title));
58
+ if (metadata.description) tags.push(meta('name', 'description', metadata.description), meta('property', 'og:description', metadata.description), meta('name', 'twitter:description', metadata.description));
59
+ if (metadata.canonical) tags.push(`<link rel="canonical" href="${escapeHtml(metadata.canonical)}">`, meta('property', 'og:url', metadata.canonical));
60
+ if (metadata.image) tags.push(meta('property', 'og:image', metadata.image), meta('name', 'twitter:image', metadata.image));
61
+ if (metadata.robots) tags.push(meta('name', 'robots', metadata.robots));
62
+ if (metadata.title || metadata.description || metadata.image) tags.push(meta('name', 'twitter:card', metadata.image ? 'summary_large_image' : 'summary'));
63
+ return tags.join('');
64
+ }
65
+
66
+ static document(markup, config = null, stylesheets = [], metadata = {}) {
67
+ const bootstrap = config ? `<script type="application/json" id="__redweb_page">${serializeJson(config)}</script>` +
68
+ `<script type="module" src="${escapeHtml(config.runtimePath)}"></script>` : '';
69
+ const links = stylesheets.map(href => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('');
70
+ const headMarkup = HtmlRenderer.head(metadata) + links;
71
+ const body = TemplateRenderer.closingTag(markup, 'body');
72
+ if (body >= 0) {
73
+ const head = TemplateRenderer.closingTag(markup, 'head');
74
+ const insertions = [{ position: body, value: bootstrap }];
75
+ if (head >= 0) {
76
+ if (headMarkup) insertions.push({ position: head, value: headMarkup });
77
+ } else if (headMarkup) {
78
+ const bodyOpen = TemplateRenderer.openingTag(markup, 'body');
79
+ insertions.push({ position: Math.max(0, bodyOpen), value: `<head>${headMarkup}</head>` });
80
+ }
81
+ return insertions.sort((left, right) => right.position - left.position)
82
+ .reduce((result, insertion) => result.slice(0, insertion.position) + insertion.value + result.slice(insertion.position), markup);
83
+ }
84
+ return `<!doctype html><html><head>${headMarkup}</head><body><main data-rw-root>${markup}</main>${bootstrap}</body></html>`;
85
+ }
86
+ }
87
+
88
+ module.exports = HtmlRenderer;