beast-devtools 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/launch.json +11 -0
- package/CHANGELOG.md +37 -0
- package/README.md +87 -0
- package/devtools/CHANGELOG.md +26 -0
- package/devtools/LICENSE +15 -0
- package/devtools/README.md +164 -0
- package/devtools/client/BeastDevtools.btsx +185 -0
- package/devtools/client/CodeView.btsx +61 -0
- package/devtools/client/ComponentsPanel.btsx +212 -0
- package/devtools/client/FileList.btsx +35 -0
- package/devtools/client/InspectorPanel.btsx +131 -0
- package/devtools/client/RefactorPanel.btsx +304 -0
- package/devtools/client/api.ts +69 -0
- package/devtools/client/compile.test.ts +22 -0
- package/devtools/client/devtools.css +1279 -0
- package/devtools/client/highlight.ts +210 -0
- package/devtools/client/mount.ts +16 -0
- package/devtools/client/runtime.ts +168 -0
- package/devtools/client/util.ts +136 -0
- package/devtools/package.json +73 -0
- package/devtools/server/analyze.test.ts +148 -0
- package/devtools/server/analyze.ts +737 -0
- package/devtools/server/diff.ts +82 -0
- package/devtools/server/line-map.ts +63 -0
- package/devtools/server/octane-bundler.d.ts +17 -0
- package/devtools/server/project.ts +369 -0
- package/devtools/server/refactor.test.ts +224 -0
- package/devtools/server/refactor.ts +224 -0
- package/devtools/server/source-scan.ts +377 -0
- package/devtools/shared/types.ts +208 -0
- package/devtools/test/fixtures/App.btsx +193 -0
- package/devtools/tsconfig.build.json +17 -0
- package/devtools/vite.ts +159 -0
- package/favicon.ico +0 -0
- package/index.html +14 -0
- package/package.json +34 -0
- package/public/beast.svg +1 -0
- package/src/App.btsx +144 -0
- package/src/AppHeader.btsx +24 -0
- package/src/LeftArticle.btsx +22 -0
- package/src/RightArticle.btsx +19 -0
- package/src/env.d.ts +8 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.ts +8 -0
- package/src/style.css +44 -0
- package/tsconfig.json +39 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small line-oriented highlighter for BTSX and generated TSRX. It favors
|
|
3
|
+
* cheap, predictable output over precision: every line is tokenized
|
|
4
|
+
* independently, except BTSX `module`/`setup`/`style` blocks, whose indented
|
|
5
|
+
* bodies switch the lexer into TypeScript or CSS mode.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type TokenType =
|
|
9
|
+
| 'text'
|
|
10
|
+
| 'keyword'
|
|
11
|
+
| 'string'
|
|
12
|
+
| 'number'
|
|
13
|
+
| 'comment'
|
|
14
|
+
| 'tag'
|
|
15
|
+
| 'component'
|
|
16
|
+
| 'selector'
|
|
17
|
+
| 'attr'
|
|
18
|
+
| 'directive'
|
|
19
|
+
| 'interp'
|
|
20
|
+
| 'punct'
|
|
21
|
+
| 'type'
|
|
22
|
+
|
|
23
|
+
export interface Token {
|
|
24
|
+
type: TokenType
|
|
25
|
+
value: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type Language = 'btsx' | 'tsrx'
|
|
29
|
+
|
|
30
|
+
const JS_KEYWORDS = new Set([
|
|
31
|
+
'as', 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'else', 'export',
|
|
32
|
+
'extends', 'false', 'for', 'from', 'function', 'if', 'import', 'in', 'interface', 'let', 'new', 'null', 'of',
|
|
33
|
+
'return', 'switch', 'throw', 'true', 'try', 'type', 'typeof', 'undefined', 'var', 'void', 'while',
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
const BTSX_KEYWORDS = new Set([
|
|
37
|
+
'case', 'catch', 'component', 'default', 'each', 'else', 'elseif', 'empty', 'fragment', 'if', 'import', 'in',
|
|
38
|
+
'key', 'module', 'pending', 'props', 'scope', 'setup', 'style', 'switch', 'try',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/** Header words of `each item, i in items key item.id` and `@for (...; index i; key k)`. */
|
|
42
|
+
const EACH_KEYWORDS = new Set(['in', 'key'])
|
|
43
|
+
const FOR_KEYWORDS = new Set(['index', 'key'])
|
|
44
|
+
|
|
45
|
+
/** BTSX keywords whose bare form opens an indented TypeScript or CSS block. */
|
|
46
|
+
const BLOCK_OPENERS: Record<string, 'ts' | 'css'> = { module: 'ts', setup: 'ts', style: 'css' }
|
|
47
|
+
|
|
48
|
+
export function highlight(source: string, language: Language): Token[][] {
|
|
49
|
+
const lines = source.split('\n')
|
|
50
|
+
if (language === 'tsrx') {
|
|
51
|
+
return lines.map((line) => merge(lexCode(line, true, /@for\b/.test(line) ? FOR_KEYWORDS : undefined)))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let block: { indent: number; mode: 'ts' | 'css' } | null = null
|
|
55
|
+
return lines.map((line) => {
|
|
56
|
+
const indent = /^ */.exec(line)![0].length
|
|
57
|
+
const trimmed = line.trim()
|
|
58
|
+
if (block !== null && (trimmed === '' || indent > block.indent)) {
|
|
59
|
+
return merge(block.mode === 'css' ? lexCss(line) : lexCode(line, false))
|
|
60
|
+
}
|
|
61
|
+
block = null
|
|
62
|
+
const opener = BLOCK_OPENERS[trimmed]
|
|
63
|
+
if (opener !== undefined) block = { indent, mode: opener }
|
|
64
|
+
return merge(lexBtsxLine(line))
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function lexBtsxLine(line: string): Token[] {
|
|
69
|
+
const lead = /^ */.exec(line)![0]
|
|
70
|
+
const body = line.slice(lead.length)
|
|
71
|
+
const tokens: Token[] = [{ type: 'text', value: lead }]
|
|
72
|
+
if (body.startsWith('//')) return [...tokens, { type: 'comment', value: body }]
|
|
73
|
+
if (body.startsWith('~')) return [...tokens, { type: 'punct', value: '~' }, ...lexCode(body.slice(1), true)]
|
|
74
|
+
if (body.startsWith('|')) return [...tokens, { type: 'punct', value: '|' }, ...lexText(body.slice(1))]
|
|
75
|
+
|
|
76
|
+
const word = /^[A-Za-z_$][\w$]*/.exec(body)?.[0]
|
|
77
|
+
if (word !== undefined && BTSX_KEYWORDS.has(word) && !/^[.#(]/.test(body.slice(word.length))) {
|
|
78
|
+
const rest = body.slice(word.length)
|
|
79
|
+
// `component Name` names a component; other keywords are followed by code.
|
|
80
|
+
if (word === 'component') {
|
|
81
|
+
return [...tokens, { type: 'keyword', value: word }, ...lexCode(rest, false).map(asComponentName)]
|
|
82
|
+
}
|
|
83
|
+
return [...tokens, { type: 'keyword', value: word }, ...lexCode(rest, false, word === 'each' ? EACH_KEYWORDS : undefined)]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const selector = /^([A-Za-z][\w.$-]*)?((?:[#.][\w-]+)*)/.exec(body)
|
|
87
|
+
if (selector === null || selector[0] === '') return [...tokens, ...lexText(body)]
|
|
88
|
+
const [, tag = '', suffix = ''] = selector
|
|
89
|
+
const isComponent = /^[A-Z]/.test(tag)
|
|
90
|
+
if (tag !== '') tokens.push({ type: isComponent ? 'component' : 'tag', value: tag })
|
|
91
|
+
if (suffix !== '') tokens.push({ type: 'selector', value: suffix })
|
|
92
|
+
|
|
93
|
+
let rest = body.slice(selector[0].length)
|
|
94
|
+
if (rest.startsWith('(')) {
|
|
95
|
+
const close = matchingParen(rest)
|
|
96
|
+
tokens.push(...lexCode(rest.slice(0, close + 1), true))
|
|
97
|
+
rest = rest.slice(close + 1)
|
|
98
|
+
}
|
|
99
|
+
return [...tokens, ...lexText(rest)]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function asComponentName(token: Token): Token {
|
|
103
|
+
return token.type === 'type' ? { type: 'component', value: token.value } : token
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Literal text with `#{...}` interpolations. */
|
|
107
|
+
function lexText(text: string): Token[] {
|
|
108
|
+
const tokens: Token[] = []
|
|
109
|
+
let i = 0
|
|
110
|
+
while (i < text.length) {
|
|
111
|
+
const start = text.indexOf('#{', i)
|
|
112
|
+
if (start === -1) {
|
|
113
|
+
tokens.push({ type: 'text', value: text.slice(i) })
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
if (start > i) tokens.push({ type: 'text', value: text.slice(i, start) })
|
|
117
|
+
let depth = 0
|
|
118
|
+
let end = start + 1
|
|
119
|
+
for (; end < text.length; end++) {
|
|
120
|
+
if (text[end] === '{') depth++
|
|
121
|
+
else if (text[end] === '}' && --depth === 0) break
|
|
122
|
+
}
|
|
123
|
+
tokens.push({ type: 'interp', value: '#{' }, ...lexCode(text.slice(start + 2, end), false))
|
|
124
|
+
if (end < text.length) tokens.push({ type: 'interp', value: '}' })
|
|
125
|
+
i = end + 1
|
|
126
|
+
}
|
|
127
|
+
return tokens
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type Classifier = (match: string, markup: boolean, keywords: ReadonlySet<string> | undefined) => TokenType | null
|
|
131
|
+
|
|
132
|
+
/** Ordered lexer rules; a classifier returning null lets later rules try. */
|
|
133
|
+
const CODE_RULES: Array<[RegExp, TokenType | Classifier]> = [
|
|
134
|
+
[/^\/\/.*/, 'comment'],
|
|
135
|
+
[/^\/\*.*?(\*\/|$)/, 'comment'],
|
|
136
|
+
[/^(['"])(?:\\.|(?!\1).)*\1?/, 'string'],
|
|
137
|
+
[/^`(?:\\.|[^`])*`?/, 'string'],
|
|
138
|
+
[/^\d[\d_]*(?:\.\d+)?/, 'number'],
|
|
139
|
+
[/^@(?:if|else|for|switch|case|default|try|pending|catch|empty)\b/, 'directive'],
|
|
140
|
+
[/^<\/?[A-Za-z][\w.:-]*/, (match, markup) => (!markup ? null : /^<\/?[A-Z]/.test(match) ? 'component' : 'tag')],
|
|
141
|
+
[/^\/?>/, (_match, markup) => (markup ? 'tag' : 'punct')],
|
|
142
|
+
[/^[A-Za-z_$][\w$-]*(?==(?!=))/, (match, markup) => (markup || match.includes('-') ? 'attr' : 'text')],
|
|
143
|
+
[/^[A-Za-z_$][\w$]*/, (match, _markup, keywords) =>
|
|
144
|
+
JS_KEYWORDS.has(match) || keywords?.has(match) === true ? 'keyword' : /^[A-Z]/.test(match) ? 'type' : 'text'],
|
|
145
|
+
[/^\s+/, 'text'],
|
|
146
|
+
[/^[{}()[\];:,.=+\-*/%!?&|<>~^@]/, 'punct'],
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
/** TypeScript-ish code; `markup` enables JSX-style tag and attribute coloring. */
|
|
150
|
+
function lexCode(code: string, markup: boolean, keywords?: ReadonlySet<string>): Token[] {
|
|
151
|
+
const tokens: Token[] = []
|
|
152
|
+
let rest = code
|
|
153
|
+
outer: while (rest.length > 0) {
|
|
154
|
+
for (const [pattern, rule] of CODE_RULES) {
|
|
155
|
+
const match = pattern.exec(rest)?.[0]
|
|
156
|
+
if (match === undefined || match === '') continue
|
|
157
|
+
const type = typeof rule === 'function' ? rule(match, markup, keywords) : rule
|
|
158
|
+
if (type === null) continue
|
|
159
|
+
tokens.push({ type, value: match })
|
|
160
|
+
rest = rest.slice(match.length)
|
|
161
|
+
continue outer
|
|
162
|
+
}
|
|
163
|
+
tokens.push({ type: 'text', value: rest[0]! })
|
|
164
|
+
rest = rest.slice(1)
|
|
165
|
+
}
|
|
166
|
+
return tokens
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function lexCss(line: string): Token[] {
|
|
170
|
+
const comment = line.indexOf('/*')
|
|
171
|
+
if (comment !== -1) return [...lexCss(line.slice(0, comment)), { type: 'comment', value: line.slice(comment) }]
|
|
172
|
+
const declaration = /^(\s*)([\w-]+)(\s*:\s*)(.*?)(;?\s*)$/.exec(line)
|
|
173
|
+
if (declaration !== null && !line.trimEnd().endsWith('{')) {
|
|
174
|
+
const [, lead = '', property = '', colon = '', value = '', end = ''] = declaration
|
|
175
|
+
return [
|
|
176
|
+
{ type: 'text', value: lead },
|
|
177
|
+
{ type: 'attr', value: property },
|
|
178
|
+
{ type: 'punct', value: colon },
|
|
179
|
+
{ type: 'string', value },
|
|
180
|
+
{ type: 'punct', value: end },
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
return [{ type: 'selector', value: line }]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function matchingParen(text: string): number {
|
|
187
|
+
let depth = 0
|
|
188
|
+
let quote: string | null = null
|
|
189
|
+
for (let i = 0; i < text.length; i++) {
|
|
190
|
+
const char = text[i]!
|
|
191
|
+
if (quote !== null) {
|
|
192
|
+
if (char === '\\') i++
|
|
193
|
+
else if (char === quote) quote = null
|
|
194
|
+
} else if (char === '"' || char === "'" || char === '`') quote = char
|
|
195
|
+
else if (char === '(' || char === '{' || char === '[') depth++
|
|
196
|
+
else if ((char === ')' || char === '}' || char === ']') && --depth === 0) return i
|
|
197
|
+
}
|
|
198
|
+
return text.length - 1
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function merge(tokens: Token[]): Token[] {
|
|
202
|
+
const merged: Token[] = []
|
|
203
|
+
for (const token of tokens) {
|
|
204
|
+
if (token.value === '') continue
|
|
205
|
+
const last = merged.at(-1)
|
|
206
|
+
if (last !== undefined && last.type === token.type) last.value += token.value
|
|
207
|
+
else merged.push({ ...token })
|
|
208
|
+
}
|
|
209
|
+
return merged
|
|
210
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createRoot } from 'octane'
|
|
2
|
+
import BeastDevtools from './BeastDevtools.btsx'
|
|
3
|
+
import './devtools.css'
|
|
4
|
+
|
|
5
|
+
const HOST_ID = 'beast-devtools'
|
|
6
|
+
|
|
7
|
+
function mount(): void {
|
|
8
|
+
if (document.getElementById(HOST_ID) !== null) return
|
|
9
|
+
const host = document.createElement('div')
|
|
10
|
+
host.id = HOST_ID
|
|
11
|
+
document.body.append(host)
|
|
12
|
+
createRoot(host).render(BeastDevtools, {})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true })
|
|
16
|
+
else mount()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External store over Octane's `__OCTANE_DEVTOOLS__` inspection hook.
|
|
3
|
+
*
|
|
4
|
+
* The overlay is itself an Octane root, so every overlay render also flushes
|
|
5
|
+
* and notifies the hook. The store therefore publishes a new snapshot only
|
|
6
|
+
* when the (overlay-filtered) tree or the selected node's detail actually
|
|
7
|
+
* changes; an overlay-only flush produces an identical key and stops there.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface HookTreeNode {
|
|
11
|
+
id: number
|
|
12
|
+
name: string
|
|
13
|
+
kind: string
|
|
14
|
+
children: HookTreeNode[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface HookCell {
|
|
18
|
+
kind: 'state' | 'reducer' | 'ref' | 'memo-or-callback' | 'other'
|
|
19
|
+
value: unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NodeDetail {
|
|
23
|
+
id: number
|
|
24
|
+
name: string
|
|
25
|
+
hooks: HookCell[]
|
|
26
|
+
context: Array<{ name: string; value: unknown }>
|
|
27
|
+
effectCount: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface OctaneDevtoolsHook {
|
|
31
|
+
version: number
|
|
32
|
+
getTree(): HookTreeNode[]
|
|
33
|
+
inspect(id: number): NodeDetail | null
|
|
34
|
+
subscribe(listener: () => void): () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RuntimeNode {
|
|
38
|
+
id: number
|
|
39
|
+
name: string
|
|
40
|
+
/** Display name: generated control-flow scopes such as `__item$1` become `@item`. */
|
|
41
|
+
label: string
|
|
42
|
+
kind: string
|
|
43
|
+
controlFlow: boolean
|
|
44
|
+
children: RuntimeNode[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RuntimeSnapshot {
|
|
48
|
+
status: 'connecting' | 'unavailable' | 'connected'
|
|
49
|
+
roots: RuntimeNode[]
|
|
50
|
+
componentCount: number
|
|
51
|
+
selectedId: number | null
|
|
52
|
+
detail: NodeDetail | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The overlay's own root component; it is hidden from the inspected tree. */
|
|
56
|
+
export const OVERLAY_ROOT = 'BeastDevtools'
|
|
57
|
+
|
|
58
|
+
const listeners = new Set<() => void>()
|
|
59
|
+
let snapshot: RuntimeSnapshot = { status: 'connecting', roots: [], componentCount: 0, selectedId: null, detail: null }
|
|
60
|
+
let treeKey = ''
|
|
61
|
+
let detailKey = ''
|
|
62
|
+
let selectedId: number | null = null
|
|
63
|
+
let frame = 0
|
|
64
|
+
let retry: ReturnType<typeof setTimeout> | undefined
|
|
65
|
+
let disconnect: (() => void) | null = null
|
|
66
|
+
|
|
67
|
+
function hook(): OctaneDevtoolsHook | undefined {
|
|
68
|
+
return (globalThis as { __OCTANE_DEVTOOLS__?: OctaneDevtoolsHook }).__OCTANE_DEVTOOLS__
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function subscribeRuntime(listener: () => void): () => void {
|
|
72
|
+
listeners.add(listener)
|
|
73
|
+
if (listeners.size === 1) connect(0)
|
|
74
|
+
return () => {
|
|
75
|
+
listeners.delete(listener)
|
|
76
|
+
if (listeners.size > 0) return
|
|
77
|
+
disconnect?.()
|
|
78
|
+
disconnect = null
|
|
79
|
+
clearTimeout(retry)
|
|
80
|
+
cancelAnimationFrame(frame)
|
|
81
|
+
frame = 0
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getRuntimeSnapshot(): RuntimeSnapshot {
|
|
86
|
+
return snapshot
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function selectRuntimeNode(id: number | null): void {
|
|
90
|
+
selectedId = id
|
|
91
|
+
refresh()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function connect(attempt: number): void {
|
|
95
|
+
const current = hook()
|
|
96
|
+
if (current === undefined) {
|
|
97
|
+
// Octane installs the hook with its first profiled root. Give slow boots a
|
|
98
|
+
// few seconds before reporting that profiling is off.
|
|
99
|
+
if (attempt < 10) retry = setTimeout(() => connect(attempt + 1), 300)
|
|
100
|
+
else publish({ ...snapshot, status: 'unavailable' })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
disconnect = current.subscribe(schedule)
|
|
104
|
+
refresh()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function schedule(): void {
|
|
108
|
+
if (frame !== 0) return
|
|
109
|
+
frame = requestAnimationFrame(() => {
|
|
110
|
+
frame = 0
|
|
111
|
+
refresh()
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function refresh(): void {
|
|
116
|
+
const current = hook()
|
|
117
|
+
if (current === undefined) return
|
|
118
|
+
|
|
119
|
+
const roots = current
|
|
120
|
+
.getTree()
|
|
121
|
+
.filter((root) => root.name !== OVERLAY_ROOT)
|
|
122
|
+
.map(describe)
|
|
123
|
+
const ids = new Set<number>()
|
|
124
|
+
let componentCount = 0
|
|
125
|
+
const visit = (node: RuntimeNode) => {
|
|
126
|
+
ids.add(node.id)
|
|
127
|
+
if (!node.controlFlow) componentCount++
|
|
128
|
+
node.children.forEach(visit)
|
|
129
|
+
}
|
|
130
|
+
roots.forEach(visit)
|
|
131
|
+
if (selectedId !== null && !ids.has(selectedId)) selectedId = null
|
|
132
|
+
|
|
133
|
+
const detail = selectedId === null ? null : current.inspect(selectedId)
|
|
134
|
+
const nextTreeKey = JSON.stringify(roots)
|
|
135
|
+
const nextDetailKey = `${selectedId}:${safeStringify(detail)}`
|
|
136
|
+
if (snapshot.status === 'connected' && nextTreeKey === treeKey && nextDetailKey === detailKey) return
|
|
137
|
+
|
|
138
|
+
treeKey = nextTreeKey
|
|
139
|
+
detailKey = nextDetailKey
|
|
140
|
+
publish({ status: 'connected', roots, componentCount, selectedId, detail })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function publish(next: RuntimeSnapshot): void {
|
|
144
|
+
snapshot = next
|
|
145
|
+
for (const listener of listeners) listener()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function describe(node: HookTreeNode): RuntimeNode {
|
|
149
|
+
const generated = /^__([A-Za-z]+)\$\d+$/.exec(node.name)
|
|
150
|
+
const controlFlow = node.kind === 'control-flow' || generated !== null
|
|
151
|
+
return {
|
|
152
|
+
id: node.id,
|
|
153
|
+
name: node.name,
|
|
154
|
+
label: generated === null ? node.name : `@${generated[1]}`,
|
|
155
|
+
kind: node.kind,
|
|
156
|
+
controlFlow,
|
|
157
|
+
children: node.children.map(describe),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeStringify(value: unknown): string {
|
|
162
|
+
try {
|
|
163
|
+
return JSON.stringify(value, (_key, item) => (typeof item === 'bigint' ? `${item}n` : item)) ?? 'undefined'
|
|
164
|
+
} catch {
|
|
165
|
+
// Must stay stable: a changing key would republish on every overlay flush.
|
|
166
|
+
return '[unserializable]'
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { DEFAULT_SETTINGS, type AnalyzerSettings } from '../shared/types.ts'
|
|
2
|
+
|
|
3
|
+
export function cx(...parts: Array<string | false | null | undefined>): string {
|
|
4
|
+
return parts.filter(Boolean).join(' ')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Value previews for inspected hook and context values
|
|
9
|
+
|
|
10
|
+
export function isExpandable(value: unknown): boolean {
|
|
11
|
+
return value !== null && typeof value === 'object' && Object.keys(value).length > 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One-line, length-capped rendering of an inspected value. */
|
|
15
|
+
export function preview(value: unknown, budget = 90): string {
|
|
16
|
+
const text = previewInner(value, 0)
|
|
17
|
+
return text.length > budget ? `${text.slice(0, budget - 1)}…` : text
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function previewInner(value: unknown, depth: number): string {
|
|
21
|
+
if (value === undefined) return 'undefined'
|
|
22
|
+
if (value === null) return 'null'
|
|
23
|
+
if (typeof value === 'string') return /^\[(Function|Node|Getter|Array|Object|Unavailable)\]$/.test(value) ? value : JSON.stringify(value)
|
|
24
|
+
if (typeof value !== 'object') return String(value)
|
|
25
|
+
if (depth >= 2) return Array.isArray(value) ? `Array(${value.length})` : '{…}'
|
|
26
|
+
if (Array.isArray(value)) return `[${value.map((item) => previewInner(item, depth + 1)).join(', ')}]`
|
|
27
|
+
const entries = Object.entries(value).map(([key, item]) => `${key}: ${previewInner(item, depth + 1)}`)
|
|
28
|
+
return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function pretty(value: unknown): string {
|
|
32
|
+
return JSON.stringify(value, (_key, item) => (item === undefined ? '__undefined__' : item), 2)
|
|
33
|
+
.replaceAll('"__undefined__"', 'undefined')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function valueTone(value: unknown): string {
|
|
37
|
+
if (value === null || value === undefined) return 'bdt-v-nil'
|
|
38
|
+
if (typeof value === 'string') return /^\[\w+\]$/.test(value) ? 'bdt-v-special' : 'bdt-v-string'
|
|
39
|
+
if (typeof value === 'number' || typeof value === 'bigint') return 'bdt-v-number'
|
|
40
|
+
if (typeof value === 'boolean') return 'bdt-v-boolean'
|
|
41
|
+
return 'bdt-v-object'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Preferences, persisted per browser
|
|
46
|
+
|
|
47
|
+
export type TabId = 'components' | 'inspector' | 'refactor'
|
|
48
|
+
|
|
49
|
+
export interface Preferences {
|
|
50
|
+
open: boolean
|
|
51
|
+
tab: TabId
|
|
52
|
+
height: number
|
|
53
|
+
file: string | null
|
|
54
|
+
showControlFlow: boolean
|
|
55
|
+
settings: AnalyzerSettings
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const STORAGE_KEY = 'beast-devtools:preferences'
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_PREFERENCES: Preferences = {
|
|
61
|
+
open: false,
|
|
62
|
+
tab: 'components',
|
|
63
|
+
height: 360,
|
|
64
|
+
file: null,
|
|
65
|
+
showControlFlow: true,
|
|
66
|
+
settings: DEFAULT_SETTINGS,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function loadPreferences(): Preferences {
|
|
70
|
+
try {
|
|
71
|
+
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Preferences>
|
|
72
|
+
return {
|
|
73
|
+
...DEFAULT_PREFERENCES,
|
|
74
|
+
...stored,
|
|
75
|
+
settings: { ...DEFAULT_SETTINGS, ...stored.settings },
|
|
76
|
+
height: clampHeight(stored.height ?? DEFAULT_PREFERENCES.height),
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
return DEFAULT_PREFERENCES
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function savePreferences(preferences: Preferences): void {
|
|
84
|
+
try {
|
|
85
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences))
|
|
86
|
+
} catch {
|
|
87
|
+
// Storage can be unavailable (private mode, blocked site data); preferences are optional.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function clampHeight(height: number): number {
|
|
92
|
+
const max = Math.max(240, (typeof window === 'undefined' ? 900 : window.innerHeight) - 48)
|
|
93
|
+
return Math.round(Math.min(max, Math.max(200, height)))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Drive a vertical resize from a pointer-down on the panel's top edge. */
|
|
97
|
+
export function startResize(event: PointerEvent, height: number, setHeight: (height: number) => void): void {
|
|
98
|
+
event.preventDefault()
|
|
99
|
+
const startY = event.clientY
|
|
100
|
+
const move = (moveEvent: PointerEvent) => setHeight(clampHeight(height + startY - moveEvent.clientY))
|
|
101
|
+
const stop = () => {
|
|
102
|
+
window.removeEventListener('pointermove', move)
|
|
103
|
+
window.removeEventListener('pointerup', stop)
|
|
104
|
+
document.documentElement.style.removeProperty('cursor')
|
|
105
|
+
}
|
|
106
|
+
document.documentElement.style.cursor = 'ns-resize'
|
|
107
|
+
window.addEventListener('pointermove', move)
|
|
108
|
+
window.addEventListener('pointerup', stop)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Scroll a line into the middle of its scroll container without moving the page. */
|
|
112
|
+
export function scrollToLine(container: HTMLElement | null, line: number | null): void {
|
|
113
|
+
if (container === null || line === null) return
|
|
114
|
+
const row = container.querySelector<HTMLElement>(`[data-line="${line}"]`)
|
|
115
|
+
if (row === null) return
|
|
116
|
+
const top = row.offsetTop - container.clientHeight / 2 + row.offsetHeight / 2
|
|
117
|
+
container.scrollTo({ top: Math.max(0, top), behavior: 'smooth' })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A scroll request; the nonce lets the same line be requested twice. */
|
|
121
|
+
export interface ScrollTarget {
|
|
122
|
+
line: number
|
|
123
|
+
nonce: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let scrollNonce = 0
|
|
127
|
+
|
|
128
|
+
export function scrollTarget(line: number): ScrollTarget {
|
|
129
|
+
return { line, nonce: ++scrollNonce }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The last applied refactor, kept by the shell so Undo survives tab switches. */
|
|
133
|
+
export interface RecentRefactor {
|
|
134
|
+
undoId: string
|
|
135
|
+
summary: string
|
|
136
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@beastjs/devtools",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "In-page devtools for Beast (BTSX) and Octane apps: live component state, BTSX → TSRX inspection, and automatic component extraction for deeply nested templates.",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "phtn",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/beastjs/devtools.git",
|
|
11
|
+
"directory": "devtools"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/beastjs/devtools/tree/main/devtools#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/beastjs/devtools/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"beast",
|
|
19
|
+
"btsx",
|
|
20
|
+
"tsrx",
|
|
21
|
+
"octane",
|
|
22
|
+
"devtools",
|
|
23
|
+
"vite",
|
|
24
|
+
"vite-plugin",
|
|
25
|
+
"refactor"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/vite.d.ts",
|
|
30
|
+
"default": "./dist/vite.js"
|
|
31
|
+
},
|
|
32
|
+
"./vite": {
|
|
33
|
+
"types": "./dist/vite.d.ts",
|
|
34
|
+
"default": "./dist/vite.js"
|
|
35
|
+
},
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"main": "./dist/vite.js",
|
|
39
|
+
"types": "./dist/vite.d.ts",
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"client",
|
|
43
|
+
"shared",
|
|
44
|
+
"!**/*.test.ts",
|
|
45
|
+
"README.md",
|
|
46
|
+
"CHANGELOG.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"sideEffects": false,
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22.22.2"
|
|
52
|
+
},
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
55
|
+
"test": "bun test",
|
|
56
|
+
"prepublishOnly": "bun run build && bun test"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"beast-tsrx": "^0.4.3",
|
|
60
|
+
"octane": "^0.4.3",
|
|
61
|
+
"vite": "^8.0.16"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/node": "^26.2.0",
|
|
65
|
+
"beast-tsrx": "0.4.3",
|
|
66
|
+
"octane": "0.4.3",
|
|
67
|
+
"typescript": "^5.9.3",
|
|
68
|
+
"vite": "^8.0.16"
|
|
69
|
+
},
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public"
|
|
72
|
+
}
|
|
73
|
+
}
|