@dimina-kit/inspect 0.3.0-dev.20260711141929

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.
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { applyStorageEvent } from './storage-reducer.js'
3
+ import type { StorageEvent, StorageItem } from './storage-types.js'
4
+
5
+ describe('applyStorageEvent: added', () => {
6
+ it('appends a new key to the end of the list', () => {
7
+ const items: StorageItem[] = [{ key: 'a', value: '1' }]
8
+ const result = applyStorageEvent(items, { type: 'added', key: 'b', newValue: '2' })
9
+ expect(result).toEqual([{ key: 'a', value: '1' }, { key: 'b', value: '2' }])
10
+ })
11
+
12
+ it('replaces the value of an existing key in place instead of duplicating it', () => {
13
+ const items: StorageItem[] = [{ key: 'a', value: '1' }, { key: 'b', value: '2' }]
14
+ const result = applyStorageEvent(items, { type: 'added', key: 'a', newValue: '99' })
15
+ expect(result).toEqual([{ key: 'a', value: '99' }, { key: 'b', value: '2' }])
16
+ })
17
+ })
18
+
19
+ describe('applyStorageEvent: updated', () => {
20
+ it('replaces the value of an existing key', () => {
21
+ const items: StorageItem[] = [{ key: 'a', value: '1' }]
22
+ const result = applyStorageEvent(items, { type: 'updated', key: 'a', oldValue: '1', newValue: '2' })
23
+ expect(result).toEqual([{ key: 'a', value: '2' }])
24
+ })
25
+
26
+ it('appends the key when an updated event arrives before its added event', () => {
27
+ const items: StorageItem[] = []
28
+ const result = applyStorageEvent(items, { type: 'updated', key: 'a', oldValue: '', newValue: '2' })
29
+ expect(result).toEqual([{ key: 'a', value: '2' }])
30
+ })
31
+ })
32
+
33
+ describe('applyStorageEvent: removed', () => {
34
+ it('drops the matching key', () => {
35
+ const items: StorageItem[] = [{ key: 'a', value: '1' }, { key: 'b', value: '2' }]
36
+ const result = applyStorageEvent(items, { type: 'removed', key: 'a' })
37
+ expect(result).toEqual([{ key: 'b', value: '2' }])
38
+ })
39
+
40
+ it('returns the list unchanged in length when the key is absent', () => {
41
+ const items: StorageItem[] = [{ key: 'a', value: '1' }]
42
+ const result = applyStorageEvent(items, { type: 'removed', key: 'missing' })
43
+ expect(result).toEqual(items)
44
+ expect(result).toHaveLength(1)
45
+ })
46
+ })
47
+
48
+ describe('applyStorageEvent: cleared', () => {
49
+ it('returns an empty list regardless of prior contents', () => {
50
+ const items: StorageItem[] = [{ key: 'a', value: '1' }, { key: 'b', value: '2' }]
51
+ const result = applyStorageEvent(items, { type: 'cleared' })
52
+ expect(result).toEqual([])
53
+ })
54
+ })
55
+
56
+ describe('applyStorageEvent: purity', () => {
57
+ it('never mutates the input array, for any event type', () => {
58
+ const items: readonly StorageItem[] = Object.freeze([
59
+ Object.freeze({ key: 'a', value: '1' }),
60
+ Object.freeze({ key: 'b', value: '2' }),
61
+ ])
62
+ const events: StorageEvent[] = [
63
+ { type: 'added', key: 'a', newValue: 'x' },
64
+ { type: 'added', key: 'c', newValue: 'x' },
65
+ { type: 'updated', key: 'a', oldValue: '1', newValue: 'x' },
66
+ { type: 'removed', key: 'a' },
67
+ { type: 'cleared' },
68
+ ]
69
+ for (const evt of events) {
70
+ expect(() => applyStorageEvent(items, evt)).not.toThrow()
71
+ }
72
+ expect(items).toEqual([{ key: 'a', value: '1' }, { key: 'b', value: '2' }])
73
+ })
74
+
75
+ it('returns a new array reference distinct from the input', () => {
76
+ const items: StorageItem[] = [{ key: 'a', value: '1' }]
77
+ const result = applyStorageEvent(items, { type: 'added', key: 'b', newValue: '2' })
78
+ expect(result).not.toBe(items)
79
+ })
80
+ })
@@ -0,0 +1,26 @@
1
+ import type { StorageEvent, StorageItem } from './storage-types.js'
2
+
3
+ /**
4
+ * Applies one incremental StorageEvent to an item list, pure-function style
5
+ * (the input array is never mutated; a new array is always returned).
6
+ * `added` and `updated` are both tolerant upserts — host change feeds can
7
+ * deliver them out of order (an `updated` may arrive before its `added`, or
8
+ * an `added` may repeat for an existing key), and the reducer must converge
9
+ * on the same list either way instead of duplicating keys or dropping data.
10
+ */
11
+ export function applyStorageEvent(items: readonly StorageItem[], evt: StorageEvent): StorageItem[] {
12
+ switch (evt.type) {
13
+ case 'added':
14
+ case 'updated': {
15
+ const idx = items.findIndex(it => it.key === evt.key)
16
+ if (idx < 0) return [...items, { key: evt.key, value: evt.newValue }]
17
+ const next = [...items]
18
+ next[idx] = { key: evt.key, value: evt.newValue }
19
+ return next
20
+ }
21
+ case 'removed':
22
+ return items.filter(it => it.key !== evt.key)
23
+ case 'cleared':
24
+ return []
25
+ }
26
+ }
@@ -0,0 +1,28 @@
1
+ // The host-transport contract behind the Storage panel: the panel's data
2
+ // wiring (seed, live event reduction, visibility gating, write forwarding)
3
+ // is written ONCE against this interface (see ConnectedStoragePanel); each
4
+ // host only implements how the operations travel — Electron IPC channels, a
5
+ // same-origin localStorage + `storage` events, or anything else.
6
+ import type { StorageEvent, StorageItem, StorageWriteResult } from './storage-types.js'
7
+
8
+ export interface StoragePanelSource {
9
+ /** Fetch the current item snapshot (seed on panel activation). */
10
+ getSnapshot(): Promise<StorageItem[]>
11
+ /** Live mutation pushes; returns an unsubscribe function. */
12
+ subscribe(onEvent: (evt: StorageEvent) => void): () => void
13
+ /** Visibility gate: hosts whose change feed costs something (listeners,
14
+ * polling) only keep it armed while some panel is visible. */
15
+ setActive(on: boolean): void
16
+ /** Write one entry. `key` carries the full `${appId}_` prefix. */
17
+ setItem(key: string, value: string): Promise<StorageWriteResult>
18
+ /** Remove one entry by full key. */
19
+ removeItem(key: string): Promise<StorageWriteResult>
20
+ /** Clear the active appId's entries only. */
21
+ clear(): Promise<StorageWriteResult>
22
+ /** Origin-wide wipe across every appId. Optional: hosts whose storage
23
+ * partition is shared with non-mini-program data must not provide it, and
24
+ * the panel then hides the「清空所有」action entirely. */
25
+ clearAll?(): Promise<StorageWriteResult>
26
+ /** The active appId namespace prefix (`${appId}_`), '' while unresolved. */
27
+ getPrefix(): Promise<string>
28
+ }
@@ -0,0 +1,26 @@
1
+ // Wire-format types for mini-program Storage inspection. Hosts transport
2
+ // them over IPC, postMessage, or read them straight off a shared
3
+ // localStorage — the shapes are the contract, not the transport.
4
+
5
+ /** One storage entry. `key` carries the full `${appId}_` namespace prefix;
6
+ * values are the raw localStorage strings (objects are JSON-stringified by
7
+ * the runtime before they land here). */
8
+ export interface StorageItem {
9
+ key: string
10
+ value: string
11
+ }
12
+
13
+ /** An incremental storage mutation pushed by a host's change feed (CDP
14
+ * DOMStorage events, `storage` DOM events, or synthesized after the host's
15
+ * own writes). Keys carry the full prefix, same as StorageItem. */
16
+ export type StorageEvent =
17
+ | { type: 'added', key: string, newValue: string }
18
+ | { type: 'updated', key: string, oldValue: string, newValue: string }
19
+ | { type: 'removed', key: string }
20
+ | { type: 'cleared' }
21
+
22
+ /** Result of a panel-initiated write. Failures carry a user-displayable
23
+ * message; the panel surfaces it inline instead of throwing. */
24
+ export type StorageWriteResult =
25
+ | { ok: true }
26
+ | { ok: false, error: string }
package/src/types.ts ADDED
@@ -0,0 +1,35 @@
1
+ // Wire-format types shared by every host that extracts or displays a WXML
2
+ // tree (Electron devtools, browser workbench panels, downstream hosts). They
3
+ // are the protocol layer: hosts may transport them over IPC, postMessage or
4
+ // any other channel, but the shapes themselves stay host-agnostic.
5
+
6
+ export interface WxmlNode {
7
+ tagName: string
8
+ attrs: Record<string, string>
9
+ children: WxmlNode[]
10
+ text?: string
11
+ sid?: string
12
+ }
13
+
14
+ /** Measurement of one element (by sid): bounding rect + the computed-style
15
+ * subset the panel footer displays. Producing it must not mutate the page —
16
+ * any visual highlight is the host's concern. */
17
+ export interface ElementInspection {
18
+ sid: string
19
+ rect: {
20
+ x: number
21
+ y: number
22
+ width: number
23
+ height: number
24
+ }
25
+ style: {
26
+ display: string
27
+ position: string
28
+ boxSizing: string
29
+ margin: string
30
+ padding: string
31
+ color: string
32
+ backgroundColor: string
33
+ fontSize: string
34
+ }
35
+ }
@@ -0,0 +1,69 @@
1
+ // The source-lifecycle wiring every connected panel shares, written once:
2
+ // subscription + feed-gate lifecycle per (source, enabled), visibility
3
+ // forwarding, and the (enabled && active) rising-edge seed (including a
4
+ // source swap while on) with late-resolution dropping. Panel-specific parts —
5
+ // what a push event does to state and how a snapshot lands — come in as the
6
+ // `subscribe`/`seed` handlers, read through a ref so effects depend only on
7
+ // (source, enabled, active) and inline handler lambdas never re-fire them.
8
+ import { useEffect, useRef } from 'react'
9
+
10
+ interface ConnectedSource {
11
+ setActive(on: boolean): void
12
+ }
13
+
14
+ export function useSourceWiring<S extends ConnectedSource>(options: {
15
+ source: S
16
+ /** Data availability gate: while false, zero source calls are made. */
17
+ enabled: boolean
18
+ /** Panel visibility (the host's tab-active state). */
19
+ active: boolean
20
+ /** Open the live push subscription; returns the unsubscriber. */
21
+ subscribe: (source: S) => () => void
22
+ /** Fetch + apply one snapshot. `isDisposed()` turns true once this seed's
23
+ * cleanup ran (unmount or a newer seed) — a resolution arriving after that
24
+ * must be dropped by the handler. */
25
+ seed: (source: S, isDisposed: () => boolean) => void
26
+ }): void {
27
+ const { source, enabled, active } = options
28
+ // Declared before every consuming effect so it runs first on each commit
29
+ // and the handlers are always current when an effect fires.
30
+ const handlers = useRef({ subscribe: options.subscribe, seed: options.seed })
31
+ useEffect(() => {
32
+ handlers.current = { subscribe: options.subscribe, seed: options.seed }
33
+ })
34
+
35
+ // Subscription + feed-gate lifecycle, per (source, enabled). Cleanup disarms
36
+ // the producer's feed so an unmounted/disabled panel costs nothing; a source
37
+ // swap tears the old transport down first.
38
+ useEffect(() => {
39
+ if (!enabled) return
40
+ const unsubscribe = handlers.current.subscribe(source)
41
+ return () => {
42
+ unsubscribe()
43
+ source.setActive(false)
44
+ }
45
+ }, [source, enabled])
46
+
47
+ // Forward the visibility gate on every change while enabled.
48
+ useEffect(() => {
49
+ if (!enabled) return
50
+ source.setActive(active)
51
+ }, [source, enabled, active])
52
+
53
+ // Seed on the (enabled && active) rising edge — including a source swap
54
+ // while on. A kept-alive tab that turns active again re-fetches, so it never
55
+ // shows data from before its invisible stretch.
56
+ const prevSeed = useRef<{ source: S | null, on: boolean }>({ source: null, on: false })
57
+ useEffect(() => {
58
+ const on = enabled && active
59
+ const prev = prevSeed.current
60
+ const rising = on && (!prev.on || prev.source !== source)
61
+ prevSeed.current = { source, on }
62
+ if (!rising) return undefined
63
+ let disposed = false
64
+ handlers.current.seed(source, () => disposed)
65
+ return () => {
66
+ disposed = true
67
+ }
68
+ }, [source, enabled, active])
69
+ }
@@ -0,0 +1,366 @@
1
+ import { registerSyntheticSid } from './sid-registry.js'
2
+ import type { WxmlNode } from './types.js'
3
+
4
+ export interface ComponentInstance extends Record<string, unknown> {
5
+ type?: Record<string, unknown>
6
+ props?: Record<string, unknown>
7
+ attrs?: Record<string, unknown>
8
+ parent?: Record<string, unknown>
9
+ appContext?: Record<string, unknown>
10
+ subTree?: Record<string, unknown>
11
+ }
12
+
13
+ const INTERNAL_CLASSES = new Set([
14
+ 'dd-swiper-wrapper', 'dd-swiper-slides', 'dd-swiper-slide-frame', 'dd-swiper-dots',
15
+ 'dd-picker-overlay', 'dd-picker-container', 'dd-picker-header', 'dd-picker-body',
16
+ ])
17
+
18
+ const FRAMEWORK_TEMPLATE_RE = /^(taro_tmpl|tmpl_\d+)/
19
+
20
+ /**
21
+ * Strip the `dd-` registration prefix and the `tpl-` prefix dimina adds to
22
+ * compiled template components (`app.component('dd-tpl-taro_tmpl', …)`), so a
23
+ * framework template wrapper normalizes to its bare name (`taro_tmpl`,
24
+ * `tmpl_0_3`) and `FRAMEWORK_TEMPLATE_RE` recognizes it.
25
+ */
26
+ function normalizeRegisteredName(regName: string): string {
27
+ const base = regName.startsWith('dd-') ? regName.slice(3) : regName
28
+ return base.startsWith('tpl-') ? base.slice(4) : base
29
+ }
30
+
31
+ function resolveTemplateNameFromParent(instance: ComponentInstance): string | null {
32
+ const parent = instance.parent as Record<string, unknown> | undefined
33
+ const parentType = parent?.type as Record<string, unknown> | undefined
34
+ const components = parentType?.components as Record<string, unknown> | undefined
35
+ if (components) {
36
+ for (const [regName, comp] of Object.entries(components)) {
37
+ if (comp === instance.type) return normalizeRegisteredName(regName)
38
+ }
39
+ }
40
+ const appComponents = instance.appContext?.components as Record<string, unknown> | undefined
41
+ if (!appComponents) return null
42
+ for (const [regName, comp] of Object.entries(appComponents)) {
43
+ if (comp === instance.type) return normalizeRegisteredName(regName)
44
+ }
45
+ return null
46
+ }
47
+
48
+ /**
49
+ * Convert a PascalCase / camelCase identifier to kebab-case. Matches the
50
+ * upstream `camelCaseToUnderscore` (dimina/fe/packages/common) so a name
51
+ * already normalized by `withInstall` round-trips identically.
52
+ * `View` -> `view`, `ScrollView` -> `scroll-view`, `CoverImage` -> `cover-image`.
53
+ */
54
+ function pascalToKebab(name: string): string {
55
+ return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
56
+ }
57
+
58
+ /**
59
+ * Read the page's source path off Vue's provide/inject. dimina runtime.js 在
60
+ * dd-page 的 setup() 里调用 `provide('path', path)`,Vue 把它存到
61
+ * `instance.provides`。我们利用这个把页面节点的 tagName 从硬编码 `page`
62
+ * 升级为页面全路径(如 `pages/index/index`),对齐微信开发者工具。
63
+ */
64
+ function resolvePagePath(instance: ComponentInstance): string | null {
65
+ // dimina runtime 在 dd-page 的 setup 里 `instance.proxy.__page__ = true` 并
66
+ // `provide('path', path)`。两个标记同时具备才视为页面层级,避免 dd-page
67
+ // 的子节点继承 provides.path 后被误判(Vue 用 Object.create 链式 provides)。
68
+ const proxy = (instance as Record<string, unknown>).proxy as
69
+ | Record<string, unknown>
70
+ | undefined
71
+ if (proxy?.__page__ !== true) return null
72
+ const provides = (instance as Record<string, unknown>).provides as
73
+ | Record<string, unknown>
74
+ | undefined
75
+ const path = provides?.path
76
+ if (typeof path !== 'string' || !path) return null
77
+ return path.startsWith('/') ? path.slice(1) : path
78
+ }
79
+
80
+ /**
81
+ * A NATIVE dimina custom component (a `usingComponents` entry) calls
82
+ * `provide('path', componentPath)` in its setup (render runtime), so its
83
+ * provided `path` DIFFERS from its parent's. A plain descendant inherits the
84
+ * same provides object (identical `path`) and is not a boundary; a Taro template
85
+ * wrapper (`dd-tpl-*`) never provides, so it never differs either. Comparing
86
+ * against the parent's provided path therefore isolates exactly the native
87
+ * component roots — matching WeChat, which shows each custom component as a node
88
+ * tagged with its full registered path (with a synthetic `#shadow-root` added by
89
+ * `wrapInShadowRoot` since the path contains `/`). Pages are handled earlier via
90
+ * their authoritative `__page__` marker, so this only fires for components.
91
+ */
92
+ function resolveComponentPath(instance: ComponentInstance): string | null {
93
+ const provides = (instance as Record<string, unknown>).provides as Record<string, unknown> | undefined
94
+ const path = provides?.path
95
+ if (typeof path !== 'string' || !path) return null
96
+ const parent = instance.parent as Record<string, unknown> | undefined
97
+ const parentProvides = parent?.provides as Record<string, unknown> | undefined
98
+ if (path === parentProvides?.path) return null
99
+ return path.startsWith('/') ? path.slice(1) : path
100
+ }
101
+
102
+ function resolveTagName(instance: ComponentInstance): string {
103
+ const type = instance.type
104
+ if (!type) return 'unknown'
105
+ // 页面层级优先:dd-page 没有 __tagName/__name,且 home 页等无 usingComponents
106
+ // 的页面 type.components 为 undefined,会落到 resolveTemplateNameFromParent
107
+ // 回到 'page'。在那之前直接用 provide('path') 的路径升级 tag 名。
108
+ const pagePath = resolvePagePath(instance)
109
+ if (pagePath) return pagePath
110
+ // Native custom-component boundary → tag with its full registered path (WeChat
111
+ // parity). Must precede the __tagName/__name/dd- shortening below, which would
112
+ // otherwise collapse `dd-foo` to the bare `foo` and lose the path.
113
+ const componentPath = resolveComponentPath(instance)
114
+ if (componentPath) return componentPath
115
+ if (typeof type.__tagName === 'string') return type.__tagName
116
+ const name = (type.__name || type.name) as string | undefined
117
+ if (!name) {
118
+ // A nameless component carrying a `__scopeId` is a compiled page, custom
119
+ // component, or framework template wrapper. The page is already resolved
120
+ // above via its authoritative `__page__` marker, so by here it is NOT a
121
+ // page — recover the registered tag (e.g. a Taro `taro_tmpl`/`tmpl_0_3`
122
+ // wrapper) and fall back to `template`. Defaulting to `page` here would
123
+ // mislabel every such wrapper as a second page root.
124
+ if (type.__scopeId) return resolveTemplateNameFromParent(instance) || 'template'
125
+ return 'unknown'
126
+ }
127
+ if (name === 'dd-page') return 'page'
128
+ if (name.startsWith('dd-')) return name.slice(3)
129
+ // Reverse-map Dimina component names back to their miniprogram tag names.
130
+ // The installer (`withInstall`) sets `__tagName = camelCaseToUnderscore(__name)`,
131
+ // but in dev builds, custom registrations, or when the installer hasn't run,
132
+ // we may only see the raw `__name`/`name` (e.g. `View`, `ScrollView`, `DdButton`).
133
+ // Without this fallback the WXML panel would surface the upstream Vue name
134
+ // verbatim, defeating the panel's purpose of showing source-level tags.
135
+ if (name.startsWith('Dd') && name.length > 2) return pascalToKebab(name.slice(2))
136
+ if (/^[A-Z]/.test(name)) return pascalToKebab(name)
137
+ return name
138
+ }
139
+
140
+ /** Props/attrs entries that never surface in the WXML panel (internal Vue plumbing, falsy booleans). */
141
+ function isSkippedProp(key: string, value: unknown): boolean {
142
+ if (typeof value === 'function' || value === undefined) return true
143
+ if (key === 'data' || key.startsWith('__')) return true
144
+ return typeof value === 'boolean' && !value
145
+ }
146
+
147
+ /** Drop the `dd-` internal-marker classes so the panel shows only user-authored classes. */
148
+ function cleanClassAttr(value: string): string {
149
+ return value.split(/\s+/).filter((c) => !c.startsWith('dd-')).join(' ')
150
+ }
151
+
152
+ function stringifyPropValue(value: unknown): string {
153
+ return typeof value === 'object' ? JSON.stringify(value) : String(value)
154
+ }
155
+
156
+ function assignProp(out: Record<string, string>, key: string, value: unknown): void {
157
+ if (isSkippedProp(key, value)) return
158
+ if (key === 'class' && typeof value === 'string') {
159
+ const cleaned = cleanClassAttr(value)
160
+ if (cleaned) out[key] = cleaned
161
+ return
162
+ }
163
+ out[key] = stringifyPropValue(value)
164
+ }
165
+
166
+ function extractProps(instance: ComponentInstance): Record<string, string> {
167
+ const out: Record<string, string> = {}
168
+ for (const raw of [instance.props, instance.attrs]) {
169
+ if (!raw) continue
170
+ for (const [k, v] of Object.entries(raw)) {
171
+ assignProp(out, k, v)
172
+ }
173
+ }
174
+ return out
175
+ }
176
+
177
+ function isInternalElement(vnode: Record<string, unknown>): boolean {
178
+ const props = vnode.props as Record<string, unknown> | null
179
+ if (!props) return false
180
+ const cls = String(props.class || '')
181
+ return cls.split(/\s+/).some((c) => INTERNAL_CLASSES.has(c))
182
+ }
183
+
184
+ function getElementSid(instance: ComponentInstance): string | undefined {
185
+ const subTree = instance.subTree as Record<string, unknown> | undefined
186
+ const el = subTree?.el as HTMLElement | undefined
187
+ if (!el?.getAttribute) return undefined
188
+ const sid = el.getAttribute('data-sid')
189
+ if (sid) return sid
190
+ return registerSyntheticSid(el)
191
+ }
192
+
193
+ function isTransparentComponent(instance: ComponentInstance, tagName: string): boolean {
194
+ if (tagName === 'unknown') return true
195
+ // A framework template wrapper (Taro `taro_tmpl` / `tmpl_0_3`, whether named
196
+ // via `props.is` or recovered from its registration) is compiler scaffolding,
197
+ // not user content — pass its children straight through.
198
+ if (FRAMEWORK_TEMPLATE_RE.test(tagName)) return true
199
+ if (tagName === 'template') {
200
+ const props = instance.props as Record<string, unknown> | undefined
201
+ const is = props?.is as string | undefined
202
+ if (is && FRAMEWORK_TEMPLATE_RE.test(is)) return true
203
+ if (!is) {
204
+ const type = instance.type as Record<string, unknown> | undefined
205
+ if (type?.__scopeId && !type.components) return true
206
+ }
207
+ }
208
+ return false
209
+ }
210
+
211
+ /**
212
+ * 检测 Vue 的 Comment vnode(`v-if`/`v-else`/`v-for` 占位锚点)。
213
+ * Vue 在条件不成立时会留下 `<!-- v-if -->` 这样的注释 vnode 用作 DOM 锚点。
214
+ * 这些 vnode 不是用户内容,必须从 wxml 树里剔除。
215
+ *
216
+ * - dev build:type 是 `Symbol('Comment')`,description = 'Comment' 或 'v-cmt'
217
+ * - prod build:description 可能丢失,但 children 一般是 'v-if'/'v-else' 等 marker
218
+ */
219
+ function isCommentVNode(vnode: Record<string, unknown>): boolean {
220
+ const type = vnode.type
221
+ if (typeof type !== 'symbol') return false
222
+ const desc = type.description
223
+ if (desc === 'Comment' || desc === 'v-cmt') return true
224
+ const children = typeof vnode.children === 'string' ? vnode.children : ''
225
+ return /^v-(if|else|else-if|for)$/.test(children)
226
+ }
227
+
228
+ function textNode(text: string): WxmlNode {
229
+ return { tagName: '#text', attrs: {}, children: [], text }
230
+ }
231
+
232
+ /** A component vnode's children come from walking its mounted instance, not `vnode.children`. */
233
+ function childrenFromComponentVNode(component: ComponentInstance, depth: number): WxmlNode[] {
234
+ const result = walkInstance(component, depth + 1)
235
+ if (!result) return []
236
+ return Array.isArray(result) ? result : [result]
237
+ }
238
+
239
+ /** A `<Suspense>` vnode's visible content is whichever branch is currently active. */
240
+ function childrenFromSuspenseVNode(suspense: Record<string, unknown>, depth: number): WxmlNode[] {
241
+ const activeBranch = suspense.activeBranch as Record<string, unknown> | null
242
+ return activeBranch ? extractChildrenFromVNode(activeBranch, depth + 1) : []
243
+ }
244
+
245
+ /**
246
+ * A vnode whose OWN `children` is a plain string only counts as a text node when
247
+ * its `type` is a symbol/string (a real element or fragment) — a component vnode
248
+ * with string `children` is slot content, not text, and is handled elsewhere.
249
+ * Returns null when this vnode is not (top-level) text, so the caller falls
250
+ * through to the array-children path.
251
+ */
252
+ function directTextChild(vnode: Record<string, unknown>): WxmlNode[] | null {
253
+ if (typeof vnode.children !== 'string' || !vnode.children.trim()) return null
254
+ const vnodeType = vnode.type
255
+ if (typeof vnodeType !== 'symbol' && typeof vnodeType !== 'string') return null
256
+ return [textNode(vnode.children.trim())]
257
+ }
258
+
259
+ function textChildEntry(child: string): WxmlNode[] {
260
+ const trimmed = child.trim()
261
+ return trimmed ? [textNode(trimmed)] : []
262
+ }
263
+
264
+ /** A DOM-typed child (`type` is its tag name string): text leaf, internal wrapper, or a normal subtree. */
265
+ function domTypedChildEntries(c: Record<string, unknown>, depth: number): WxmlNode[] {
266
+ if (isInternalElement(c)) return extractChildrenFromVNode(c, depth + 1)
267
+ if (typeof c.children === 'string' && c.children.trim()) return [textNode(c.children.trim())]
268
+ return extractChildrenFromVNode(c, depth + 1)
269
+ }
270
+
271
+ /** One entry from a vnode's `children` array, normalized to zero or more WXML nodes. */
272
+ function extractChildEntry(child: unknown, depth: number): WxmlNode[] {
273
+ if (!child) return []
274
+ if (typeof child === 'string') return textChildEntry(child)
275
+ if (typeof child !== 'object') return []
276
+ const c = child as Record<string, unknown>
277
+ if (c.component) return childrenFromComponentVNode(c.component as ComponentInstance, depth)
278
+ if (typeof c.type === 'symbol') return extractChildrenFromVNode(c, depth + 1)
279
+ if (typeof c.type === 'string') return domTypedChildEntries(c, depth)
280
+ return extractChildrenFromVNode(c, depth + 1)
281
+ }
282
+
283
+ function extractChildrenFromVNode(vnode: Record<string, unknown> | null | undefined, depth: number): WxmlNode[] {
284
+ if (!vnode || depth > 50) return []
285
+ if (isCommentVNode(vnode)) return []
286
+ if (vnode.component) return childrenFromComponentVNode(vnode.component as ComponentInstance, depth)
287
+ if (vnode.suspense) return childrenFromSuspenseVNode(vnode.suspense as Record<string, unknown>, depth)
288
+ const directText = directTextChild(vnode)
289
+ if (directText) return directText
290
+ const kids = vnode.children
291
+ if (!Array.isArray(kids)) return []
292
+ const result: WxmlNode[] = []
293
+ for (const child of kids) {
294
+ result.push(...extractChildEntry(child, depth))
295
+ }
296
+ return result
297
+ }
298
+
299
+ /**
300
+ * Reverse-map Dimina's `<wrapper name="/components/foo/foo">` (used to host
301
+ * every user-defined component) back to the source-level tag the user wrote
302
+ * in WXML (e.g. `<foo>`).
303
+ *
304
+ * The wrapper carries the registered component path in its `name` Vue prop.
305
+ * By convention (also followed by miniprogram tooling), the registered key is
306
+ * the last path segment, optionally stripping a trailing `/index`. We can't
307
+ * see the page's `usingComponents` map from inside the Vue instance, so this
308
+ * convention-based recovery is best-effort: if the user picked a different
309
+ * registration key than the directory name (e.g. `usingComponents:
310
+ * { myCounter: '/components/counter/counter' }`), the panel will show
311
+ * `counter`, not `myCounter`.
312
+ *
313
+ * The path heuristic also resolves a name-collision risk: a user-written
314
+ * `<counter name="x">` would land in the same `attrs.name` slot as the
315
+ * wrapper-internal path. We only unwrap when the value looks like an absolute
316
+ * component path (leading `/`), which dimina always emits but a user would
317
+ * almost never type as a literal attr.
318
+ */
319
+ function unwrapCustomComponent(node: WxmlNode): WxmlNode {
320
+ if (node.tagName !== 'wrapper') return node
321
+ const path = node.attrs?.name
322
+ if (typeof path !== 'string' || !path.startsWith('/')) return node
323
+ // 去掉前导 `/` 后保留原路径形式(保留所有斜杠,不做 kebab/dash 转换)。
324
+ // 仅当路径以 `/index` 结尾且剥离后仍至少剩一段时才剥(`/index` 单独存在则
325
+ // 退回 wrapper,避免出现空 tagName)。
326
+ const stripped = path.replace(/^\//, '')
327
+ const withoutIndex = stripped.endsWith('/index') ? stripped.slice(0, -'/index'.length) : stripped
328
+ const recovered = withoutIndex || stripped
329
+ if (!recovered || recovered === 'index') return node
330
+ const nextAttrs: Record<string, string> = {}
331
+ for (const [k, v] of Object.entries(node.attrs)) {
332
+ if (k === 'name') continue
333
+ nextAttrs[k] = v
334
+ }
335
+ return { ...node, tagName: recovered, attrs: nextAttrs }
336
+ }
337
+
338
+ /**
339
+ * 把"用户授权"层级(页面 / 自定义组件,tagName 以路径形式呈现,含 `/`)
340
+ * 的 children 包一层合成 `#shadow-root`,对齐微信开发者工具:组件本身
341
+ * 与内部实现之间用 shadow-root 边界视觉分隔。
342
+ *
343
+ * 内置组件(view/text/button 等)和合成节点(#text/#fragment)tagName 都
344
+ * 不含 `/`,自然不会被包裹。children 为空也不插入(避免空壳)。重复调用
345
+ * 是幂等的:若 children[0] 已经是 #shadow-root 就跳过。
346
+ */
347
+ function wrapInShadowRoot(node: WxmlNode): WxmlNode {
348
+ if (!node.tagName.includes('/')) return node
349
+ if (node.children.length === 0) return node
350
+ if (node.children[0]?.tagName === '#shadow-root') return node
351
+ return {
352
+ ...node,
353
+ children: [{ tagName: '#shadow-root', attrs: {}, children: node.children }],
354
+ }
355
+ }
356
+
357
+ export function walkInstance(instance: ComponentInstance, depth: number): WxmlNode | WxmlNode[] | null {
358
+ if (depth > 50) return null
359
+ const tagName = resolveTagName(instance)
360
+ const children = extractChildrenFromVNode(instance.subTree as Record<string, unknown>, depth)
361
+ if (isTransparentComponent(instance, tagName)) return children.length > 0 ? children : null
362
+ const node: WxmlNode = { tagName, attrs: extractProps(instance), children }
363
+ const sid = getElementSid(instance)
364
+ if (sid) node.sid = sid
365
+ return wrapInShadowRoot(unwrapCustomComponent(node))
366
+ }