@abide/abide 0.31.1 → 0.32.1
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/AGENTS.md +2 -1
- package/CHANGELOG.md +26 -0
- package/package.json +1 -2
- package/src/checkAbide.ts +11 -6
- package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +4 -3
- package/src/lib/server/runtime/createServer.ts +28 -23
- package/src/lib/server/runtime/createUiPageRenderer.ts +1 -1
- package/src/lib/ui/compile/AbideCompileError.ts +16 -0
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +0 -1
- package/src/lib/ui/compile/abideUiPlugin.ts +25 -2
- package/src/lib/ui/compile/bindListenEvent.ts +19 -0
- package/src/lib/ui/compile/compileShadow.ts +65 -52
- package/src/lib/ui/compile/componentWrapperTag.ts +20 -0
- package/src/lib/ui/compile/generateBuild.ts +114 -212
- package/src/lib/ui/compile/generateSSR.ts +54 -88
- package/src/lib/ui/compile/lowerContext.ts +64 -0
- package/src/lib/ui/compile/lowerDocAccess.ts +6 -1
- package/src/lib/ui/compile/offsetToLineColumn.ts +16 -0
- package/src/lib/ui/compile/scopeAttr.ts +9 -0
- package/src/lib/ui/compile/staticAttr.ts +11 -0
- package/src/lib/ui/compile/staticTextPart.ts +12 -0
- package/src/lib/ui/compile/unwrapParens.ts +10 -0
- package/src/lib/ui/dom/applyResolved.ts +9 -6
- package/src/lib/ui/dom/awaitBlock.ts +27 -21
- package/src/lib/ui/dom/clearBetween.ts +16 -0
- package/src/lib/ui/dom/each.ts +64 -38
- package/src/lib/ui/dom/eachAsync.ts +41 -54
- package/src/lib/ui/dom/fillBefore.ts +16 -0
- package/src/lib/ui/dom/moveRange.ts +19 -0
- package/src/lib/ui/dom/openMarker.ts +22 -0
- package/src/lib/ui/dom/removeRange.ts +18 -0
- package/src/lib/ui/dom/switchBlock.ts +32 -40
- package/src/lib/ui/dom/tryBlock.ts +31 -35
- package/src/lib/ui/dom/types/EachRow.ts +10 -3
- package/src/lib/ui/dom/types/SwitchCase.ts +3 -2
- package/src/lib/ui/dom/when.ts +34 -43
- package/src/lib/ui/installHotBridge.ts +0 -2
- package/src/lib/ui/renderToStream.ts +14 -11
- package/src/lib/ui/state.ts +14 -5
- package/src/lib/ui/compile/branchElements.ts +0 -50
- package/src/lib/ui/dom/openRoot.ts +0 -20
package/AGENTS.md
CHANGED
|
@@ -174,13 +174,14 @@ Same selector grammar as `cache.invalidate`; also accept a `Subscribable`. See C
|
|
|
174
174
|
- `abide/ui/router(...)`, `abide/ui/startClient(...)`, `abide/ui/renderToStream(render)` — bootstrap/render runtime (compiler/launcher uses these).
|
|
175
175
|
|
|
176
176
|
### DOM + render runtime — `@readme plumbing` (compiler-emitted; you don't hand-write these)
|
|
177
|
-
`abide/ui/dom/{mount,mountChild,hydrate,text,openChild,
|
|
177
|
+
`abide/ui/dom/{mount,mountChild,hydrate,text,openChild,appendText,appendSnippet,appendStatic,cloneStatic,attr,on,attach,each,eachAsync,when,awaitBlock,tryBlock,switchBlock,applyResolved}` and `abide/ui/runtime/{nextBlockId,enterRenderPass,exitRenderPass}`. These are what `analyzeComponent → generateBuild/generateSSR` lower a `.abide` file into. Read them only to understand compiler output.
|
|
178
178
|
- `abide/ui/remoteProxy`, `abide/ui/socketProxy` — the browser-side implementations the bundler swaps in for `GET(...)` / `socket(...)`.
|
|
179
179
|
|
|
180
180
|
### `.abide` component format (see `src/lib/ui/README.md`)
|
|
181
181
|
Valid HTML with `<script>` + native `<template>` control flow + scoped `<style>`.
|
|
182
182
|
- **Bindings:** `{expr}` text, `name={expr}` attr, `onclick={fn}`, `bind:value={…}` / `bind:checked` / `bind:group`, `attach={fn}` (node-lifetime attachment — the dual of `on`; the `use:`-action / `{@attach}` equivalent, lowered to `ui/dom/attach`).
|
|
183
183
|
- **Control flow (native `<template>`):** `if`/`else`, `each={list} as="x" key="x.id"`, `await={p}`/`then`/`catch`, `switch`/`case`/`default`.
|
|
184
|
+
- **A branch is a lexical scope:** any control-flow branch may host its own nested `<script>` and `<style>`. The nested `<script>` declares branch-local **plain** signals (`state`/`derived`/`linked`/`prop`) — owned by the branch's render scope, re-seeded each mount (not the serializable top-level `doc`) — with the branch's binding in scope (`then`/`catch`'s `as` value, the `each` `as`/`key` row), so it can derive from the awaited/iterated value; its bindings cover the branch subtree and later siblings auto-deref them. The nested `<style>` is scoped to that branch alone (its own `data-a-<hash>`), not the whole component.
|
|
184
185
|
- **Components:** capitalised tags (`<Layout title=…>`); children fill `<slot/>`; props are reactive (passed as thunks). A component has no directives — every attribute is a prop under its written name (so `onclick=`/`bind:open=`/`attach=` pass through as props, e.g. callbacks, not the DOM-element directives those are on a lowercase tag) and is type-checked against the child's declared props. `prop('name')` reads a typed component prop (the parent-supplied thunk, reactive + read-only); route params come from the `page` proxy (`page.params.name`), not `prop()`.
|
|
185
186
|
- **Snippets / named slots:** `<template name="x" args={…}>` declares a reusable named builder (the `snippet()` form), rendered like a function — covers named slots / `{@render}`.
|
|
186
187
|
- **Reactivity:** write plain assignment (`count += 1`, `items.push(x)`); the compiler lowers it to patches. Deep-field edits wake only that field.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.32.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [`660ce08`](https://github.com/briancray/abide/commit/660ce08109160156dc9697fcb223fbb335229243) - fold asset-serve request logging into a timedServe helper ([`17aa14a`](https://github.com/briancray/abide/commit/17aa14aa3f04abe76a2e06b0c2fb0a8d4e725603))
|
|
8
|
+
|
|
9
|
+
- [`660ce08`](https://github.com/briancray/abide/commit/660ce08109160156dc9697fcb223fbb335229243) - swallow the superseded each-async iterator's return rejection ([`5e2898f`](https://github.com/briancray/abide/commit/5e2898ff8a785508aa8fde70f3fc351dcdc8e014))
|
|
10
|
+
|
|
11
|
+
- [`9643066`](https://github.com/briancray/abide/commit/9643066cf19bb944a55509333fd1421a3a8cce7a) - fix(check): narrow `<template else>` against the condition's negation. The shadow type-checker emitted a nested `else` child inside the `if` block, so the else body inherited the if's positive narrowing — a literal-union compare read as "no overlap" and a `typeof`-narrowed branch saw the wrong member. It now pairs the `else` child as a real `if (…) {…} else {…}`, matching the runtime's pairing.
|
|
12
|
+
|
|
13
|
+
- [`9643066`](https://github.com/briancray/abide/commit/9643066cf19bb944a55509333fd1421a3a8cce7a) - fix(check): value-project a nested control-flow `<script>` like the leading one. The shadow type-checker emitted a branch-scoped `<script>` body raw, so its `state`/`derived` declarations kept their `State<T>`/`Derived<T>` types — every read of a nested signal in the branch's markup false-positived (`'Derived<string>' and 'string' have no overlap`, `Property 'length' does not exist on type 'Derived<…>'`). It now rewrites a nested script's reactive declarations to their value types, matching the runtime, which derefs nested-script signals through the rest of the branch.
|
|
14
|
+
|
|
15
|
+
- [`0c54e5a`](https://github.com/briancray/abide/commit/0c54e5a025eef96ebc7eece3f51b8a167241434d) - fix(ui): don't emit a void element as a component's mount wrapper. A component instance mounts into a wrapper tag derived from its name (`<Search>`→`<search>`); when the name lowercases to a void element (`<Input>`→`<input>`, `<Img>`→`<img>`) the wrapper self-closes and the HTML parser reparents the component's own markup as the wrapper's siblings, so on hydration `openChild` finds the wrapper empty, claims `null`, and `attr` throws `null is not an object (setAttribute)` — aborting hydration. Such names now map to a hyphenated custom-element tag (`abide-input`, never void) made layout-transparent with `display:contents`, so the child's real root still lays out as a direct child of the parent. Both the SSR string and the client build go through the shared `componentWrapperTag`, keeping them in agreement.
|
|
16
|
+
|
|
17
|
+
- [`9695bea`](https://github.com/briancray/abide/commit/9695bea26309b693d6b61a4dec1641559421c714) - perf(ui): carry the streamed await-block resume value in a `<script type="application/json">` child instead of a `data-resume` attribute. The attribute form HTML-escaped the JSON (`"` → `"`, plus `&`/`<` passes), inflating the raw payload ~38% and costing two full-string regex passes per render (~2.6 ms/MB; ~10 ms for a 3.7 MB payload). Script content is raw text, so only `<` is neutralized as the JSON escape — ~130–150× cheaper to encode and no quote inflation. `applyResolved` and the inline `SSR_SWAP_SCRIPT` now read the value via `.textContent` and drop the script before swapping the resolved markup into its boundary.
|
|
18
|
+
|
|
19
|
+
## 0.32.0
|
|
20
|
+
|
|
21
|
+
### Minor Changes
|
|
22
|
+
|
|
23
|
+
- [`786a496`](https://github.com/briancray/abide/commit/786a4965fa3c94954ea8d66aa41b0f94e0ec8320) - render control-flow branches into marker-bounded ranges ([`23efa06`](https://github.com/briancray/abide/commit/23efa0606b90b69ff262679673d660a71baf443c))
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- [`786a496`](https://github.com/briancray/abide/commit/786a4965fa3c94954ea8d66aa41b0f94e0ec8320) - report checked component count on success ([`9ea83bb`](https://github.com/briancray/abide/commit/9ea83bb3c072d72e67486906b191964505e872f2))
|
|
28
|
+
|
|
3
29
|
## 0.31.1
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abide/abide",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
|
|
@@ -82,7 +82,6 @@
|
|
|
82
82
|
"./ui/dom/hydrate": "./src/lib/ui/dom/hydrate.ts",
|
|
83
83
|
"./ui/dom/text": "./src/lib/ui/dom/text.ts",
|
|
84
84
|
"./ui/dom/openChild": "./src/lib/ui/dom/openChild.ts",
|
|
85
|
-
"./ui/dom/openRoot": "./src/lib/ui/dom/openRoot.ts",
|
|
86
85
|
"./ui/dom/appendText": "./src/lib/ui/dom/appendText.ts",
|
|
87
86
|
"./ui/dom/appendSnippet": "./src/lib/ui/dom/appendSnippet.ts",
|
|
88
87
|
"./ui/dom/appendStatic": "./src/lib/ui/dom/appendStatic.ts",
|
package/src/checkAbide.ts
CHANGED
|
@@ -14,7 +14,7 @@ the same as each package checked on its own — and the same as the LSP. Returns
|
|
|
14
14
|
the error count so the CLI can set its exit code.
|
|
15
15
|
*/
|
|
16
16
|
export async function checkAbide({ cwd }: { cwd: string }): Promise<number> {
|
|
17
|
-
const diagnostics = collectByProject(cwd)
|
|
17
|
+
const { diagnostics, checked } = collectByProject(cwd)
|
|
18
18
|
const byFile = new Map<string, AbideDiagnostic[]>()
|
|
19
19
|
for (const diagnostic of diagnostics) {
|
|
20
20
|
const bucket = byFile.get(diagnostic.file) ?? []
|
|
@@ -32,11 +32,14 @@ export async function checkAbide({ cwd }: { cwd: string }): Promise<number> {
|
|
|
32
32
|
const errors = diagnostics.filter(
|
|
33
33
|
(diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error,
|
|
34
34
|
).length
|
|
35
|
-
|
|
35
|
+
/* Success reports components *checked* (the glob count); failure reports the
|
|
36
|
+
files *with* errors. Reporting `byFile.size` on success printed `0` — it only
|
|
37
|
+
holds files that had diagnostics. */
|
|
38
|
+
const fileCount = byFile.size
|
|
36
39
|
console.log(
|
|
37
40
|
errors === 0
|
|
38
|
-
? `\n[abide check] no type errors in ${
|
|
39
|
-
: `\n[abide check] ${errors} error${errors === 1 ? '' : 's'} in ${
|
|
41
|
+
? `\n[abide check] no type errors in ${checked} component${checked === 1 ? '' : 's'}`
|
|
42
|
+
: `\n[abide check] ${errors} error${errors === 1 ? '' : 's'} in ${fileCount} file${fileCount === 1 ? '' : 's'}`,
|
|
40
43
|
)
|
|
41
44
|
return errors
|
|
42
45
|
}
|
|
@@ -45,7 +48,7 @@ export async function checkAbide({ cwd }: { cwd: string }): Promise<number> {
|
|
|
45
48
|
project's components against that project's options, then concatenates the
|
|
46
49
|
diagnostics. Imported components from another project resolve on demand through
|
|
47
50
|
the host, so the per-project root set stays each project's own files. */
|
|
48
|
-
function collectByProject(cwd: string): AbideDiagnostic[] {
|
|
51
|
+
function collectByProject(cwd: string): { diagnostics: AbideDiagnostic[]; checked: number } {
|
|
49
52
|
const byProject = new Map<string, string[]>()
|
|
50
53
|
for (const relative of new Bun.Glob('**/*.abide').scanSync({ cwd, onlyFiles: true })) {
|
|
51
54
|
if (relative.includes('node_modules')) {
|
|
@@ -55,9 +58,11 @@ function collectByProject(cwd: string): AbideDiagnostic[] {
|
|
|
55
58
|
const root = nearestProjectRoot(path, cwd)
|
|
56
59
|
byProject.set(root, [...(byProject.get(root) ?? []), path])
|
|
57
60
|
}
|
|
58
|
-
|
|
61
|
+
const diagnostics = [...byProject].flatMap(([root, paths]) =>
|
|
59
62
|
collectAbideDiagnostics(createShadowProgram(root, paths)),
|
|
60
63
|
)
|
|
64
|
+
const checked = [...byProject.values()].reduce((total, paths) => total + paths.length, 0)
|
|
65
|
+
return { diagnostics, checked }
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/* Renders one diagnostic as `path:line:col severity message` plus the offending
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/*
|
|
2
2
|
The tiny inline script the abide-ui SSR stream ships in <head>. For each streamed
|
|
3
|
-
`<abide-resolve data-id
|
|
3
|
+
`<abide-resolve data-id>` frame it reads the leading `<script type=application/json>`,
|
|
4
|
+
registers the resolved value into
|
|
4
5
|
`window.__abideResume` (the resume manifest hydration reads) and swaps the resolved
|
|
5
6
|
markup into the matching `<!--abide:await:ID-->…<!--/abide:await:ID-->` boundary —
|
|
6
7
|
so the pending shell paints instantly and each value lands as it arrives, before
|
|
@@ -9,8 +10,8 @@ minified to one line so it inlines cheaply ahead of the document body.
|
|
|
9
10
|
*/
|
|
10
11
|
export const SSR_SWAP_SCRIPT =
|
|
11
12
|
"function __abideSwap(){var f=document.querySelector('abide-resolve');while(f){" +
|
|
12
|
-
"var id=f.getAttribute('data-id'),w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
|
|
13
|
-
"try{(window.__abideResume=window.__abideResume||{})[id]=JSON.parse(
|
|
13
|
+
"var id=f.getAttribute('data-id'),p=f.firstChild,w=document.createTreeWalker(document.body,NodeFilter.SHOW_COMMENT),o=null,c;" +
|
|
14
|
+
"if(p&&p.nodeName==='SCRIPT'){try{(window.__abideResume=window.__abideResume||{})[id]=JSON.parse(p.textContent||'null');}catch(e){}p.remove();}" +
|
|
14
15
|
"while((c=w.nextNode())){if(c.data==='abide:await:'+id){o=c;break;}}" +
|
|
15
16
|
"if(o){var n=o.nextSibling;while(n&&!(n.nodeType===8&&n.data==='/abide:await:'+id)){var x=n.nextSibling;n.remove();n=x;}" +
|
|
16
17
|
"while(f.firstChild){o.parentNode.insertBefore(f.firstChild,n);}}f.remove();f=document.querySelector('abide-resolve');}}"
|
|
@@ -238,6 +238,32 @@ export async function createServer({
|
|
|
238
238
|
/* Request closing records are on by default — DEBUG=-abide is the off switch (negation, like the abide channel itself). */
|
|
239
239
|
const logRequests = !isDebugNegated('abide')
|
|
240
240
|
|
|
241
|
+
/*
|
|
242
|
+
Time an asset serve and emit its closing record when logging is on. A miss
|
|
243
|
+
(undefined, from the public server) passes through unlogged so the request
|
|
244
|
+
can fall through to the 404 path.
|
|
245
|
+
*/
|
|
246
|
+
const timedServe = async <T extends Response | undefined>(
|
|
247
|
+
serve: () => Promise<T>,
|
|
248
|
+
req: Request,
|
|
249
|
+
url: URL,
|
|
250
|
+
): Promise<T> => {
|
|
251
|
+
if (!logRequests) {
|
|
252
|
+
return serve()
|
|
253
|
+
}
|
|
254
|
+
const start = Bun.nanoseconds()
|
|
255
|
+
const response = await serve()
|
|
256
|
+
if (response) {
|
|
257
|
+
logClosingRecord(
|
|
258
|
+
req.method,
|
|
259
|
+
`${url.pathname}${url.search}`,
|
|
260
|
+
response.status,
|
|
261
|
+
(Bun.nanoseconds() - start) / 1e6,
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
return response
|
|
265
|
+
}
|
|
266
|
+
|
|
241
267
|
// App-configured headers extend the in-process forward allowlist for the process lifetime.
|
|
242
268
|
extraForwardHeaders.set(app?.forwardHeaders ?? [])
|
|
243
269
|
|
|
@@ -546,19 +572,7 @@ export async function createServer({
|
|
|
546
572
|
catches anything that goes wrong inside serveAppAsset.
|
|
547
573
|
*/
|
|
548
574
|
if (url.pathname.startsWith('/_app/')) {
|
|
549
|
-
|
|
550
|
-
return serveAppAsset(req, url)
|
|
551
|
-
}
|
|
552
|
-
const start = Bun.nanoseconds()
|
|
553
|
-
const response = await serveAppAsset(req, url)
|
|
554
|
-
const ms = (Bun.nanoseconds() - start) / 1e6
|
|
555
|
-
logClosingRecord(
|
|
556
|
-
req.method,
|
|
557
|
-
`${url.pathname}${url.search}`,
|
|
558
|
-
response.status,
|
|
559
|
-
ms,
|
|
560
|
-
)
|
|
561
|
-
return response
|
|
575
|
+
return timedServe(() => serveAppAsset(req, url), req, url)
|
|
562
576
|
}
|
|
563
577
|
/*
|
|
564
578
|
Files under public/ are served at the site root, sidestepping
|
|
@@ -566,17 +580,8 @@ export async function createServer({
|
|
|
566
580
|
undefined so the request falls through to the 404 / middleware
|
|
567
581
|
path below.
|
|
568
582
|
*/
|
|
569
|
-
const
|
|
570
|
-
const publicResponse = await servePublicAsset(req, url)
|
|
583
|
+
const publicResponse = await timedServe(() => servePublicAsset(req, url), req, url)
|
|
571
584
|
if (publicResponse) {
|
|
572
|
-
if (logRequests) {
|
|
573
|
-
logClosingRecord(
|
|
574
|
-
req.method,
|
|
575
|
-
`${url.pathname}${url.search}`,
|
|
576
|
-
publicResponse.status,
|
|
577
|
-
(Bun.nanoseconds() - publicStart) / 1e6,
|
|
578
|
-
)
|
|
579
|
-
}
|
|
580
585
|
return publicResponse
|
|
581
586
|
}
|
|
582
587
|
/*
|
|
@@ -27,7 +27,7 @@ Response out.
|
|
|
27
27
|
|
|
28
28
|
A page with no `await` block renders synchronously and ships buffered. A page with
|
|
29
29
|
await blocks STREAMS: the pending shell flushes first, then each resolved fragment
|
|
30
|
-
(`<abide-resolve
|
|
30
|
+
(`<abide-resolve>` carrying a JSON `<script>`) as its promise settles, swapped into its boundary
|
|
31
31
|
by the inline SSR_SWAP_SCRIPT — which also registers the value into the resume
|
|
32
32
|
manifest so client hydration adopts it without re-fetching (see abide/ui/awaitBlock).
|
|
33
33
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
A compile-time `.abide` error carrying the absolute source offset of the
|
|
3
|
+
offending node (when the parser tracked one). The loader catches it, resolves the
|
|
4
|
+
offset to `line:col` against the file, and re-throws with the component path and
|
|
5
|
+
position in the message — so a failed build names the exact file and line, not the
|
|
6
|
+
entry page at offset 0 (Bun frames plugin throws at `<file>:0` regardless).
|
|
7
|
+
*/
|
|
8
|
+
export class AbideCompileError extends Error {
|
|
9
|
+
readonly offset: number | undefined
|
|
10
|
+
|
|
11
|
+
constructor(message: string, offset?: number) {
|
|
12
|
+
super(message)
|
|
13
|
+
this.name = 'AbideCompileError'
|
|
14
|
+
this.offset = offset
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -15,7 +15,6 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string }[] = [
|
|
|
15
15
|
{ name: 'effect', specifier: 'ui/effect' },
|
|
16
16
|
{ name: 'mount', specifier: 'ui/dom/mount' },
|
|
17
17
|
{ name: 'openChild', specifier: 'ui/dom/openChild' },
|
|
18
|
-
{ name: 'openRoot', specifier: 'ui/dom/openRoot' },
|
|
19
18
|
{ name: 'appendText', specifier: 'ui/dom/appendText' },
|
|
20
19
|
{ name: 'appendSnippet', specifier: 'ui/dom/appendSnippet' },
|
|
21
20
|
{ name: 'appendStatic', specifier: 'ui/dom/appendStatic' },
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { relative } from 'node:path'
|
|
2
2
|
import type { BunPlugin } from 'bun'
|
|
3
|
+
import { AbideCompileError } from './AbideCompileError.ts'
|
|
3
4
|
import { analyzeComponent } from './analyzeComponent.ts'
|
|
4
5
|
import { compileModule } from './compileModule.ts'
|
|
5
6
|
import { nearestProjectRoot } from './nearestProjectRoot.ts'
|
|
7
|
+
import { offsetToLineColumn } from './offsetToLineColumn.ts'
|
|
6
8
|
|
|
7
9
|
/*
|
|
8
10
|
Bun plugin that loads `.abide` single-file components: compiles each to the ES
|
|
@@ -40,11 +42,32 @@ export const abideUiPlugin: BunPlugin = {
|
|
|
40
42
|
const source = await Bun.file(args.path).text()
|
|
41
43
|
const moduleId = relative(nearestProjectRoot(args.path, process.cwd()), args.path)
|
|
42
44
|
const isLayout = (args.path.split('/').pop() ?? '') === 'layout.abide'
|
|
43
|
-
|
|
45
|
+
/* Bun frames a plugin throw at `<file>:0` regardless of the real spot, so
|
|
46
|
+
carry the component path + resolved line:col in the message — otherwise a
|
|
47
|
+
control-flow / compile error reads as `:0` and (in deep imports) can look
|
|
48
|
+
like it came from the entry page rather than this component. */
|
|
49
|
+
const compileAbide = <T>(step: () => T): T => {
|
|
50
|
+
try {
|
|
51
|
+
return step()
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const offset = error instanceof AbideCompileError ? error.offset : undefined
|
|
54
|
+
const at =
|
|
55
|
+
offset === undefined
|
|
56
|
+
? moduleId
|
|
57
|
+
: (({ line, column }) => `${moduleId}:${line}:${column}`)(
|
|
58
|
+
offsetToLineColumn(source, offset),
|
|
59
|
+
)
|
|
60
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
61
|
+
throw new Error(`${message.replace(/^\[abide\]\s*/, `[abide] ${at} — `)}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const code = compileAbide(() => compileModule(source, { isLayout, moduleId }))
|
|
44
65
|
/* Browser build with `<style>`(s): concatenate every scoped block's CSS and
|
|
45
66
|
pull it into the bundle via one virtual import, keyed by `moduleId` so the
|
|
46
67
|
registry id and the CSS id agree. */
|
|
47
|
-
const styles =
|
|
68
|
+
const styles = compileAbide(() =>
|
|
69
|
+
toBrowser ? analyzeComponent(source, moduleId).styles : [],
|
|
70
|
+
)
|
|
48
71
|
if (styles.length === 0) {
|
|
49
72
|
return { contents: code, loader: 'ts' }
|
|
50
73
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The DOM event a generic two-way `bind:<property>` listens on to write the bound
|
|
3
|
+
path back. Most form fields report edits via `input`, but a few properties are
|
|
4
|
+
driven by their own event and never fire `input` — `<details open>` fires
|
|
5
|
+
`toggle`, a checkbox/radio `checked` fires `change`, and a `<select>` settles its
|
|
6
|
+
`value` on `change`. Picking the wrong event silently breaks the write-back.
|
|
7
|
+
*/
|
|
8
|
+
export function bindListenEvent(property: string, tag: string): string {
|
|
9
|
+
if (property === 'open') {
|
|
10
|
+
return 'toggle'
|
|
11
|
+
}
|
|
12
|
+
if (property === 'checked') {
|
|
13
|
+
return 'change'
|
|
14
|
+
}
|
|
15
|
+
if (property === 'value' && tag === 'select') {
|
|
16
|
+
return 'change'
|
|
17
|
+
}
|
|
18
|
+
return 'input'
|
|
19
|
+
}
|
|
@@ -184,6 +184,27 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
|
|
|
184
184
|
return { imports, types, scope, props }
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
|
|
188
|
+
projects the leading script's scope: reactive declarations become their value
|
|
189
|
+
type, every other statement stays verbatim. Returns the projected source text
|
|
190
|
+
(unmapped — nested scripts carry no source offset yet), so a branch's markup
|
|
191
|
+
reads a nested signal as its value type instead of the raw `State`/`Derived`. */
|
|
192
|
+
function projectNestedScript(code: string): string {
|
|
193
|
+
const file = ts.createSourceFile('nested.ts', code, ts.ScriptTarget.Latest, true)
|
|
194
|
+
const verbatim = (node: ts.Node): string => code.slice(node.getStart(file), node.getEnd())
|
|
195
|
+
/* No mapping: a zero-length segment the caller drops. */
|
|
196
|
+
const span = (): ShadowMapping => ({ shadowStart: 0, sourceStart: 0, length: 0 })
|
|
197
|
+
return file.statements
|
|
198
|
+
.flatMap((statement) => {
|
|
199
|
+
const reactive = reactiveDeclarations(statement)
|
|
200
|
+
if (reactive === undefined) {
|
|
201
|
+
return [verbatim(statement)]
|
|
202
|
+
}
|
|
203
|
+
return reactive.map((declaration) => scopeLineFor(declaration, [], verbatim, span).text)
|
|
204
|
+
})
|
|
205
|
+
.join('\n')
|
|
206
|
+
}
|
|
207
|
+
|
|
187
208
|
/* The `state`/`derived`/`prop` declarations in a variable statement, or undefined
|
|
188
209
|
if it isn't one declaring them (so the caller emits it verbatim). A statement
|
|
189
210
|
mixing reactive and plain declarations is rare; treated as all-verbatim. */
|
|
@@ -229,7 +250,14 @@ function scopeLineFor(
|
|
|
229
250
|
const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
|
|
230
251
|
const init = call.arguments[0]
|
|
231
252
|
if (init === undefined) {
|
|
232
|
-
|
|
253
|
+
/* No initial (`state<T>()`): the value is `T | undefined`. A definite-
|
|
254
|
+
assignment assertion (`!`) gives that union without a use-before-assign
|
|
255
|
+
false-positive AND without control-flow narrowing it to just `undefined`
|
|
256
|
+
(an `= undefined` initializer, never reassigned in the shadow, would make
|
|
257
|
+
a guard like `x !== undefined` collapse to `never`). Unguarded access is
|
|
258
|
+
then correctly flagged possibly-undefined; a guard narrows cleanly. */
|
|
259
|
+
const valueType = annotation === '' ? ': unknown' : `${annotation} | undefined`
|
|
260
|
+
return { text: `let ${name}!${valueType};`, segments: [] }
|
|
233
261
|
}
|
|
234
262
|
const prefix = `let ${name}${annotation} = (`
|
|
235
263
|
return { text: `${prefix}${verbatim(init)});`, segments: [span(init, prefix.length)] }
|
|
@@ -257,51 +285,13 @@ function scopeLineFor(
|
|
|
257
285
|
return { text: `let ${name} = props[${JSON.stringify(keyText)}];`, segments: [] }
|
|
258
286
|
}
|
|
259
287
|
|
|
260
|
-
/* Emits a sibling list
|
|
261
|
-
(the next meaningful sibling — a `case` with no match) fuse into one
|
|
262
|
-
`if (…) {…} else {…}`, giving the else branch the condition's negative narrowing
|
|
263
|
-
instead of being checked bare against the un-narrowed type. Every other node is
|
|
264
|
-
emitted standalone via `emitNode`. */
|
|
288
|
+
/* Emits a sibling list — each node standalone via `emitNode`. */
|
|
265
289
|
function emitNodes(nodes: TemplateNode[], builder: Builder): void {
|
|
266
|
-
for (
|
|
267
|
-
|
|
268
|
-
if (node === undefined) {
|
|
269
|
-
continue
|
|
270
|
-
}
|
|
271
|
-
if (node.kind !== 'if') {
|
|
290
|
+
for (const node of nodes) {
|
|
291
|
+
if (node !== undefined) {
|
|
272
292
|
emitNode(node, builder)
|
|
273
|
-
continue
|
|
274
|
-
}
|
|
275
|
-
builder.raw('if ')
|
|
276
|
-
builder.expr(node.condition, node.loc)
|
|
277
|
-
builder.raw(' {\n')
|
|
278
|
-
emitNodes(node.children, builder)
|
|
279
|
-
builder.raw('}')
|
|
280
|
-
const elseIndex = nextMeaningful(nodes, index + 1)
|
|
281
|
-
const elseNode = elseIndex === -1 ? undefined : nodes[elseIndex]
|
|
282
|
-
if (elseNode?.kind === 'case' && elseNode.match === undefined) {
|
|
283
|
-
builder.raw(' else {\n')
|
|
284
|
-
emitNodes(elseNode.children, builder)
|
|
285
|
-
builder.raw('}')
|
|
286
|
-
index = elseIndex
|
|
287
|
-
}
|
|
288
|
-
builder.raw('\n')
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/* Index of the next node that isn't whitespace-only text (which separates the `if`
|
|
293
|
-
and `else` tags in source but carries no checkable content); -1 if none remain. */
|
|
294
|
-
function nextMeaningful(nodes: TemplateNode[], from: number): number {
|
|
295
|
-
for (let index = from; index < nodes.length; index += 1) {
|
|
296
|
-
const node = nodes[index]
|
|
297
|
-
const blank =
|
|
298
|
-
node?.kind === 'text' &&
|
|
299
|
-
node.parts.every((part) => part.kind === 'static' && part.value.trim() === '')
|
|
300
|
-
if (!blank) {
|
|
301
|
-
return index
|
|
302
293
|
}
|
|
303
294
|
}
|
|
304
|
-
return -1
|
|
305
295
|
}
|
|
306
296
|
|
|
307
297
|
/* Emits a template node's expressions into the shadow's render body. Control flow
|
|
@@ -346,15 +336,31 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
346
336
|
emitNodes(node.children, builder)
|
|
347
337
|
return
|
|
348
338
|
}
|
|
349
|
-
case 'if':
|
|
350
|
-
/*
|
|
351
|
-
|
|
339
|
+
case 'if': {
|
|
340
|
+
/* The optional `<template else>` is a match-less `case` CHILD (the runtime
|
|
341
|
+
pairs it the same way — see `generateIf`); the rest are the then-content.
|
|
342
|
+
Emitting it as a real `else` gives its body the condition's NEGATIVE
|
|
343
|
+
narrowing — emitting it inside the `if` block (as a plain child) instead
|
|
344
|
+
gave it the positive narrowing, so a literal-union compare read as a
|
|
345
|
+
"no overlap" and a typeof-narrowed branch saw the wrong member. */
|
|
346
|
+
const elseChild = node.children.find(
|
|
347
|
+
(child): child is Extract<TemplateNode, { kind: 'case' }> =>
|
|
348
|
+
child.kind === 'case' && child.match === undefined,
|
|
349
|
+
)
|
|
350
|
+
const thenChildren = node.children.filter((child) => child !== elseChild)
|
|
352
351
|
builder.raw('if ')
|
|
353
352
|
builder.expr(node.condition, node.loc)
|
|
354
353
|
builder.raw(' {\n')
|
|
355
|
-
emitNodes(
|
|
356
|
-
builder.raw('}
|
|
354
|
+
emitNodes(thenChildren, builder)
|
|
355
|
+
builder.raw('}')
|
|
356
|
+
if (elseChild !== undefined) {
|
|
357
|
+
builder.raw(' else {\n')
|
|
358
|
+
emitNodes(elseChild.children, builder)
|
|
359
|
+
builder.raw('}')
|
|
360
|
+
}
|
|
361
|
+
builder.raw('\n')
|
|
357
362
|
return
|
|
363
|
+
}
|
|
358
364
|
case 'each':
|
|
359
365
|
/* `for await` over an async each's AsyncIterable, plain `for…of` otherwise —
|
|
360
366
|
so the item binds to the element type under either iteration protocol. */
|
|
@@ -429,8 +435,8 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
429
435
|
builder.raw('}\n')
|
|
430
436
|
return
|
|
431
437
|
case 'case':
|
|
432
|
-
/* Reached only for a stray case outside a switch/if
|
|
433
|
-
|
|
438
|
+
/* Reached only for a stray case outside a switch/if (none today); a `switch`
|
|
439
|
+
emits its own cases and the `if` handler consumes its `else` child. */
|
|
434
440
|
if (node.match !== undefined) {
|
|
435
441
|
builder.stmt(node.match, node.loc)
|
|
436
442
|
}
|
|
@@ -457,9 +463,16 @@ function emitNode(node: TemplateNode, builder: Builder): void {
|
|
|
457
463
|
builder.raw('};\n')
|
|
458
464
|
return
|
|
459
465
|
case 'script':
|
|
460
|
-
/* A scoped reactive `<script
|
|
461
|
-
|
|
462
|
-
|
|
466
|
+
/* A scoped reactive `<script>`: value-project its reactive declarations to
|
|
467
|
+
their value types (`derived(…)` → the computed value, `state(…)` → the
|
|
468
|
+
initial) exactly as the leading script's scope lines are, so the branch's
|
|
469
|
+
markup type-checks a nested signal as its value — matching the runtime,
|
|
470
|
+
which derefs nested-script signals through the rest of the branch. Emitted
|
|
471
|
+
INLINE in the current block, not a nested `{…}`, so its bindings reach the
|
|
472
|
+
branch's later siblings (a nested if/each); a wrapping block trapped them,
|
|
473
|
+
surfacing "Cannot find name". Leading `;` guards a preceding semicolon-less
|
|
474
|
+
call from merging in (see the component case). Not yet position-mapped. */
|
|
475
|
+
builder.raw(`;\n${projectNestedScript(node.code)}\n`)
|
|
463
476
|
return
|
|
464
477
|
case 'style':
|
|
465
478
|
/* CSS, not TypeScript — nothing for the shadow to type-check. */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { VOID_TAGS } from './VOID_TAGS.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The element tag a component instance mounts into. Normally the component name
|
|
5
|
+
lowercased — readable in devtools, a real box like any abide wrapper. But a name
|
|
6
|
+
that lowercases to a VOID element (`Input`→`input`, `Img`→`img`) would yield a
|
|
7
|
+
wrapper the HTML parser self-closes, reparenting the component's own markup as the
|
|
8
|
+
wrapper's siblings — so on hydration `openChild` finds the wrapper empty, claims
|
|
9
|
+
`null`, and `attr` throws on it. Those names map to a hyphenated custom-element tag
|
|
10
|
+
(a custom element is never void) made layout-transparent with `display:contents`,
|
|
11
|
+
so the component's real root still lays out as a direct child of the parent the way
|
|
12
|
+
the (parse-broken) void wrapper effectively did. Both back-ends call this so the SSR
|
|
13
|
+
string and the client build agree on the wrapper.
|
|
14
|
+
*/
|
|
15
|
+
export function componentWrapperTag(name: string): { tag: string; transparent: boolean } {
|
|
16
|
+
const lower = name.toLowerCase()
|
|
17
|
+
return VOID_TAGS.has(lower)
|
|
18
|
+
? { tag: `abide-${lower}`, transparent: true }
|
|
19
|
+
: { tag: lower, transparent: false }
|
|
20
|
+
}
|