redweb 0.9.0 → 0.11.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 +22 -0
- package/README.md +187 -23
- package/docs/LIVE_HTML.md +352 -0
- package/examples/live-html/cards.css +36 -0
- package/examples/live-html/cards.html +11 -0
- package/examples/live-html/cards.js +91 -0
- package/examples/live-html/cards.ts +35 -0
- package/examples/live-html/chatroom.css +156 -0
- package/examples/live-html/chatroom.js +268 -0
- package/examples/live-html/chatroom.ts +217 -0
- package/examples/live-html/components.css +7 -0
- package/examples/live-html/components.js +113 -0
- package/examples/live-html/components.ts +41 -0
- package/examples/live-html/counter.css +24 -0
- package/examples/live-html/counter.html +10 -0
- package/examples/live-html/counter.js +73 -0
- package/examples/live-html/counter.ts +21 -0
- package/examples/live-html/jsx-page.js +81 -0
- package/examples/live-html/jsx-page.tsx +41 -0
- package/examples/live-html/tsconfig.json +19 -0
- package/index.d.ts +219 -1
- package/index.js +18 -1
- 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 +47 -3
- package/src/htmx/Html.js +137 -0
- package/src/htmx/HtmlRenderer.js +88 -0
- package/src/htmx/HtmlSyntax.js +168 -0
- package/src/htmx/Jsx.js +86 -0
- package/src/htmx/LiveHtmlServer.js +91 -0
- package/src/htmx/LivePage.js +232 -0
- package/src/htmx/PageAssetLoader.js +34 -0
- package/src/htmx/PageManager.js +435 -0
- package/src/htmx/StaticExporter.js +78 -0
- package/src/htmx/StaticSite.js +182 -0
- package/src/htmx/TemplateRenderer.js +231 -0
- package/src/htmx/browserRuntime.js +97 -0
- package/src/htmx/index.js +10 -0
- package/src/htmx/metadata.js +349 -0
- package/src/htmx/sourceRoot.js +28 -0
- package/src/htmx/start.js +17 -0
- package/src/htmx/synchronous.js +9 -0
- package/src/http/BaseHttpServer.js +0 -35
- package/src/ws/BaseSocketServer.js +4 -0
- package/src/htmx/HtmxRenderer.js +0 -73
- package/src/htmx/RedWebHtmxComponent.js +0 -11
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { decoratorDirectory } = require('./sourceRoot');
|
|
5
|
+
const { getPageTemplateRoot, page, pageCache, pageHead, setPageStylesheetRoots } = require('./metadata');
|
|
6
|
+
const { exportStatic } = require('./StaticExporter');
|
|
7
|
+
|
|
8
|
+
function plainObject(value, label) {
|
|
9
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${label} must be an object.`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function files(value, label) {
|
|
14
|
+
if (value === undefined) return [];
|
|
15
|
+
const result = Array.isArray(value) ? value : [value];
|
|
16
|
+
if (!result.length || result.some(file => typeof file !== 'string' || !file)) {
|
|
17
|
+
throw new TypeError(`${label} must be a non-empty path or array of non-empty paths.`);
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function siteOrigin(value) {
|
|
23
|
+
if (value === undefined) return undefined;
|
|
24
|
+
if (typeof value !== 'string' || !value) throw new TypeError('Site origin must be an absolute HTTP(S) origin.');
|
|
25
|
+
let parsed;
|
|
26
|
+
try { parsed = new URL(value); }
|
|
27
|
+
catch { throw new TypeError('Site origin must be an absolute HTTP(S) origin.'); }
|
|
28
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password ||
|
|
29
|
+
parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
30
|
+
throw new TypeError('Site origin must be an absolute HTTP(S) origin.');
|
|
31
|
+
}
|
|
32
|
+
return parsed.origin;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveHead(origin, route, defaults, overrides) {
|
|
36
|
+
const head = { ...defaults, ...overrides };
|
|
37
|
+
if (origin && head.canonical === undefined) head.canonical = new URL(route, `${origin}/`).href;
|
|
38
|
+
if (origin && typeof head.image === 'string' && head.image.startsWith('/')) head.image = new URL(head.image, `${origin}/`).href;
|
|
39
|
+
return Object.keys(head).length ? head : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function publicFiles(publicDir) {
|
|
43
|
+
if (publicDir === undefined) return [];
|
|
44
|
+
const source = path.resolve(publicDir);
|
|
45
|
+
const files = [];
|
|
46
|
+
const visit = (directory, relative = '') => {
|
|
47
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
48
|
+
const entryPath = path.join(directory, entry.name);
|
|
49
|
+
const entryRelative = path.join(relative, entry.name);
|
|
50
|
+
if (entry.isSymbolicLink()) throw new TypeError(`Site publicDir cannot contain links: ${entryPath}`);
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
visit(entryPath, entryRelative);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
/* istanbul ignore else -- Windows cannot create a directory-contained special file for this guard. */
|
|
56
|
+
if (entry.isFile()) {
|
|
57
|
+
files.push({ source: entryPath, relative: entryRelative });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
/* istanbul ignore next */
|
|
61
|
+
throw new TypeError(`Site publicDir can contain only files and directories: ${entryPath}`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const details = fs.lstatSync(source);
|
|
65
|
+
if (details.isSymbolicLink() || !details.isDirectory()) throw new TypeError('Site publicDir must be a directory, not a link.');
|
|
66
|
+
visit(source);
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function outputKey(file) {
|
|
71
|
+
return file.replaceAll('\\', '/').toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rejectCollisions(files, occupied = []) {
|
|
75
|
+
const seen = new Set(occupied.map(outputKey));
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const key = outputKey(file);
|
|
78
|
+
if (seen.has(key)) throw new TypeError(`Site output paths collide: ${file}`);
|
|
79
|
+
seen.add(key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function rejectOutputLinks(outDir) {
|
|
84
|
+
if (!fs.existsSync(outDir)) return;
|
|
85
|
+
const visit = directory => {
|
|
86
|
+
const details = fs.lstatSync(directory);
|
|
87
|
+
if (details.isSymbolicLink()) throw new TypeError(`Site outDir cannot contain links: ${directory}`);
|
|
88
|
+
if (!details.isDirectory()) return;
|
|
89
|
+
for (const name of fs.readdirSync(directory)) visit(path.join(directory, name));
|
|
90
|
+
};
|
|
91
|
+
visit(outDir);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function merge(directory, outDir) {
|
|
95
|
+
rejectOutputLinks(outDir);
|
|
96
|
+
for (const entry of publicFiles(directory)) {
|
|
97
|
+
const destination = path.join(outDir, entry.relative);
|
|
98
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
99
|
+
fs.copyFileSync(entry.source, destination);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function defineSite(options = {}) {
|
|
104
|
+
const siteRoot = decoratorDirectory();
|
|
105
|
+
plainObject(options, 'Site options');
|
|
106
|
+
const allowed = new Set(['origin', 'css', 'head', 'cache', 'layout']);
|
|
107
|
+
const unknown = Object.keys(options).find(name => !allowed.has(name));
|
|
108
|
+
if (unknown) throw new TypeError(`Unknown site option: ${unknown}.`);
|
|
109
|
+
const origin = siteOrigin(options.origin);
|
|
110
|
+
const sharedCss = files(options.css, 'Site css');
|
|
111
|
+
const defaultHead = options.head === undefined ? {} : Object.freeze({ ...plainObject(options.head, 'Site head') });
|
|
112
|
+
const defaultCache = pageCache(options.cache, false);
|
|
113
|
+
const defaultLayout = options.layout;
|
|
114
|
+
if (defaultLayout !== undefined && typeof defaultLayout !== 'function') throw new TypeError('Site layout must be a function.');
|
|
115
|
+
pageHead(resolveHead(origin, '/', defaultHead));
|
|
116
|
+
|
|
117
|
+
const decorate = (route, pageOptions = {}) => {
|
|
118
|
+
plainObject(pageOptions, 'Site page options');
|
|
119
|
+
if (pageOptions.live !== undefined && pageOptions.live !== false) {
|
|
120
|
+
throw new TypeError('Site pages must use live: false.');
|
|
121
|
+
}
|
|
122
|
+
if (pageOptions.head !== undefined) plainObject(pageOptions.head, 'Page head');
|
|
123
|
+
const css = [...new Set([...sharedCss, ...files(pageOptions.css, 'Page css')])];
|
|
124
|
+
const decorator = page(route, {
|
|
125
|
+
...pageOptions,
|
|
126
|
+
live: false,
|
|
127
|
+
...(css.length && { css }),
|
|
128
|
+
head: resolveHead(origin, route, defaultHead, pageOptions.head),
|
|
129
|
+
cache: pageOptions.cache === undefined ? defaultCache : pageOptions.cache,
|
|
130
|
+
layout: pageOptions.layout === undefined ? defaultLayout : pageOptions.layout,
|
|
131
|
+
});
|
|
132
|
+
return PageClass => {
|
|
133
|
+
decorator(PageClass);
|
|
134
|
+
const pageRoot = getPageTemplateRoot(PageClass);
|
|
135
|
+
const shared = new Set(sharedCss);
|
|
136
|
+
setPageStylesheetRoots(PageClass, css.map(file => shared.has(file) ? siteRoot : pageRoot));
|
|
137
|
+
return PageClass;
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const exportSite = async (pageOrPages, exportOptions = {}) => {
|
|
142
|
+
plainObject(exportOptions, 'Site export options');
|
|
143
|
+
const { publicDir, ...staticOptions } = exportOptions;
|
|
144
|
+
if (typeof staticOptions.outDir !== 'string' || !staticOptions.outDir) {
|
|
145
|
+
throw new TypeError('Site export requires a non-empty outDir.');
|
|
146
|
+
}
|
|
147
|
+
if (publicDir !== undefined && (typeof publicDir !== 'string' || !publicDir)) {
|
|
148
|
+
throw new TypeError('Site publicDir must be a non-empty path.');
|
|
149
|
+
}
|
|
150
|
+
const outDir = path.resolve(staticOptions.outDir);
|
|
151
|
+
const source = publicDir === undefined ? undefined : path.resolve(publicDir);
|
|
152
|
+
if (source && (source === outDir || outDir.startsWith(`${source}${path.sep}`))) {
|
|
153
|
+
throw new TypeError('Site outDir cannot be the publicDir or one of its descendants.');
|
|
154
|
+
}
|
|
155
|
+
const plannedPublic = publicFiles(publicDir);
|
|
156
|
+
rejectCollisions(plannedPublic.map(entry => entry.relative));
|
|
157
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'redweb-site-'));
|
|
158
|
+
try {
|
|
159
|
+
for (const entry of plannedPublic) {
|
|
160
|
+
const destination = path.join(staging, entry.relative);
|
|
161
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
162
|
+
fs.copyFileSync(entry.source, destination);
|
|
163
|
+
}
|
|
164
|
+
const staged = await exportStatic(pageOrPages, { ...staticOptions, outDir: staging });
|
|
165
|
+
const generated = [...staged.pages, ...staged.assets].map(file => path.relative(staging, file));
|
|
166
|
+
rejectCollisions(plannedPublic.map(entry => entry.relative), generated);
|
|
167
|
+
merge(staging, outDir);
|
|
168
|
+
const destination = file => path.join(outDir, path.relative(staging, file));
|
|
169
|
+
const publicAssets = plannedPublic.map(entry => path.join(outDir, entry.relative));
|
|
170
|
+
return Object.freeze({
|
|
171
|
+
pages: Object.freeze(staged.pages.map(destination)),
|
|
172
|
+
assets: Object.freeze([...new Set([...staged.assets.map(destination), ...publicAssets])]),
|
|
173
|
+
});
|
|
174
|
+
} finally {
|
|
175
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return Object.freeze({ page: decorate, export: exportSite });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { defineSite };
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
const { escapeHtml, isHtml, renderValue } = require('./Html');
|
|
2
|
+
const {
|
|
3
|
+
RAW_TEXT,
|
|
4
|
+
closingTag,
|
|
5
|
+
isHtmlSpace,
|
|
6
|
+
isNonStartMarkup,
|
|
7
|
+
openingTag,
|
|
8
|
+
rawClosingTag,
|
|
9
|
+
tagEnd,
|
|
10
|
+
} = require('./HtmlSyntax');
|
|
11
|
+
|
|
12
|
+
const BINDING = /{{\s*([A-Za-z_$][\w$]*)\s*}}/g;
|
|
13
|
+
const ATTRIBUTE_BINDING = /{{\s*[A-Za-z_$][\w$]*\s*}}/;
|
|
14
|
+
const NAME = /^[A-Za-z_$][\w$]*$/;
|
|
15
|
+
const DIRECTIVES = new Set(['data-rw-state', 'data-rw-html', 'rw-each']);
|
|
16
|
+
const COMPONENT_DIRECTIVES = new Set(['data-rw-component', 'data-rw-state', 'rw-bind', 'rw-click', 'rw-submit']);
|
|
17
|
+
|
|
18
|
+
function attributes(tag, nameEnd, tracked = DIRECTIVES) {
|
|
19
|
+
const found = new Map();
|
|
20
|
+
let position = nameEnd;
|
|
21
|
+
while (position < tag.length - 1) {
|
|
22
|
+
while (isHtmlSpace(tag[position])) position += 1;
|
|
23
|
+
if (tag[position] === '>' || (tag[position] === '/' && tag[position + 1] === '>')) break;
|
|
24
|
+
const start = position;
|
|
25
|
+
while (position < tag.length && !/[ \t\n\f\r=/>]/.test(tag[position])) position += 1;
|
|
26
|
+
if (start === position) throw new Error('Malformed HTML attribute.');
|
|
27
|
+
const name = tag.slice(start, position).toLowerCase();
|
|
28
|
+
while (isHtmlSpace(tag[position])) position += 1;
|
|
29
|
+
let value = null;
|
|
30
|
+
if (tag[position] === '=') {
|
|
31
|
+
position += 1;
|
|
32
|
+
while (isHtmlSpace(tag[position])) position += 1;
|
|
33
|
+
const quote = tag[position];
|
|
34
|
+
if (quote === '"' || quote === "'") {
|
|
35
|
+
const valueStart = ++position;
|
|
36
|
+
while (position < tag.length && tag[position] !== quote) position += 1;
|
|
37
|
+
value = tag.slice(valueStart, position++);
|
|
38
|
+
} else {
|
|
39
|
+
const valueStart = position;
|
|
40
|
+
while (position < tag.length && !/[ \t\n\f\r>]/.test(tag[position])) position += 1;
|
|
41
|
+
value = tag.slice(valueStart, position);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (value && ATTRIBUTE_BINDING.test(value)) {
|
|
45
|
+
throw new TypeError('Template bindings are only allowed in element text.');
|
|
46
|
+
}
|
|
47
|
+
if (tracked.has(name)) {
|
|
48
|
+
if (found.has(name)) throw new Error(`Duplicate Live HTML directive "${name}".`);
|
|
49
|
+
found.set(name, value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return found;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class TemplateRenderer {
|
|
56
|
+
constructor(source, page, collection, reactive) {
|
|
57
|
+
this.source = source;
|
|
58
|
+
this.page = page;
|
|
59
|
+
this.collection = collection;
|
|
60
|
+
this.reactive = reactive;
|
|
61
|
+
this.position = 0;
|
|
62
|
+
this.output = '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
render() {
|
|
66
|
+
while (this.position < this.source.length) {
|
|
67
|
+
if (this.source.startsWith('<!--', this.position)) this.comment();
|
|
68
|
+
else if (this.source[this.position] === '<') this.markup();
|
|
69
|
+
else this.text();
|
|
70
|
+
}
|
|
71
|
+
return this.output;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
comment() {
|
|
75
|
+
const end = this.source.indexOf('-->', this.position + 4);
|
|
76
|
+
const next = end < 0 ? this.source.length : end + 3;
|
|
77
|
+
this.output += this.source.slice(this.position, next);
|
|
78
|
+
this.position = next;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
markup() {
|
|
82
|
+
const parsed = this.startTag();
|
|
83
|
+
if (!parsed) {
|
|
84
|
+
const recognizedMarkup = isNonStartMarkup(this.source, this.position);
|
|
85
|
+
const recognizedStart = /[A-Za-z]/.test(this.source[this.position + 1]);
|
|
86
|
+
const beginsMarkup = recognizedMarkup || recognizedStart;
|
|
87
|
+
const end = beginsMarkup ? this.tagEnd(this.position + 1) : -1;
|
|
88
|
+
if (beginsMarkup && end < 0) {
|
|
89
|
+
this.output += this.source.slice(this.position);
|
|
90
|
+
this.position = this.source.length;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const next = end < 0 ? this.position + 1 : end + 1;
|
|
94
|
+
this.output += this.source.slice(this.position, next);
|
|
95
|
+
this.position = next;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
this.position = parsed.end;
|
|
99
|
+
if (RAW_TEXT.has(parsed.name)) {
|
|
100
|
+
if ([...parsed.attributes.keys()].some(name => DIRECTIVES.has(name))) {
|
|
101
|
+
throw new Error('Live HTML directives are not allowed on raw-text elements.');
|
|
102
|
+
}
|
|
103
|
+
const close = parsed.name === 'plaintext' ? null : rawClosingTag(this.source, parsed.name, this.position);
|
|
104
|
+
const next = close ? close.end : this.source.length;
|
|
105
|
+
this.output += this.source.slice(parsed.start, next);
|
|
106
|
+
this.position = next;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const each = parsed.attributes.get('rw-each');
|
|
110
|
+
const state = parsed.attributes.get('data-rw-state');
|
|
111
|
+
const hasEach = parsed.attributes.has('rw-each');
|
|
112
|
+
const hasState = parsed.attributes.has('data-rw-state');
|
|
113
|
+
const hasHtml = parsed.attributes.has('data-rw-html');
|
|
114
|
+
if (hasHtml && parsed.attributes.get('data-rw-html') !== null) {
|
|
115
|
+
throw new Error('data-rw-html must be a boolean attribute.');
|
|
116
|
+
}
|
|
117
|
+
if (hasHtml && !hasEach && !hasState) {
|
|
118
|
+
throw new Error('data-rw-html requires data-rw-state or rw-each.');
|
|
119
|
+
}
|
|
120
|
+
if (!hasEach && !hasState) {
|
|
121
|
+
this.output += parsed.source;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (hasEach && !NAME.test(each || '')) throw new Error('rw-each requires a valid state name.');
|
|
125
|
+
if (hasState && !NAME.test(state || '')) throw new Error('data-rw-state requires a valid state name.');
|
|
126
|
+
if (hasEach && hasState && each !== state) {
|
|
127
|
+
throw new Error(`Page collection "${each}" conflicts with state binding "${state}".`);
|
|
128
|
+
}
|
|
129
|
+
const name = each || state;
|
|
130
|
+
if (!(name in this.page)) throw new Error(hasEach ? `Unknown page collection "${name}".` : `Unknown page binding "${name}".`);
|
|
131
|
+
const closing = this.emptyClosing(parsed.name);
|
|
132
|
+
const value = hasEach ? this.collection(this.page, name, this.page[name]) : renderValue(this.page[name]);
|
|
133
|
+
const html = hasEach || isHtml(this.page[name]);
|
|
134
|
+
let opening = parsed.source;
|
|
135
|
+
if (!hasState) opening = opening.replace(/\/?>(?=$)/, ` data-rw-state="${name}"$&`);
|
|
136
|
+
if (html && !hasHtml) opening = opening.replace(/\/?>(?=$)/, ' data-rw-html$&');
|
|
137
|
+
this.output += opening + value + closing.source;
|
|
138
|
+
this.position = closing.end;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
startTag() {
|
|
142
|
+
const start = this.position;
|
|
143
|
+
let position = start + 1;
|
|
144
|
+
if (!/[A-Za-z]/.test(this.source[position] || '')) return null;
|
|
145
|
+
while (/[A-Za-z0-9:_-]/.test(this.source[position] || '')) position += 1;
|
|
146
|
+
const name = this.source.slice(start + 1, position).toLowerCase();
|
|
147
|
+
const end = this.tagEnd(position);
|
|
148
|
+
if (end < 0) return null;
|
|
149
|
+
const source = this.source.slice(start, end + 1);
|
|
150
|
+
return { start, end: end + 1, name, source, attributes: attributes(source, position - start) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
tagEnd(position) {
|
|
154
|
+
return tagEnd(this.source, position);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
emptyClosing(name) {
|
|
158
|
+
const contentStart = this.position;
|
|
159
|
+
while (this.position < this.source.length && isHtmlSpace(this.source[this.position])) this.position += 1;
|
|
160
|
+
const close = new RegExp(`^<\\/${name}[ \\t\\n\\f\\r]*>`, 'i').exec(this.source.slice(this.position));
|
|
161
|
+
if (!close) throw new Error(`Live HTML binding on <${name}> requires an empty container.`);
|
|
162
|
+
return {
|
|
163
|
+
source: this.source.slice(contentStart, this.position) + close[0],
|
|
164
|
+
end: this.position + close[0].length,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
text() {
|
|
169
|
+
const end = this.source.indexOf('<', this.position);
|
|
170
|
+
const next = end < 0 ? this.source.length : end;
|
|
171
|
+
const text = this.source.slice(this.position, next).replace(BINDING, (_match, name) => {
|
|
172
|
+
if (!(name in this.page)) throw new Error(`Unknown page binding "${name}".`);
|
|
173
|
+
const value = this.page[name];
|
|
174
|
+
if (!this.reactive) return renderValue(value);
|
|
175
|
+
return `<span data-rw-state="${name}"${isHtml(value) ? ' data-rw-html' : ''}>${renderValue(value)}</span>`;
|
|
176
|
+
});
|
|
177
|
+
this.output += text;
|
|
178
|
+
this.position = next;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
TemplateRenderer.closingTag = closingTag;
|
|
183
|
+
TemplateRenderer.openingTag = openingTag;
|
|
184
|
+
TemplateRenderer.component = (source, id) => {
|
|
185
|
+
let output = '';
|
|
186
|
+
let position = 0;
|
|
187
|
+
while (position < source.length) {
|
|
188
|
+
const start = source.indexOf('<', position);
|
|
189
|
+
if (start < 0) return output + source.slice(position);
|
|
190
|
+
output += source.slice(position, start);
|
|
191
|
+
if (source.startsWith('<!--', start)) {
|
|
192
|
+
const commentEnd = source.indexOf('-->', start + 4);
|
|
193
|
+
const next = commentEnd < 0 ? source.length : commentEnd + 3;
|
|
194
|
+
output += source.slice(start, next);
|
|
195
|
+
position = next;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (isNonStartMarkup(source, start)) {
|
|
199
|
+
const end = tagEnd(source, start + 1);
|
|
200
|
+
if (end < 0) return output + source.slice(start);
|
|
201
|
+
output += source.slice(start, end + 1);
|
|
202
|
+
position = end + 1;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const opening = /^<([A-Za-z][\w:-]*)/.exec(source.slice(start));
|
|
206
|
+
if (!opening) {
|
|
207
|
+
output += '<';
|
|
208
|
+
position = start + 1;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const end = tagEnd(source, start + opening[0].length);
|
|
212
|
+
if (end < 0) return output + source.slice(start);
|
|
213
|
+
const tag = source.slice(start, end + 1);
|
|
214
|
+
const found = attributes(tag, opening[0].length, COMPONENT_DIRECTIVES);
|
|
215
|
+
const scoped = [...COMPONENT_DIRECTIVES].some(name => name !== 'data-rw-component' && found.has(name));
|
|
216
|
+
output += scoped && !found.has('data-rw-component')
|
|
217
|
+
? tag.replace(/\/?>(?=$)/, ` data-rw-component="${escapeHtml(id)}"$&`)
|
|
218
|
+
: tag;
|
|
219
|
+
position = end + 1;
|
|
220
|
+
const name = opening[1].toLowerCase();
|
|
221
|
+
if (RAW_TEXT.has(name)) {
|
|
222
|
+
const close = name === 'plaintext' ? null : rawClosingTag(source, name, position);
|
|
223
|
+
const next = close ? close.end : source.length;
|
|
224
|
+
output += source.slice(position, next);
|
|
225
|
+
position = next;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return output;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
module.exports = TemplateRenderer;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
function browserRuntime(clientPath) {
|
|
2
|
+
return `import { RedwebClient } from ${JSON.stringify(clientPath)};
|
|
3
|
+
|
|
4
|
+
const configNode = document.getElementById('__redweb_page');
|
|
5
|
+
const config = JSON.parse(configNode.textContent);
|
|
6
|
+
const client = new RedwebClient(config.socketPath + '?pageId=' + encodeURIComponent(config.pageId), {
|
|
7
|
+
baseUrl: window.location.href,
|
|
8
|
+
version: config.version,
|
|
9
|
+
reconnect: { enabled: true, maxAttempts: 8 },
|
|
10
|
+
maxQueueSize: 32
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const emit = (type, detail) => document.dispatchEvent(new CustomEvent(type, { detail }));
|
|
14
|
+
let stateTargets = new Map();
|
|
15
|
+
const componentOf = node => node.closest('[data-rw-component]')?.getAttribute('data-rw-component') || null;
|
|
16
|
+
const stateKey = (component, name) => (component || '') + '\\0' + name;
|
|
17
|
+
const indexState = () => {
|
|
18
|
+
stateTargets = new Map();
|
|
19
|
+
document.querySelectorAll('[data-rw-state]').forEach(node => {
|
|
20
|
+
const name = node.getAttribute('data-rw-state');
|
|
21
|
+
const key = stateKey(componentOf(node), name);
|
|
22
|
+
const targets = stateTargets.get(key) || [];
|
|
23
|
+
targets.push(node);
|
|
24
|
+
stateTargets.set(key, targets);
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
const named = (attribute, name, component) => attribute === 'data-rw-state'
|
|
28
|
+
? (stateTargets.get(stateKey(component, name)) || [])
|
|
29
|
+
: [...document.querySelectorAll('[' + attribute + ']')].filter(node =>
|
|
30
|
+
node.getAttribute(attribute) === name && componentOf(node) === component);
|
|
31
|
+
indexState();
|
|
32
|
+
|
|
33
|
+
client.on('redweb:state', message => {
|
|
34
|
+
const update = message.payload;
|
|
35
|
+
const component = update.component || null;
|
|
36
|
+
named('data-rw-state', update.name, component).forEach(node => {
|
|
37
|
+
if (update.html) {
|
|
38
|
+
node.innerHTML = update.value;
|
|
39
|
+
indexState();
|
|
40
|
+
}
|
|
41
|
+
else node.textContent = update.value;
|
|
42
|
+
});
|
|
43
|
+
named('rw-bind', update.name, component).forEach(node => {
|
|
44
|
+
if (node.type === 'checkbox') node.checked = update.value === true || update.value === 'true';
|
|
45
|
+
else if (node.value !== update.value) node.value = update.value;
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const report = error => emit('redweb:error', error);
|
|
50
|
+
const send = payload => {
|
|
51
|
+
try { client.send('redweb:html', payload); }
|
|
52
|
+
catch (error) { report(error); }
|
|
53
|
+
};
|
|
54
|
+
const formValues = form => {
|
|
55
|
+
const values = {};
|
|
56
|
+
for (const [name, value] of new FormData(form)) {
|
|
57
|
+
if (!(name in values)) values[name] = value;
|
|
58
|
+
else values[name] = Array.isArray(values[name]) ? [...values[name], value] : [values[name], value];
|
|
59
|
+
}
|
|
60
|
+
return values;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
document.addEventListener('click', event => {
|
|
64
|
+
const target = event.target.closest('[rw-click]');
|
|
65
|
+
if (!target) return;
|
|
66
|
+
event.preventDefault();
|
|
67
|
+
client.request('redweb:html', {
|
|
68
|
+
kind: 'action', name: target.getAttribute('rw-click'), component: componentOf(target), args: []
|
|
69
|
+
}).catch(report);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
document.addEventListener('submit', event => {
|
|
73
|
+
const form = event.target.closest('form[rw-submit]');
|
|
74
|
+
if (!form) return;
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
client.request('redweb:html', {
|
|
77
|
+
kind: 'action', name: form.getAttribute('rw-submit'), component: componentOf(form), args: [formValues(form)]
|
|
78
|
+
}).then(() => form.reset()).catch(report);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
document.addEventListener('input', event => {
|
|
82
|
+
const target = event.target.closest('[rw-bind]');
|
|
83
|
+
if (target) send({
|
|
84
|
+
kind: 'state',
|
|
85
|
+
name: target.getAttribute('rw-bind'),
|
|
86
|
+
component: componentOf(target),
|
|
87
|
+
value: target.type === 'checkbox' ? target.checked : target.value
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
client.onError(report);
|
|
92
|
+
client.onStateChange(state => emit('redweb:connection', state));
|
|
93
|
+
client.connect().catch(report);
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = browserRuntime;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const HtmlRenderer = require('./HtmlRenderer');
|
|
2
|
+
const LiveHtmlServer = require('./LiveHtmlServer');
|
|
3
|
+
const LivePage = require('./LivePage');
|
|
4
|
+
const { attribute, codeBlock, each, html, safeUrl: url } = require('./Html');
|
|
5
|
+
const { action, component, page, state, view } = require('./metadata');
|
|
6
|
+
const { start } = require('./start');
|
|
7
|
+
const { exportStatic } = require('./StaticExporter');
|
|
8
|
+
const { defineSite } = require('./StaticSite');
|
|
9
|
+
|
|
10
|
+
module.exports = { action, attribute, codeBlock, component, defineSite, each, exportStatic, html, HtmlRenderer, LiveHtmlServer, LivePage, page, start, state, url, view };
|