@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.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/dist/connected-panel.js +28 -0
- package/dist/connected-storage-panel.js +33 -0
- package/dist/index.js +4 -0
- package/dist/inspector.js +122 -0
- package/dist/panel-source.js +1 -0
- package/dist/panel-view.js +136 -0
- package/dist/panel.js +7 -0
- package/dist/sid-registry.js +45 -0
- package/dist/storage-panel-view.js +127 -0
- package/dist/storage-reducer.js +25 -0
- package/dist/storage-source.js +1 -0
- package/dist/storage-types.js +4 -0
- package/dist/types.js +5 -0
- package/dist/use-source-wiring.js +52 -0
- package/dist/wxml-extract.js +381 -0
- package/package.json +72 -0
- package/src/connected-panel.test.tsx +224 -0
- package/src/connected-panel.tsx +53 -0
- package/src/connected-storage-panel.test.tsx +289 -0
- package/src/connected-storage-panel.tsx +62 -0
- package/src/index.ts +16 -0
- package/src/inspector.test.ts +282 -0
- package/src/inspector.ts +144 -0
- package/src/panel-source.ts +20 -0
- package/src/panel-view.tsx +355 -0
- package/src/panel.tsx +7 -0
- package/src/sid-registry.ts +45 -0
- package/src/storage-panel-view.tsx +252 -0
- package/src/storage-reducer.test.ts +80 -0
- package/src/storage-reducer.ts +26 -0
- package/src/storage-source.ts +28 -0
- package/src/storage-types.ts +26 -0
- package/src/types.ts +35 -0
- package/src/use-source-wiring.ts +69 -0
- package/src/wxml-extract.ts +366 -0
|
@@ -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
|
+
}
|