redweb 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -1
- package/README.md +67 -4
- package/bin/redweb.js +20 -0
- package/config/tsconfig.json +14 -0
- package/docs/LIVE_HTML.md +53 -1
- package/examples/live-html/chatroom.js +232 -232
- package/examples/live-html/jsx-page.js +81 -0
- package/examples/live-html/jsx-page.tsx +41 -0
- package/examples/live-html/tsconfig.json +9 -12
- package/jsx-dev-runtime.d.ts +13 -0
- package/jsx-dev-runtime.js +9 -0
- package/jsx-runtime.d.ts +28 -0
- package/jsx-runtime.js +5 -0
- package/package.json +42 -3
- package/src/cli/ProjectInitializer.js +30 -0
- package/src/cli/templates.js +87 -0
- package/src/htmx/Html.js +22 -18
- package/src/htmx/Jsx.js +86 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
# Changelog
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
- Added `npx redweb init [directory]` to create a minimal TypeScript + TSX application without overwriting existing files.
|
|
6
|
+
- Added the reusable `redweb/tsconfig.json` preset so TypeScript builds and editors resolve Redweb's JSX runtime consistently.
|
|
7
|
+
- Updated the Live HTML examples to inherit the shared preset and added real-filesystem CLI, generated-project compilation, and packed-package verification.
|
|
8
|
+
|
|
9
|
+
## 0.11.0
|
|
10
|
+
|
|
11
|
+
- Added dependency-free server-side TSX rendering through `redweb/jsx-runtime` and `redweb/jsx-dev-runtime`, with fragments, function components, automatic text and attribute escaping, safe URL validation, boolean attributes, and direct interoperability with existing `HtmlFragment` values.
|
|
12
|
+
- Added a compiled TSX Live HTML example plus real HTTP, WebSocket, type-checking, and packed-consumer verification.
|
|
2
13
|
|
|
3
14
|
## 0.10.0
|
|
4
15
|
|
package/README.md
CHANGED
|
@@ -7,8 +7,18 @@ Version 0.9 adds production-minded multiplayer building blocks while preserving
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install redweb
|
|
11
|
-
```
|
|
10
|
+
npm install redweb
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Start a TypeScript + TSX project with Redweb's compiler preset and a small server-rendered page:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx redweb init
|
|
17
|
+
npm install
|
|
18
|
+
npm run dev
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Pass a directory to create a new project there: `npx redweb init my-app`. Existing files are never overwritten, so rerunning the command is safe.
|
|
12
22
|
|
|
13
23
|
## Exports
|
|
14
24
|
|
|
@@ -41,7 +51,60 @@ const {
|
|
|
41
51
|
|
|
42
52
|
## Live HTML
|
|
43
53
|
|
|
44
|
-
`start(PageClass)` combines server-rendered `.html` templates and Redweb WebSockets on one listener. Decorated plain classes hold the behavior
|
|
54
|
+
`start(PageClass)` combines server-rendered TSX or `.html` templates and Redweb WebSockets on one listener. Decorated plain classes hold the behavior. Redweb injects a small browser runtime backed by [`redweb-client`](https://www.npmjs.com/package/redweb-client), binds the HTTP render to an expiring page token, and disposes connection-owned state after disconnect.
|
|
55
|
+
|
|
56
|
+
TSX is the concise default for new pages. It renders straight to Redweb's existing `HtmlFragment`; there is no React dependency, virtual DOM, hydration pass, or client component runtime:
|
|
57
|
+
|
|
58
|
+
Extend Redweb's TypeScript preset so builds and editors use the dependency-free JSX runtime consistently:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"extends": "redweb/tsconfig.json",
|
|
63
|
+
"compilerOptions": {
|
|
64
|
+
"rootDir": "src",
|
|
65
|
+
"outDir": "dist"
|
|
66
|
+
},
|
|
67
|
+
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { LivePage, action, component, page, start, state } from 'redweb';
|
|
73
|
+
import type { Child } from 'redweb/jsx-runtime';
|
|
74
|
+
|
|
75
|
+
const Card = component((props: { title: string; children?: Child }) => (
|
|
76
|
+
<article class="card">
|
|
77
|
+
<h2>{props.title}</h2>
|
|
78
|
+
{props.children}
|
|
79
|
+
</article>
|
|
80
|
+
));
|
|
81
|
+
|
|
82
|
+
@page('/', { css: 'counter.css' })
|
|
83
|
+
class CounterPage extends LivePage {
|
|
84
|
+
@state() count = 0;
|
|
85
|
+
|
|
86
|
+
@action()
|
|
87
|
+
increment() { this.count += 1; }
|
|
88
|
+
|
|
89
|
+
render() {
|
|
90
|
+
return (
|
|
91
|
+
<main>
|
|
92
|
+
<Card title="Server counter">
|
|
93
|
+
<button rw-click="increment">
|
|
94
|
+
Count <output data-rw-state="count">{this.count}</output>
|
|
95
|
+
</button>
|
|
96
|
+
</Card>
|
|
97
|
+
</main>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
start(CounterPage, { port: 8181 });
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Text and attribute values are escaped automatically. URL attributes use Redweb's existing safe-protocol policy. `on*`, inline `style`, `srcdoc`, `srcset`, and executable `<script>` or `<style>` children are rejected; use `rw-*` server directives and external CSS or JavaScript assets. Existing `html` fragments can be nested in TSX, and TSX fragments can be nested in `html`, so migration can be incremental.
|
|
106
|
+
|
|
107
|
+
Ordinary declarative `.html` templates remain available when separating markup into a standalone file is preferable:
|
|
45
108
|
|
|
46
109
|
```ts
|
|
47
110
|
import { page, start, state } from 'redweb';
|
|
@@ -162,7 +225,7 @@ An `html` fragment returned by `render()` is final safe markup, so documentation
|
|
|
162
225
|
|
|
163
226
|
The same API serves HTTPS/WSS when `ssl` is provided. For private pages, an optional `authenticate(request)` callback binds the page token to the same stable user identity across the HTTP render and WebSocket upgrade. Initial connections and reconnects always receive a complete authoritative state snapshot.
|
|
164
227
|
|
|
165
|
-
See the [Live HTML guide](docs/LIVE_HTML.md), runnable TypeScript [server counter](examples/live-html/counter.ts), component-based [chatroom](examples/live-html/chatroom.ts), and [persistent card collection](examples/live-html/cards.ts). The chatroom separates joining from its stable message composer, tracks online members, preserves bounded history, restores identity and missed messages after reconnect, and creates an isolated room for every server. The cards page uses `shared: true`, so additions survive reloads, reconnects, and new visitors while its server is running. Run the examples with `npm run example:counter`, `npm run example:chatroom`, and `npm run example:cards`. The decorated sources are compiled and exercised unchanged by mock-free HTTP/WebSocket integration tests and a real-Chromium DOM gate.
|
|
228
|
+
See the [Live HTML guide](docs/LIVE_HTML.md), runnable [TSX page](examples/live-html/jsx-page.tsx), TypeScript [server counter](examples/live-html/counter.ts), component-based [chatroom](examples/live-html/chatroom.ts), and [persistent card collection](examples/live-html/cards.ts). The chatroom separates joining from its stable message composer, tracks online members, preserves bounded history, restores identity and missed messages after reconnect, and creates an isolated room for every server. The cards page uses `shared: true`, so additions survive reloads, reconnects, and new visitors while its server is running. Run the examples with `npm run example:jsx`, `npm run example:counter`, `npm run example:chatroom`, and `npm run example:cards`. The decorated sources are compiled and exercised unchanged by mock-free HTTP/WebSocket integration tests and a real-Chromium DOM gate.
|
|
166
229
|
|
|
167
230
|
Reusable snippets can own server behavior without page-level forwarding methods. Decorate a class with `@component()`, put instances in page fields, and interpolate them directly: `` html`<main>${this.primary}${this.secondary}</main>` ``. Each instance gets isolated `@state()`, scoped `@action()` methods, nested-component support, and page-owned lifecycle cleanup. See the runnable [component counters](examples/live-html/components.ts) or run `npm run example:components`.
|
|
168
231
|
|
package/bin/redweb.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const ProjectInitializer = require('../src/cli/ProjectInitializer');
|
|
6
|
+
const { version } = require('../package.json');
|
|
7
|
+
|
|
8
|
+
const [command, target = '.', ...extra] = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
if (command === 'init' && extra.length === 0) {
|
|
11
|
+
const result = new ProjectInitializer(version).initialize(path.resolve(process.cwd(), target));
|
|
12
|
+
console.log(`Redweb project ready in ${result.root}`);
|
|
13
|
+
if (result.created.length) console.log(`Created: ${result.created.join(', ')}`);
|
|
14
|
+
if (result.skipped.length) console.log(`Kept existing: ${result.skipped.join(', ')}`);
|
|
15
|
+
console.log('Next: npm install && npm run dev');
|
|
16
|
+
} else {
|
|
17
|
+
const stream = command === undefined || command === '--help' || command === '-h' ? process.stdout : process.stderr;
|
|
18
|
+
stream.write('Usage: redweb init [directory]\n');
|
|
19
|
+
if (stream === process.stderr) process.exitCode = 1;
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"jsxImportSource": "redweb",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"experimentalDecorators": false,
|
|
11
|
+
"useDefineForClassFields": true,
|
|
12
|
+
"skipLibCheck": false
|
|
13
|
+
}
|
|
14
|
+
}
|
package/docs/LIVE_HTML.md
CHANGED
|
@@ -2,6 +2,57 @@
|
|
|
2
2
|
|
|
3
3
|
Live HTML is Redweb's decorator-first server-rendering layer. It uses the existing `HttpServer`, `SocketRoute`, admission, protocol, ordering, backpressure, and shutdown implementations rather than maintaining a second network stack.
|
|
4
4
|
|
|
5
|
+
## TSX rendering
|
|
6
|
+
|
|
7
|
+
New pages can return TSX directly. Run `npx redweb init` for a starter project, or extend `redweb/tsconfig.json` from an existing project's root `tsconfig.json`. The preset makes builds and editors use Redweb's dependency-free JSX runtime consistently:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"extends": "redweb/tsconfig.json",
|
|
12
|
+
"compilerOptions": {
|
|
13
|
+
"rootDir": "src",
|
|
14
|
+
"outDir": "dist"
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Redweb renders TSX immediately to `HtmlFragment` values:
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { LivePage, action, component, page, state } from 'redweb';
|
|
24
|
+
import type { Child } from 'redweb/jsx-runtime';
|
|
25
|
+
|
|
26
|
+
const Panel = component((props: { title: string; children?: Child }) => (
|
|
27
|
+
<section class="panel">
|
|
28
|
+
<h2>{props.title}</h2>
|
|
29
|
+
{props.children}
|
|
30
|
+
</section>
|
|
31
|
+
));
|
|
32
|
+
|
|
33
|
+
@page('/counter', { css: 'counter.css' })
|
|
34
|
+
class CounterPage extends LivePage {
|
|
35
|
+
@state() count = 0;
|
|
36
|
+
|
|
37
|
+
@action()
|
|
38
|
+
increment() { this.count += 1; }
|
|
39
|
+
|
|
40
|
+
render() {
|
|
41
|
+
return (
|
|
42
|
+
<Panel title="Server counter">
|
|
43
|
+
<button rw-click="increment">
|
|
44
|
+
Count <output data-rw-state="count">{this.count}</output>
|
|
45
|
+
</button>
|
|
46
|
+
</Panel>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Intrinsic elements, fragments (`<>...</>`), nested readonly arrays, and synchronous function components are supported. Strings, numbers, and attributes are escaped once; null, undefined, and boolean children render nothing. Safe existing `html` fragments compose in either direction.
|
|
53
|
+
|
|
54
|
+
JSX intentionally remains a server serializer rather than a React compatibility layer. It retains no tree and provides no hooks, refs, hydration, client event functions, or object-style API. Use `rw-click`, `rw-submit`, `rw-bind`, and the other Redweb directives for server actions, and use `@page({ css })` or external assets for styling and scripts. Unsafe URL protocols, `on*`, dynamic `style`, `srcdoc`, `srcset`, children on void elements, and executable `<script>` or `<style>` children are rejected.
|
|
55
|
+
|
|
5
56
|
This layer deliberately owns page concerns only: `@page`, `@state`, `@view`, and `@action`. It does not clone jax.on's `@get`/`@post` controller API. Continue using Redweb's `services` option for ordinary HTTP APIs; a unified controller decorator surface is a separate compatibility decision rather than hidden behavior in the rendering layer.
|
|
6
57
|
|
|
7
58
|
## Page model
|
|
@@ -250,8 +301,9 @@ The internal paths and application page paths must be unique.
|
|
|
250
301
|
- `examples/live-html/chatroom.ts` uses a connection-scoped `@component()` backed by a room service created by `createChatroomPage()`, so separate server instances cannot leak history or names. Visitors join once, receive a stable dedicated composer, see a capped presence list with the total online count, share bounded history, and recover their identity and missed messages after reconnect.
|
|
251
302
|
- `examples/live-html/cards.ts` uses a shared decorated page, `@view()`, and `rw-each` to prove server-rendered collection SSR, realtime replacement, and persistence across reloads and reconnects while the server is running.
|
|
252
303
|
- `examples/live-html/components.ts` uses two instances of one `@component()` class to prove reusable markup, isolated server state, scoped actions, and component CSS composition.
|
|
304
|
+
- `examples/live-html/jsx-page.tsx` uses Redweb's automatic JSX runtime, a function component, decorated state, and a server action without HTML template strings.
|
|
253
305
|
|
|
254
|
-
Run the examples immediately with `npm run example:counter`, `npm run example:chatroom`, `npm run example:cards`, and `npm run example:
|
|
306
|
+
Run the examples immediately with `npm run example:counter`, `npm run example:chatroom`, `npm run example:cards`, `npm run example:components`, and `npm run example:jsx`. Their checked-in JavaScript artifacts are generated from the decorated TypeScript or TSX sources, and every test and package build rejects stale output. The artifacts are launched unchanged by `tests/integration/live-html.integration.test.js` over real loopback HTTP and WebSocket connections. Run the focused gate with `npm run verify:live-html`, or the complete 100% coverage suite with `npm test`.
|
|
255
307
|
|
|
256
308
|
## Static pages and documentation export
|
|
257
309
|
|
|
@@ -1,205 +1,205 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
3
|
-
var useValue = arguments.length > 2;
|
|
4
|
-
for (var i = 0; i < initializers.length; i++) {
|
|
5
|
-
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
6
|
-
}
|
|
7
|
-
return useValue ? value : void 0;
|
|
8
|
-
};
|
|
9
|
-
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
10
|
-
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
11
|
-
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
12
|
-
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
13
|
-
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
14
|
-
var _, done = false;
|
|
15
|
-
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
16
|
-
var context = {};
|
|
17
|
-
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
18
|
-
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
19
|
-
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
20
|
-
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
21
|
-
if (kind === "accessor") {
|
|
22
|
-
if (result === void 0) continue;
|
|
23
|
-
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
24
|
-
if (_ = accept(result.get)) descriptor.get = _;
|
|
25
|
-
if (_ = accept(result.set)) descriptor.set = _;
|
|
26
|
-
if (_ = accept(result.init)) initializers.unshift(_);
|
|
27
|
-
}
|
|
28
|
-
else if (_ = accept(result)) {
|
|
29
|
-
if (kind === "field") initializers.unshift(_);
|
|
30
|
-
else descriptor[key] = _;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
34
|
-
done = true;
|
|
35
|
-
};
|
|
36
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
-
exports.ChatroomComponent = void 0;
|
|
38
|
-
exports.createChatroomPage = createChatroomPage;
|
|
39
|
-
const redweb_1 = require('../..');
|
|
40
|
-
const MAX_VISIBLE_MEMBERS = 100;
|
|
41
|
-
const UNSAFE_TEXT = /[\p{Cc}\p{Cf}]/u;
|
|
42
|
-
function messageView(messages) {
|
|
43
|
-
return messages.length
|
|
44
|
-
? (0, redweb_1.each)([...messages], entry => (0, redweb_1.html) `<li><strong>${entry.sender}</strong><p>${entry.text}</p></li>`)
|
|
45
|
-
: (0, redweb_1.html) `<li class="empty-message">No messages yet. Say hello.</li>`;
|
|
46
|
-
}
|
|
47
|
-
function presenceView(members) {
|
|
48
|
-
const visible = members.slice(0, MAX_VISIBLE_MEMBERS);
|
|
49
|
-
const remaining = members.length - visible.length;
|
|
1
|
+
"use strict";
|
|
2
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
3
|
+
var useValue = arguments.length > 2;
|
|
4
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
5
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
6
|
+
}
|
|
7
|
+
return useValue ? value : void 0;
|
|
8
|
+
};
|
|
9
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
10
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
11
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
12
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
13
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
14
|
+
var _, done = false;
|
|
15
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
16
|
+
var context = {};
|
|
17
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
18
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
19
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
20
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
21
|
+
if (kind === "accessor") {
|
|
22
|
+
if (result === void 0) continue;
|
|
23
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
24
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
25
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
26
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
27
|
+
}
|
|
28
|
+
else if (_ = accept(result)) {
|
|
29
|
+
if (kind === "field") initializers.unshift(_);
|
|
30
|
+
else descriptor[key] = _;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
34
|
+
done = true;
|
|
35
|
+
};
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.ChatroomComponent = void 0;
|
|
38
|
+
exports.createChatroomPage = createChatroomPage;
|
|
39
|
+
const redweb_1 = require('../..');
|
|
40
|
+
const MAX_VISIBLE_MEMBERS = 100;
|
|
41
|
+
const UNSAFE_TEXT = /[\p{Cc}\p{Cf}]/u;
|
|
42
|
+
function messageView(messages) {
|
|
43
|
+
return messages.length
|
|
44
|
+
? (0, redweb_1.each)([...messages], entry => (0, redweb_1.html) `<li><strong>${entry.sender}</strong><p>${entry.text}</p></li>`)
|
|
45
|
+
: (0, redweb_1.html) `<li class="empty-message">No messages yet. Say hello.</li>`;
|
|
46
|
+
}
|
|
47
|
+
function presenceView(members) {
|
|
48
|
+
const visible = members.slice(0, MAX_VISIBLE_MEMBERS);
|
|
49
|
+
const remaining = members.length - visible.length;
|
|
50
50
|
return (0, redweb_1.html) `
|
|
51
51
|
<p class="eyebrow">Online · ${members.length}</p>
|
|
52
52
|
<ul>
|
|
53
53
|
${(0, redweb_1.each)([...visible], member => (0, redweb_1.html) `<li>${member}</li>`)}
|
|
54
54
|
${remaining ? (0, redweb_1.html) `<li class="more-members">+${remaining} more</li>` : (0, redweb_1.html) ``}
|
|
55
55
|
</ul>
|
|
56
|
-
`;
|
|
57
|
-
}
|
|
58
|
-
class ChatRoom {
|
|
59
|
-
history = [];
|
|
60
|
-
participants = new Set();
|
|
61
|
-
online = new Set();
|
|
62
|
-
join(participant) {
|
|
63
|
-
const name = participant.displayName.toLocaleLowerCase();
|
|
64
|
-
if ([...this.participants].some(member => member !== participant && member.displayName.toLocaleLowerCase() === name)) {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
this.participants.add(participant);
|
|
68
|
-
this.online.add(participant);
|
|
69
|
-
participant.updateMessages(messageView(this.history));
|
|
70
|
-
this.publishPresence();
|
|
71
|
-
return true;
|
|
72
|
-
}
|
|
73
|
-
disconnect(participant) {
|
|
74
|
-
if (!this.online.delete(participant))
|
|
75
|
-
return;
|
|
76
|
-
this.publishPresence();
|
|
77
|
-
}
|
|
78
|
-
leave(participant) {
|
|
79
|
-
this.online.delete(participant);
|
|
80
|
-
if (!this.participants.delete(participant))
|
|
81
|
-
return;
|
|
82
|
-
this.publishPresence();
|
|
83
|
-
}
|
|
84
|
-
send(participant, text) {
|
|
85
|
-
if (!this.online.has(participant))
|
|
86
|
-
return false;
|
|
87
|
-
this.history = [...this.history, { sender: participant.displayName, text }].slice(-100);
|
|
88
|
-
const messages = messageView(this.history);
|
|
89
|
-
for (const member of this.participants)
|
|
90
|
-
member.updateMessages(messages);
|
|
91
|
-
return true;
|
|
92
|
-
}
|
|
93
|
-
publishPresence() {
|
|
94
|
-
const members = [...this.online].map(participant => participant.displayName);
|
|
95
|
-
const presence = presenceView(members);
|
|
96
|
-
for (const participant of this.participants)
|
|
97
|
-
participant.updatePresence(presence);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
let ChatroomComponent = (() => {
|
|
101
|
-
let _classDecorators = [(0, redweb_1.component)()];
|
|
102
|
-
let _classDescriptor;
|
|
103
|
-
let _classExtraInitializers = [];
|
|
104
|
-
let _classThis;
|
|
105
|
-
let _instanceExtraInitializers = [];
|
|
106
|
-
let _screen_decorators;
|
|
107
|
-
let _screen_initializers = [];
|
|
108
|
-
let _screen_extraInitializers = [];
|
|
109
|
-
let _messages_decorators;
|
|
110
|
-
let _messages_initializers = [];
|
|
111
|
-
let _messages_extraInitializers = [];
|
|
112
|
-
let _presence_decorators;
|
|
113
|
-
let _presence_initializers = [];
|
|
114
|
-
let _presence_extraInitializers = [];
|
|
115
|
-
let _join_decorators;
|
|
116
|
-
let _send_decorators;
|
|
117
|
-
let _leave_decorators;
|
|
118
|
-
var ChatroomComponent = class {
|
|
119
|
-
static { _classThis = this; }
|
|
120
|
-
static {
|
|
121
|
-
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
|
|
122
|
-
_screen_decorators = [(0, redweb_1.state)()];
|
|
123
|
-
_messages_decorators = [(0, redweb_1.state)()];
|
|
124
|
-
_presence_decorators = [(0, redweb_1.state)()];
|
|
125
|
-
_join_decorators = [(0, redweb_1.action)()];
|
|
126
|
-
_send_decorators = [(0, redweb_1.action)()];
|
|
127
|
-
_leave_decorators = [(0, redweb_1.action)()];
|
|
128
|
-
__esDecorate(this, null, _join_decorators, { kind: "method", name: "join", static: false, private: false, access: { has: obj => "join" in obj, get: obj => obj.join }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
129
|
-
__esDecorate(this, null, _send_decorators, { kind: "method", name: "send", static: false, private: false, access: { has: obj => "send" in obj, get: obj => obj.send }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
130
|
-
__esDecorate(this, null, _leave_decorators, { kind: "method", name: "leave", static: false, private: false, access: { has: obj => "leave" in obj, get: obj => obj.leave }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
131
|
-
__esDecorate(null, null, _screen_decorators, { kind: "field", name: "screen", static: false, private: false, access: { has: obj => "screen" in obj, get: obj => obj.screen, set: (obj, value) => { obj.screen = value; } }, metadata: _metadata }, _screen_initializers, _screen_extraInitializers);
|
|
132
|
-
__esDecorate(null, null, _messages_decorators, { kind: "field", name: "messages", static: false, private: false, access: { has: obj => "messages" in obj, get: obj => obj.messages, set: (obj, value) => { obj.messages = value; } }, metadata: _metadata }, _messages_initializers, _messages_extraInitializers);
|
|
133
|
-
__esDecorate(null, null, _presence_decorators, { kind: "field", name: "presence", static: false, private: false, access: { has: obj => "presence" in obj, get: obj => obj.presence, set: (obj, value) => { obj.presence = value; } }, metadata: _metadata }, _presence_initializers, _presence_extraInitializers);
|
|
134
|
-
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
|
135
|
-
ChatroomComponent = _classThis = _classDescriptor.value;
|
|
136
|
-
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
137
|
-
__runInitializers(_classThis, _classExtraInitializers);
|
|
138
|
-
}
|
|
139
|
-
room = __runInitializers(this, _instanceExtraInitializers);
|
|
140
|
-
displayName = '';
|
|
141
|
-
screen = __runInitializers(this, _screen_initializers, this.joinScreen());
|
|
142
|
-
messages = (__runInitializers(this, _screen_extraInitializers), __runInitializers(this, _messages_initializers, messageView([])));
|
|
143
|
-
presence = (__runInitializers(this, _messages_extraInitializers), __runInitializers(this, _presence_initializers, presenceView([])));
|
|
144
|
-
constructor(room) {
|
|
145
|
-
__runInitializers(this, _presence_extraInitializers);
|
|
146
|
-
this.room = room;
|
|
147
|
-
}
|
|
148
|
-
connected() {
|
|
149
|
-
if (this.displayName)
|
|
150
|
-
this.room.join(this);
|
|
151
|
-
}
|
|
152
|
-
disconnected() {
|
|
153
|
-
this.room.disconnect(this);
|
|
154
|
-
}
|
|
155
|
-
disposed() {
|
|
156
|
-
this.room.leave(this);
|
|
157
|
-
}
|
|
158
|
-
join({ name }) {
|
|
159
|
-
if (this.displayName)
|
|
160
|
-
return false;
|
|
161
|
-
if (typeof name !== 'string') {
|
|
162
|
-
this.screen = this.joinScreen('Display name must be text.');
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
165
|
-
const displayName = name.normalize('NFKC').trim();
|
|
166
|
-
if (!displayName || displayName.length > 40 || UNSAFE_TEXT.test(displayName)) {
|
|
167
|
-
this.screen = this.joinScreen('Choose a visible display name of at most 40 characters.');
|
|
168
|
-
return false;
|
|
169
|
-
}
|
|
170
|
-
this.displayName = displayName;
|
|
171
|
-
if (this.room.join(this)) {
|
|
172
|
-
this.screen = this.roomScreen();
|
|
173
|
-
return true;
|
|
174
|
-
}
|
|
175
|
-
this.displayName = '';
|
|
176
|
-
this.screen = this.joinScreen('That display name is already in use.');
|
|
177
|
-
return false;
|
|
178
|
-
}
|
|
179
|
-
send({ message }) {
|
|
180
|
-
if (typeof message !== 'string')
|
|
181
|
-
return false;
|
|
182
|
-
const text = message.normalize('NFKC').trim();
|
|
183
|
-
if (!text || text.length > 500 || UNSAFE_TEXT.test(text))
|
|
184
|
-
return false;
|
|
185
|
-
return this.room.send(this, text);
|
|
186
|
-
}
|
|
187
|
-
leave() {
|
|
188
|
-
this.room.leave(this);
|
|
189
|
-
this.displayName = '';
|
|
190
|
-
this.screen = this.joinScreen();
|
|
191
|
-
}
|
|
192
|
-
updateMessages(messages) {
|
|
193
|
-
this.messages = messages;
|
|
194
|
-
}
|
|
195
|
-
updatePresence(presence) {
|
|
196
|
-
this.presence = presence;
|
|
197
|
-
}
|
|
198
|
-
render() {
|
|
199
|
-
return (0, redweb_1.html) `<section class="chatroom" data-rw-state="screen">${this.screen}</section>`;
|
|
200
|
-
}
|
|
201
|
-
joinScreen(error = '') {
|
|
202
|
-
const feedback = error ? (0, redweb_1.html) `<p class="form-error" role="alert">${error}</p>` : (0, redweb_1.html) ``;
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
class ChatRoom {
|
|
59
|
+
history = [];
|
|
60
|
+
participants = new Set();
|
|
61
|
+
online = new Set();
|
|
62
|
+
join(participant) {
|
|
63
|
+
const name = participant.displayName.toLocaleLowerCase();
|
|
64
|
+
if ([...this.participants].some(member => member !== participant && member.displayName.toLocaleLowerCase() === name)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
this.participants.add(participant);
|
|
68
|
+
this.online.add(participant);
|
|
69
|
+
participant.updateMessages(messageView(this.history));
|
|
70
|
+
this.publishPresence();
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
disconnect(participant) {
|
|
74
|
+
if (!this.online.delete(participant))
|
|
75
|
+
return;
|
|
76
|
+
this.publishPresence();
|
|
77
|
+
}
|
|
78
|
+
leave(participant) {
|
|
79
|
+
this.online.delete(participant);
|
|
80
|
+
if (!this.participants.delete(participant))
|
|
81
|
+
return;
|
|
82
|
+
this.publishPresence();
|
|
83
|
+
}
|
|
84
|
+
send(participant, text) {
|
|
85
|
+
if (!this.online.has(participant))
|
|
86
|
+
return false;
|
|
87
|
+
this.history = [...this.history, { sender: participant.displayName, text }].slice(-100);
|
|
88
|
+
const messages = messageView(this.history);
|
|
89
|
+
for (const member of this.participants)
|
|
90
|
+
member.updateMessages(messages);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
publishPresence() {
|
|
94
|
+
const members = [...this.online].map(participant => participant.displayName);
|
|
95
|
+
const presence = presenceView(members);
|
|
96
|
+
for (const participant of this.participants)
|
|
97
|
+
participant.updatePresence(presence);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
let ChatroomComponent = (() => {
|
|
101
|
+
let _classDecorators = [(0, redweb_1.component)()];
|
|
102
|
+
let _classDescriptor;
|
|
103
|
+
let _classExtraInitializers = [];
|
|
104
|
+
let _classThis;
|
|
105
|
+
let _instanceExtraInitializers = [];
|
|
106
|
+
let _screen_decorators;
|
|
107
|
+
let _screen_initializers = [];
|
|
108
|
+
let _screen_extraInitializers = [];
|
|
109
|
+
let _messages_decorators;
|
|
110
|
+
let _messages_initializers = [];
|
|
111
|
+
let _messages_extraInitializers = [];
|
|
112
|
+
let _presence_decorators;
|
|
113
|
+
let _presence_initializers = [];
|
|
114
|
+
let _presence_extraInitializers = [];
|
|
115
|
+
let _join_decorators;
|
|
116
|
+
let _send_decorators;
|
|
117
|
+
let _leave_decorators;
|
|
118
|
+
var ChatroomComponent = class {
|
|
119
|
+
static { _classThis = this; }
|
|
120
|
+
static {
|
|
121
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
|
|
122
|
+
_screen_decorators = [(0, redweb_1.state)()];
|
|
123
|
+
_messages_decorators = [(0, redweb_1.state)()];
|
|
124
|
+
_presence_decorators = [(0, redweb_1.state)()];
|
|
125
|
+
_join_decorators = [(0, redweb_1.action)()];
|
|
126
|
+
_send_decorators = [(0, redweb_1.action)()];
|
|
127
|
+
_leave_decorators = [(0, redweb_1.action)()];
|
|
128
|
+
__esDecorate(this, null, _join_decorators, { kind: "method", name: "join", static: false, private: false, access: { has: obj => "join" in obj, get: obj => obj.join }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
129
|
+
__esDecorate(this, null, _send_decorators, { kind: "method", name: "send", static: false, private: false, access: { has: obj => "send" in obj, get: obj => obj.send }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
130
|
+
__esDecorate(this, null, _leave_decorators, { kind: "method", name: "leave", static: false, private: false, access: { has: obj => "leave" in obj, get: obj => obj.leave }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
131
|
+
__esDecorate(null, null, _screen_decorators, { kind: "field", name: "screen", static: false, private: false, access: { has: obj => "screen" in obj, get: obj => obj.screen, set: (obj, value) => { obj.screen = value; } }, metadata: _metadata }, _screen_initializers, _screen_extraInitializers);
|
|
132
|
+
__esDecorate(null, null, _messages_decorators, { kind: "field", name: "messages", static: false, private: false, access: { has: obj => "messages" in obj, get: obj => obj.messages, set: (obj, value) => { obj.messages = value; } }, metadata: _metadata }, _messages_initializers, _messages_extraInitializers);
|
|
133
|
+
__esDecorate(null, null, _presence_decorators, { kind: "field", name: "presence", static: false, private: false, access: { has: obj => "presence" in obj, get: obj => obj.presence, set: (obj, value) => { obj.presence = value; } }, metadata: _metadata }, _presence_initializers, _presence_extraInitializers);
|
|
134
|
+
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
|
135
|
+
ChatroomComponent = _classThis = _classDescriptor.value;
|
|
136
|
+
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
137
|
+
__runInitializers(_classThis, _classExtraInitializers);
|
|
138
|
+
}
|
|
139
|
+
room = __runInitializers(this, _instanceExtraInitializers);
|
|
140
|
+
displayName = '';
|
|
141
|
+
screen = __runInitializers(this, _screen_initializers, this.joinScreen());
|
|
142
|
+
messages = (__runInitializers(this, _screen_extraInitializers), __runInitializers(this, _messages_initializers, messageView([])));
|
|
143
|
+
presence = (__runInitializers(this, _messages_extraInitializers), __runInitializers(this, _presence_initializers, presenceView([])));
|
|
144
|
+
constructor(room) {
|
|
145
|
+
__runInitializers(this, _presence_extraInitializers);
|
|
146
|
+
this.room = room;
|
|
147
|
+
}
|
|
148
|
+
connected() {
|
|
149
|
+
if (this.displayName)
|
|
150
|
+
this.room.join(this);
|
|
151
|
+
}
|
|
152
|
+
disconnected() {
|
|
153
|
+
this.room.disconnect(this);
|
|
154
|
+
}
|
|
155
|
+
disposed() {
|
|
156
|
+
this.room.leave(this);
|
|
157
|
+
}
|
|
158
|
+
join({ name }) {
|
|
159
|
+
if (this.displayName)
|
|
160
|
+
return false;
|
|
161
|
+
if (typeof name !== 'string') {
|
|
162
|
+
this.screen = this.joinScreen('Display name must be text.');
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const displayName = name.normalize('NFKC').trim();
|
|
166
|
+
if (!displayName || displayName.length > 40 || UNSAFE_TEXT.test(displayName)) {
|
|
167
|
+
this.screen = this.joinScreen('Choose a visible display name of at most 40 characters.');
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
this.displayName = displayName;
|
|
171
|
+
if (this.room.join(this)) {
|
|
172
|
+
this.screen = this.roomScreen();
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
this.displayName = '';
|
|
176
|
+
this.screen = this.joinScreen('That display name is already in use.');
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
send({ message }) {
|
|
180
|
+
if (typeof message !== 'string')
|
|
181
|
+
return false;
|
|
182
|
+
const text = message.normalize('NFKC').trim();
|
|
183
|
+
if (!text || text.length > 500 || UNSAFE_TEXT.test(text))
|
|
184
|
+
return false;
|
|
185
|
+
return this.room.send(this, text);
|
|
186
|
+
}
|
|
187
|
+
leave() {
|
|
188
|
+
this.room.leave(this);
|
|
189
|
+
this.displayName = '';
|
|
190
|
+
this.screen = this.joinScreen();
|
|
191
|
+
}
|
|
192
|
+
updateMessages(messages) {
|
|
193
|
+
this.messages = messages;
|
|
194
|
+
}
|
|
195
|
+
updatePresence(presence) {
|
|
196
|
+
this.presence = presence;
|
|
197
|
+
}
|
|
198
|
+
render() {
|
|
199
|
+
return (0, redweb_1.html) `<section class="chatroom" data-rw-state="screen">${this.screen}</section>`;
|
|
200
|
+
}
|
|
201
|
+
joinScreen(error = '') {
|
|
202
|
+
const feedback = error ? (0, redweb_1.html) `<p class="form-error" role="alert">${error}</p>` : (0, redweb_1.html) ``;
|
|
203
203
|
return (0, redweb_1.html) `
|
|
204
204
|
<section class="join-panel">
|
|
205
205
|
<p class="eyebrow">Live room</p>
|
|
@@ -214,9 +214,9 @@ let ChatroomComponent = (() => {
|
|
|
214
214
|
</div>
|
|
215
215
|
</form>
|
|
216
216
|
</section>
|
|
217
|
-
`;
|
|
218
|
-
}
|
|
219
|
-
roomScreen() {
|
|
217
|
+
`;
|
|
218
|
+
}
|
|
219
|
+
roomScreen() {
|
|
220
220
|
return (0, redweb_1.html) `
|
|
221
221
|
<div class="room-layout">
|
|
222
222
|
<section class="conversation">
|
|
@@ -233,36 +233,36 @@ let ChatroomComponent = (() => {
|
|
|
233
233
|
</section>
|
|
234
234
|
<aside class="presence" aria-label="People in the room" data-rw-state="presence">${this.presence}</aside>
|
|
235
235
|
</div>
|
|
236
|
-
`;
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
|
-
return ChatroomComponent = _classThis;
|
|
240
|
-
})();
|
|
241
|
-
exports.ChatroomComponent = ChatroomComponent;
|
|
242
|
-
function createChatroomPage() {
|
|
243
|
-
const room = new ChatRoom();
|
|
244
|
-
let ChatroomPage = (() => {
|
|
245
|
-
let _classDecorators = [(0, redweb_1.page)('/', { css: 'chatroom.css' })];
|
|
246
|
-
let _classDescriptor;
|
|
247
|
-
let _classExtraInitializers = [];
|
|
248
|
-
let _classThis;
|
|
249
|
-
var ChatroomPage = class {
|
|
250
|
-
static { _classThis = this; }
|
|
251
|
-
static {
|
|
252
|
-
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
|
|
253
|
-
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
|
254
|
-
ChatroomPage = _classThis = _classDescriptor.value;
|
|
255
|
-
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
256
|
-
__runInitializers(_classThis, _classExtraInitializers);
|
|
257
|
-
}
|
|
258
|
-
chat = new ChatroomComponent(room);
|
|
259
|
-
render() {
|
|
260
|
-
return (0, redweb_1.html) `<main>${this.chat}</main>`;
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
|
-
return ChatroomPage = _classThis;
|
|
264
|
-
})();
|
|
265
|
-
return ChatroomPage;
|
|
266
|
-
}
|
|
267
|
-
if (require.main === module)
|
|
268
|
-
(0, redweb_1.start)(createChatroomPage(), { port: 8080 });
|
|
236
|
+
`;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
return ChatroomComponent = _classThis;
|
|
240
|
+
})();
|
|
241
|
+
exports.ChatroomComponent = ChatroomComponent;
|
|
242
|
+
function createChatroomPage() {
|
|
243
|
+
const room = new ChatRoom();
|
|
244
|
+
let ChatroomPage = (() => {
|
|
245
|
+
let _classDecorators = [(0, redweb_1.page)('/', { css: 'chatroom.css' })];
|
|
246
|
+
let _classDescriptor;
|
|
247
|
+
let _classExtraInitializers = [];
|
|
248
|
+
let _classThis;
|
|
249
|
+
var ChatroomPage = class {
|
|
250
|
+
static { _classThis = this; }
|
|
251
|
+
static {
|
|
252
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0;
|
|
253
|
+
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
|
254
|
+
ChatroomPage = _classThis = _classDescriptor.value;
|
|
255
|
+
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
256
|
+
__runInitializers(_classThis, _classExtraInitializers);
|
|
257
|
+
}
|
|
258
|
+
chat = new ChatroomComponent(room);
|
|
259
|
+
render() {
|
|
260
|
+
return (0, redweb_1.html) `<main>${this.chat}</main>`;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
return ChatroomPage = _classThis;
|
|
264
|
+
})();
|
|
265
|
+
return ChatroomPage;
|
|
266
|
+
}
|
|
267
|
+
if (require.main === module)
|
|
268
|
+
(0, redweb_1.start)(createChatroomPage(), { port: 8080 });
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
3
|
+
var useValue = arguments.length > 2;
|
|
4
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
5
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
6
|
+
}
|
|
7
|
+
return useValue ? value : void 0;
|
|
8
|
+
};
|
|
9
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
10
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
11
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
12
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
13
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
14
|
+
var _, done = false;
|
|
15
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
16
|
+
var context = {};
|
|
17
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
18
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
19
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
20
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
21
|
+
if (kind === "accessor") {
|
|
22
|
+
if (result === void 0) continue;
|
|
23
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
24
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
25
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
26
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
27
|
+
}
|
|
28
|
+
else if (_ = accept(result)) {
|
|
29
|
+
if (kind === "field") initializers.unshift(_);
|
|
30
|
+
else descriptor[key] = _;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
34
|
+
done = true;
|
|
35
|
+
};
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.JsxPage = void 0;
|
|
38
|
+
const jsx_runtime_1 = require('../../jsx-runtime');
|
|
39
|
+
const redweb_1 = require('../..');
|
|
40
|
+
const Card = (0, redweb_1.component)(({ title, children }) => ((0, jsx_runtime_1.jsxs)("article", { class: "counter-card", children: [(0, jsx_runtime_1.jsx)("h2", { children: title }), children] })));
|
|
41
|
+
let JsxPage = (() => {
|
|
42
|
+
let _classDecorators = [(0, redweb_1.page)('/jsx', { css: 'components.css' })];
|
|
43
|
+
let _classDescriptor;
|
|
44
|
+
let _classExtraInitializers = [];
|
|
45
|
+
let _classThis;
|
|
46
|
+
let _classSuper = redweb_1.LivePage;
|
|
47
|
+
let _instanceExtraInitializers = [];
|
|
48
|
+
let _count_decorators;
|
|
49
|
+
let _count_initializers = [];
|
|
50
|
+
let _count_extraInitializers = [];
|
|
51
|
+
let _increment_decorators;
|
|
52
|
+
var JsxPage = class extends _classSuper {
|
|
53
|
+
static { _classThis = this; }
|
|
54
|
+
static {
|
|
55
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
56
|
+
_count_decorators = [(0, redweb_1.state)()];
|
|
57
|
+
_increment_decorators = [(0, redweb_1.action)()];
|
|
58
|
+
__esDecorate(this, null, _increment_decorators, { kind: "method", name: "increment", static: false, private: false, access: { has: obj => "increment" in obj, get: obj => obj.increment }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
59
|
+
__esDecorate(null, null, _count_decorators, { kind: "field", name: "count", static: false, private: false, access: { has: obj => "count" in obj, get: obj => obj.count, set: (obj, value) => { obj.count = value; } }, metadata: _metadata }, _count_initializers, _count_extraInitializers);
|
|
60
|
+
__esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers);
|
|
61
|
+
JsxPage = _classThis = _classDescriptor.value;
|
|
62
|
+
if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
63
|
+
__runInitializers(_classThis, _classExtraInitializers);
|
|
64
|
+
}
|
|
65
|
+
count = (__runInitializers(this, _instanceExtraInitializers), __runInitializers(this, _count_initializers, 0));
|
|
66
|
+
increment() {
|
|
67
|
+
this.count += 1;
|
|
68
|
+
}
|
|
69
|
+
render() {
|
|
70
|
+
return ((0, jsx_runtime_1.jsxs)("main", { class: "page-shell", children: [(0, jsx_runtime_1.jsx)("h1", { children: "Redweb JSX" }), (0, jsx_runtime_1.jsxs)(Card, { title: "Server rendered", children: [(0, jsx_runtime_1.jsx)("p", { children: "Plain TSX, escaped by default, with no browser framework." }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "rw-click": "increment", children: ["Count ", (0, jsx_runtime_1.jsx)("output", { "data-rw-state": "count", children: this.count })] })] })] }));
|
|
71
|
+
}
|
|
72
|
+
constructor() {
|
|
73
|
+
super(...arguments);
|
|
74
|
+
__runInitializers(this, _count_extraInitializers);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return JsxPage = _classThis;
|
|
78
|
+
})();
|
|
79
|
+
exports.JsxPage = JsxPage;
|
|
80
|
+
if (require.main === module)
|
|
81
|
+
(0, redweb_1.start)(JsxPage, { port: 8181 });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { LivePage, action, component, page, start, state } from 'redweb';
|
|
2
|
+
import type { Child } from 'redweb/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
interface CardProperties {
|
|
5
|
+
title: string;
|
|
6
|
+
children?: Child;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const Card = component(({ title, children }: CardProperties) => (
|
|
10
|
+
<article class="counter-card">
|
|
11
|
+
<h2>{title}</h2>
|
|
12
|
+
{children}
|
|
13
|
+
</article>
|
|
14
|
+
));
|
|
15
|
+
|
|
16
|
+
@page('/jsx', { css: 'components.css' })
|
|
17
|
+
export class JsxPage extends LivePage {
|
|
18
|
+
@state()
|
|
19
|
+
count = 0;
|
|
20
|
+
|
|
21
|
+
@action()
|
|
22
|
+
increment() {
|
|
23
|
+
this.count += 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
render() {
|
|
27
|
+
return (
|
|
28
|
+
<main class="page-shell">
|
|
29
|
+
<h1>Redweb JSX</h1>
|
|
30
|
+
<Card title="Server rendered">
|
|
31
|
+
<p>Plain TSX, escaped by default, with no browser framework.</p>
|
|
32
|
+
<button type="button" rw-click="increment">
|
|
33
|
+
Count <output data-rw-state="count">{this.count}</output>
|
|
34
|
+
</button>
|
|
35
|
+
</Card>
|
|
36
|
+
</main>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (require.main === module) start(JsxPage, { port: 8181 });
|
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
{
|
|
2
|
-
"
|
|
3
|
-
|
|
4
|
-
"module": "CommonJS",
|
|
5
|
-
"moduleResolution": "Node",
|
|
6
|
-
"
|
|
7
|
-
"experimentalDecorators": false,
|
|
8
|
-
"useDefineForClassFields": true,
|
|
9
|
-
"skipLibCheck": false,
|
|
10
|
-
"baseUrl": "../..",
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../config/tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"module": "CommonJS",
|
|
5
|
+
"moduleResolution": "Node",
|
|
6
|
+
"baseUrl": "../..",
|
|
11
7
|
"paths": {
|
|
12
|
-
"redweb": ["."]
|
|
8
|
+
"redweb": ["."],
|
|
9
|
+
"redweb/*": ["*"]
|
|
13
10
|
}
|
|
14
11
|
},
|
|
15
|
-
"files": ["counter.ts", "chatroom.ts", "cards.ts", "components.ts"]
|
|
12
|
+
"files": ["counter.ts", "chatroom.ts", "cards.ts", "components.ts", "jsx-page.tsx"]
|
|
16
13
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { Fragment } from './jsx-runtime';
|
|
2
|
+
export type { Child, ElementType, IntrinsicAttributes, IntrinsicProperties, JSX } from './jsx-runtime';
|
|
3
|
+
import type { HtmlFragment } from 'redweb';
|
|
4
|
+
import type { ElementType, IntrinsicProperties } from './jsx-runtime';
|
|
5
|
+
|
|
6
|
+
export function jsxDEV(
|
|
7
|
+
type: ElementType,
|
|
8
|
+
properties: IntrinsicProperties | null,
|
|
9
|
+
key?: string | number,
|
|
10
|
+
isStaticChildren?: boolean,
|
|
11
|
+
source?: unknown,
|
|
12
|
+
self?: unknown,
|
|
13
|
+
): HtmlFragment;
|
package/jsx-runtime.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { HtmlFragment } from 'redweb';
|
|
2
|
+
|
|
3
|
+
export type Child = HtmlFragment | string | number | bigint | boolean | null | undefined | readonly Child[];
|
|
4
|
+
|
|
5
|
+
export interface IntrinsicAttributes {
|
|
6
|
+
key?: string | number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface IntrinsicProperties extends IntrinsicAttributes {
|
|
10
|
+
children?: Child;
|
|
11
|
+
class?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
id?: string;
|
|
14
|
+
htmlFor?: string;
|
|
15
|
+
[name: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export namespace JSX {
|
|
19
|
+
type Element = HtmlFragment;
|
|
20
|
+
interface ElementChildrenAttribute { children: {}; }
|
|
21
|
+
interface IntrinsicAttributes { key?: string | number; }
|
|
22
|
+
interface IntrinsicElements { [name: string]: IntrinsicProperties; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const Fragment: unique symbol;
|
|
26
|
+
export type ElementType = string | typeof Fragment | ((properties: any) => HtmlFragment | readonly HtmlFragment[]);
|
|
27
|
+
export function jsx(type: ElementType, properties: IntrinsicProperties | null, key?: string | number): HtmlFragment;
|
|
28
|
+
export const jsxs: typeof jsx;
|
package/jsx-runtime.js
ADDED
package/package.json
CHANGED
|
@@ -1,16 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redweb",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "A small Node.js foundation for HTTP, WebSockets, multiplayer services, and server-rendered HTML",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"redweb": "bin/redweb.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./index.d.ts",
|
|
13
|
+
"import": "./index.js",
|
|
14
|
+
"require": "./index.js",
|
|
15
|
+
"default": "./index.js"
|
|
16
|
+
},
|
|
17
|
+
"./client": {
|
|
18
|
+
"types": "./client.d.ts",
|
|
19
|
+
"import": "./client.js",
|
|
20
|
+
"require": "./client.js",
|
|
21
|
+
"default": "./client.js"
|
|
22
|
+
},
|
|
23
|
+
"./jsx-runtime": {
|
|
24
|
+
"types": "./jsx-runtime.d.ts",
|
|
25
|
+
"import": "./jsx-runtime.js",
|
|
26
|
+
"require": "./jsx-runtime.js",
|
|
27
|
+
"default": "./jsx-runtime.js"
|
|
28
|
+
},
|
|
29
|
+
"./jsx-dev-runtime": {
|
|
30
|
+
"types": "./jsx-dev-runtime.d.ts",
|
|
31
|
+
"import": "./jsx-dev-runtime.js",
|
|
32
|
+
"require": "./jsx-dev-runtime.js",
|
|
33
|
+
"default": "./jsx-dev-runtime.js"
|
|
34
|
+
},
|
|
35
|
+
"./tsconfig.json": "./config/tsconfig.json",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
7
38
|
"scripts": {
|
|
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",
|
|
39
|
+
"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.jsxdev.json && tsc -p tests/types/tsconfig.standard.json",
|
|
9
40
|
"prepack": "node scripts/build-live-html-examples.js --check",
|
|
10
41
|
"example:counter": "node examples/live-html/counter.js",
|
|
11
42
|
"example:chatroom": "node examples/live-html/chatroom.js",
|
|
12
43
|
"example:cards": "node examples/live-html/cards.js",
|
|
13
44
|
"example:components": "node examples/live-html/components.js",
|
|
45
|
+
"example:jsx": "node examples/live-html/jsx-page.js",
|
|
14
46
|
"generate:protocol-types": "node scripts/generate-protocol-types.js",
|
|
15
47
|
"verify:overhead": "node scripts/verify-disabled-overhead.js",
|
|
16
48
|
"verify:soak": "node --expose-gc scripts/verify-soak.js",
|
|
@@ -20,13 +52,20 @@
|
|
|
20
52
|
"verify:live-html": "node scripts/build-live-html-examples.js --check && npx jest tests/integration/live-html.integration.test.js --runInBand --coverage=false",
|
|
21
53
|
"verify:live-html:browser": "node scripts/build-live-html-examples.js --check && node scripts/verify-live-html-browser.js",
|
|
22
54
|
"verify:live-html:load": "node scripts/build-live-html-examples.js --check && node --expose-gc scripts/verify-live-html-load.js",
|
|
55
|
+
"verify:jsx:performance": "node --expose-gc scripts/verify-jsx-performance.js",
|
|
23
56
|
"verify:live-html:package": "node scripts/verify-live-html-package.js",
|
|
24
57
|
"test": "npx jest"
|
|
25
58
|
},
|
|
26
59
|
"files": [
|
|
60
|
+
"bin",
|
|
61
|
+
"config",
|
|
27
62
|
"src/*",
|
|
28
63
|
"client.js",
|
|
29
64
|
"client.d.ts",
|
|
65
|
+
"jsx-runtime.js",
|
|
66
|
+
"jsx-runtime.d.ts",
|
|
67
|
+
"jsx-dev-runtime.js",
|
|
68
|
+
"jsx-dev-runtime.d.ts",
|
|
30
69
|
"CHANGELOG.md",
|
|
31
70
|
"index.d.ts",
|
|
32
71
|
"docs",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { projectFiles } = require('./templates');
|
|
6
|
+
|
|
7
|
+
class ProjectInitializer {
|
|
8
|
+
constructor(version) {
|
|
9
|
+
this.files = projectFiles(version);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
initialize(target) {
|
|
13
|
+
const root = path.resolve(target);
|
|
14
|
+
const created = [];
|
|
15
|
+
const skipped = [];
|
|
16
|
+
for (const file of this.files) {
|
|
17
|
+
const destination = path.join(root, file.path);
|
|
18
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
19
|
+
if (fs.existsSync(destination)) {
|
|
20
|
+
skipped.push(file.path);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
fs.writeFileSync(destination, file.content, { encoding: 'utf8', flag: 'wx' });
|
|
24
|
+
created.push(file.path);
|
|
25
|
+
}
|
|
26
|
+
return Object.freeze({ root, created: Object.freeze(created), skipped: Object.freeze(skipped) });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = ProjectInitializer;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const TYPESCRIPT_CONFIG = `${JSON.stringify({
|
|
4
|
+
extends: 'redweb/tsconfig.json',
|
|
5
|
+
compilerOptions: { rootDir: 'src', outDir: 'dist' },
|
|
6
|
+
include: ['src/**/*.ts', 'src/**/*.tsx'],
|
|
7
|
+
}, null, 2)}\n`;
|
|
8
|
+
|
|
9
|
+
const APP_SOURCE = `import path from 'node:path';
|
|
10
|
+
import { page, start } from 'redweb';
|
|
11
|
+
|
|
12
|
+
@page('/', { css: 'app.css', live: false })
|
|
13
|
+
class HomePage {
|
|
14
|
+
render() {
|
|
15
|
+
return (
|
|
16
|
+
<main class="home">
|
|
17
|
+
<span class="eyebrow">Redweb</span>
|
|
18
|
+
<h1>Your server-rendered app is ready.</h1>
|
|
19
|
+
<p>Edit <code>src/app.tsx</code>, then build again.</p>
|
|
20
|
+
</main>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
start(HomePage, {
|
|
26
|
+
port: Number(process.env.PORT ?? 8181),
|
|
27
|
+
templateRoot: path.resolve('src'),
|
|
28
|
+
});
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
const APP_STYLES = `:root {
|
|
32
|
+
color-scheme: dark;
|
|
33
|
+
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
34
|
+
background: #08090d;
|
|
35
|
+
color: #fff;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
body { margin: 0; }
|
|
39
|
+
|
|
40
|
+
.home {
|
|
41
|
+
width: min(42rem, calc(100% - 2rem));
|
|
42
|
+
margin: 18vh auto 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.eyebrow {
|
|
46
|
+
color: #ff5064;
|
|
47
|
+
font-size: 0.75rem;
|
|
48
|
+
font-weight: 800;
|
|
49
|
+
letter-spacing: 0.18em;
|
|
50
|
+
text-transform: uppercase;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
h1 {
|
|
54
|
+
margin: 0.75rem 0;
|
|
55
|
+
font-size: clamp(2.5rem, 8vw, 5rem);
|
|
56
|
+
line-height: 0.98;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
p { color: rgb(255 255 255 / 65%); }
|
|
60
|
+
code { color: #ff8795; }
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
function projectManifest(version) {
|
|
64
|
+
return `${JSON.stringify({
|
|
65
|
+
name: 'redweb-app',
|
|
66
|
+
private: true,
|
|
67
|
+
version: '0.0.0',
|
|
68
|
+
scripts: {
|
|
69
|
+
build: 'tsc',
|
|
70
|
+
start: 'node dist/app.js',
|
|
71
|
+
dev: 'npm run build && npm start',
|
|
72
|
+
},
|
|
73
|
+
dependencies: { redweb: `^${version}` },
|
|
74
|
+
devDependencies: { typescript: '^5.9.3' },
|
|
75
|
+
}, null, 2)}\n`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function projectFiles(version) {
|
|
79
|
+
return Object.freeze([
|
|
80
|
+
Object.freeze({ path: 'package.json', content: projectManifest(version) }),
|
|
81
|
+
Object.freeze({ path: 'tsconfig.json', content: TYPESCRIPT_CONFIG }),
|
|
82
|
+
Object.freeze({ path: 'src/app.tsx', content: APP_SOURCE }),
|
|
83
|
+
Object.freeze({ path: 'src/app.css', content: APP_STYLES }),
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { projectFiles };
|
package/src/htmx/Html.js
CHANGED
|
@@ -27,8 +27,9 @@ function markHtml(value, toString) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function trustedHtml(value) {
|
|
30
|
-
const
|
|
31
|
-
|
|
30
|
+
const rendered = String(value);
|
|
31
|
+
const fragment = { toString: () => rendered };
|
|
32
|
+
markHtml(fragment, fragment.toString);
|
|
32
33
|
return Object.freeze(fragment);
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -67,21 +68,7 @@ function renderValue(value) {
|
|
|
67
68
|
function renderInterpolation(source, value) {
|
|
68
69
|
const context = interpolationContext(source);
|
|
69
70
|
if (context.kind === 'attribute') {
|
|
70
|
-
|
|
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);
|
|
71
|
+
return renderAttributeValue(context.name, value);
|
|
85
72
|
}
|
|
86
73
|
if (context.kind !== 'text') throw new TypeError('html interpolations are only allowed in element text.');
|
|
87
74
|
if (value?.[HTML_ATTRIBUTE] || value?.[HTML_URL]) {
|
|
@@ -90,6 +77,23 @@ function renderInterpolation(source, value) {
|
|
|
90
77
|
return renderValue(value);
|
|
91
78
|
}
|
|
92
79
|
|
|
80
|
+
function renderAttributeValue(name, value) {
|
|
81
|
+
if (name.startsWith('on') || FORBIDDEN_ATTRIBUTES.has(name)) {
|
|
82
|
+
throw new TypeError(`Dynamic ${name} attributes are not allowed.`);
|
|
83
|
+
}
|
|
84
|
+
if (URL_ATTRIBUTES.has(name)) {
|
|
85
|
+
if (!value?.[HTML_URL]) {
|
|
86
|
+
if (value?.[HTML_ATTRIBUTE]) throw new TypeError(`The ${name} attribute requires url().`);
|
|
87
|
+
value = safeUrl(value);
|
|
88
|
+
}
|
|
89
|
+
} else if (!value?.[HTML_ATTRIBUTE]) {
|
|
90
|
+
if (value?.[HTML_URL]) throw new TypeError(`The ${name} attribute requires attribute().`);
|
|
91
|
+
if (isHtml(value)) throw new TypeError(`The ${name} attribute requires a primitive value.`);
|
|
92
|
+
value = attribute(value);
|
|
93
|
+
}
|
|
94
|
+
return escapeHtml(value.value);
|
|
95
|
+
}
|
|
96
|
+
|
|
93
97
|
function html(strings, ...values) {
|
|
94
98
|
if (!Array.isArray(strings) || !Object.prototype.hasOwnProperty.call(strings, 'raw')) {
|
|
95
99
|
throw new TypeError('html must be used as a tagged template literal.');
|
|
@@ -130,4 +134,4 @@ function codeBlock(code, options = {}) {
|
|
|
130
134
|
return html`<figure class="redweb-code">${caption}<pre><code class="${attribute(`language-${language}`)}">${content}</code></pre></figure>`;
|
|
131
135
|
}
|
|
132
136
|
|
|
133
|
-
module.exports = { attribute, codeBlock, each, escapeHtml, html, isHtml, markHtml, renderValue, safeUrl, trustedHtml };
|
|
137
|
+
module.exports = { attribute, codeBlock, each, escapeHtml, html, isHtml, markHtml, renderAttributeValue, renderValue, safeUrl, trustedHtml };
|
package/src/htmx/Jsx.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isHtml, renderAttributeValue, renderValue, trustedHtml } = require('./Html');
|
|
4
|
+
const synchronous = require('./synchronous');
|
|
5
|
+
|
|
6
|
+
const Fragment = Symbol('redweb.Fragment');
|
|
7
|
+
const NAME = /^[A-Za-z][A-Za-z0-9:._-]*$/;
|
|
8
|
+
const VOID_ELEMENTS = new Set([
|
|
9
|
+
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
|
|
10
|
+
'meta', 'param', 'source', 'track', 'wbr',
|
|
11
|
+
]);
|
|
12
|
+
const RAW_TEXT_ELEMENTS = new Set(['script', 'style']);
|
|
13
|
+
const TERMINAL_ELEMENTS = new Set(['plaintext']);
|
|
14
|
+
const BOOLEAN_VALUE_ATTRIBUTES = new Set(['contenteditable', 'draggable', 'spellcheck', 'writingsuggestions']);
|
|
15
|
+
const ATTRIBUTE_ALIASES = Object.freeze({ className: 'class', htmlFor: 'for' });
|
|
16
|
+
|
|
17
|
+
function renderChild(value) {
|
|
18
|
+
if (value === null || value === undefined || typeof value === 'boolean') return '';
|
|
19
|
+
if (Array.isArray(value)) return value.map(renderChild).join('');
|
|
20
|
+
if (isHtml(value)) return renderValue(value);
|
|
21
|
+
if (['string', 'number', 'bigint'].includes(typeof value)) return renderValue(value);
|
|
22
|
+
throw new TypeError('JSX children must be text, numbers, HtmlFragment values, or arrays of those values.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function renderAttributes(properties) {
|
|
26
|
+
const attributes = [];
|
|
27
|
+
const renderedNames = new Set();
|
|
28
|
+
for (const originalName of Object.keys(properties)) {
|
|
29
|
+
if (originalName === 'children' || originalName === 'key') continue;
|
|
30
|
+
const name = Object.hasOwn(ATTRIBUTE_ALIASES, originalName) ? ATTRIBUTE_ALIASES[originalName] : originalName;
|
|
31
|
+
if (!NAME.test(name)) throw new TypeError(`Invalid JSX attribute name: ${name}.`);
|
|
32
|
+
const normalizedName = name.toLowerCase();
|
|
33
|
+
if (renderedNames.has(normalizedName)) throw new TypeError(`Duplicate JSX attribute: ${name}.`);
|
|
34
|
+
renderedNames.add(normalizedName);
|
|
35
|
+
let value = properties[originalName];
|
|
36
|
+
if (normalizedName === 'translate' && typeof value === 'boolean') value = value ? 'yes' : 'no';
|
|
37
|
+
const preservesFalse = normalizedName.startsWith('aria-') || normalizedName.startsWith('data-') ||
|
|
38
|
+
BOOLEAN_VALUE_ATTRIBUTES.has(normalizedName);
|
|
39
|
+
if (value === null || value === undefined || (value === false && !preservesFalse)) continue;
|
|
40
|
+
if (value === true && !preservesFalse) {
|
|
41
|
+
renderAttributeValue(normalizedName, value);
|
|
42
|
+
attributes.push(name);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
attributes.push(`${name}="${renderAttributeValue(normalizedName, value)}"`);
|
|
46
|
+
}
|
|
47
|
+
return attributes.length ? ` ${attributes.join(' ')}` : '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderIntrinsic(name, properties) {
|
|
51
|
+
if (!NAME.test(name)) throw new TypeError(`Invalid JSX element name: ${name}.`);
|
|
52
|
+
const normalizedName = name.toLowerCase();
|
|
53
|
+
if (TERMINAL_ELEMENTS.has(normalizedName)) throw new TypeError(`JSX <${name}> is not supported because it prevents subsequent HTML from rendering.`);
|
|
54
|
+
const attributes = renderAttributes(properties);
|
|
55
|
+
const children = properties.children;
|
|
56
|
+
const renderedChildren = renderChild(children);
|
|
57
|
+
if (VOID_ELEMENTS.has(normalizedName)) {
|
|
58
|
+
if (renderedChildren) {
|
|
59
|
+
throw new TypeError(`JSX void element <${name}> cannot have children.`);
|
|
60
|
+
}
|
|
61
|
+
return trustedHtml(`<${name}${attributes}>`);
|
|
62
|
+
}
|
|
63
|
+
if (RAW_TEXT_ELEMENTS.has(normalizedName) && renderedChildren) {
|
|
64
|
+
throw new TypeError(`JSX <${name}> children are not supported; use an external asset.`);
|
|
65
|
+
}
|
|
66
|
+
return trustedHtml(`<${name}${attributes}>${renderedChildren}</${name}>`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderComponent(Component, properties) {
|
|
70
|
+
const result = synchronous(Component(properties), 'JSX components must render synchronously.');
|
|
71
|
+
if (!isHtml(result)) throw new TypeError('JSX components must return an HtmlFragment.');
|
|
72
|
+
return Array.isArray(result) ? trustedHtml(renderValue(result)) : result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createElement(type, properties) {
|
|
76
|
+
const props = properties == null ? {} : properties;
|
|
77
|
+
if (!props || typeof props !== 'object' || Array.isArray(props)) {
|
|
78
|
+
throw new TypeError('JSX properties must be an object.');
|
|
79
|
+
}
|
|
80
|
+
if (type === Fragment) return trustedHtml(renderChild(props.children));
|
|
81
|
+
if (typeof type === 'string') return renderIntrinsic(type, props);
|
|
82
|
+
if (typeof type === 'function') return renderComponent(type, props);
|
|
83
|
+
throw new TypeError('JSX element types must be intrinsic names or function components.');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { Fragment, createElement, renderChild };
|