@abide/abide 0.36.0 → 0.37.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/AGENTS.md +1 -1
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/lib/server/runtime/buildInFlightSnapshot.ts +35 -0
- package/src/lib/server/runtime/buildInspectorSurface.ts +9 -1
- package/src/lib/server/runtime/createServer.ts +10 -1
- package/src/lib/server/runtime/inFlightRequests.ts +13 -0
- package/src/lib/server/runtime/maybeMountInspector.ts +7 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +7 -0
- package/src/lib/server/runtime/types/InspectorContext.ts +4 -0
- package/src/lib/server/runtime/types/InspectorInFlightRequest.ts +20 -0
- package/src/lib/server/runtime/types/InspectorInFlightSnapshot.ts +10 -0
- package/src/lib/server/runtime/types/InspectorPrompt.ts +15 -0
- package/src/lib/server/runtime/types/InspectorSurface.ts +6 -3
- package/src/lib/shared/emitLogRecord.ts +18 -5
- package/src/lib/ui/compile/componentWrapperTag.ts +10 -20
- package/src/lib/ui/compile/generateBuild.ts +18 -9
- package/src/lib/ui/compile/generateSSR.ts +2 -2
- package/src/lib/ui/createScope.ts +11 -0
- package/src/lib/ui/dom/hydrate.ts +2 -1
- package/src/lib/ui/dom/mount.ts +2 -1
- package/src/lib/ui/dom/scopeLabel.ts +15 -0
- package/src/lib/ui/dom/skeleton.ts +13 -1
- package/src/lib/ui/installInspectorBridge.ts +138 -0
- package/src/lib/ui/navigate.ts +11 -3
- package/src/lib/ui/router.ts +77 -9
- package/src/lib/ui/runtime/historyEntries.ts +113 -0
- package/src/lib/ui/runtime/liveScopes.ts +15 -0
- package/src/lib/ui/runtime/types/AbideHistoryState.ts +9 -0
- package/src/lib/ui/startClient.ts +6 -0
- package/src/lib/ui/types/Scope.ts +3 -0
- package/src/lib/ui/compile/HTML_TAGS.ts +0 -132
package/AGENTS.md
CHANGED
|
@@ -100,7 +100,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
|
|
|
100
100
|
|
|
101
101
|
- `abide/server/AppModule` — the type of `src/app.ts`'s optional hooks: `init` (boot + cleanup), `handle` (middleware), `handleError`, `health` (merges into `/__abide/health`), `forwardHeaders`.
|
|
102
102
|
- `abide/server/agent` — `agent(engine, messages)`: run a model engine against the current request's MCP surface (caller auth forwarded into every tool call), yielding neutral `AgentFrame`s to wrap in `jsonl`/`sse`.
|
|
103
|
-
- `abide/server/InspectorContext` — the capability bundle (`app`, `loadSurface`, `cacheSnapshot`, `onRecord`) handed to `@abide/inspector` when `ABIDE_ENABLE_INSPECTOR=true`.
|
|
103
|
+
- `abide/server/InspectorContext` — the capability bundle (`app`, `loadSurface`, `cacheSnapshot`, `inFlightSnapshot`, `onRecord`) handed to `@abide/inspector` when `ABIDE_ENABLE_INSPECTOR=true`.
|
|
104
104
|
|
|
105
105
|
### Prompts — @documentation plumbing
|
|
106
106
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.37.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - manual scroll restoration + hash-only navigation ([`1b71ef1`](https://github.com/briancray/abide/commit/1b71ef195186c0cee5a36527307f5f981833b546))
|
|
8
|
+
|
|
9
|
+
- [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - lead every tsv line with a wall-clock column ([`5784d47`](https://github.com/briancray/abide/commit/5784d47ddd2d52696fb9db111092c6f571270086))
|
|
10
|
+
|
|
11
|
+
- [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - always-`abide-` component wrapper tag ([`711de46`](https://github.com/briancray/abide/commit/711de46f8b5210cbbff5c73cd7aceda410d87f47))
|
|
12
|
+
|
|
13
|
+
- [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - inspector in-flight requests + prompts surface ([`823392d`](https://github.com/briancray/abide/commit/823392d0e644b3a84661293f86b28ca10d9a9de9))
|
|
14
|
+
|
|
15
|
+
- [`dfd9d9e`](https://github.com/briancray/abide/commit/dfd9d9e9149c16761c539f7b45358e9a969174d8) - inspector client scope/router bridge ([`c25b45e`](https://github.com/briancray/abide/commit/c25b45e778f6f17eeaf0875a4b7ecdd63fccb978))
|
|
16
|
+
|
|
3
17
|
## 0.36.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { inFlightRequests } from './inFlightRequests.ts'
|
|
2
|
+
import type { InspectorInFlightRequest } from './types/InspectorInFlightRequest.ts'
|
|
3
|
+
import type { InspectorInFlightSnapshot } from './types/InspectorInFlightSnapshot.ts'
|
|
4
|
+
import type { RequestStore } from './types/RequestStore.ts'
|
|
5
|
+
|
|
6
|
+
function projectStore(store: RequestStore, now: number): InspectorInFlightRequest {
|
|
7
|
+
return {
|
|
8
|
+
trace: store.trace.traceId,
|
|
9
|
+
method: store.req.method,
|
|
10
|
+
path: `${store.url.pathname}${store.url.search}`,
|
|
11
|
+
/* store.start is Bun.nanoseconds() at scope entry; elapsed is current at the read. */
|
|
12
|
+
elapsedMs: (now - store.start) / 1e6,
|
|
13
|
+
route: store.route,
|
|
14
|
+
params: store.params,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
Snapshots the request scopes currently executing their handler. Read at call
|
|
20
|
+
time so it reflects the in-flight set as it stands; returns empty when the
|
|
21
|
+
inspector hasn't installed the tracking Set or the server is idle. Sorted by
|
|
22
|
+
elapsed ascending — newest (least elapsed) first, so a long-running request
|
|
23
|
+
sinks to the bottom as its elapsed grows.
|
|
24
|
+
*/
|
|
25
|
+
export function buildInFlightSnapshot(): InspectorInFlightSnapshot {
|
|
26
|
+
const tracked = inFlightRequests.tracked
|
|
27
|
+
if (!tracked) {
|
|
28
|
+
return { requests: [] }
|
|
29
|
+
}
|
|
30
|
+
const now = Bun.nanoseconds()
|
|
31
|
+
const requests = Array.from(tracked, (store) => projectStore(store, now)).sort(
|
|
32
|
+
(left, right) => left.elapsedMs - right.elapsedMs,
|
|
33
|
+
)
|
|
34
|
+
return { requests }
|
|
35
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsonSchemaForSchema } from '../../shared/jsonSchemaForSchema.ts'
|
|
2
|
+
import { promptRegistry } from '../prompts/promptRegistry.ts'
|
|
2
3
|
import { verbRegistry } from '../rpc/verbRegistry.ts'
|
|
3
4
|
import { socketOperations } from '../sockets/socketOperations.ts'
|
|
4
5
|
import { socketRegistry } from '../sockets/socketRegistry.ts'
|
|
@@ -33,5 +34,12 @@ export function buildInspectorSurface(): InspectorSurface {
|
|
|
33
34
|
restUrl: operation.restUrl,
|
|
34
35
|
})),
|
|
35
36
|
}))
|
|
36
|
-
|
|
37
|
+
/* jsonSchema is already JSON Schema (built from the frontmatter `arguments`
|
|
38
|
+
list by the resolver plugin), so it's passed through, not re-projected. */
|
|
39
|
+
const prompts = Array.from(promptRegistry.values()).map((entry) => ({
|
|
40
|
+
name: entry.prompt.name,
|
|
41
|
+
description: entry.prompt.description,
|
|
42
|
+
inputSchema: entry.jsonSchema,
|
|
43
|
+
}))
|
|
44
|
+
return { verbs, sockets, prompts }
|
|
37
45
|
}
|
|
@@ -186,6 +186,13 @@ export async function createServer({
|
|
|
186
186
|
// In dev, append the live-reload client to the shell so every rendered
|
|
187
187
|
// page reconnects to /__abide/dev and reloads after a restart.
|
|
188
188
|
const devShell = dev ? shell.replace('</body>', `${DEV_RELOAD_CLIENT_SCRIPT}</body>`) : shell
|
|
189
|
+
// When the inspector is enabled, flag the client so startClient installs the
|
|
190
|
+
// BroadcastChannel bridge that streams scope + router state to the inspector
|
|
191
|
+
// page. Independent of dev — the inspector can run on a build server too.
|
|
192
|
+
const inspectedShell =
|
|
193
|
+
process.env.ABIDE_ENABLE_INSPECTOR === 'true'
|
|
194
|
+
? devShell.replace('</body>', `<script>window.__abideInspect=true</script></body>`)
|
|
195
|
+
: devShell
|
|
189
196
|
/*
|
|
190
197
|
Mount base from APP_URL's pathname (e.g. https://foo.com/v2 → /v2). Install
|
|
191
198
|
the server-side resolver so url() prefixes SSR-generated links, and rewrite
|
|
@@ -197,7 +204,9 @@ export async function createServer({
|
|
|
197
204
|
setBaseResolver(() => base)
|
|
198
205
|
// Rebase the shell's rooted `/_app/` entry refs onto the mount base, matching
|
|
199
206
|
// either quote style so a custom app.html using single quotes still rewrites.
|
|
200
|
-
const activeShell = base
|
|
207
|
+
const activeShell = base
|
|
208
|
+
? inspectedShell.replace(/(["'])\/_app\//g, `$1${base}/_app/`)
|
|
209
|
+
: inspectedShell
|
|
201
210
|
/*
|
|
202
211
|
Boot-path disk scans run concurrently — they share no data, and under
|
|
203
212
|
`abide dev` the worker-swap window is bounded by exactly this boot.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RequestStore } from './types/RequestStore.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The set of request scopes currently executing their handler, for the inspector's
|
|
5
|
+
in-flight view. `tracked` stays undefined until the inspector mounts and swaps in
|
|
6
|
+
a live Set — so the request path adds nothing (no allocation, no membership
|
|
7
|
+
churn) when the inspector is off. runWithRequestScope adds a store on scope entry
|
|
8
|
+
and removes it when the handler settles; buildInFlightSnapshot reads it. Mirrors
|
|
9
|
+
the on-demand, opt-in shape of the log/socket tap slots.
|
|
10
|
+
*/
|
|
11
|
+
export const inFlightRequests: { tracked: Set<RequestStore> | undefined } = {
|
|
12
|
+
tracked: undefined,
|
|
13
|
+
}
|
|
@@ -4,7 +4,9 @@ import { requestScopeSlot } from '../../shared/requestScopeSlot.ts'
|
|
|
4
4
|
import { socketTapSlot } from '../../shared/socketTapSlot.ts'
|
|
5
5
|
import type { LogRecord } from '../../shared/types/LogRecord.ts'
|
|
6
6
|
import { buildCacheSnapshot } from './buildCacheSnapshot.ts'
|
|
7
|
+
import { buildInFlightSnapshot } from './buildInFlightSnapshot.ts'
|
|
7
8
|
import { buildInspectorSurface } from './buildInspectorSurface.ts'
|
|
9
|
+
import { inFlightRequests } from './inFlightRequests.ts'
|
|
8
10
|
import { ensureRegistriesLoaded } from './registryManifests.ts'
|
|
9
11
|
import type { InspectorContext } from './types/InspectorContext.ts'
|
|
10
12
|
|
|
@@ -63,6 +65,10 @@ export async function maybeMountInspector(app: {
|
|
|
63
65
|
)
|
|
64
66
|
return undefined
|
|
65
67
|
}
|
|
68
|
+
/* Arm in-flight tracking now the inspector is mounting: runWithRequestScope
|
|
69
|
+
fills this Set per request, kept process-wide (the events tap teardown is
|
|
70
|
+
per-connection; the Set outlives any single feed). */
|
|
71
|
+
inFlightRequests.tracked = new Set()
|
|
66
72
|
const context: InspectorContext = {
|
|
67
73
|
app,
|
|
68
74
|
loadSurface: async () => {
|
|
@@ -70,6 +76,7 @@ export async function maybeMountInspector(app: {
|
|
|
70
76
|
return buildInspectorSurface()
|
|
71
77
|
},
|
|
72
78
|
cacheSnapshot: buildCacheSnapshot,
|
|
79
|
+
inFlightSnapshot: buildInFlightSnapshot,
|
|
73
80
|
/*
|
|
74
81
|
One process-wide tap each for the log and socket chokepoints; the
|
|
75
82
|
inspector owns both and fans out to its readers. Socket frames arrive
|
|
@@ -5,6 +5,7 @@ import { formatTraceparent } from '../../shared/formatTraceparent.ts'
|
|
|
5
5
|
import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
|
|
6
6
|
import { logClosingRecord } from '../../shared/logClosingRecord.ts'
|
|
7
7
|
import type { AppModule } from '../AppModule.ts'
|
|
8
|
+
import { inFlightRequests } from './inFlightRequests.ts'
|
|
8
9
|
import { internalErrorResponse } from './internalErrorResponse.ts'
|
|
9
10
|
import { requestContext } from './requestContext.ts'
|
|
10
11
|
import type { RequestStore } from './types/RequestStore.ts'
|
|
@@ -34,6 +35,10 @@ export function runWithRequestScope(
|
|
|
34
35
|
start: Bun.nanoseconds(),
|
|
35
36
|
}
|
|
36
37
|
return requestContext.run(store, async () => {
|
|
38
|
+
/* Register the live handler for the inspector's in-flight view; the Set is
|
|
39
|
+
absent (a no-op) unless the inspector mounted. Removed once the handler
|
|
40
|
+
settles — its compute is done even if a streamed body keeps piping. */
|
|
41
|
+
inFlightRequests.tracked?.add(store)
|
|
37
42
|
let response: Response
|
|
38
43
|
try {
|
|
39
44
|
response = await body(store)
|
|
@@ -44,6 +49,8 @@ export function runWithRequestScope(
|
|
|
44
49
|
abideLog.error(error)
|
|
45
50
|
response = internalErrorResponse(error)
|
|
46
51
|
}
|
|
52
|
+
} finally {
|
|
53
|
+
inFlightRequests.tracked?.delete(store)
|
|
47
54
|
}
|
|
48
55
|
/*
|
|
49
56
|
Flush any cookies the handler set onto the outgoing response. Only when
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LogRecord } from '../../../shared/types/LogRecord.ts'
|
|
2
2
|
import type { InspectorCacheSnapshot } from './InspectorCacheSnapshot.ts'
|
|
3
|
+
import type { InspectorInFlightSnapshot } from './InspectorInFlightSnapshot.ts'
|
|
3
4
|
import type { InspectorSurface } from './InspectorSurface.ts'
|
|
4
5
|
|
|
5
6
|
/*
|
|
@@ -19,6 +20,9 @@ export type InspectorContext = {
|
|
|
19
20
|
/* Snapshots the persistent (global) cache store — current entries with their
|
|
20
21
|
lifecycle state, retention, tags, and a value preview. */
|
|
21
22
|
cacheSnapshot: () => InspectorCacheSnapshot
|
|
23
|
+
/* Snapshots the requests whose handler is executing right now — the live
|
|
24
|
+
counterpart to the closing records onRecord emits once a request settles. */
|
|
25
|
+
inFlightSnapshot: () => InspectorInFlightSnapshot
|
|
22
26
|
/*
|
|
23
27
|
Subscribes to the unified event stream: every emitted log record (the log's
|
|
24
28
|
structured form, trace context and all) plus published socket frames shaped
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
One in-flight request projected for the inspector — the serializable facts the
|
|
3
|
+
In-flight tab renders for a handler that's currently executing. The held Request
|
|
4
|
+
and CacheStore aren't included; what an operator wants is which request it is,
|
|
5
|
+
how long it's been running, and where it landed in routing.
|
|
6
|
+
*/
|
|
7
|
+
export type InspectorInFlightRequest = {
|
|
8
|
+
/* The W3C trace id, so the row links to the same trace the Logs/Traces tabs group by. */
|
|
9
|
+
trace: string
|
|
10
|
+
/* HTTP method of the inbound request. */
|
|
11
|
+
method: string
|
|
12
|
+
/* Request path with query string (app-space, as logged). */
|
|
13
|
+
path: string
|
|
14
|
+
/* Ms the handler has been running, measured from scope entry to snapshot time. */
|
|
15
|
+
elapsedMs: number
|
|
16
|
+
/* The matched page route pattern, once routing has landed; undefined on rpc/socket requests or before a match. */
|
|
17
|
+
route: string | undefined
|
|
18
|
+
/* The matched route's decoded params, when a page route landed. */
|
|
19
|
+
params: Record<string, string> | undefined
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { InspectorInFlightRequest } from './InspectorInFlightRequest.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The requests executing their handler at snapshot time — a point-in-time read of
|
|
5
|
+
the in-flight set, the live counterpart to the closing records the Logs tab
|
|
6
|
+
shows once a request settles. Empty when the server is idle.
|
|
7
|
+
*/
|
|
8
|
+
export type InspectorInFlightSnapshot = {
|
|
9
|
+
requests: InspectorInFlightRequest[]
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
One declared MCP prompt projected for the inspector: the serializable facts the
|
|
3
|
+
Surface tab renders. The render closure isn't included — what an operator wants
|
|
4
|
+
is the prompt's name, what it's for, and the arguments it interpolates, the same
|
|
5
|
+
trio `prompts/list` advertises. Prompts are MCP-only, so there are no client
|
|
6
|
+
surface flags to show.
|
|
7
|
+
*/
|
|
8
|
+
export type InspectorPrompt = {
|
|
9
|
+
/* The prompt's name (stamped from its file path under src/mcp/prompts/). */
|
|
10
|
+
name: string
|
|
11
|
+
/* The frontmatter description, when the prompt declared one. */
|
|
12
|
+
description: string | undefined
|
|
13
|
+
/* The argument shape as JSON Schema (from the frontmatter `arguments` list); undefined when it takes none. */
|
|
14
|
+
inputSchema: Record<string, unknown> | undefined
|
|
15
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import type { InspectorPrompt } from './InspectorPrompt.ts'
|
|
1
2
|
import type { InspectorSocket } from './InspectorSocket.ts'
|
|
2
3
|
import type { InspectorVerb } from './InspectorVerb.ts'
|
|
3
4
|
|
|
4
5
|
/*
|
|
5
6
|
The app's machine surface projected for the inspector — the static catalog the
|
|
6
|
-
UI renders. Built by reading the verb and
|
|
7
|
-
eager-loaded, so a freshly-booted server lists its whole surface, not
|
|
8
|
-
verbs hit so far. Pages stay out: they're the human surface, already
|
|
7
|
+
UI renders. Built by reading the verb, socket, and prompt registries after
|
|
8
|
+
they're eager-loaded, so a freshly-booted server lists its whole surface, not
|
|
9
|
+
just the verbs hit so far. Pages stay out: they're the human surface, already
|
|
10
|
+
navigable.
|
|
9
11
|
*/
|
|
10
12
|
export type InspectorSurface = {
|
|
11
13
|
verbs: InspectorVerb[]
|
|
12
14
|
sockets: InspectorSocket[]
|
|
15
|
+
prompts: InspectorPrompt[]
|
|
13
16
|
}
|
|
@@ -66,6 +66,15 @@ function formatElapsed(ms: number): string {
|
|
|
66
66
|
return `+${ms.toFixed(2)}ms`
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/* `14:23:01.072` — local wall-clock to the ms. Every record carries `ts`, so the
|
|
70
|
+
tsv line always leads with one; `+elapsedMs` still trails as the request-relative
|
|
71
|
+
timing — the two are different axes (when it happened vs how long it took). */
|
|
72
|
+
function formatClock(ts: number): string {
|
|
73
|
+
const date = new Date(ts)
|
|
74
|
+
const pad = (value: number, width = 2): string => String(value).padStart(width, '0')
|
|
75
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`
|
|
76
|
+
}
|
|
77
|
+
|
|
69
78
|
/*
|
|
70
79
|
Builds the record from the emission's own fields plus the ambient request
|
|
71
80
|
scope (trace, elapsed, verb+path), then renders it through the active
|
|
@@ -123,11 +132,12 @@ function consoleFor(level: LogRecord['level']): (...args: unknown[]) => void {
|
|
|
123
132
|
|
|
124
133
|
/*
|
|
125
134
|
The unified tsv line (the default format): tab-separated
|
|
126
|
-
`<trace8> <verb path> [channel] <message> +0.00ms`.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
135
|
+
`<clock> <trace8> <verb path> [channel] <message> +0.00ms`. The
|
|
136
|
+
wall-clock leads every line (each record carries `ts`); inside a request scope
|
|
137
|
+
the trace column follows and the elapsed-at-emission timing trails; a closing
|
|
138
|
+
record emitted outside one (asset hits sidestep the scope) pads a blank trace
|
|
139
|
+
column and trails its serve duration instead, so request lines stay aligned
|
|
140
|
+
whatever produced them. Every record speaks on a channel
|
|
131
141
|
(the app name, 'abide', or a diagnostic channel), shown as a dim `[name]`
|
|
132
142
|
tag opening the message field. The verb+path pair is one field — it's the
|
|
133
143
|
line's anchor unit — and the tag folds into the message field so field
|
|
@@ -136,6 +146,9 @@ positions stay stable for cut/awk consumers.
|
|
|
136
146
|
function printTsv(record: LogRecord, voice?: LogVoice): void {
|
|
137
147
|
const fields: string[] = []
|
|
138
148
|
const closing = record.status !== undefined
|
|
149
|
+
/* Wall-clock leads every line — a stable first column, unlike the conditional
|
|
150
|
+
trace field, and the axis `+elapsedMs` can't give (when, not how-long). */
|
|
151
|
+
fields.push(dim(formatClock(record.ts)))
|
|
139
152
|
if (record.trace) {
|
|
140
153
|
fields.push(dim(record.trace.slice(0, 8)))
|
|
141
154
|
} else if (closing) {
|
|
@@ -1,23 +1,13 @@
|
|
|
1
|
-
import { HTML_TAGS } from './HTML_TAGS.ts'
|
|
2
|
-
|
|
3
1
|
/*
|
|
4
|
-
The element tag a component instance mounts into
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
layout-transparent with `display:contents`, so the component's real root still lays
|
|
13
|
-
out as a direct child of the parent the way the (parse-broken) wrapper would have.
|
|
14
|
-
A name that is NOT a known HTML element (the common case — `Card`, `Dropdown`) is an
|
|
15
|
-
inert unknown tag that holds any content untouched, so it stays as-is. Both back-ends
|
|
16
|
-
call this so the SSR string and the client build agree on the wrapper.
|
|
2
|
+
The element tag a component instance mounts into: always `abide-<name>` lowercased. The
|
|
3
|
+
`abide-` prefix makes every wrapper a valid custom element (contains a hyphen) — never
|
|
4
|
+
void, no content model — so it holds the component's own markup untouched and hydrates
|
|
5
|
+
cleanly, regardless of whether the name collides with an HTML element (`Button`, `Input`).
|
|
6
|
+
Emitted with `display:contents` (see the back-ends) so the wrapper stays out of layout: a
|
|
7
|
+
pure mount host whose real root lays out as a direct child of the parent, keeping the
|
|
8
|
+
component invisible to `grid`/`subgrid`/`flex`. Both back-ends call this so the SSR string
|
|
9
|
+
and the client build agree on the wrapper.
|
|
17
10
|
*/
|
|
18
|
-
export function componentWrapperTag(name: string):
|
|
19
|
-
|
|
20
|
-
return HTML_TAGS.has(lower)
|
|
21
|
-
? { tag: `abide-${lower}`, transparent: true }
|
|
22
|
-
: { tag: lower, transparent: false }
|
|
11
|
+
export function componentWrapperTag(name: string): string {
|
|
12
|
+
return `abide-${name.toLowerCase()}`
|
|
23
13
|
}
|
|
@@ -4,9 +4,9 @@ import { bindListenEvent } from './bindListenEvent.ts'
|
|
|
4
4
|
import { componentWrapperTag } from './componentWrapperTag.ts'
|
|
5
5
|
import { groupBindParts } from './groupBindParts.ts'
|
|
6
6
|
import { isControlFlow } from './isControlFlow.ts'
|
|
7
|
-
import { isTextLeaf } from './isTextLeaf.ts'
|
|
8
7
|
import { lowerContext } from './lowerContext.ts'
|
|
9
8
|
import { scopeAttr } from './scopeAttr.ts'
|
|
9
|
+
import { skeletonContext } from './skeletonContext.ts'
|
|
10
10
|
import { staticAttr } from './staticAttr.ts'
|
|
11
11
|
import { staticTextPart } from './staticTextPart.ts'
|
|
12
12
|
import type { TemplateNode } from './types/TemplateNode.ts'
|
|
@@ -32,6 +32,13 @@ export function generateBuild(
|
|
|
32
32
|
let counter = 0
|
|
33
33
|
const nextVar = (prefix: string): string => `${prefix}${counter++}`
|
|
34
34
|
|
|
35
|
+
/* Per-node skeleton position from the SAME pass the SSR back-end reads — so the client's
|
|
36
|
+
anchor/text-leaf decisions consult one source of truth instead of re-deriving the
|
|
37
|
+
position structurally (the drift the shared context exists to prevent). `asOutlet`
|
|
38
|
+
only rewrites layout `<slot>`s and preserves text-node identity, so the original
|
|
39
|
+
`nodes` key the same text markers the build traversal looks up. */
|
|
40
|
+
const { markText } = skeletonContext(nodes)
|
|
41
|
+
|
|
35
42
|
/* The shared signal→`model` lowering + branch-scoped nested-script deref scope. */
|
|
36
43
|
const {
|
|
37
44
|
expression: lowerExpression,
|
|
@@ -135,13 +142,12 @@ export function generateBuild(
|
|
|
135
142
|
}
|
|
136
143
|
if (node.kind === 'component') {
|
|
137
144
|
/* The wrapper element is a positioned hole in the skeleton; the child mounts
|
|
138
|
-
into the located node
|
|
139
|
-
|
|
140
|
-
const
|
|
145
|
+
into the located node. display:contents (idempotent, static so it lives in
|
|
146
|
+
the markup) keeps the wrapper out of layout — a pure mount host. */
|
|
147
|
+
const tag = componentWrapperTag(node.name)
|
|
141
148
|
const { code } = mountComponent(node, `${skVar}.el[${counter.el++}]`)
|
|
142
149
|
binds.push(code)
|
|
143
|
-
|
|
144
|
-
return `<${tag} ${HOLE_ATTRIBUTE}${style}></${tag}>`
|
|
150
|
+
return `<${tag} ${HOLE_ATTRIBUTE} style="display:contents"></${tag}>`
|
|
145
151
|
}
|
|
146
152
|
if (node.kind === 'script') {
|
|
147
153
|
/* A nested `<script>` (scoped reactive block) emits no markup — its lowered body
|
|
@@ -172,13 +178,16 @@ export function generateBuild(
|
|
|
172
178
|
return '<!--a-->'
|
|
173
179
|
}
|
|
174
180
|
const hasReactiveAttr = node.attrs.some((attr) => attr.kind !== 'static')
|
|
175
|
-
const
|
|
181
|
+
const reactiveTextChild = node.children.find(
|
|
176
182
|
(child) => child.kind === 'text' && child.parts.some((part) => part.kind !== 'static'),
|
|
177
183
|
)
|
|
178
184
|
/* A text-leaf (only text/style children) with reactive text binds marker-free via
|
|
179
185
|
`generateChildren` on the located element; otherwise reactive text is interleaved
|
|
180
|
-
and uses `<!--a-->` anchors during the child recursion below.
|
|
181
|
-
|
|
186
|
+
and uses `<!--a-->` anchors during the child recursion below. The shared context
|
|
187
|
+
records the leaf's text as NOT interleaved (`markText` false) — read that flag the
|
|
188
|
+
SSR back-end also reads, rather than re-deriving leaf-ness via `isTextLeaf` here. */
|
|
189
|
+
const textLeafBind =
|
|
190
|
+
reactiveTextChild !== undefined && markText.get(reactiveTextChild) === false
|
|
182
191
|
let openTag = `<${node.tag}`
|
|
183
192
|
let elVar = ''
|
|
184
193
|
if (hasReactiveAttr || textLeafBind) {
|
|
@@ -184,7 +184,7 @@ export function generateSSR(
|
|
|
184
184
|
the same wrapper the client mounts into, so SSR and client agree.
|
|
185
185
|
Props pass as thunks; slot content passes as a string-returning
|
|
186
186
|
`$children` the child invokes from its <slot>. */
|
|
187
|
-
const
|
|
187
|
+
const tag = componentWrapperTag(node.name)
|
|
188
188
|
const parts = node.props.map(
|
|
189
189
|
(prop) => `${JSON.stringify(prop.name)}: () => (${lowerExpression(prop.code)})`,
|
|
190
190
|
)
|
|
@@ -206,7 +206,7 @@ export function generateSSR(
|
|
|
206
206
|
the enclosing render body, including from branch closures.) */
|
|
207
207
|
const result = nextVar('$child')
|
|
208
208
|
return (
|
|
209
|
-
push(target, `<${tag}
|
|
209
|
+
push(target, `<${tag} style="display:contents">`) +
|
|
210
210
|
`const ${result} = ${node.name}.render({ ${parts.join(', ')} });\n` +
|
|
211
211
|
`${target}.push(${result}.html);\n` +
|
|
212
212
|
`for (const $a of ${result}.awaits) { $awaits.push($a); }\n` +
|
|
@@ -3,6 +3,7 @@ import { history } from './history.ts'
|
|
|
3
3
|
import { linked } from './linked.ts'
|
|
4
4
|
import { persist as persistDoc } from './persist.ts'
|
|
5
5
|
import { createDoc } from './runtime/createDoc.ts'
|
|
6
|
+
import { liveScopes } from './runtime/liveScopes.ts'
|
|
6
7
|
import type { Doc } from './runtime/types/Doc.ts'
|
|
7
8
|
import { state } from './state.ts'
|
|
8
9
|
import { sync } from './sync.ts'
|
|
@@ -28,6 +29,7 @@ export function createScope(
|
|
|
28
29
|
initial: unknown = {},
|
|
29
30
|
parent: Scope | undefined = undefined,
|
|
30
31
|
awaiting = false,
|
|
32
|
+
label: string | undefined = undefined,
|
|
31
33
|
): Scope {
|
|
32
34
|
/* Eager unless awaiting adoption; `data()` lazily mints an empty doc if a body
|
|
33
35
|
never created one (a stateless component that still reaches for its scope). */
|
|
@@ -41,6 +43,7 @@ export function createScope(
|
|
|
41
43
|
|
|
42
44
|
const self: Scope = {
|
|
43
45
|
id,
|
|
46
|
+
label,
|
|
44
47
|
parent,
|
|
45
48
|
read: (path) => data().read(path),
|
|
46
49
|
replace: (path, value) => data().replace(path, value),
|
|
@@ -85,7 +88,15 @@ export function createScope(
|
|
|
85
88
|
persistence = undefined
|
|
86
89
|
unsync?.()
|
|
87
90
|
unsync = undefined
|
|
91
|
+
if (liveScopes.enabled) {
|
|
92
|
+
liveScopes.scopes.delete(self)
|
|
93
|
+
}
|
|
88
94
|
},
|
|
89
95
|
}
|
|
96
|
+
/* Dev-only: register for the inspector's scope-tree view. Gated, so production
|
|
97
|
+
never touches the set. */
|
|
98
|
+
if (liveScopes.enabled) {
|
|
99
|
+
liveScopes.scopes.add(self)
|
|
100
|
+
}
|
|
90
101
|
return self
|
|
91
102
|
}
|
|
@@ -4,6 +4,7 @@ import { enterRenderPass } from '../runtime/enterRenderPass.ts'
|
|
|
4
4
|
import { exitRenderPass } from '../runtime/exitRenderPass.ts'
|
|
5
5
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
6
6
|
import { scope } from '../runtime/scope.ts'
|
|
7
|
+
import { scopeLabel } from './scopeLabel.ts'
|
|
7
8
|
|
|
8
9
|
/*
|
|
9
10
|
Adopts existing server-rendered DOM instead of rebuilding it. Runs `build(host)`
|
|
@@ -28,7 +29,7 @@ export function hydrate(host: Element, build: (host: Element) => void): () => vo
|
|
|
28
29
|
/* Same lexical scope establishment as `mount` — a hydrated component owns a scope
|
|
29
30
|
too, adopting the model its build adopts. */
|
|
30
31
|
const parentScope = CURRENT_SCOPE.current
|
|
31
|
-
const lexical = createScope({}, parentScope, true)
|
|
32
|
+
const lexical = createScope({}, parentScope, true, scopeLabel(host))
|
|
32
33
|
CURRENT_SCOPE.current = lexical
|
|
33
34
|
try {
|
|
34
35
|
const stop = scope(() => {
|
package/src/lib/ui/dom/mount.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { CURRENT_SCOPE } from '../runtime/CURRENT_SCOPE.ts'
|
|
|
3
3
|
import { enterRenderPass } from '../runtime/enterRenderPass.ts'
|
|
4
4
|
import { exitRenderPass } from '../runtime/exitRenderPass.ts'
|
|
5
5
|
import { scope } from '../runtime/scope.ts'
|
|
6
|
+
import { scopeLabel } from './scopeLabel.ts'
|
|
6
7
|
|
|
7
8
|
/*
|
|
8
9
|
Mounts a component into `host`: runs `build(host)` under an ownership scope so
|
|
@@ -22,7 +23,7 @@ export function mount(host: Element, build: (host: Element) => void): () => void
|
|
|
22
23
|
`scope()` and its capabilities resolve to it during the build; the previous
|
|
23
24
|
scope is restored after (synchronous build, so the restore is exact). */
|
|
24
25
|
const parentScope = CURRENT_SCOPE.current
|
|
25
|
-
const lexical = createScope({}, parentScope, true)
|
|
26
|
+
const lexical = createScope({}, parentScope, true, scopeLabel(host))
|
|
26
27
|
CURRENT_SCOPE.current = lexical
|
|
27
28
|
const stop = scope(() => {
|
|
28
29
|
try {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The human-readable name for the scope a mount/hydrate establishes, derived from
|
|
3
|
+
its host element's tag. A nested component mounts into its `abide-<name>` wrapper
|
|
4
|
+
(see `componentWrapperTag`), so stripping the `abide-` prefix recovers the
|
|
5
|
+
component name; any other host (a page/layout outlet) yields its lowercased tag.
|
|
6
|
+
Dev-only — feeds the inspector's Reactive tab so a scope reads `<Counter>` rather
|
|
7
|
+
than an opaque counter id. Returns undefined when there's no element to name from.
|
|
8
|
+
*/
|
|
9
|
+
export function scopeLabel(host: Element): string | undefined {
|
|
10
|
+
const tag = host.tagName?.toLowerCase()
|
|
11
|
+
if (tag === undefined) {
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
return tag.startsWith('abide-') ? tag.slice('abide-'.length) : tag
|
|
15
|
+
}
|
|
@@ -26,6 +26,16 @@ function isElement(node: Node): node is Element {
|
|
|
26
26
|
return typeof (node as Element).hasAttribute === 'function'
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/* A child component's mount wrapper (`abide-<name>`, see `componentWrapperTag`). Its
|
|
30
|
+
content is a SEPARATE skeleton (the child's own), so the parent's walks must treat it
|
|
31
|
+
as opaque: in the shallow skeleton it's an empty leaf, so the compiler counts no
|
|
32
|
+
anchors inside it; on hydrate it's populated, so a descent would over-collect the
|
|
33
|
+
child's anchors and shift every parent index past it (same hazard as a block range,
|
|
34
|
+
but bounded by the wrapper element instead of `[`…`]` markers). */
|
|
35
|
+
function isComponentWrapper(node: Node): boolean {
|
|
36
|
+
return isElement(node) && (node.tagName ?? '').toLowerCase().startsWith('abide-')
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
/* A comment node's data, or undefined for elements/text. A comment is a node that is
|
|
30
40
|
neither an element (`hasAttribute`) nor a text node (`splitText`); the mini-dom
|
|
31
41
|
exposes no `nodeType`, so detect by method. */
|
|
@@ -113,7 +123,9 @@ function scanAnchors(nodes: ArrayLike<Node>, anchors: Node[]): void {
|
|
|
113
123
|
const node = nodes[index] as Node
|
|
114
124
|
const data = commentData(node)
|
|
115
125
|
if (data === undefined) {
|
|
116
|
-
|
|
126
|
+
/* Recurse into this skeleton's own elements, but NOT a child component's
|
|
127
|
+
wrapper — its anchors belong to the child's skeleton (see above). */
|
|
128
|
+
if (isElement(node) && depth === 0 && !isComponentWrapper(node)) {
|
|
117
129
|
scanAnchors(node.childNodes, anchors)
|
|
118
130
|
}
|
|
119
131
|
} else if (isCloseMarker(data)) {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { effect } from './effect.ts'
|
|
2
|
+
import { clientPage } from './runtime/clientPage.ts'
|
|
3
|
+
import { historyEntries } from './runtime/historyEntries.ts'
|
|
4
|
+
import { liveScopes } from './runtime/liveScopes.ts'
|
|
5
|
+
import { PATCH_BUS } from './runtime/PATCH_BUS.ts'
|
|
6
|
+
import { runtimePath } from './runtime/runtimePath.ts'
|
|
7
|
+
|
|
8
|
+
/* Same-origin cross-tab channel the inspector page listens on. The app tab
|
|
9
|
+
publishes scope + router state; the inspector subscribes. Web-standard, so no
|
|
10
|
+
server route or buffer is involved — core publishes, the package consumes. */
|
|
11
|
+
const CHANNEL = 'abide:inspector'
|
|
12
|
+
|
|
13
|
+
/* Coalesce a burst of patches into one snapshot — the post is the cost, the read is cheap. */
|
|
14
|
+
const SNAPSHOT_DEBOUNCE_MS = 60
|
|
15
|
+
|
|
16
|
+
/* JSON round-trip to strip anything structured-clone can't carry (functions,
|
|
17
|
+
class instances, DOM nodes) before postMessage, degrading to undefined rather
|
|
18
|
+
than throwing into the channel. */
|
|
19
|
+
function cloneable(value: unknown): unknown {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(JSON.stringify(value))
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* The live scope forest as a flat list — the inspector rebuilds the tree from
|
|
28
|
+
each node's `parent` id (the Scope surface exposes no children accessor). */
|
|
29
|
+
function scopeNodes(): Array<{
|
|
30
|
+
id: string
|
|
31
|
+
label: string | undefined
|
|
32
|
+
parent: string | undefined
|
|
33
|
+
state: unknown
|
|
34
|
+
recorded: boolean
|
|
35
|
+
}> {
|
|
36
|
+
return Array.from(liveScopes.scopes, (scope) => ({
|
|
37
|
+
id: scope.id,
|
|
38
|
+
label: scope.label,
|
|
39
|
+
parent: scope.parent?.id,
|
|
40
|
+
state: cloneable(scope.snapshot()),
|
|
41
|
+
recorded: scope.canUndo() || scope.canRedo(),
|
|
42
|
+
}))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Router state off the existing reactive holders — read, not subscribed, here;
|
|
46
|
+
the nav effect drives re-reads. */
|
|
47
|
+
function routerState() {
|
|
48
|
+
const page = clientPage.value
|
|
49
|
+
return {
|
|
50
|
+
path: runtimePath.value,
|
|
51
|
+
route: page.route,
|
|
52
|
+
params: page.params,
|
|
53
|
+
navigating: page.navigating,
|
|
54
|
+
url: page.url.href,
|
|
55
|
+
entry: historyEntries.current,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
Installs the client→inspector bridge: a BroadcastChannel the inspector page reads
|
|
61
|
+
to render its Reactive + Router tabs. Gated by `__abideInspect` (server-injected
|
|
62
|
+
only when the inspector is enabled) and called from startClient before the router
|
|
63
|
+
builds any scope — so registration is armed before the first scope exists. A
|
|
64
|
+
no-op where BroadcastChannel is absent (SSR, old runtimes). Mirrors
|
|
65
|
+
installHotBridge: dev instrumentation, not public surface.
|
|
66
|
+
*/
|
|
67
|
+
export function installInspectorBridge(): void {
|
|
68
|
+
/* No BroadcastChannel means no consumer for the scope registry — bail before
|
|
69
|
+
arming `liveScopes.enabled`, so createScope never pays scope tracking for a
|
|
70
|
+
set nothing reads. */
|
|
71
|
+
if (typeof BroadcastChannel === 'undefined') {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
liveScopes.enabled = true
|
|
75
|
+
const channel = new BroadcastChannel(CHANNEL)
|
|
76
|
+
const tab = typeof crypto !== 'undefined' ? crypto.randomUUID() : String(performance.now())
|
|
77
|
+
const post = (message: object) => {
|
|
78
|
+
try {
|
|
79
|
+
channel.postMessage({ tab, ...message })
|
|
80
|
+
} catch {
|
|
81
|
+
/* A non-cloneable slipped through despite the JSON pass — drop the frame
|
|
82
|
+
rather than throw into a patch/nav handler. */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const announce = () =>
|
|
87
|
+
post({
|
|
88
|
+
kind: 'announce',
|
|
89
|
+
url: typeof location !== 'undefined' ? location.href : '',
|
|
90
|
+
app: typeof document !== 'undefined' ? document.title : '',
|
|
91
|
+
})
|
|
92
|
+
const sendSnapshot = () =>
|
|
93
|
+
post({ kind: 'snapshot', scopes: scopeNodes(), router: routerState() })
|
|
94
|
+
|
|
95
|
+
let snapshotTimer: ReturnType<typeof setTimeout> | undefined
|
|
96
|
+
const scheduleSnapshot = () => {
|
|
97
|
+
clearTimeout(snapshotTimer)
|
|
98
|
+
snapshotTimer = setTimeout(sendSnapshot, SNAPSHOT_DEBOUNCE_MS)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* Inspector handshakes: `hello` (it just opened — everyone re-announce + push)
|
|
102
|
+
and `request` (it wants a fresh snapshot of this tab). */
|
|
103
|
+
channel.onmessage = (event) => {
|
|
104
|
+
const message = event.data as { kind?: string; tab?: string }
|
|
105
|
+
if (message.kind === 'hello') {
|
|
106
|
+
announce()
|
|
107
|
+
sendSnapshot()
|
|
108
|
+
} else if (message.kind === 'request' && message.tab === tab) {
|
|
109
|
+
sendSnapshot()
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* Every doc mutation flows through PATCH_BUS; forward the patch as a live
|
|
114
|
+
mutation event and re-snapshot (debounced) so values stay current. */
|
|
115
|
+
PATCH_BUS.subscribe((patchEvent) => {
|
|
116
|
+
post({ kind: 'patch', op: patchEvent.patch.op, path: patchEvent.patch.path })
|
|
117
|
+
scheduleSnapshot()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
/* Reading the page + path States tracks them, so this re-runs on navigation. */
|
|
121
|
+
effect(() => {
|
|
122
|
+
clientPage.value
|
|
123
|
+
runtimePath.value
|
|
124
|
+
post({ kind: 'nav', router: routerState() })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
if (typeof addEventListener === 'function') {
|
|
128
|
+
addEventListener('pagehide', () => {
|
|
129
|
+
post({ kind: 'bye' })
|
|
130
|
+
channel.close()
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
announce()
|
|
135
|
+
/* First snapshot once the router has built the initial scope tree (this runs
|
|
136
|
+
before router()). */
|
|
137
|
+
setTimeout(sendSnapshot, 0)
|
|
138
|
+
}
|
package/src/lib/ui/navigate.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
+
import { historyEntries } from './runtime/historyEntries.ts'
|
|
1
2
|
import { runtimePath } from './runtime/runtimePath.ts'
|
|
2
3
|
|
|
3
4
|
/* Navigates to `path`: writes a history entry (when available) and updates the
|
|
4
5
|
reactive route, which re-mounts the matching page via `router`. `replace` swaps
|
|
5
6
|
the current entry instead of pushing — used when honouring a server redirect, so
|
|
6
|
-
the blocked URL isn't left behind in history.
|
|
7
|
+
the blocked URL isn't left behind in history. Each entry carries a monotonic
|
|
8
|
+
`abideEntry` id so the router can bucket/restore its scroll offset across the page
|
|
9
|
+
teardown the rebuild does. A push leaves the current entry behind — its scroll is
|
|
10
|
+
bucketed so back restores it — and mints a fresh id. A replace destroys the current
|
|
11
|
+
entry and lands fresh content (a redirect), so its saved scroll no longer applies:
|
|
12
|
+
the bucket is discarded and the id kept, so the new page restores to top/anchor. */
|
|
7
13
|
// @documentation navigate
|
|
8
14
|
export function navigate(path: string, replace = false): void {
|
|
9
15
|
if (typeof history !== 'undefined') {
|
|
10
16
|
if (replace) {
|
|
11
|
-
|
|
17
|
+
historyEntries.discard()
|
|
18
|
+
history.replaceState({ abideEntry: historyEntries.current }, '', path)
|
|
12
19
|
} else {
|
|
13
|
-
|
|
20
|
+
historyEntries.save()
|
|
21
|
+
history.pushState({ abideEntry: historyEntries.next() }, '', path)
|
|
14
22
|
}
|
|
15
23
|
}
|
|
16
24
|
runtimePath.value = path
|
package/src/lib/ui/router.ts
CHANGED
|
@@ -6,7 +6,9 @@ import { clientPage } from './runtime/clientPage.ts'
|
|
|
6
6
|
import { enterRenderPass } from './runtime/enterRenderPass.ts'
|
|
7
7
|
import { exitRenderPass } from './runtime/exitRenderPass.ts'
|
|
8
8
|
import { firstOutlet } from './runtime/firstOutlet.ts'
|
|
9
|
+
import { historyEntries } from './runtime/historyEntries.ts'
|
|
9
10
|
import { runtimePath } from './runtime/runtimePath.ts'
|
|
11
|
+
import type { AbideHistoryState } from './runtime/types/AbideHistoryState.ts'
|
|
10
12
|
import type { NavVerdict } from './runtime/types/NavVerdict.ts'
|
|
11
13
|
import type { Route } from './runtime/types/Route.ts'
|
|
12
14
|
import type { RouteLoader } from './runtime/types/RouteLoader.ts'
|
|
@@ -20,6 +22,15 @@ type MountedLayout = { key: string; dispose: () => void; outlet: Element }
|
|
|
20
22
|
/* A layout mount that returns no disposer still needs one for the chain teardown. */
|
|
21
23
|
const noop = (): void => {}
|
|
22
24
|
|
|
25
|
+
/* The destination URL for a navigation `path`. On the server / headless there is no
|
|
26
|
+
`location`, so resolve against a localhost origin; in the browser, against the real
|
|
27
|
+
origin — `location` is already updated to `path` by the time a swap reads it, so this
|
|
28
|
+
matches `new URL(location.href)`. */
|
|
29
|
+
const resolveUrl = (path: string): URL =>
|
|
30
|
+
typeof location === 'undefined'
|
|
31
|
+
? new URL(`http://localhost${path}`)
|
|
32
|
+
: new URL(path, location.origin)
|
|
33
|
+
|
|
23
34
|
/*
|
|
24
35
|
A minimal client router on the History API. `router` matches the current path
|
|
25
36
|
against the route patterns (literal / `[name]` / `[...rest]`, via matchRoute),
|
|
@@ -34,6 +45,11 @@ is disposed and rebuilt, and the page (always the leaf) re-mounts every time. Th
|
|
|
34
45
|
reactive `page` proxy means a persisted layout reading route/params updates in place
|
|
35
46
|
without a remount. Each chunk loads only on first visit, cached after.
|
|
36
47
|
|
|
48
|
+
Scroll restoration is manual (`historyEntries`): because the page is rebuilt after the
|
|
49
|
+
browser would restore scroll, each history entry's offset is bucketed by an `abideEntry`
|
|
50
|
+
id and reapplied once the destination DOM exists — back/forward returns to its offset, a
|
|
51
|
+
fresh navigation scrolls to the top.
|
|
52
|
+
|
|
37
53
|
`probe` (when given) runs each post-boot navigation's destination through the
|
|
38
54
|
server's app.handle first, so auth/redirect gating applies to client navigation
|
|
39
55
|
just as it does to a fresh load; its verdict either clears the mount, soft-redirects
|
|
@@ -134,9 +150,22 @@ export function router(
|
|
|
134
150
|
run()
|
|
135
151
|
}
|
|
136
152
|
|
|
153
|
+
const entryOf = (): number =>
|
|
154
|
+
(history.state as AbideHistoryState | null)?.abideEntry ?? historyEntries.current
|
|
137
155
|
const onPopState = (): void => {
|
|
156
|
+
/* Bucket the leaving entry's scroll (current still its id) before adopting the
|
|
157
|
+
one back/forward landed on; `swap` restores the adopted entry's offset once
|
|
158
|
+
its DOM is rebuilt. */
|
|
159
|
+
historyEntries.save()
|
|
160
|
+
historyEntries.adopt(entryOf())
|
|
138
161
|
runtimePath.value = location.pathname + location.search + location.hash
|
|
139
162
|
}
|
|
163
|
+
const onPageHide = (): void => {
|
|
164
|
+
/* Mirror the live scroll into the active entry's state before it unloads, so a
|
|
165
|
+
reload can recover it — the in-memory bucket is gone and `manual` keeps the
|
|
166
|
+
browser from restoring. */
|
|
167
|
+
historyEntries.persist()
|
|
168
|
+
}
|
|
140
169
|
const onClick = (event: MouseEvent): void => {
|
|
141
170
|
/* Let the browser own anything that isn't a plain primary-button click:
|
|
142
171
|
modified clicks (open in a new tab/window), middle/right buttons, and
|
|
@@ -175,7 +204,22 @@ export function router(
|
|
|
175
204
|
navigate(destination.pathname + destination.search + destination.hash)
|
|
176
205
|
}
|
|
177
206
|
if (typeof window !== 'undefined') {
|
|
207
|
+
/* Own scroll restoration: the browser would restore against the pre-teardown
|
|
208
|
+
DOM. Adopt the initial entry's id (survives a reload) and stamp it onto the
|
|
209
|
+
landing entry — merging so any `scroll` a prior unload persisted into this
|
|
210
|
+
entry's state stays put for the first-paint `restore` to recover. */
|
|
211
|
+
if ('scrollRestoration' in history) {
|
|
212
|
+
history.scrollRestoration = 'manual'
|
|
213
|
+
}
|
|
214
|
+
historyEntries.adopt(entryOf())
|
|
215
|
+
const landingState = (history.state as AbideHistoryState | null) ?? {}
|
|
216
|
+
history.replaceState(
|
|
217
|
+
{ ...landingState, abideEntry: historyEntries.current },
|
|
218
|
+
'',
|
|
219
|
+
location.href,
|
|
220
|
+
)
|
|
178
221
|
window.addEventListener('popstate', onPopState)
|
|
222
|
+
window.addEventListener('pagehide', onPageHide)
|
|
179
223
|
document.addEventListener('click', onClick as EventListener)
|
|
180
224
|
}
|
|
181
225
|
|
|
@@ -200,6 +244,29 @@ export function router(
|
|
|
200
244
|
/* The route matches on the pathname only; the query/hash ride along for
|
|
201
245
|
the probe (so server gating sees them) and for clientPage.url. */
|
|
202
246
|
const pathname = path.split(/[?#]/)[0] ?? path
|
|
247
|
+
/* A same-document navigation — only the `#hash` (and thus scroll) differs from
|
|
248
|
+
the mounted page — needs no teardown: the live page stays, page.url
|
|
249
|
+
republishes (so `page.url.hash` updates in place), and we restore the entry's
|
|
250
|
+
scroll bucket or scroll to the anchor. A differing pathname or query still
|
|
251
|
+
rebuilds (a query is page data). Skipped on first paint — nothing is mounted. */
|
|
252
|
+
const targetUrl = resolveUrl(path)
|
|
253
|
+
const mountedUrl = clientPage.value.url
|
|
254
|
+
if (
|
|
255
|
+
!first &&
|
|
256
|
+
mountedUrl.pathname === targetUrl.pathname &&
|
|
257
|
+
mountedUrl.search === targetUrl.search &&
|
|
258
|
+
mountedUrl.hash !== targetUrl.hash
|
|
259
|
+
) {
|
|
260
|
+
/* Invalidate any in-flight full navigation so its late `.then` can't
|
|
261
|
+
rebuild over the page this hash hop keeps mounted (the token guard
|
|
262
|
+
only bails on a NEWER sequence). That bailed `.then` is also the only
|
|
263
|
+
writer of `navigating: false`, so clear the flag here — a hash hop is
|
|
264
|
+
synchronous and settles immediately. */
|
|
265
|
+
sequence += 1
|
|
266
|
+
clientPage.value = { ...clientPage.value, url: targetUrl, navigating: false }
|
|
267
|
+
historyEntries.restore(targetUrl.hash)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
203
270
|
const matched = matchRoute(patterns, pathname)
|
|
204
271
|
const key = matched?.route ?? '*'
|
|
205
272
|
const params = matched?.params ?? {}
|
|
@@ -275,19 +342,19 @@ export function router(
|
|
|
275
342
|
while it's still mounted. Disposing first kills that scope; surviving
|
|
276
343
|
prefix layouts then update in place on publish. */
|
|
277
344
|
const base = disposeFrom(divergence)
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
params,
|
|
281
|
-
url:
|
|
282
|
-
typeof location === 'undefined'
|
|
283
|
-
? new URL(`http://localhost${path}`)
|
|
284
|
-
: new URL(location.href),
|
|
285
|
-
navigating: false,
|
|
286
|
-
}
|
|
345
|
+
const url = resolveUrl(path)
|
|
346
|
+
clientPage.value = { route: chainRoute, params, url, navigating: false }
|
|
287
347
|
if (!hydrating) {
|
|
288
348
|
base.textContent = ''
|
|
289
349
|
}
|
|
290
350
|
buildFrom(base, divergence, chainKeys, layoutViews, pageView, params, hydrating)
|
|
351
|
+
/* Reapply the destination entry's scroll once its DOM exists — a
|
|
352
|
+
back/forward restores its offset, a fresh nav scrolls to the `#hash`
|
|
353
|
+
anchor (now built) or the top. Runs on the initial paint too: with
|
|
354
|
+
`scrollRestoration='manual'` the browser does NOT restore a reload's
|
|
355
|
+
offset, so first paint recovers it from the persisted `history.state`
|
|
356
|
+
(a fresh load with no persisted offset falls through to hash/top). */
|
|
357
|
+
historyEntries.restore(url.hash)
|
|
291
358
|
}
|
|
292
359
|
/* Wrap the swap in a View Transition where the browser supports it, so
|
|
293
360
|
the page change cross-fades (and shared `view-transition-name` elements
|
|
@@ -311,6 +378,7 @@ export function router(
|
|
|
311
378
|
disposed = true
|
|
312
379
|
if (typeof window !== 'undefined') {
|
|
313
380
|
window.removeEventListener('popstate', onPopState)
|
|
381
|
+
window.removeEventListener('pagehide', onPageHide)
|
|
314
382
|
document.removeEventListener('click', onClick as EventListener)
|
|
315
383
|
}
|
|
316
384
|
stop()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { AbideHistoryState } from './types/AbideHistoryState.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Per-history-entry scroll buckets for manual scroll restoration. The browser's own
|
|
5
|
+
restoration is disabled (`history.scrollRestoration = 'manual'` at router boot)
|
|
6
|
+
because the router tears the page down and rebuilds it AFTER the browser would have
|
|
7
|
+
restored scroll — so the offset is lost against a node that no longer exists. Instead
|
|
8
|
+
each history entry carries a monotonic id (stamped into `history.state.abideEntry` by
|
|
9
|
+
`navigate`), and this module buckets that entry's scroll offset: `save()` records the
|
|
10
|
+
outgoing offset before history moves, `restore()` reapplies the destination entry's
|
|
11
|
+
offset after its DOM is rebuilt (or, for an entry seen for the first time — a fresh
|
|
12
|
+
navigation — honours a `#hash` anchor when one resolves, else scrolls to the top). A
|
|
13
|
+
back/forward `adopt`s the entry id read from history.state; a `replace` `discard`s the
|
|
14
|
+
current bucket (its content is superseded). The in-memory `offsets` Map covers the
|
|
15
|
+
same-session back/forward path; `persist()` also mirrors the live scroll into
|
|
16
|
+
`history.state` (on pagehide, when state still reflects the active entry) so a RELOAD
|
|
17
|
+
— where the Map is gone and `scrollRestoration='manual'` keeps the browser from
|
|
18
|
+
restoring — can still recover it via `restore()`'s `persistedOffset` fallback.
|
|
19
|
+
|
|
20
|
+
Scroll APIs are reached off `globalThis` (guarded) so the module is a no-op on the
|
|
21
|
+
server and in headless tests that never install a window. Shared mutable singleton on
|
|
22
|
+
one object, reached without a barrel — mirrors `reactiveAbortState`/`clientPage`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/* The scroll surface: the real `Window` shape, made `Partial` so every member is
|
|
26
|
+
optional — the module degrades to a no-op on the server / in headless tests that
|
|
27
|
+
never install a window. */
|
|
28
|
+
const view = globalThis as unknown as Partial<Window>
|
|
29
|
+
|
|
30
|
+
/* The active entry's offset persisted in `history.state` — survives a reload (the
|
|
31
|
+
in-memory `offsets` Map does not). Honoured only when the stored id matches `current`,
|
|
32
|
+
so a foreign history entry's state never restores the wrong scroll. */
|
|
33
|
+
function persistedOffset(): [number, number] | undefined {
|
|
34
|
+
const state = view.history?.state as AbideHistoryState | null
|
|
35
|
+
if (state?.abideEntry === current && Array.isArray(state.scroll)) {
|
|
36
|
+
return state.scroll
|
|
37
|
+
}
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* The element a `#hash` addresses, if the document holds one. */
|
|
42
|
+
function anchorFor(hash: string | undefined): HTMLElement | undefined {
|
|
43
|
+
if (hash === undefined || hash.length <= 1 || view.document === undefined) {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
return view.document.getElementById(hash.slice(1)) ?? undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* The active entry's scroll offset is buckets.get(current); `seq` mints fresh ids. */
|
|
50
|
+
const offsets = new Map<number, [number, number]>()
|
|
51
|
+
let current = 0
|
|
52
|
+
let seq = 0
|
|
53
|
+
|
|
54
|
+
export const historyEntries = {
|
|
55
|
+
/* The active history entry's id — stamped into history.state by `navigate`. */
|
|
56
|
+
get current(): number {
|
|
57
|
+
return current
|
|
58
|
+
},
|
|
59
|
+
/* Mint the next entry id for a pushed history entry, making it active. */
|
|
60
|
+
next(): number {
|
|
61
|
+
current = seq += 1
|
|
62
|
+
return current
|
|
63
|
+
},
|
|
64
|
+
/* A back/forward landed on an existing entry — make it active so the following
|
|
65
|
+
save/restore target its bucket. Keeps `seq` ahead of any adopted id. */
|
|
66
|
+
adopt(entry: number): void {
|
|
67
|
+
current = entry
|
|
68
|
+
if (entry > seq) {
|
|
69
|
+
seq = entry
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
/* Bucket the active entry's current scroll offset, before history moves away. */
|
|
73
|
+
save(): void {
|
|
74
|
+
if (typeof view.scrollTo === 'function') {
|
|
75
|
+
offsets.set(current, [view.scrollX ?? 0, view.scrollY ?? 0])
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
/* Drop the active entry's bucket — a replace lands fresh content, so the saved
|
|
79
|
+
scroll no longer applies. (The replace's own `replaceState` overwrites the
|
|
80
|
+
persisted copy in `history.state`, so the durable fallback clears too.) */
|
|
81
|
+
discard(): void {
|
|
82
|
+
offsets.delete(current)
|
|
83
|
+
},
|
|
84
|
+
/* Mirror the live scroll into the active entry's `history.state` so it survives a
|
|
85
|
+
reload (the in-memory Map does not). Called on pagehide — when `history.state`
|
|
86
|
+
still reflects the active entry — merging to keep its `abideEntry` stamp. */
|
|
87
|
+
persist(): void {
|
|
88
|
+
if (typeof view.scrollTo !== 'function' || view.history === undefined) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const state = (view.history.state as AbideHistoryState | null) ?? {}
|
|
92
|
+
view.history.replaceState({ ...state, scroll: [view.scrollX ?? 0, view.scrollY ?? 0] }, '')
|
|
93
|
+
},
|
|
94
|
+
/* Reapply the active entry's scroll once its DOM exists. A back/forward to a saved
|
|
95
|
+
entry returns to its offset (in-memory, else the reload-durable persisted copy);
|
|
96
|
+
a fresh entry honours a `#hash` anchor when one resolves, else scrolls to the top. */
|
|
97
|
+
restore(hash?: string): void {
|
|
98
|
+
if (typeof view.scrollTo !== 'function') {
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
const offset = offsets.get(current) ?? persistedOffset()
|
|
102
|
+
if (offset !== undefined) {
|
|
103
|
+
view.scrollTo(offset[0], offset[1])
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const anchor = anchorFor(hash)
|
|
107
|
+
if (anchor !== undefined) {
|
|
108
|
+
anchor.scrollIntoView()
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
view.scrollTo(0, 0)
|
|
112
|
+
},
|
|
113
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Scope } from '../types/Scope.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Dev-only registry of every live scope, for the inspector's Reactive tab. `scopes`
|
|
5
|
+
stays empty and untouched unless installInspectorBridge flips `enabled` (gated by
|
|
6
|
+
the server-injected `__abideInspect`), so production allocates and tracks nothing.
|
|
7
|
+
createScope adds on construction and removes on dispose; the bridge reconstructs
|
|
8
|
+
the scope forest from each entry's `id` + `parent.id` (the Scope surface exposes
|
|
9
|
+
no children accessor, so the flat set + parent links is the traversal path). One
|
|
10
|
+
mutable singleton object — mirrors `reactiveAbortState`, reached without a barrel.
|
|
11
|
+
*/
|
|
12
|
+
export const liveScopes: { enabled: boolean; scopes: Set<Scope> } = {
|
|
13
|
+
enabled: false,
|
|
14
|
+
scopes: new Set(),
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/* The shape abide stamps into `History.state`: a monotonic `abideEntry` id the router
|
|
2
|
+
buckets scroll by, and the last `scroll` offset persisted for that entry (so a reload
|
|
3
|
+
can recover it — the in-memory bucket does not survive). Both optional: a foreign or
|
|
4
|
+
bare entry (one another script pushed, or the first landing before a stamp) carries
|
|
5
|
+
neither, which the readers treat as "no abide data". */
|
|
6
|
+
export type AbideHistoryState = {
|
|
7
|
+
abideEntry?: number
|
|
8
|
+
scroll?: [number, number]
|
|
9
|
+
}
|
|
@@ -6,6 +6,7 @@ import { setPageResolver } from '../shared/setPageResolver.ts'
|
|
|
6
6
|
import type { CacheSnapshotEntry } from '../shared/types/CacheSnapshotEntry.ts'
|
|
7
7
|
import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
|
|
8
8
|
import { installHotBridge } from './installHotBridge.ts'
|
|
9
|
+
import { installInspectorBridge } from './installInspectorBridge.ts'
|
|
9
10
|
import { probeNavigation } from './probeNavigation.ts'
|
|
10
11
|
import { router } from './router.ts'
|
|
11
12
|
import { clientPage } from './runtime/clientPage.ts'
|
|
@@ -42,6 +43,11 @@ export function startClient(
|
|
|
42
43
|
if ((globalThis as { __abideDev?: boolean }).__abideDev) {
|
|
43
44
|
installHotBridge()
|
|
44
45
|
}
|
|
46
|
+
/* Inspector only: the server injects `__abideInspect` when ABIDE_ENABLE_INSPECTOR
|
|
47
|
+
is on, so the scope/router bridge arms before the router builds any scope. */
|
|
48
|
+
if ((globalThis as { __abideInspect?: boolean }).__abideInspect) {
|
|
49
|
+
installInspectorBridge()
|
|
50
|
+
}
|
|
45
51
|
const ssr = (globalThis as { __SSR__?: SsrPayload }).__SSR__ ?? {}
|
|
46
52
|
setBaseResolver(() => ssr.base ?? '')
|
|
47
53
|
/* The `page` proxy reads route/params/url off the router-updated snapshot. */
|
|
@@ -22,6 +22,9 @@ the ONLY public entry; everything else is a method reached through it (the
|
|
|
22
22
|
*/
|
|
23
23
|
export type Scope = {
|
|
24
24
|
readonly id: string
|
|
25
|
+
/* Dev-only display name (the host component/element it mounted into) for the
|
|
26
|
+
inspector's Reactive tab; undefined for SSR/detached/child scopes. */
|
|
27
|
+
readonly label?: string
|
|
25
28
|
readonly parent: Scope | undefined
|
|
26
29
|
/* data — mirrors Doc */
|
|
27
30
|
read: <T>(path: string) => T
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Every standard HTML element name (lowercase), plus the two foreign-content roots
|
|
3
|
-
`svg`/`math`. A component wrapper tag that collides with one of these is a real
|
|
4
|
-
element with a content model and parser quirks — `<button>`/`<a>` reject interactive
|
|
5
|
-
descendants, table/list/select families foster or auto-close non-conforming children,
|
|
6
|
-
void elements self-close, and `<svg>`/`<math>` switch the parser into SVG/MathML
|
|
7
|
-
namespace so the wrapper's HTML children break out (foster-parent) of it entirely —
|
|
8
|
-
so the parser reparents the component's own markup out of the wrapper and hydration
|
|
9
|
-
claims `null`. `componentWrapperTag` remaps any such name to a transparent custom
|
|
10
|
-
element instead. Pure SVG/MathML descendant names (`circle`, `path`, `mrow`, …) are
|
|
11
|
-
NOT here: in HTML context they are inert unknown elements that hold children fine, so
|
|
12
|
-
a `<Circle>` component stays as-is like any non-element name. Superset of VOID_TAGS
|
|
13
|
-
(which the parser/SSR still use for self-closing); this set is only the
|
|
14
|
-
wrapper-safety check.
|
|
15
|
-
*/
|
|
16
|
-
export const HTML_TAGS: ReadonlySet<string> = new Set([
|
|
17
|
-
'a',
|
|
18
|
-
'abbr',
|
|
19
|
-
'address',
|
|
20
|
-
'area',
|
|
21
|
-
'article',
|
|
22
|
-
'aside',
|
|
23
|
-
'audio',
|
|
24
|
-
'b',
|
|
25
|
-
'base',
|
|
26
|
-
'bdi',
|
|
27
|
-
'bdo',
|
|
28
|
-
'blockquote',
|
|
29
|
-
'body',
|
|
30
|
-
'br',
|
|
31
|
-
'button',
|
|
32
|
-
'canvas',
|
|
33
|
-
'caption',
|
|
34
|
-
'cite',
|
|
35
|
-
'code',
|
|
36
|
-
'col',
|
|
37
|
-
'colgroup',
|
|
38
|
-
'data',
|
|
39
|
-
'datalist',
|
|
40
|
-
'dd',
|
|
41
|
-
'del',
|
|
42
|
-
'details',
|
|
43
|
-
'dfn',
|
|
44
|
-
'dialog',
|
|
45
|
-
'div',
|
|
46
|
-
'dl',
|
|
47
|
-
'dt',
|
|
48
|
-
'em',
|
|
49
|
-
'embed',
|
|
50
|
-
'fieldset',
|
|
51
|
-
'figcaption',
|
|
52
|
-
'figure',
|
|
53
|
-
'footer',
|
|
54
|
-
'form',
|
|
55
|
-
'h1',
|
|
56
|
-
'h2',
|
|
57
|
-
'h3',
|
|
58
|
-
'h4',
|
|
59
|
-
'h5',
|
|
60
|
-
'h6',
|
|
61
|
-
'head',
|
|
62
|
-
'header',
|
|
63
|
-
'hgroup',
|
|
64
|
-
'hr',
|
|
65
|
-
'html',
|
|
66
|
-
'i',
|
|
67
|
-
'iframe',
|
|
68
|
-
'img',
|
|
69
|
-
'input',
|
|
70
|
-
'ins',
|
|
71
|
-
'kbd',
|
|
72
|
-
'label',
|
|
73
|
-
'legend',
|
|
74
|
-
'li',
|
|
75
|
-
'link',
|
|
76
|
-
'main',
|
|
77
|
-
'map',
|
|
78
|
-
'mark',
|
|
79
|
-
'math',
|
|
80
|
-
'menu',
|
|
81
|
-
'meta',
|
|
82
|
-
'meter',
|
|
83
|
-
'nav',
|
|
84
|
-
'noscript',
|
|
85
|
-
'object',
|
|
86
|
-
'ol',
|
|
87
|
-
'optgroup',
|
|
88
|
-
'option',
|
|
89
|
-
'output',
|
|
90
|
-
'p',
|
|
91
|
-
'param',
|
|
92
|
-
'picture',
|
|
93
|
-
'pre',
|
|
94
|
-
'progress',
|
|
95
|
-
'q',
|
|
96
|
-
'rp',
|
|
97
|
-
'rt',
|
|
98
|
-
'ruby',
|
|
99
|
-
's',
|
|
100
|
-
'samp',
|
|
101
|
-
'script',
|
|
102
|
-
'search',
|
|
103
|
-
'section',
|
|
104
|
-
'select',
|
|
105
|
-
'slot',
|
|
106
|
-
'small',
|
|
107
|
-
'source',
|
|
108
|
-
'span',
|
|
109
|
-
'strong',
|
|
110
|
-
'style',
|
|
111
|
-
'sub',
|
|
112
|
-
'summary',
|
|
113
|
-
'sup',
|
|
114
|
-
'svg',
|
|
115
|
-
'table',
|
|
116
|
-
'tbody',
|
|
117
|
-
'td',
|
|
118
|
-
'template',
|
|
119
|
-
'textarea',
|
|
120
|
-
'tfoot',
|
|
121
|
-
'th',
|
|
122
|
-
'thead',
|
|
123
|
-
'time',
|
|
124
|
-
'title',
|
|
125
|
-
'tr',
|
|
126
|
-
'track',
|
|
127
|
-
'u',
|
|
128
|
-
'ul',
|
|
129
|
-
'var',
|
|
130
|
-
'video',
|
|
131
|
-
'wbr',
|
|
132
|
-
])
|