@brust/core 2.0.0-alpha.2 → 2.0.0-alpha.4

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.
Files changed (58) hide show
  1. package/README.md +64 -6
  2. package/native/index.d.ts +65 -2
  3. package/native/index.js +59 -54
  4. package/package.json +16 -8
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +24 -2
  8. package/src/actions-bridge.ts +29 -11
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +199 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +48 -12
  14. package/src/build/cf-assets.ts +83 -0
  15. package/src/build/cf-core.ts +90 -0
  16. package/src/build/cf-entry.ts +76 -0
  17. package/src/build/cf-wrangler.ts +169 -0
  18. package/src/build/cloudflare.ts +315 -0
  19. package/src/build/compile.ts +41 -4
  20. package/src/build/config.ts +255 -10
  21. package/src/build/css.ts +286 -0
  22. package/src/build/index.ts +199 -16
  23. package/src/build/manifest.ts +41 -4
  24. package/src/build/scan.ts +59 -20
  25. package/src/build/ssg-render.ts +65 -0
  26. package/src/build/ssg.ts +146 -0
  27. package/src/build/static-worker.ts +91 -0
  28. package/src/build/static.ts +258 -0
  29. package/src/build-time.ts +10 -0
  30. package/src/cache-adapter/bridge.ts +91 -0
  31. package/src/cache-adapter/conformance.ts +226 -0
  32. package/src/cache-adapter/index.ts +10 -0
  33. package/src/cache-adapter/load.ts +144 -0
  34. package/src/cache-adapter/memory.ts +229 -0
  35. package/src/cache-adapter/runtime.ts +124 -0
  36. package/src/cache-adapter/types.ts +96 -0
  37. package/src/cache-adapter/workers.ts +118 -0
  38. package/src/cache.ts +4 -1
  39. package/src/cf.ts +19 -0
  40. package/src/cli.ts +50 -2
  41. package/src/config.ts +127 -25
  42. package/src/css-modules.d.ts +8 -0
  43. package/src/dev/bun.ts +365 -0
  44. package/src/dev/client.ts +92 -0
  45. package/src/dev/devtools-data.ts +23 -0
  46. package/src/dev/devtools.ts +356 -0
  47. package/src/dev/diag.ts +30 -0
  48. package/src/dev/proxy.ts +236 -0
  49. package/src/dev/tui.ts +144 -0
  50. package/src/dev/watch.ts +81 -0
  51. package/src/dev.ts +93 -0
  52. package/src/handlers.ts +282 -0
  53. package/src/index.ts +3 -1
  54. package/src/native.ts +38 -1
  55. package/src/routes.ts +20 -1
  56. package/src/run.ts +46 -9
  57. package/src/server-handlers.ts +32 -0
  58. package/src/worker.ts +125 -245
@@ -0,0 +1,92 @@
1
+ // The browser half of `brust dev`, served as `/_brust/dev/client.js?g=<gen>` and injected into every HTML
2
+ // response by the proxy. One WebSocket to the parent (`/_brust/dev/ws`):
3
+ // hello {gen, state, diag} on every (re)connect — a page older than `gen` reloads (the catch-up), a failing
4
+ // build shows the overlay above the stale page
5
+ // reload a new generation is serving
6
+ // error {diag} a build/boot failed
7
+ // css {href, v} a public/*.css changed: swap the <link>, keep the page
8
+ // css-swap {gen, map} only built stylesheets changed (L19/L21): a new generation serves; swap each
9
+ // data-brust-css link old href -> new href, keep the page (no reload)
10
+ // WS, not SSE: SSE holds one of the six per-host connections, so six tabs would stall (R13.2).
11
+ // The overlay lives in a shadow root, so page CSS cannot touch it and it cannot touch the page.
12
+ export const DEV_CLIENT_JS = String.raw`
13
+ let pageGen = Number(new URL(import.meta.url).searchParams.get('g') || 0)
14
+ let host = null
15
+ let delay = 150
16
+
17
+ function esc(s) {
18
+ return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
19
+ }
20
+
21
+ function clearOverlay() {
22
+ host?.remove()
23
+ host = null
24
+ }
25
+
26
+ function showOverlay(diag) {
27
+ clearOverlay()
28
+ host = document.createElement('brust-dev-overlay')
29
+ const root = host.attachShadow({ mode: 'open' })
30
+ const where = diag.file ? esc(diag.file) + (diag.line ? ':' + diag.line + (diag.col ? ':' + diag.col : '') : '') : ''
31
+ root.innerHTML =
32
+ '<style>:host{all:initial}.box{position:fixed;inset:0;z-index:2147483647;overflow:auto;background:rgba(20,0,0,.88);color:#fdd;font:14px/1.5 ui-monospace,Menlo,monospace;padding:32px}' +
33
+ 'h1{margin:0 0 8px;font-size:18px;color:#ff8a80}.where{color:#ffd180;margin-bottom:12px}pre{white-space:pre-wrap;margin:0;color:#fff}small{display:block;margin-top:24px;color:#bbb}</style>' +
34
+ '<div class="box" role="alert" data-testid="brust-dev-overlay"><h1>brust: ' + esc(diag.rule || 'error') + '</h1>' +
35
+ (where ? '<div class="where">' + where + '</div>' : '') +
36
+ '<pre>' + esc(diag.raw || diag.message || '') + '</pre><small>The page below is the last good build. Fix the error and save: this overlay goes away on its own.</small></div>'
37
+ document.documentElement.appendChild(host)
38
+ }
39
+
40
+ function swapCss(href, v) {
41
+ for (const old of document.querySelectorAll('link[rel~="stylesheet"]')) {
42
+ const u = new URL(old.href, location.href)
43
+ if (u.origin !== location.origin || u.pathname !== href) continue
44
+ const next = old.cloneNode()
45
+ next.href = href + '?v=' + v
46
+ next.onload = () => old.remove()
47
+ next.onerror = () => next.remove()
48
+ old.after(next)
49
+ }
50
+ }
51
+
52
+ function swapBuiltCss(map) {
53
+ for (const old of document.querySelectorAll('link[data-brust-css]')) {
54
+ const to = map[new URL(old.href, location.href).pathname]
55
+ if (!to) continue
56
+ const next = old.cloneNode()
57
+ next.href = to
58
+ next.onload = () => old.remove()
59
+ next.onerror = () => next.remove()
60
+ old.after(next)
61
+ }
62
+ }
63
+
64
+ function handle(m) {
65
+ if (m.type === 'hello') {
66
+ if (m.state === 'error' && m.diag) showOverlay(m.diag)
67
+ else {
68
+ clearOverlay()
69
+ if (m.gen > pageGen) location.reload()
70
+ }
71
+ } else if (m.type === 'reload') location.reload()
72
+ else if (m.type === 'error') showOverlay(m.diag)
73
+ else if (m.type === 'css') swapCss(m.href, m.v)
74
+ else if (m.type === 'css-swap') {
75
+ pageGen = m.gen
76
+ swapBuiltCss(m.map)
77
+ }
78
+ }
79
+
80
+ function connect() {
81
+ const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/_brust/dev/ws')
82
+ ws.onopen = () => {
83
+ delay = 150
84
+ }
85
+ ws.onmessage = (e) => handle(JSON.parse(e.data))
86
+ ws.onclose = () => {
87
+ setTimeout(connect, delay)
88
+ delay = Math.min(delay * 2, 2000)
89
+ }
90
+ }
91
+ connect()
92
+ `
@@ -0,0 +1,23 @@
1
+ // Pure data helpers of the devtools overlay (no DOM at import time: unit-testable under Bun).
2
+ export type Tier = 'static' | 'native' | 'react'
3
+ export interface DevMeta { components?: Record<string, { tier: Tier }> }
4
+
5
+ export function tierCounts(rendered: string[], meta: DevMeta | null): Record<Tier, number> {
6
+ const out = { static: 0, native: 0, react: 0 }
7
+ for (const id of rendered) {
8
+ const t = meta?.components?.[id]?.tier
9
+ if (t) out[t]++
10
+ }
11
+ return out
12
+ }
13
+
14
+ /** Contrast ratio of two sRGB colours given as `[r, g, b]` 0..255 (WCAG 2.x). */
15
+ export function contrast(a: [number, number, number], b: [number, number, number]): number {
16
+ const lum = (c: [number, number, number]) => {
17
+ const [r, g, bl] = c.map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }) as [number, number, number]
18
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bl
19
+ }
20
+ const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x) as [number, number]
21
+ return (hi + 0.05) / (lo + 0.05)
22
+ }
23
+
@@ -0,0 +1,356 @@
1
+ // `<brust-devtools>` (L15): the browser overlay of `brust dev`. Served by the dev proxy as `/_brust/dev/devtools.js`
2
+ // (bundled from this file on first request) and injected by it into HTML responses: nothing of it exists under
3
+ // `brust start` or in a production build. Everything it shows comes from the trace (`#__brust_trace`,
4
+ // `/_brust/dev/traces`), `dev.json` (`/_brust/dev/meta`), the DOM or the dev global (`__brustDev`): nothing is invented.
5
+ // It lives in a shadow root: no style leaks either way.
6
+
7
+ import { tierCounts, type Tier } from './devtools-data'
8
+ interface Diag { class: string; rule: string; line: number; col: number; message: string; remediation: string }
9
+ export interface DevComponent { file: string; tier: Tier; reason?: string; diagnostics: Diag[] }
10
+ interface DevMeta { components?: Record<string, DevComponent> }
11
+ interface TraceJob { call_id: string; kind: string; component_id: string; user_key: string | null; tags: string[]; hit: boolean }
12
+ interface Trace {
13
+ route: { id: string; pattern: string; chain: string[]; params: Record<string, string> } | null
14
+ status: number
15
+ cache: string
16
+ loader_ms: number | null
17
+ render_ms: number | null
18
+ render_error: string | null
19
+ bun_calls: number
20
+ jobs: TraceJob[]
21
+ jobs_more: number
22
+ rendered: string[]
23
+ truncated?: boolean
24
+ }
25
+ interface Entry { id: number; at: number; kind: string; method: string; path: string; status: number; ms: number; trace: Trace | null }
26
+ interface BuildState { state: 'building' | 'ready' | 'error'; gen: number; diag: { rule: string; file?: string; line?: number; col?: number; message: string } | null }
27
+
28
+ const TABS = ['Page', 'Components', 'Jobs', 'Store', 'Islands', 'Build', 'Requests'] as const
29
+ type TabName = (typeof TABS)[number]
30
+
31
+ const CSS = `
32
+ :host { all: initial; position: fixed; width: 0; height: 0; --bg: #ffffff; --fg: #1a1a1f; --muted: #4a4a55; --line: #c9c9d2; --accent: #0b57d0; --accent-fg: #ffffff;
33
+ --static: #0b6b3a; --native: #0b57d0; --react: #8a3b00; --err: #b3261e; --warn: #7a5300; --hl: rgba(11, 87, 208, .25); }
34
+ @media (prefers-color-scheme: dark) { :host { --bg: #1b1b1f; --fg: #f2f2f5; --muted: #c4c4cf; --line: #4a4a55; --accent: #8ab4f8; --accent-fg: #0a0a0c;
35
+ --static: #6ee7a8; --native: #8ab4f8; --react: #ffb27a; --err: #ff8a80; --warn: #ffd180; --hl: rgba(138, 180, 248, .3); } }
36
+ * { box-sizing: border-box; }
37
+ button { font: inherit; color: inherit; }
38
+ .ind { position: fixed; right: 12px; bottom: 12px; z-index: 2147483646; display: flex; gap: 8px; align-items: center; padding: 6px 10px; border-radius: 999px;
39
+ background: var(--bg); color: var(--fg); border: 1px solid var(--line); font: 12px/1.2 ui-monospace, Menlo, monospace; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.25); }
40
+ .ind:focus-visible, .tab:focus-visible, .x:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
41
+ .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--static); }
42
+ .dot.building { background: var(--warn); } .dot.error { background: var(--err); }
43
+ .panel { position: fixed; right: 12px; bottom: 52px; z-index: 2147483646; width: min(560px, calc(100vw - 24px)); max-height: min(70vh, 560px); display: flex; flex-direction: column;
44
+ background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; font: 12px/1.45 ui-monospace, Menlo, monospace; box-shadow: 0 6px 24px rgba(0,0,0,.35); }
45
+ .panel[hidden] { display: none; }
46
+ .bar { display: flex; align-items: center; border-bottom: 1px solid var(--line); }
47
+ .tabs { display: flex; flex: 1; overflow-x: auto; }
48
+ .tab { padding: 8px 10px; background: none; border: 0; border-bottom: 2px solid transparent; cursor: pointer; color: var(--muted); }
49
+ .tab[aria-selected="true"] { color: var(--fg); border-bottom-color: var(--accent); }
50
+ .x { padding: 6px 10px; background: none; border: 0; cursor: pointer; color: var(--muted); }
51
+ .body { padding: 10px; overflow: auto; }
52
+ table { border-collapse: collapse; width: 100%; } th, td { text-align: left; padding: 3px 6px; border-bottom: 1px solid var(--line); vertical-align: top; }
53
+ th { color: var(--muted); font-weight: 600; }
54
+ .b { display: inline-block; padding: 0 6px; border-radius: 4px; border: 1px solid currentColor; font-weight: 700; }
55
+ .b.static { color: var(--static); } .b.native { color: var(--native); } .b.react { color: var(--react); }
56
+ .err { color: var(--err); } .warn { color: var(--warn); } .muted { color: var(--muted); }
57
+ ul.tree { list-style: none; margin: 0; padding-left: 14px; } ul.tree.root { padding-left: 0; }
58
+ .node { padding: 2px 4px; border-radius: 4px; cursor: default; } .node:hover, .node:focus { background: var(--hl); outline: none; }
59
+ .hl { position: fixed; z-index: 2147483645; pointer-events: none; background: var(--hl); border: 2px solid var(--accent); border-radius: 3px; }
60
+ code { font: inherit; } h3 { margin: 0 0 6px; font-size: 12px; }
61
+ `
62
+
63
+ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string> = {}, ...kids: (Node | string)[]): HTMLElementTagNameMap[K] => {
64
+ const e = document.createElement(tag)
65
+ for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v)
66
+ for (const k of kids) e.append(k)
67
+ return e
68
+ }
69
+ const badge = (t: Tier | 'unknown') => el('span', { class: `b ${t}` }, t)
70
+ const ms = (n: number | null | undefined) => (typeof n === 'number' ? `${n.toFixed(2)} ms` : '—')
71
+
72
+ class BrustDevtools extends HTMLElement {
73
+ private root = this.attachShadow({ mode: 'open' })
74
+ private ind!: HTMLButtonElement
75
+ private panel!: HTMLElement
76
+ private body!: HTMLElement
77
+ private tabs: HTMLButtonElement[] = []
78
+ private hl = el('div', { class: 'hl', hidden: '' })
79
+ private tab: TabName = 'Page'
80
+ private opener: Element | null = null
81
+ private meta: DevMeta | null = null
82
+ private entries: Entry[] = []
83
+ private build: BuildState = { state: 'building', gen: 0, diag: null }
84
+ private trace: Trace | null = null
85
+
86
+ connectedCallback(): void {
87
+ this.trace = readTrace()
88
+ const style = el('style'); style.textContent = CSS
89
+ this.ind = el('button', { class: 'ind', type: 'button', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', 'aria-label': 'brust devtools' })
90
+ this.panel = el('div', { class: 'panel', role: 'dialog', 'aria-label': 'brust devtools', hidden: '' })
91
+ const tablist = el('div', { class: 'tabs', role: 'tablist', 'aria-label': 'devtools panels' })
92
+ for (const t of TABS) {
93
+ const b = el('button', { class: 'tab', type: 'button', role: 'tab', id: `tab-${t}`, 'aria-selected': 'false', tabindex: '-1', 'aria-controls': 'body' }, t)
94
+ b.onclick = () => this.select(t)
95
+ this.tabs.push(b); tablist.append(b)
96
+ }
97
+ const close = el('button', { class: 'x', type: 'button', 'aria-label': 'Close devtools' }, '✕')
98
+ close.onclick = () => this.close()
99
+ this.body = el('div', { class: 'body', id: 'body', role: 'tabpanel' })
100
+ this.panel.append(el('div', { class: 'bar' }, tablist, close), this.body)
101
+ this.root.append(style, this.hl, this.ind, this.panel)
102
+ this.ind.onclick = () => this.toggle()
103
+ tablist.onkeydown = (e) => this.tabKeys(e)
104
+ this.panel.onkeydown = (e) => { if (e.key === 'Escape') { e.stopPropagation(); this.close() } }
105
+ window.addEventListener('keydown', (e) => { if (e.altKey && e.shiftKey && (e.key === 'B' || e.key === 'b' || e.code === 'KeyB')) { e.preventDefault(); this.toggle() } })
106
+ this.paintIndicator()
107
+ void this.loadMeta()
108
+ this.connect()
109
+ }
110
+
111
+ private async loadMeta(): Promise<void> {
112
+ try { this.meta = (await (await fetch('/_brust/dev/meta')).json()) as DevMeta } catch { this.meta = null }
113
+ this.paintIndicator()
114
+ if (!this.panel.hidden) this.render()
115
+ }
116
+
117
+ private async loadEntries(): Promise<void> {
118
+ try { this.entries = ((await (await fetch('/_brust/dev/traces')).json()) as { traces: Entry[] }).traces } catch { this.entries = [] }
119
+ }
120
+
121
+ private connect(): void {
122
+ const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_brust/dev/ws`)
123
+ ws.onmessage = (e) => {
124
+ const m = JSON.parse(e.data as string) as { type: string; gen?: number; state?: BuildState['state']; diag?: BuildState['diag'] }
125
+ if (m.type === 'hello') this.build = { state: m.state ?? 'ready', gen: m.gen ?? 0, diag: m.diag ?? null }
126
+ else if (m.type === 'error') this.build = { state: 'error', gen: this.build.gen, diag: m.diag ?? null }
127
+ else return
128
+ this.paintIndicator()
129
+ if (!this.panel.hidden && this.tab === 'Build') this.render()
130
+ }
131
+ ws.onclose = () => setTimeout(() => this.connect(), 1000)
132
+ }
133
+
134
+ private counts(): Record<Tier, number> { return tierCounts(this.trace?.rendered ?? [], this.meta) }
135
+
136
+ private paintIndicator(): void {
137
+ const c = this.counts()
138
+ this.ind.replaceChildren(el('span', { class: `dot ${this.build.state}`, 'aria-hidden': 'true' }), `${this.build.state} · ${c.static}s ${c.native}n ${c.react}r`)
139
+ this.ind.setAttribute('data-state', this.build.state)
140
+ this.ind.title = `static ${c.static} · native ${c.native} · react ${c.react} (Alt+Shift+B)`
141
+ }
142
+
143
+ toggle(): void { if (this.panel.hidden) this.open(); else this.close() }
144
+
145
+ open(): void {
146
+ this.opener = deepActive()
147
+ this.panel.hidden = false
148
+ this.ind.setAttribute('aria-expanded', 'true')
149
+ void this.loadEntries().then(() => { if (this.tab === 'Requests') this.render() })
150
+ this.select(this.tab)
151
+ this.tabs[TABS.indexOf(this.tab)]?.focus()
152
+ }
153
+
154
+ close(): void {
155
+ this.panel.hidden = true
156
+ this.hl.hidden = true
157
+ this.ind.setAttribute('aria-expanded', 'false')
158
+ const back = this.opener instanceof HTMLElement && this.opener !== document.body && this.opener.isConnected && !this.contains(this.opener) ? this.opener : this.ind
159
+ back.focus()
160
+ }
161
+
162
+ private tabKeys(e: KeyboardEvent): void {
163
+ const i = TABS.indexOf(this.tab)
164
+ const to = e.key === 'ArrowRight' ? (i + 1) % TABS.length : e.key === 'ArrowLeft' ? (i + TABS.length - 1) % TABS.length : e.key === 'Home' ? 0 : e.key === 'End' ? TABS.length - 1 : -1
165
+ if (to < 0) return
166
+ e.preventDefault()
167
+ this.select(TABS[to] as TabName)
168
+ this.tabs[to]?.focus()
169
+ }
170
+
171
+ private select(t: TabName): void {
172
+ this.tab = t
173
+ this.tabs.forEach((b, i) => { const on = TABS[i] === t; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1 })
174
+ this.body.setAttribute('aria-labelledby', `tab-${t}`)
175
+ this.render()
176
+ }
177
+
178
+ private render(): void {
179
+ const t = this.trace
180
+ const kids: Node[] = []
181
+ switch (this.tab) {
182
+ case 'Page': kids.push(this.pagePanel(t)); break
183
+ case 'Components': kids.push(this.componentsPanel(t)); break
184
+ case 'Jobs': kids.push(this.jobsPanel(t)); break
185
+ case 'Store': kids.push(this.storePanel()); break
186
+ case 'Islands': kids.push(this.islandsPanel()); break
187
+ case 'Build': kids.push(this.buildPanel()); break
188
+ case 'Requests': kids.push(this.requestsPanel()); break
189
+ }
190
+ this.body.replaceChildren(...kids)
191
+ }
192
+
193
+ private kv(rows: [string, string | Node][]): HTMLElement {
194
+ const tb = el('table')
195
+ for (const [k, v] of rows) tb.append(el('tr', {}, el('th', { scope: 'row' }, k), el('td', {}, v)))
196
+ return tb
197
+ }
198
+
199
+ private pagePanel(t: Trace | null): Node {
200
+ if (!t?.route) return el('p', { class: 'muted' }, 'No trace for this page (not served through a traced core, or the route did not match).')
201
+ const params = Object.entries(t.route.params)
202
+ return this.kv([
203
+ ['route', `${t.route.id} ${t.route.pattern}`],
204
+ ['params', params.length ? params.map(([k, v]) => `${k}=${v}`).join(', ') : '—'],
205
+ ['status', String(t.status)],
206
+ ['cache', t.cache === '-' ? 'none (this route has no cache)' : t.cache],
207
+ ['loader', ms(t.loader_ms)],
208
+ ['render', ms(t.render_ms)],
209
+ ['worker calls', String(t.bun_calls)],
210
+ ['render error', t.render_error ?? '—'],
211
+ ...(t.truncated ? ([['trace', 'truncated to fit the header']] as [string, string][]) : []),
212
+ ])
213
+ }
214
+
215
+ private componentsPanel(t: Trace | null): Node {
216
+ const chain = t?.route?.chain ?? []
217
+ if (chain.length === 0) return el('p', { class: 'muted' }, 'No component data for this page.')
218
+ const extras = (t?.rendered ?? []).filter((id) => !chain.includes(id))
219
+ const node = (id: string, nested: Node | null): HTMLElement => {
220
+ const c = this.meta?.components?.[id]
221
+ const row = el('div', { class: 'node', tabindex: '0', 'data-component': id }, badge(c?.tier ?? 'unknown'), ' ', el('code', {}, id), c ? el('span', { class: 'muted' }, ` ${c.file}`) : '')
222
+ if (c?.tier === 'react') {
223
+ row.append(el('div', { class: 'muted' }, `reason: ${c.reason ?? '—'}`))
224
+ for (const d of c.diagnostics.filter((x) => x.class !== 'error')) row.append(el('div', { class: 'warn' }, `${c.file}:${d.line}:${d.col} ${d.rule}`))
225
+ }
226
+ const on = () => this.highlight(id)
227
+ row.onmouseenter = on; row.onfocus = on
228
+ row.onmouseleave = () => { this.hl.hidden = true }; row.onblur = () => { this.hl.hidden = true }
229
+ const li = el('li', {}, row)
230
+ if (nested) li.append(nested)
231
+ return li
232
+ }
233
+ let tree: Node | null = null
234
+ for (const id of [...chain].reverse()) tree = el('ul', { class: 'tree' }, node(id, tree))
235
+ const rootList = el('ul', { class: 'tree root' })
236
+ rootList.append(...(tree ? [...tree.childNodes] : []))
237
+ const out = el('div', {}, rootList)
238
+ if (extras.length) out.append(el('h3', {}, 'Also on the page (children)'), el('ul', { class: 'tree root' }, ...extras.map((id) => node(id, null))))
239
+ return out
240
+ }
241
+
242
+ /** Highlight a component on the page: chain marker (Range between `<!--brust:id-->` and its close), native `[x-data]`, island `brust-island`. */
243
+ private highlight(id: string): void {
244
+ const rects = componentRects(id)
245
+ if (rects.length === 0) { this.hl.hidden = true; return }
246
+ const r = rects[0] as DOMRect
247
+ Object.assign(this.hl.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` })
248
+ this.hl.hidden = false
249
+ }
250
+
251
+ private jobsPanel(t: Trace | null): Node {
252
+ if (!t) return el('p', { class: 'muted' }, 'No trace for this page.')
253
+ if (t.jobs.length === 0) return el('p', { class: 'muted' }, t.cache === 'HIT' ? 'A cache HIT: no jobs ran.' : 'No jobs on this page.')
254
+ const tb = el('table', {}, el('tr', {}, ...['call', 'kind', 'result', 'key / tags'].map((h) => el('th', {}, h))))
255
+ for (const j of t.jobs) tb.append(el('tr', {}, el('td', {}, j.call_id), el('td', {}, j.kind), el('td', {}, j.hit ? 'hit' : 'miss'), el('td', {}, [j.user_key, ...j.tags].filter(Boolean).join(', ') || '—')))
256
+ const out = el('div', {}, tb)
257
+ if (t.jobs_more) out.append(el('p', { class: 'muted' }, `+${t.jobs_more} more not listed`))
258
+ return out
259
+ }
260
+
261
+ private storePanel(): Node {
262
+ const seeds = [...document.querySelectorAll('script[data-brust-store]')]
263
+ if (seeds.length === 0) return el('p', { class: 'muted' }, 'No stores on this page.')
264
+ const reg = (globalThis as Record<symbol, Record<string, { instance: Record<string, unknown> }> | undefined>)[Symbol.for('brust.stores')]
265
+ const out = el('div')
266
+ for (const s of seeds) {
267
+ const name = s.getAttribute('data-brust-store') ?? '?'
268
+ let seed: unknown = null
269
+ try { seed = JSON.parse(s.textContent ?? 'null') } catch { seed = s.textContent }
270
+ out.append(el('h3', {}, name), this.kv([['seed (SSR)', JSON.stringify(seed)], ['live', reg?.[name] ? JSON.stringify(liveValues(reg[name].instance)) : 'not mounted natively (no native component read it)']]))
271
+ }
272
+ return out
273
+ }
274
+
275
+ private islandsPanel(): Node {
276
+ const islands = [...document.querySelectorAll('brust-island')]
277
+ if (islands.length === 0) return el('p', { class: 'muted' }, 'No React islands on this page.')
278
+ const tb = el('table', {}, el('tr', {}, ...['island', 'hydrated'].map((h) => el('th', {}, h))))
279
+ for (const i of islands) tb.append(el('tr', {}, el('td', {}, i.getAttribute('data-id') ?? '?'), el('td', { class: i.getAttribute('data-hydrated') === '1' ? '' : 'warn' }, i.getAttribute('data-hydrated') === '1' ? 'yes' : 'no')))
280
+ return tb
281
+ }
282
+
283
+ private buildPanel(): Node {
284
+ const out = el('div', {}, el('p', {}, `build: ${this.build.state} (generation ${this.build.gen})`))
285
+ const d = this.build.diag
286
+ if (d) out.append(el('p', { class: 'err' }, `${d.rule}${d.file ? ` ${d.file}${d.line ? `:${d.line}${d.col ? `:${d.col}` : ''}` : ''}` : ''} ${d.message}`))
287
+ const rows: Node[] = []
288
+ for (const [id, c] of Object.entries(this.meta?.components ?? {})) {
289
+ for (const x of c.diagnostics) rows.push(el('tr', {}, el('td', { class: x.class === 'error' ? 'err' : 'warn' }, x.class), el('td', {}, `${c.file}:${x.line}:${x.col}`), el('td', {}, x.rule), el('td', {}, x.message + (x.remediation ? ` — ${x.remediation}` : '')), el('td', { class: 'muted' }, id)))
290
+ }
291
+ if (rows.length === 0) out.append(el('p', { class: 'muted' }, 'No warnings.'))
292
+ else out.append(el('table', {}, el('tr', {}, ...['class', 'location', 'rule', 'message', 'component'].map((h) => el('th', {}, h))), ...rows))
293
+ return out
294
+ }
295
+
296
+ private requestsPanel(): Node {
297
+ if (this.entries.length === 0) return el('p', { class: 'muted' }, 'No traced requests yet.')
298
+ const tb = el('table', {}, el('tr', {}, ...['', 'request', 'status', 'ms', 'cache'].map((h) => el('th', {}, h))))
299
+ for (const e of [...this.entries].reverse()) tb.append(el('tr', {}, el('td', { class: 'muted' }, e.kind), el('td', {}, `${e.method} ${e.path}`), el('td', { class: e.status >= 500 ? 'err' : '' }, String(e.status)), el('td', {}, String(e.ms)), el('td', {}, e.trace?.cache ?? '—')))
300
+ return tb
301
+ }
302
+ }
303
+
304
+ /** The focused element, looking through shadow roots (the devtools' own buttons live in one). */
305
+ function deepActive(): Element | null {
306
+ let a: Element | null = document.activeElement
307
+ while (a?.shadowRoot?.activeElement) a = a.shadowRoot.activeElement
308
+ return a
309
+ }
310
+
311
+ function readTrace(): Trace | null {
312
+ try { return JSON.parse(document.getElementById('__brust_trace')?.textContent ?? 'null') as Trace | null } catch { return null }
313
+ }
314
+
315
+ /** Live values of a store instance. Only signals and computeds are READ (marked by the runtime with `Symbol.for`); actions are never called. */
316
+ function liveValues(instance: Record<string, unknown>): Record<string, unknown> {
317
+ const SIGNAL = Symbol.for('brust.signal'); const COMPUTED = Symbol.for('brust.computed')
318
+ const out: Record<string, unknown> = {}
319
+ for (const [k, v] of Object.entries(instance)) {
320
+ if (typeof v !== 'function') continue
321
+ const f = v as unknown as Record<symbol, unknown> & (() => unknown)
322
+ if (f[SIGNAL] === true || f[COMPUTED] === true) out[k] = f()
323
+ }
324
+ return out
325
+ }
326
+
327
+ /** Client rects of component `id` on the page. */
328
+ export function componentRects(id: string): DOMRect[] {
329
+ const rects: DOMRect[] = []
330
+ for (const e of document.querySelectorAll(`[x-data]`)) if (e.getAttribute('x-data') === id) rects.push(e.getBoundingClientRect())
331
+ for (const e of document.querySelectorAll('brust-island')) if (e.getAttribute('data-id') === id) rects.push(e.getBoundingClientRect())
332
+ if (rects.length) return rects
333
+ // A chain level: the nodes between `<!--brust:id-->` and its matching `<!--/brust-->`.
334
+ const w = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT)
335
+ let open: Comment | null = null
336
+ let depth = 0
337
+ for (let n = w.nextNode() as Comment | null; n; n = w.nextNode() as Comment | null) {
338
+ const v = n.nodeValue ?? ''
339
+ if (!open) { if (v === `brust:${id}`) { open = n; depth = 1 } continue }
340
+ if (v.startsWith('brust:')) depth++
341
+ else if (v === '/brust' && --depth === 0) {
342
+ const r = document.createRange()
343
+ r.setStartAfter(open); r.setEndBefore(n)
344
+ const box = r.getBoundingClientRect()
345
+ return box.width || box.height ? [box] : []
346
+ }
347
+ }
348
+ return []
349
+ }
350
+
351
+ if (typeof customElements !== 'undefined' && !customElements.get('brust-devtools')) {
352
+ customElements.define('brust-devtools', BrustDevtools)
353
+ const mount = () => { if (!document.querySelector('brust-devtools')) document.body.append(document.createElement('brust-devtools')) }
354
+ if (document.body) mount()
355
+ else addEventListener('DOMContentLoaded', mount)
356
+ }
@@ -0,0 +1,30 @@
1
+ // Parsing `brust build` output for the dev server: `error <rule> <file:line:col> <message>` (cli.ts) and
2
+ // `warning <rule> <file:line:col> <message>` (compile.ts).
3
+ export interface Diag {
4
+ rule: string
5
+ file?: string
6
+ line?: number
7
+ col?: number
8
+ message: string
9
+ /** The whole error text (stack, extra lines). */
10
+ raw: string
11
+ }
12
+
13
+ /** The first `error …` line of `out` and everything after it; null when there is none. */
14
+ export function parseBuildError(out: string): Diag | null {
15
+ const i = out.search(/^error /m)
16
+ if (i < 0) return null
17
+ const raw = out.slice(i).trim()
18
+ const m = /^error (\S+) (?:(\S+?):(\d+):(\d+)[: ]\s*)?([^\n]*)/.exec(raw)
19
+ if (!m) return { rule: 'build', message: raw.split('\n')[0] ?? raw, raw }
20
+ const [, rule, file, line, col, message] = m
21
+ return { rule: rule ?? 'build', ...(file ? { file, line: Number(line), col: Number(col) } : {}), message: message ?? '', raw }
22
+ }
23
+
24
+ /** Every `warning …` line of `out`, without the `warning ` prefix. */
25
+ export function parseWarnings(out: string): string[] {
26
+ return out
27
+ .split('\n')
28
+ .filter((l) => l.startsWith('warning '))
29
+ .map((l) => l.slice('warning '.length).trim())
30
+ }