@wabot-dev/framework 0.9.80 → 2.0.0-beta.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/bin/skills.mjs +151 -0
- package/bin/wabot-skills.mjs +120 -0
- package/dist/build/build.js +1031 -8
- package/dist/src/addon/chat-bot/in-memory/InMemoryChatMemory.js +1 -3
- package/dist/src/addon/chat-bot/xai/XAIChatAdapter.js +180 -0
- package/dist/src/addon/chat-controller/cmd/cmdChannelSocketPath.js +1 -5
- package/dist/src/addon/chat-controller/hubspot/@hubspot.js +28 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotChannel.js +81 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotChannelConfig.js +20 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotReceiver.js +42 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotSender.js +118 -0
- package/dist/src/addon/chat-controller/hubspot/HubSpotWebhookController.js +122 -0
- package/dist/src/addon/chat-controller/hubspot/downloadHubSpotAttachments.js +45 -0
- package/dist/src/addon/chat-controller/hubspot/hubspotChannelName.js +3 -0
- package/dist/src/addon/chat-controller/hubspot/verifyHubSpotSignatureV3.js +28 -0
- package/dist/src/addon/chat-controller/{telegram/markdownToTelegramHtml.js → markdown/markdownToChatHtml.js} +5 -8
- package/dist/src/addon/chat-controller/slack/@slack.js +22 -0
- package/dist/src/addon/chat-controller/slack/SlackChannel.js +187 -0
- package/dist/src/addon/chat-controller/slack/SlackChannelConfig.js +12 -0
- package/dist/src/addon/chat-controller/slack/markdownToSlackMrkdwn.js +38 -0
- package/dist/src/addon/chat-controller/slack/slackChannelName.js +3 -0
- package/dist/src/addon/chat-controller/telegram/TelegramChannel.js +2 -2
- package/dist/src/addon/ui/preact/PreactRenderer.js +86 -0
- package/dist/src/addon/ui/preact/outlet.js +22 -0
- package/dist/src/addon/ui/preact/preactClientRuntime.js +67 -0
- package/dist/src/core/repository/CrudRepository.js +7 -7
- package/dist/src/feature/async/computeDedupKey.js +1 -1
- package/dist/src/feature/pg/@pgExtension.js +2 -4
- package/dist/src/feature/project-runner/ProjectRunner.js +62 -10
- package/dist/src/feature/project-runner/scanner.js +1 -1
- package/dist/src/feature/repository/@memExtension.js +1 -2
- package/dist/src/feature/ui-controller/actions.js +35 -0
- package/dist/src/feature/ui-controller/bundler/UiBundler.js +191 -0
- package/dist/src/feature/ui-controller/bundler/devMiddleware.js +41 -0
- package/dist/src/feature/ui-controller/bundler/index.js +4 -0
- package/dist/src/feature/ui-controller/bundler/manifest.js +34 -0
- package/dist/src/feature/ui-controller/bundler/navRuntime.js +236 -0
- package/dist/src/feature/ui-controller/bundler/pageAssets.js +30 -0
- package/dist/src/feature/ui-controller/document/escape.js +17 -0
- package/dist/src/feature/ui-controller/document/helpers.js +13 -0
- package/dist/src/feature/ui-controller/document/renderDocument.js +43 -0
- package/dist/src/feature/ui-controller/island/IslandRegistry.js +68 -0
- package/dist/src/feature/ui-controller/island/island.js +40 -0
- package/dist/src/feature/ui-controller/island/serialize.js +35 -0
- package/dist/src/feature/ui-controller/metadata/@action.js +18 -0
- package/dist/src/feature/ui-controller/metadata/@uiController.js +19 -0
- package/dist/src/feature/ui-controller/metadata/@uiMiddleware.js +20 -0
- package/dist/src/feature/ui-controller/metadata/@view.js +18 -0
- package/dist/src/feature/ui-controller/metadata/UiControllerMetadataStore.js +107 -0
- package/dist/src/feature/ui-controller/renderer/UiRendererRegistry.js +42 -0
- package/dist/src/feature/ui-controller/runUiControllers.js +285 -0
- package/dist/src/index.d.ts +632 -3
- package/dist/src/index.js +30 -1
- package/dist/src/testing/index.d.ts +43 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/uiHarness.js +102 -0
- package/dist/src/ui/client.js +6 -0
- package/dist/src/ui/index.d.ts +427 -0
- package/dist/src/ui/index.js +29 -0
- package/dist/src/ui/jsx-dev-runtime.d.ts +1 -0
- package/dist/src/ui/jsx-dev-runtime.js +1 -0
- package/dist/src/ui/jsx-runtime.d.ts +1 -0
- package/dist/src/ui/jsx-runtime.js +1 -0
- package/package.json +33 -13
- package/skills/wabot-async/SKILL.md +143 -0
- package/skills/wabot-auth/SKILL.md +153 -0
- package/skills/wabot-chat/SKILL.md +140 -0
- package/skills/wabot-di-config/SKILL.md +117 -0
- package/skills/wabot-framework/SKILL.md +81 -0
- package/skills/wabot-framework/references/quickstart.md +85 -0
- package/skills/wabot-mindset/SKILL.md +159 -0
- package/skills/wabot-ops/SKILL.md +151 -0
- package/skills/wabot-persistence/SKILL.md +159 -0
- package/skills/wabot-rest-socket/SKILL.md +167 -0
- package/skills/wabot-testing/SKILL.md +214 -0
- package/skills/wabot-ui/SKILL.md +201 -0
- package/skills/wabot-validation/SKILL.md +108 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Generic client runtime for "boosted" navigation between the views of an
|
|
2
|
+
// `app: true` UI controller. Renderer-agnostic: the island hydrate/unmount
|
|
3
|
+
// hooks are injected by the bundled nav entry (which imports them from the
|
|
4
|
+
// renderer's runtime module, so they share the island registry singleton).
|
|
5
|
+
//
|
|
6
|
+
// Flow per in-scope <a> click / popstate:
|
|
7
|
+
// 1. If the URL is cached -> apply it instantly (front-only navigation),
|
|
8
|
+
// then revalidate in the background unless still "fresh" (swr.maxAge).
|
|
9
|
+
// 2. Otherwise fetch the fragment, apply it, and cache it.
|
|
10
|
+
// Revalidation sends If-None-Match; a 304 means nothing changed (no re-render),
|
|
11
|
+
// a 200 swaps only the changed fragment. Any failure falls back to a hard load,
|
|
12
|
+
// so navigation is always a progressive enhancement over normal links.
|
|
13
|
+
const NAV_HEADER = 'X-Wabot-Nav';
|
|
14
|
+
const NOT_MODIFIED = Symbol('not-modified');
|
|
15
|
+
const normalizePath = (pathname) => '/' + pathname.replace(/^\/+|\/+$/g, '');
|
|
16
|
+
/** Compile an Express-style route pattern (":param" segments, "*") into a matcher. */
|
|
17
|
+
function compileRoute(pattern) {
|
|
18
|
+
const source = normalizePath(pattern)
|
|
19
|
+
.split('/')
|
|
20
|
+
.map((seg) => {
|
|
21
|
+
if (seg.startsWith(':'))
|
|
22
|
+
return '[^/]+';
|
|
23
|
+
if (seg === '*')
|
|
24
|
+
return '.*';
|
|
25
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
})
|
|
27
|
+
.join('/');
|
|
28
|
+
return new RegExp('^' + source + '$');
|
|
29
|
+
}
|
|
30
|
+
/** True when the pathname matches any of the controller's route patterns. */
|
|
31
|
+
function matchesRoute(pathname, patterns) {
|
|
32
|
+
const p = normalizePath(pathname);
|
|
33
|
+
return patterns.some((pattern) => compileRoute(pattern).test(p));
|
|
34
|
+
}
|
|
35
|
+
function startNavigation(hooks) {
|
|
36
|
+
if (typeof window === 'undefined' || typeof document === 'undefined')
|
|
37
|
+
return;
|
|
38
|
+
// Soft-navigate only the controller's declared view routes (matched as
|
|
39
|
+
// patterns so ":param" routes work), so an app mounted at "/" doesn't
|
|
40
|
+
// intercept links to the rest of the origin.
|
|
41
|
+
const routes = window.__wabotApp?.routes;
|
|
42
|
+
if (!routes || routes.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
const matchers = routes.map(compileRoute);
|
|
45
|
+
const cache = new Map();
|
|
46
|
+
const keyOf = (url) => url.pathname + url.search;
|
|
47
|
+
const currentKey = () => location.pathname + location.search;
|
|
48
|
+
const inScope = (url) => url.origin === location.origin && matchers.some((m) => m.test(normalizePath(url.pathname)));
|
|
49
|
+
history.replaceState({ __wabot: true }, '', location.href);
|
|
50
|
+
document.addEventListener('click', onClick);
|
|
51
|
+
window.addEventListener('popstate', () => navigate(location.href, false));
|
|
52
|
+
// Public hooks for manual revalidation / optimistic updates from island code.
|
|
53
|
+
window.__wabotNav = {
|
|
54
|
+
revalidate(url) {
|
|
55
|
+
const key = url ? keyOf(new URL(url, location.href)) : currentKey();
|
|
56
|
+
const entry = cache.get(key);
|
|
57
|
+
if (entry)
|
|
58
|
+
revalidate(key, location.origin + key, entry);
|
|
59
|
+
else if (key === currentKey())
|
|
60
|
+
navigate(location.href, false);
|
|
61
|
+
},
|
|
62
|
+
mutate(url, fragment) {
|
|
63
|
+
const key = keyOf(new URL(url, location.href));
|
|
64
|
+
if (!fragment) {
|
|
65
|
+
cache.delete(key);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const prev = cache.get(key);
|
|
69
|
+
const next = {
|
|
70
|
+
html: '',
|
|
71
|
+
...prev,
|
|
72
|
+
...fragment,
|
|
73
|
+
etag: prev?.etag ?? '',
|
|
74
|
+
ts: Date.now(),
|
|
75
|
+
};
|
|
76
|
+
cache.set(key, next);
|
|
77
|
+
if (key === currentKey())
|
|
78
|
+
apply(next);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
function onClick(e) {
|
|
82
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
83
|
+
return;
|
|
84
|
+
const anchor = e.target?.closest?.('a');
|
|
85
|
+
if (!anchor)
|
|
86
|
+
return;
|
|
87
|
+
if (anchor.target === '_blank' ||
|
|
88
|
+
anchor.hasAttribute('download') ||
|
|
89
|
+
anchor.getAttribute('rel') === 'external' ||
|
|
90
|
+
anchor.dataset.wabotReload != null)
|
|
91
|
+
return;
|
|
92
|
+
const href = anchor.getAttribute('href');
|
|
93
|
+
if (!href || href.startsWith('#'))
|
|
94
|
+
return;
|
|
95
|
+
const url = new URL(href, location.href);
|
|
96
|
+
if (!inScope(url))
|
|
97
|
+
return;
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
if (keyOf(url) === currentKey())
|
|
100
|
+
return;
|
|
101
|
+
navigate(url.href, true);
|
|
102
|
+
}
|
|
103
|
+
async function navigate(href, push) {
|
|
104
|
+
const url = new URL(href, location.href);
|
|
105
|
+
// popstate can land on a URL outside the app (e.g. the entry page); hard-load it.
|
|
106
|
+
if (!inScope(url))
|
|
107
|
+
return hardNav(href);
|
|
108
|
+
const key = keyOf(url);
|
|
109
|
+
const cached = cache.get(key);
|
|
110
|
+
if (cached) {
|
|
111
|
+
apply(cached, { scroll: push });
|
|
112
|
+
if (push)
|
|
113
|
+
history.pushState({ __wabot: true }, '', href);
|
|
114
|
+
const fresh = (cached.maxAge ?? 0) > 0 && Date.now() - cached.ts < (cached.maxAge ?? 0) * 1000;
|
|
115
|
+
if (!fresh)
|
|
116
|
+
revalidate(key, href, cached);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const result = await fetchFragment(href, null);
|
|
121
|
+
if (result === NOT_MODIFIED || !result)
|
|
122
|
+
return hardNav(href);
|
|
123
|
+
cache.set(key, result);
|
|
124
|
+
apply(result, { scroll: push });
|
|
125
|
+
if (push)
|
|
126
|
+
history.pushState({ __wabot: true }, '', href);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
hardNav(href);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function revalidate(key, href, cached) {
|
|
133
|
+
try {
|
|
134
|
+
const result = await fetchFragment(href, cached.etag);
|
|
135
|
+
if (result === NOT_MODIFIED) {
|
|
136
|
+
cached.ts = Date.now();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!result)
|
|
140
|
+
return;
|
|
141
|
+
cache.set(key, result);
|
|
142
|
+
if (currentKey() === key)
|
|
143
|
+
apply(result);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* keep showing the stale entry on revalidation errors */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function fetchFragment(href, etag) {
|
|
150
|
+
const headers = { [NAV_HEADER]: '1', Accept: 'application/json' };
|
|
151
|
+
if (etag)
|
|
152
|
+
headers['If-None-Match'] = etag;
|
|
153
|
+
const res = await fetch(href, { headers, credentials: 'same-origin' });
|
|
154
|
+
if (res.status === 304)
|
|
155
|
+
return NOT_MODIFIED;
|
|
156
|
+
if (!res.ok)
|
|
157
|
+
return null;
|
|
158
|
+
const ct = res.headers.get('Content-Type') ?? '';
|
|
159
|
+
if (!ct.includes('application/json'))
|
|
160
|
+
return null;
|
|
161
|
+
const data = (await res.json());
|
|
162
|
+
return { ...data, etag: res.headers.get('ETag') ?? '', ts: Date.now() };
|
|
163
|
+
}
|
|
164
|
+
async function apply(entry, opts = {}) {
|
|
165
|
+
ensureStyles(entry.styles ?? []);
|
|
166
|
+
// With a layout, only the outlet swaps so the shell (and its islands) keep
|
|
167
|
+
// their state; without one, the fragment is the whole body.
|
|
168
|
+
const target = document.querySelector('wabot-outlet') ?? document.body;
|
|
169
|
+
swapContent(target, entry.html);
|
|
170
|
+
hooks.unmountRemoved();
|
|
171
|
+
if (entry.title != null)
|
|
172
|
+
document.title = entry.title;
|
|
173
|
+
applyMeta(entry.meta ?? null);
|
|
174
|
+
await Promise.all((entry.scripts ?? []).map((src) => import(src).catch(() => { })));
|
|
175
|
+
hooks.hydrateAll();
|
|
176
|
+
if (opts.scroll)
|
|
177
|
+
window.scrollTo(0, 0);
|
|
178
|
+
}
|
|
179
|
+
// Replace the target's content, but preserve live island hosts (their mounted
|
|
180
|
+
// Preact tree) across the swap when their serialized props are unchanged.
|
|
181
|
+
// Changed props fall through to a fresh SSR host so revalidated data renders.
|
|
182
|
+
function swapContent(target, html) {
|
|
183
|
+
const live = new Map();
|
|
184
|
+
target.querySelectorAll('wabot-island[data-island]').forEach((el) => {
|
|
185
|
+
const id = el.getAttribute('data-island');
|
|
186
|
+
if (id && !live.has(id))
|
|
187
|
+
live.set(id, el);
|
|
188
|
+
});
|
|
189
|
+
target.innerHTML = html;
|
|
190
|
+
if (live.size === 0)
|
|
191
|
+
return;
|
|
192
|
+
target.querySelectorAll('wabot-island[data-island]').forEach((placeholder) => {
|
|
193
|
+
const id = placeholder.getAttribute('data-island');
|
|
194
|
+
if (!id)
|
|
195
|
+
return;
|
|
196
|
+
const kept = live.get(id);
|
|
197
|
+
if (!kept)
|
|
198
|
+
return;
|
|
199
|
+
if (kept.getAttribute('data-props') === placeholder.getAttribute('data-props')) {
|
|
200
|
+
placeholder.replaceWith(kept);
|
|
201
|
+
}
|
|
202
|
+
live.delete(id);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function ensureStyles(hrefs) {
|
|
206
|
+
for (const href of hrefs) {
|
|
207
|
+
if (document.querySelector(`link[rel="stylesheet"][href="${cssEscape(href)}"]`))
|
|
208
|
+
continue;
|
|
209
|
+
const link = document.createElement('link');
|
|
210
|
+
link.rel = 'stylesheet';
|
|
211
|
+
link.href = href;
|
|
212
|
+
document.head.appendChild(link);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function applyMeta(meta) {
|
|
216
|
+
if (!meta)
|
|
217
|
+
return;
|
|
218
|
+
for (const [name, content] of Object.entries(meta)) {
|
|
219
|
+
let tag = document.head.querySelector(`meta[name="${cssEscape(name)}"]`);
|
|
220
|
+
if (!tag) {
|
|
221
|
+
tag = document.createElement('meta');
|
|
222
|
+
tag.setAttribute('name', name);
|
|
223
|
+
document.head.appendChild(tag);
|
|
224
|
+
}
|
|
225
|
+
tag.setAttribute('content', content);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function hardNav(href) {
|
|
229
|
+
location.href = href;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function cssEscape(value) {
|
|
233
|
+
return value.replace(/["\\]/g, '\\$&');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export { compileRoute, matchesRoute, normalizePath, startNavigation };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Map the islands a page rendered to the script/style tags it needs. */
|
|
2
|
+
function pageAssetsFromManifest(manifest, islands, options = {}) {
|
|
3
|
+
const scripts = [];
|
|
4
|
+
const styles = [];
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
for (const island of islands) {
|
|
7
|
+
if (seen.has(island.id))
|
|
8
|
+
continue;
|
|
9
|
+
seen.add(island.id);
|
|
10
|
+
const asset = manifest.islands[island.id];
|
|
11
|
+
if (!asset)
|
|
12
|
+
continue;
|
|
13
|
+
scripts.push(asset.js);
|
|
14
|
+
for (const css of asset.css)
|
|
15
|
+
if (!styles.includes(css))
|
|
16
|
+
styles.push(css);
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
scripts,
|
|
20
|
+
styles,
|
|
21
|
+
navScript: manifest.nav,
|
|
22
|
+
bodyEndHtml: options.liveReloadPath ? liveReloadSnippet(options.liveReloadPath) : undefined,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function liveReloadSnippet(path) {
|
|
26
|
+
return (`<script>(function(){try{var s=new EventSource(${JSON.stringify(path)});` +
|
|
27
|
+
`s.onmessage=function(e){if(e.data==='reload')location.reload()};}catch(_){}})()</script>`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { liveReloadSnippet, pageAssetsFromManifest };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const HTML_ENTITIES = {
|
|
2
|
+
'&': '&',
|
|
3
|
+
'<': '<',
|
|
4
|
+
'>': '>',
|
|
5
|
+
'"': '"',
|
|
6
|
+
"'": ''',
|
|
7
|
+
};
|
|
8
|
+
/** Escape text for safe interpolation into HTML element content. */
|
|
9
|
+
function escapeHtml(value) {
|
|
10
|
+
return value.replace(/[&<>"']/g, (c) => HTML_ENTITIES[c]);
|
|
11
|
+
}
|
|
12
|
+
/** Escape a value for safe interpolation into a double-quoted HTML attribute. */
|
|
13
|
+
function escapeAttr(value) {
|
|
14
|
+
return escapeHtml(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export { escapeAttr, escapeHtml };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const REDIRECT_MARKER = Symbol.for('wabot.ui.redirect');
|
|
2
|
+
/**
|
|
3
|
+
* Return this from a @view or @action to send an HTTP redirect instead of
|
|
4
|
+
* rendering. Used for the post/redirect/get pattern after form actions.
|
|
5
|
+
*/
|
|
6
|
+
function redirect(location, status = 302) {
|
|
7
|
+
return { [REDIRECT_MARKER]: true, location, status };
|
|
8
|
+
}
|
|
9
|
+
function isRedirect(value) {
|
|
10
|
+
return typeof value === 'object' && value != null && value[REDIRECT_MARKER] === true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { REDIRECT_MARKER, isRedirect, redirect };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { escapeHtml, escapeAttr } from './escape.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render the default HTML document shell around a view's body. Kept
|
|
5
|
+
* renderer-agnostic (plain string templating) so it works with any UiRenderer.
|
|
6
|
+
*/
|
|
7
|
+
function renderDocument(options) {
|
|
8
|
+
const { bodyHtml, title, meta = {}, styles = [], links = [], scripts = [], headHtml = '', bodyEndHtml = '', lang = 'en', } = options;
|
|
9
|
+
const titleTag = title ? `<title>${escapeHtml(title)}</title>` : '';
|
|
10
|
+
const metaTags = Object.entries(meta)
|
|
11
|
+
.map(([name, content]) => `<meta name="${escapeAttr(name)}" content="${escapeAttr(content)}">`)
|
|
12
|
+
.join('');
|
|
13
|
+
// Preload/preconnect first so the browser starts fetching those before CSS.
|
|
14
|
+
const linkTags = links.map((attrs) => `<link ${renderAttrs(attrs)}>`).join('');
|
|
15
|
+
const styleTags = styles
|
|
16
|
+
.map((href) => `<link rel="stylesheet" href="${escapeAttr(href)}">`)
|
|
17
|
+
.join('');
|
|
18
|
+
const scriptTags = scripts
|
|
19
|
+
.map((src) => `<script type="module" src="${escapeAttr(src)}"></script>`)
|
|
20
|
+
.join('');
|
|
21
|
+
return (`<!doctype html><html lang="${escapeAttr(lang)}"><head>` +
|
|
22
|
+
`<meta charset="utf-8">` +
|
|
23
|
+
`<meta name="viewport" content="width=device-width, initial-scale=1">` +
|
|
24
|
+
titleTag +
|
|
25
|
+
metaTags +
|
|
26
|
+
linkTags +
|
|
27
|
+
styleTags +
|
|
28
|
+
headHtml +
|
|
29
|
+
`</head><body>` +
|
|
30
|
+
bodyHtml +
|
|
31
|
+
scriptTags +
|
|
32
|
+
bodyEndHtml +
|
|
33
|
+
`</body></html>`);
|
|
34
|
+
}
|
|
35
|
+
/** Render an attribute map: string => key="value", `true` => bare key, false/null skipped. */
|
|
36
|
+
function renderAttrs(attrs) {
|
|
37
|
+
return Object.entries(attrs)
|
|
38
|
+
.filter(([, value]) => value !== false && value != null)
|
|
39
|
+
.map(([key, value]) => value === true ? escapeAttr(key) : `${escapeAttr(key)}="${escapeAttr(String(value))}"`)
|
|
40
|
+
.join(' ');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { renderDocument };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { __decorate } from 'tslib';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path__default from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { singleton } from '../../../core/injection/index.js';
|
|
6
|
+
import { Logger } from '../../../core/logger/Logger.js';
|
|
7
|
+
import { isIsland, setIslandId } from './island.js';
|
|
8
|
+
|
|
9
|
+
/** Files matching this are treated as islands (default export wrapped with island()). */
|
|
10
|
+
const ISLAND_FILE_PATTERN = /\.island\.(tsx|jsx)$/;
|
|
11
|
+
/** Deterministic, readable, collision-resistant id from a project-relative path. */
|
|
12
|
+
function toIslandId(relPath) {
|
|
13
|
+
const base = path__default
|
|
14
|
+
.basename(relPath)
|
|
15
|
+
.replace(ISLAND_FILE_PATTERN, '')
|
|
16
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_') || 'island';
|
|
17
|
+
const hash = createHash('sha1').update(relPath).digest('hex').slice(0, 8);
|
|
18
|
+
return `${base}-${hash}`;
|
|
19
|
+
}
|
|
20
|
+
function isIslandFile(file) {
|
|
21
|
+
return ISLAND_FILE_PATTERN.test(file);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Discovers `*.island.tsx` modules, assigns each a stable id, and stamps that
|
|
25
|
+
* id onto the imported island component (ESM module singletons mean the view
|
|
26
|
+
* renders the same instance, so the renderer can read the id during SSR).
|
|
27
|
+
*/
|
|
28
|
+
let IslandRegistry = class IslandRegistry {
|
|
29
|
+
islands = new Map();
|
|
30
|
+
logger = new Logger('wabot:ui:islands');
|
|
31
|
+
async discover(files, cwd = process.cwd()) {
|
|
32
|
+
for (const absPath of files.filter(isIslandFile)) {
|
|
33
|
+
const relPath = path__default.relative(cwd, absPath).split(path__default.sep).join('/');
|
|
34
|
+
const id = toIslandId(relPath);
|
|
35
|
+
try {
|
|
36
|
+
const mod = await import(pathToFileURL(absPath).href);
|
|
37
|
+
const component = mod.default;
|
|
38
|
+
if (!isIsland(component)) {
|
|
39
|
+
this.logger.warn(`${relPath}: default export is not an island(); skipping`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
setIslandId(component, id);
|
|
43
|
+
this.islands.set(id, { id, importPath: absPath, relPath });
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
this.logger.error(`Failed to load island ${relPath}`, err);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return this.list();
|
|
50
|
+
}
|
|
51
|
+
register(island) {
|
|
52
|
+
this.islands.set(island.id, island);
|
|
53
|
+
}
|
|
54
|
+
list() {
|
|
55
|
+
return [...this.islands.values()];
|
|
56
|
+
}
|
|
57
|
+
get(id) {
|
|
58
|
+
return this.islands.get(id);
|
|
59
|
+
}
|
|
60
|
+
get size() {
|
|
61
|
+
return this.islands.size;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
IslandRegistry = __decorate([
|
|
65
|
+
singleton()
|
|
66
|
+
], IslandRegistry);
|
|
67
|
+
|
|
68
|
+
export { ISLAND_FILE_PATTERN, IslandRegistry, isIslandFile, toIslandId };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker attached to components wrapped with {@link island}. The UI renderer
|
|
3
|
+
* uses it to decide which components must hydrate on the client; the bundler
|
|
4
|
+
* uses {@link IslandMeta.id} (assigned from the `*.island.tsx` file location) to
|
|
5
|
+
* emit a per-island client bundle.
|
|
6
|
+
*/
|
|
7
|
+
const ISLAND_MARKER = Symbol.for('wabot.ui.island');
|
|
8
|
+
/**
|
|
9
|
+
* Mark a component as an interactive client "island". The component still
|
|
10
|
+
* renders on the server, but only islands ship JavaScript and hydrate in the
|
|
11
|
+
* browser. Islands must be the export of a `*.island.tsx` file so the bundler
|
|
12
|
+
* can give them a stable id.
|
|
13
|
+
*
|
|
14
|
+
* // Counter.island.tsx
|
|
15
|
+
* function Counter() { ... }
|
|
16
|
+
* export default island(Counter)
|
|
17
|
+
*/
|
|
18
|
+
function island(component, name) {
|
|
19
|
+
const resolvedName = name ?? component.name ?? 'Island';
|
|
20
|
+
const wrapped = ((props) => component(props));
|
|
21
|
+
wrapped[ISLAND_MARKER] = { component, name: resolvedName, id: undefined };
|
|
22
|
+
wrapped.displayName = resolvedName;
|
|
23
|
+
return wrapped;
|
|
24
|
+
}
|
|
25
|
+
function getIslandMeta(component) {
|
|
26
|
+
return typeof component === 'function'
|
|
27
|
+
? component[ISLAND_MARKER]
|
|
28
|
+
: undefined;
|
|
29
|
+
}
|
|
30
|
+
function isIsland(component) {
|
|
31
|
+
return getIslandMeta(component) != null;
|
|
32
|
+
}
|
|
33
|
+
/** Assign the stable bundle id to an island, done during island discovery. */
|
|
34
|
+
function setIslandId(component, id) {
|
|
35
|
+
const meta = getIslandMeta(component);
|
|
36
|
+
if (meta)
|
|
37
|
+
meta.id = id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { ISLAND_MARKER, getIslandMeta, isIsland, island, setIslandId };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const SKIP_PROPS = new Set(['children', 'ref', 'key']);
|
|
2
|
+
/**
|
|
3
|
+
* Serialize the props an island was rendered with so the client can hydrate it
|
|
4
|
+
* with the same data. Drops `children`/`ref`/`key` and any function-valued
|
|
5
|
+
* props (event handlers belong inside the island, not in its serialized props).
|
|
6
|
+
*/
|
|
7
|
+
function serializeProps(props) {
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const key in props) {
|
|
10
|
+
if (SKIP_PROPS.has(key))
|
|
11
|
+
continue;
|
|
12
|
+
const value = props[key];
|
|
13
|
+
if (typeof value === 'function')
|
|
14
|
+
continue;
|
|
15
|
+
out[key] = value;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return JSON.stringify(out);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return '{}';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function deserializeProps(raw) {
|
|
25
|
+
if (!raw)
|
|
26
|
+
return {};
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { deserializeProps, serializeProps };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { container } from '../../../core/injection/index.js';
|
|
2
|
+
import { UiControllerMetadataStore } from './UiControllerMetadataStore.js';
|
|
3
|
+
|
|
4
|
+
function action(config) {
|
|
5
|
+
return function (target, propertyKey) {
|
|
6
|
+
const functionName = propertyKey.toString();
|
|
7
|
+
const paramsTypes = Reflect.getMetadata('design:paramtypes', target, functionName);
|
|
8
|
+
const store = container.resolve(UiControllerMetadataStore);
|
|
9
|
+
store.saveActionMetadata({
|
|
10
|
+
controllerConstructor: target.constructor,
|
|
11
|
+
functionName,
|
|
12
|
+
config: typeof config === 'string' ? { path: config } : config,
|
|
13
|
+
paramsTypes,
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { action };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { container, injectable } from '../../../core/injection/index.js';
|
|
2
|
+
import { UiControllerMetadataStore } from './UiControllerMetadataStore.js';
|
|
3
|
+
|
|
4
|
+
function uiController(config) {
|
|
5
|
+
return function (target) {
|
|
6
|
+
const store = container.resolve(UiControllerMetadataStore);
|
|
7
|
+
store.saveControllerMetadata({
|
|
8
|
+
controllerConstructor: target,
|
|
9
|
+
path: typeof config === 'string' ? config : config.path,
|
|
10
|
+
middlewares: typeof config === 'string' ? [] : (config.middlewares ?? []),
|
|
11
|
+
app: typeof config === 'string' ? false : (config.app ?? false),
|
|
12
|
+
layout: typeof config === 'string' ? undefined : config.layout,
|
|
13
|
+
head: typeof config === 'string' ? undefined : config.head,
|
|
14
|
+
});
|
|
15
|
+
injectable()(target);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { uiController };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { container } from '../../../core/injection/index.js';
|
|
2
|
+
import { UiControllerMetadataStore } from './UiControllerMetadataStore.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Attach a middleware (e.g. an auth guard) to a single @view or @action.
|
|
6
|
+
* Controller-wide middlewares can instead be declared on @uiController({ middlewares }).
|
|
7
|
+
*/
|
|
8
|
+
function uiMiddleware(middlewareConstructor) {
|
|
9
|
+
return function (target, propertyKey) {
|
|
10
|
+
const functionName = propertyKey.toString();
|
|
11
|
+
const store = container.resolve(UiControllerMetadataStore);
|
|
12
|
+
store.saveMiddlewareMetadata({
|
|
13
|
+
controllerConstructor: target.constructor,
|
|
14
|
+
functionName,
|
|
15
|
+
middlewareConstructor,
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { uiMiddleware };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { container } from '../../../core/injection/index.js';
|
|
2
|
+
import { UiControllerMetadataStore } from './UiControllerMetadataStore.js';
|
|
3
|
+
|
|
4
|
+
function view(config) {
|
|
5
|
+
return function (target, propertyKey) {
|
|
6
|
+
const functionName = propertyKey.toString();
|
|
7
|
+
const paramsTypes = Reflect.getMetadata('design:paramtypes', target, functionName);
|
|
8
|
+
const store = container.resolve(UiControllerMetadataStore);
|
|
9
|
+
store.saveViewMetadata({
|
|
10
|
+
controllerConstructor: target.constructor,
|
|
11
|
+
functionName,
|
|
12
|
+
config: typeof config === 'string' ? { path: config } : config,
|
|
13
|
+
paramsTypes,
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { view };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { __decorate } from 'tslib';
|
|
2
|
+
import { singleton } from '../../../core/injection/index.js';
|
|
3
|
+
|
|
4
|
+
function getClassHierarchy(cls) {
|
|
5
|
+
const classes = [];
|
|
6
|
+
let proto = Object.getPrototypeOf(cls.prototype);
|
|
7
|
+
while (proto && proto.constructor !== Object) {
|
|
8
|
+
classes.push(proto.constructor);
|
|
9
|
+
proto = Object.getPrototypeOf(proto);
|
|
10
|
+
}
|
|
11
|
+
return classes;
|
|
12
|
+
}
|
|
13
|
+
let UiControllerMetadataStore = class UiControllerMetadataStore {
|
|
14
|
+
controllers = new Map();
|
|
15
|
+
views = new Map();
|
|
16
|
+
actions = new Map();
|
|
17
|
+
middlewares = new Map();
|
|
18
|
+
saveControllerMetadata(metadata) {
|
|
19
|
+
this.controllers.set(metadata.controllerConstructor, metadata);
|
|
20
|
+
}
|
|
21
|
+
saveViewMetadata(metadata) {
|
|
22
|
+
let controllerViews = this.views.get(metadata.controllerConstructor);
|
|
23
|
+
if (!controllerViews) {
|
|
24
|
+
this.views.set(metadata.controllerConstructor, (controllerViews = new Map()));
|
|
25
|
+
}
|
|
26
|
+
controllerViews.set(metadata.functionName, metadata);
|
|
27
|
+
}
|
|
28
|
+
saveActionMetadata(metadata) {
|
|
29
|
+
let controllerActions = this.actions.get(metadata.controllerConstructor);
|
|
30
|
+
if (!controllerActions) {
|
|
31
|
+
this.actions.set(metadata.controllerConstructor, (controllerActions = new Map()));
|
|
32
|
+
}
|
|
33
|
+
controllerActions.set(metadata.functionName, metadata);
|
|
34
|
+
}
|
|
35
|
+
saveMiddlewareMetadata(metadata) {
|
|
36
|
+
let controllerMiddlewares = this.middlewares.get(metadata.controllerConstructor);
|
|
37
|
+
if (!controllerMiddlewares) {
|
|
38
|
+
this.middlewares.set(metadata.controllerConstructor, (controllerMiddlewares = new Map()));
|
|
39
|
+
}
|
|
40
|
+
let methodMiddlewares = controllerMiddlewares.get(metadata.functionName);
|
|
41
|
+
if (!methodMiddlewares) {
|
|
42
|
+
controllerMiddlewares.set(metadata.functionName, (methodMiddlewares = []));
|
|
43
|
+
}
|
|
44
|
+
methodMiddlewares.unshift(metadata);
|
|
45
|
+
}
|
|
46
|
+
getAllUiControllerConstructors() {
|
|
47
|
+
return Array.from(this.controllers.keys());
|
|
48
|
+
}
|
|
49
|
+
getController(controllerConstructor) {
|
|
50
|
+
const controller = this.controllers.get(controllerConstructor);
|
|
51
|
+
if (!controller) {
|
|
52
|
+
throw new Error(`${controllerConstructor.name} should be decorated with @uiController`);
|
|
53
|
+
}
|
|
54
|
+
return controller;
|
|
55
|
+
}
|
|
56
|
+
collectMethodMiddlewares(hierarchy, functionName) {
|
|
57
|
+
const middlewares = [];
|
|
58
|
+
for (const cls of [...hierarchy].reverse()) {
|
|
59
|
+
const classMiddlewares = this.middlewares.get(cls)?.get(functionName);
|
|
60
|
+
if (classMiddlewares) {
|
|
61
|
+
middlewares.push(...classMiddlewares);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return middlewares;
|
|
65
|
+
}
|
|
66
|
+
getControllerViewsInfo(controllerConstructor) {
|
|
67
|
+
const controller = this.getController(controllerConstructor);
|
|
68
|
+
const hierarchy = [controllerConstructor, ...getClassHierarchy(controllerConstructor)];
|
|
69
|
+
const viewsMap = new Map();
|
|
70
|
+
for (const cls of [...hierarchy].reverse()) {
|
|
71
|
+
const classViews = this.views.get(cls);
|
|
72
|
+
if (classViews) {
|
|
73
|
+
for (const [name, view] of classViews)
|
|
74
|
+
viewsMap.set(name, view);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return [...viewsMap.values()].map((view) => ({
|
|
78
|
+
...view,
|
|
79
|
+
controllerConstructor,
|
|
80
|
+
controller,
|
|
81
|
+
middlewares: this.collectMethodMiddlewares(hierarchy, view.functionName),
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
getControllerActionsInfo(controllerConstructor) {
|
|
85
|
+
const controller = this.getController(controllerConstructor);
|
|
86
|
+
const hierarchy = [controllerConstructor, ...getClassHierarchy(controllerConstructor)];
|
|
87
|
+
const actionsMap = new Map();
|
|
88
|
+
for (const cls of [...hierarchy].reverse()) {
|
|
89
|
+
const classActions = this.actions.get(cls);
|
|
90
|
+
if (classActions) {
|
|
91
|
+
for (const [name, action] of classActions)
|
|
92
|
+
actionsMap.set(name, action);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return [...actionsMap.values()].map((action) => ({
|
|
96
|
+
...action,
|
|
97
|
+
controllerConstructor,
|
|
98
|
+
controller,
|
|
99
|
+
middlewares: this.collectMethodMiddlewares(hierarchy, action.functionName),
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
UiControllerMetadataStore = __decorate([
|
|
104
|
+
singleton()
|
|
105
|
+
], UiControllerMetadataStore);
|
|
106
|
+
|
|
107
|
+
export { UiControllerMetadataStore };
|