@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,52 @@
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
+ export function useSourceWiring(options) {
10
+ const { source, enabled, active } = options;
11
+ // Declared before every consuming effect so it runs first on each commit
12
+ // and the handlers are always current when an effect fires.
13
+ const handlers = useRef({ subscribe: options.subscribe, seed: options.seed });
14
+ useEffect(() => {
15
+ handlers.current = { subscribe: options.subscribe, seed: options.seed };
16
+ });
17
+ // Subscription + feed-gate lifecycle, per (source, enabled). Cleanup disarms
18
+ // the producer's feed so an unmounted/disabled panel costs nothing; a source
19
+ // swap tears the old transport down first.
20
+ useEffect(() => {
21
+ if (!enabled)
22
+ return;
23
+ const unsubscribe = handlers.current.subscribe(source);
24
+ return () => {
25
+ unsubscribe();
26
+ source.setActive(false);
27
+ };
28
+ }, [source, enabled]);
29
+ // Forward the visibility gate on every change while enabled.
30
+ useEffect(() => {
31
+ if (!enabled)
32
+ return;
33
+ source.setActive(active);
34
+ }, [source, enabled, active]);
35
+ // Seed on the (enabled && active) rising edge — including a source swap
36
+ // while on. A kept-alive tab that turns active again re-fetches, so it never
37
+ // shows data from before its invisible stretch.
38
+ const prevSeed = useRef({ source: null, on: false });
39
+ useEffect(() => {
40
+ const on = enabled && active;
41
+ const prev = prevSeed.current;
42
+ const rising = on && (!prev.on || prev.source !== source);
43
+ prevSeed.current = { source, on };
44
+ if (!rising)
45
+ return undefined;
46
+ let disposed = false;
47
+ handlers.current.seed(source, () => disposed);
48
+ return () => {
49
+ disposed = true;
50
+ };
51
+ }, [source, enabled, active]);
52
+ }
@@ -0,0 +1,381 @@
1
+ import { registerSyntheticSid } from './sid-registry.js';
2
+ const INTERNAL_CLASSES = new Set([
3
+ 'dd-swiper-wrapper', 'dd-swiper-slides', 'dd-swiper-slide-frame', 'dd-swiper-dots',
4
+ 'dd-picker-overlay', 'dd-picker-container', 'dd-picker-header', 'dd-picker-body',
5
+ ]);
6
+ const FRAMEWORK_TEMPLATE_RE = /^(taro_tmpl|tmpl_\d+)/;
7
+ /**
8
+ * Strip the `dd-` registration prefix and the `tpl-` prefix dimina adds to
9
+ * compiled template components (`app.component('dd-tpl-taro_tmpl', …)`), so a
10
+ * framework template wrapper normalizes to its bare name (`taro_tmpl`,
11
+ * `tmpl_0_3`) and `FRAMEWORK_TEMPLATE_RE` recognizes it.
12
+ */
13
+ function normalizeRegisteredName(regName) {
14
+ const base = regName.startsWith('dd-') ? regName.slice(3) : regName;
15
+ return base.startsWith('tpl-') ? base.slice(4) : base;
16
+ }
17
+ function resolveTemplateNameFromParent(instance) {
18
+ const parent = instance.parent;
19
+ const parentType = parent?.type;
20
+ const components = parentType?.components;
21
+ if (components) {
22
+ for (const [regName, comp] of Object.entries(components)) {
23
+ if (comp === instance.type)
24
+ return normalizeRegisteredName(regName);
25
+ }
26
+ }
27
+ const appComponents = instance.appContext?.components;
28
+ if (!appComponents)
29
+ return null;
30
+ for (const [regName, comp] of Object.entries(appComponents)) {
31
+ if (comp === instance.type)
32
+ return normalizeRegisteredName(regName);
33
+ }
34
+ return null;
35
+ }
36
+ /**
37
+ * Convert a PascalCase / camelCase identifier to kebab-case. Matches the
38
+ * upstream `camelCaseToUnderscore` (dimina/fe/packages/common) so a name
39
+ * already normalized by `withInstall` round-trips identically.
40
+ * `View` -> `view`, `ScrollView` -> `scroll-view`, `CoverImage` -> `cover-image`.
41
+ */
42
+ function pascalToKebab(name) {
43
+ return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
44
+ }
45
+ /**
46
+ * Read the page's source path off Vue's provide/inject. dimina runtime.js 在
47
+ * dd-page 的 setup() 里调用 `provide('path', path)`,Vue 把它存到
48
+ * `instance.provides`。我们利用这个把页面节点的 tagName 从硬编码 `page`
49
+ * 升级为页面全路径(如 `pages/index/index`),对齐微信开发者工具。
50
+ */
51
+ function resolvePagePath(instance) {
52
+ // dimina runtime 在 dd-page 的 setup 里 `instance.proxy.__page__ = true` 并
53
+ // `provide('path', path)`。两个标记同时具备才视为页面层级,避免 dd-page
54
+ // 的子节点继承 provides.path 后被误判(Vue 用 Object.create 链式 provides)。
55
+ const proxy = instance.proxy;
56
+ if (proxy?.__page__ !== true)
57
+ return null;
58
+ const provides = instance.provides;
59
+ const path = provides?.path;
60
+ if (typeof path !== 'string' || !path)
61
+ return null;
62
+ return path.startsWith('/') ? path.slice(1) : path;
63
+ }
64
+ /**
65
+ * A NATIVE dimina custom component (a `usingComponents` entry) calls
66
+ * `provide('path', componentPath)` in its setup (render runtime), so its
67
+ * provided `path` DIFFERS from its parent's. A plain descendant inherits the
68
+ * same provides object (identical `path`) and is not a boundary; a Taro template
69
+ * wrapper (`dd-tpl-*`) never provides, so it never differs either. Comparing
70
+ * against the parent's provided path therefore isolates exactly the native
71
+ * component roots — matching WeChat, which shows each custom component as a node
72
+ * tagged with its full registered path (with a synthetic `#shadow-root` added by
73
+ * `wrapInShadowRoot` since the path contains `/`). Pages are handled earlier via
74
+ * their authoritative `__page__` marker, so this only fires for components.
75
+ */
76
+ function resolveComponentPath(instance) {
77
+ const provides = instance.provides;
78
+ const path = provides?.path;
79
+ if (typeof path !== 'string' || !path)
80
+ return null;
81
+ const parent = instance.parent;
82
+ const parentProvides = parent?.provides;
83
+ if (path === parentProvides?.path)
84
+ return null;
85
+ return path.startsWith('/') ? path.slice(1) : path;
86
+ }
87
+ function resolveTagName(instance) {
88
+ const type = instance.type;
89
+ if (!type)
90
+ return 'unknown';
91
+ // 页面层级优先:dd-page 没有 __tagName/__name,且 home 页等无 usingComponents
92
+ // 的页面 type.components 为 undefined,会落到 resolveTemplateNameFromParent
93
+ // 回到 'page'。在那之前直接用 provide('path') 的路径升级 tag 名。
94
+ const pagePath = resolvePagePath(instance);
95
+ if (pagePath)
96
+ return pagePath;
97
+ // Native custom-component boundary → tag with its full registered path (WeChat
98
+ // parity). Must precede the __tagName/__name/dd- shortening below, which would
99
+ // otherwise collapse `dd-foo` to the bare `foo` and lose the path.
100
+ const componentPath = resolveComponentPath(instance);
101
+ if (componentPath)
102
+ return componentPath;
103
+ if (typeof type.__tagName === 'string')
104
+ return type.__tagName;
105
+ const name = (type.__name || type.name);
106
+ if (!name) {
107
+ // A nameless component carrying a `__scopeId` is a compiled page, custom
108
+ // component, or framework template wrapper. The page is already resolved
109
+ // above via its authoritative `__page__` marker, so by here it is NOT a
110
+ // page — recover the registered tag (e.g. a Taro `taro_tmpl`/`tmpl_0_3`
111
+ // wrapper) and fall back to `template`. Defaulting to `page` here would
112
+ // mislabel every such wrapper as a second page root.
113
+ if (type.__scopeId)
114
+ return resolveTemplateNameFromParent(instance) || 'template';
115
+ return 'unknown';
116
+ }
117
+ if (name === 'dd-page')
118
+ return 'page';
119
+ if (name.startsWith('dd-'))
120
+ return name.slice(3);
121
+ // Reverse-map Dimina component names back to their miniprogram tag names.
122
+ // The installer (`withInstall`) sets `__tagName = camelCaseToUnderscore(__name)`,
123
+ // but in dev builds, custom registrations, or when the installer hasn't run,
124
+ // we may only see the raw `__name`/`name` (e.g. `View`, `ScrollView`, `DdButton`).
125
+ // Without this fallback the WXML panel would surface the upstream Vue name
126
+ // verbatim, defeating the panel's purpose of showing source-level tags.
127
+ if (name.startsWith('Dd') && name.length > 2)
128
+ return pascalToKebab(name.slice(2));
129
+ if (/^[A-Z]/.test(name))
130
+ return pascalToKebab(name);
131
+ return name;
132
+ }
133
+ /** Props/attrs entries that never surface in the WXML panel (internal Vue plumbing, falsy booleans). */
134
+ function isSkippedProp(key, value) {
135
+ if (typeof value === 'function' || value === undefined)
136
+ return true;
137
+ if (key === 'data' || key.startsWith('__'))
138
+ return true;
139
+ return typeof value === 'boolean' && !value;
140
+ }
141
+ /** Drop the `dd-` internal-marker classes so the panel shows only user-authored classes. */
142
+ function cleanClassAttr(value) {
143
+ return value.split(/\s+/).filter((c) => !c.startsWith('dd-')).join(' ');
144
+ }
145
+ function stringifyPropValue(value) {
146
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
147
+ }
148
+ function assignProp(out, key, value) {
149
+ if (isSkippedProp(key, value))
150
+ return;
151
+ if (key === 'class' && typeof value === 'string') {
152
+ const cleaned = cleanClassAttr(value);
153
+ if (cleaned)
154
+ out[key] = cleaned;
155
+ return;
156
+ }
157
+ out[key] = stringifyPropValue(value);
158
+ }
159
+ function extractProps(instance) {
160
+ const out = {};
161
+ for (const raw of [instance.props, instance.attrs]) {
162
+ if (!raw)
163
+ continue;
164
+ for (const [k, v] of Object.entries(raw)) {
165
+ assignProp(out, k, v);
166
+ }
167
+ }
168
+ return out;
169
+ }
170
+ function isInternalElement(vnode) {
171
+ const props = vnode.props;
172
+ if (!props)
173
+ return false;
174
+ const cls = String(props.class || '');
175
+ return cls.split(/\s+/).some((c) => INTERNAL_CLASSES.has(c));
176
+ }
177
+ function getElementSid(instance) {
178
+ const subTree = instance.subTree;
179
+ const el = subTree?.el;
180
+ if (!el?.getAttribute)
181
+ return undefined;
182
+ const sid = el.getAttribute('data-sid');
183
+ if (sid)
184
+ return sid;
185
+ return registerSyntheticSid(el);
186
+ }
187
+ function isTransparentComponent(instance, tagName) {
188
+ if (tagName === 'unknown')
189
+ return true;
190
+ // A framework template wrapper (Taro `taro_tmpl` / `tmpl_0_3`, whether named
191
+ // via `props.is` or recovered from its registration) is compiler scaffolding,
192
+ // not user content — pass its children straight through.
193
+ if (FRAMEWORK_TEMPLATE_RE.test(tagName))
194
+ return true;
195
+ if (tagName === 'template') {
196
+ const props = instance.props;
197
+ const is = props?.is;
198
+ if (is && FRAMEWORK_TEMPLATE_RE.test(is))
199
+ return true;
200
+ if (!is) {
201
+ const type = instance.type;
202
+ if (type?.__scopeId && !type.components)
203
+ return true;
204
+ }
205
+ }
206
+ return false;
207
+ }
208
+ /**
209
+ * 检测 Vue 的 Comment vnode(`v-if`/`v-else`/`v-for` 占位锚点)。
210
+ * Vue 在条件不成立时会留下 `<!-- v-if -->` 这样的注释 vnode 用作 DOM 锚点。
211
+ * 这些 vnode 不是用户内容,必须从 wxml 树里剔除。
212
+ *
213
+ * - dev build:type 是 `Symbol('Comment')`,description = 'Comment' 或 'v-cmt'
214
+ * - prod build:description 可能丢失,但 children 一般是 'v-if'/'v-else' 等 marker
215
+ */
216
+ function isCommentVNode(vnode) {
217
+ const type = vnode.type;
218
+ if (typeof type !== 'symbol')
219
+ return false;
220
+ const desc = type.description;
221
+ if (desc === 'Comment' || desc === 'v-cmt')
222
+ return true;
223
+ const children = typeof vnode.children === 'string' ? vnode.children : '';
224
+ return /^v-(if|else|else-if|for)$/.test(children);
225
+ }
226
+ function textNode(text) {
227
+ return { tagName: '#text', attrs: {}, children: [], text };
228
+ }
229
+ /** A component vnode's children come from walking its mounted instance, not `vnode.children`. */
230
+ function childrenFromComponentVNode(component, depth) {
231
+ const result = walkInstance(component, depth + 1);
232
+ if (!result)
233
+ return [];
234
+ return Array.isArray(result) ? result : [result];
235
+ }
236
+ /** A `<Suspense>` vnode's visible content is whichever branch is currently active. */
237
+ function childrenFromSuspenseVNode(suspense, depth) {
238
+ const activeBranch = suspense.activeBranch;
239
+ return activeBranch ? extractChildrenFromVNode(activeBranch, depth + 1) : [];
240
+ }
241
+ /**
242
+ * A vnode whose OWN `children` is a plain string only counts as a text node when
243
+ * its `type` is a symbol/string (a real element or fragment) — a component vnode
244
+ * with string `children` is slot content, not text, and is handled elsewhere.
245
+ * Returns null when this vnode is not (top-level) text, so the caller falls
246
+ * through to the array-children path.
247
+ */
248
+ function directTextChild(vnode) {
249
+ if (typeof vnode.children !== 'string' || !vnode.children.trim())
250
+ return null;
251
+ const vnodeType = vnode.type;
252
+ if (typeof vnodeType !== 'symbol' && typeof vnodeType !== 'string')
253
+ return null;
254
+ return [textNode(vnode.children.trim())];
255
+ }
256
+ function textChildEntry(child) {
257
+ const trimmed = child.trim();
258
+ return trimmed ? [textNode(trimmed)] : [];
259
+ }
260
+ /** A DOM-typed child (`type` is its tag name string): text leaf, internal wrapper, or a normal subtree. */
261
+ function domTypedChildEntries(c, depth) {
262
+ if (isInternalElement(c))
263
+ return extractChildrenFromVNode(c, depth + 1);
264
+ if (typeof c.children === 'string' && c.children.trim())
265
+ return [textNode(c.children.trim())];
266
+ return extractChildrenFromVNode(c, depth + 1);
267
+ }
268
+ /** One entry from a vnode's `children` array, normalized to zero or more WXML nodes. */
269
+ function extractChildEntry(child, depth) {
270
+ if (!child)
271
+ return [];
272
+ if (typeof child === 'string')
273
+ return textChildEntry(child);
274
+ if (typeof child !== 'object')
275
+ return [];
276
+ const c = child;
277
+ if (c.component)
278
+ return childrenFromComponentVNode(c.component, depth);
279
+ if (typeof c.type === 'symbol')
280
+ return extractChildrenFromVNode(c, depth + 1);
281
+ if (typeof c.type === 'string')
282
+ return domTypedChildEntries(c, depth);
283
+ return extractChildrenFromVNode(c, depth + 1);
284
+ }
285
+ function extractChildrenFromVNode(vnode, depth) {
286
+ if (!vnode || depth > 50)
287
+ return [];
288
+ if (isCommentVNode(vnode))
289
+ return [];
290
+ if (vnode.component)
291
+ return childrenFromComponentVNode(vnode.component, depth);
292
+ if (vnode.suspense)
293
+ return childrenFromSuspenseVNode(vnode.suspense, depth);
294
+ const directText = directTextChild(vnode);
295
+ if (directText)
296
+ return directText;
297
+ const kids = vnode.children;
298
+ if (!Array.isArray(kids))
299
+ return [];
300
+ const result = [];
301
+ for (const child of kids) {
302
+ result.push(...extractChildEntry(child, depth));
303
+ }
304
+ return result;
305
+ }
306
+ /**
307
+ * Reverse-map Dimina's `<wrapper name="/components/foo/foo">` (used to host
308
+ * every user-defined component) back to the source-level tag the user wrote
309
+ * in WXML (e.g. `<foo>`).
310
+ *
311
+ * The wrapper carries the registered component path in its `name` Vue prop.
312
+ * By convention (also followed by miniprogram tooling), the registered key is
313
+ * the last path segment, optionally stripping a trailing `/index`. We can't
314
+ * see the page's `usingComponents` map from inside the Vue instance, so this
315
+ * convention-based recovery is best-effort: if the user picked a different
316
+ * registration key than the directory name (e.g. `usingComponents:
317
+ * { myCounter: '/components/counter/counter' }`), the panel will show
318
+ * `counter`, not `myCounter`.
319
+ *
320
+ * The path heuristic also resolves a name-collision risk: a user-written
321
+ * `<counter name="x">` would land in the same `attrs.name` slot as the
322
+ * wrapper-internal path. We only unwrap when the value looks like an absolute
323
+ * component path (leading `/`), which dimina always emits but a user would
324
+ * almost never type as a literal attr.
325
+ */
326
+ function unwrapCustomComponent(node) {
327
+ if (node.tagName !== 'wrapper')
328
+ return node;
329
+ const path = node.attrs?.name;
330
+ if (typeof path !== 'string' || !path.startsWith('/'))
331
+ return node;
332
+ // 去掉前导 `/` 后保留原路径形式(保留所有斜杠,不做 kebab/dash 转换)。
333
+ // 仅当路径以 `/index` 结尾且剥离后仍至少剩一段时才剥(`/index` 单独存在则
334
+ // 退回 wrapper,避免出现空 tagName)。
335
+ const stripped = path.replace(/^\//, '');
336
+ const withoutIndex = stripped.endsWith('/index') ? stripped.slice(0, -'/index'.length) : stripped;
337
+ const recovered = withoutIndex || stripped;
338
+ if (!recovered || recovered === 'index')
339
+ return node;
340
+ const nextAttrs = {};
341
+ for (const [k, v] of Object.entries(node.attrs)) {
342
+ if (k === 'name')
343
+ continue;
344
+ nextAttrs[k] = v;
345
+ }
346
+ return { ...node, tagName: recovered, attrs: nextAttrs };
347
+ }
348
+ /**
349
+ * 把"用户授权"层级(页面 / 自定义组件,tagName 以路径形式呈现,含 `/`)
350
+ * 的 children 包一层合成 `#shadow-root`,对齐微信开发者工具:组件本身
351
+ * 与内部实现之间用 shadow-root 边界视觉分隔。
352
+ *
353
+ * 内置组件(view/text/button 等)和合成节点(#text/#fragment)tagName 都
354
+ * 不含 `/`,自然不会被包裹。children 为空也不插入(避免空壳)。重复调用
355
+ * 是幂等的:若 children[0] 已经是 #shadow-root 就跳过。
356
+ */
357
+ function wrapInShadowRoot(node) {
358
+ if (!node.tagName.includes('/'))
359
+ return node;
360
+ if (node.children.length === 0)
361
+ return node;
362
+ if (node.children[0]?.tagName === '#shadow-root')
363
+ return node;
364
+ return {
365
+ ...node,
366
+ children: [{ tagName: '#shadow-root', attrs: {}, children: node.children }],
367
+ };
368
+ }
369
+ export function walkInstance(instance, depth) {
370
+ if (depth > 50)
371
+ return null;
372
+ const tagName = resolveTagName(instance);
373
+ const children = extractChildrenFromVNode(instance.subTree, depth);
374
+ if (isTransparentComponent(instance, tagName))
375
+ return children.length > 0 ? children : null;
376
+ const node = { tagName, attrs: extractProps(instance), children };
377
+ const sid = getElementSid(instance);
378
+ if (sid)
379
+ node.sid = sid;
380
+ return wrapInShadowRoot(unwrapCustomComponent(node));
381
+ }
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@dimina-kit/inspect",
3
+ "version": "0.3.0-dev.20260711141929",
4
+ "description": "Host-agnostic WXML tree extraction and inspection for dimina render layers: Vue runtime walk, stable-id registry, mutation-observing inspector, and a React tree panel.",
5
+ "keywords": [
6
+ "dimina",
7
+ "wxml",
8
+ "devtools",
9
+ "inspector"
10
+ ],
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "license": "MIT",
17
+ "author": "EchoTechFE",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/EchoTechFE/dimina-kit.git",
21
+ "directory": "packages/inspect"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/EchoTechFE/dimina-kit/issues"
25
+ },
26
+ "homepage": "https://github.com/EchoTechFE/dimina-kit/tree/main/packages/inspect#readme",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./src/index.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./panel": {
33
+ "types": "./src/panel.tsx",
34
+ "default": "./dist/panel.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "src"
40
+ ],
41
+ "peerDependencies": {
42
+ "react": ">=18"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "react": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "devDependencies": {
50
+ "@types/react": "^18.3.12",
51
+ "@testing-library/react": "^16.3.2",
52
+ "react-dom": "^18.3.1",
53
+ "@vitest/coverage-v8": "^4.1.4",
54
+ "eslint": "^10.2.1",
55
+ "jsdom": "^29.0.2",
56
+ "react": "^18.3.1",
57
+ "typescript": "5.9.2",
58
+ "vitest": "^4.1.4",
59
+ "@dimina-kit/eslint-config": "0.1.0",
60
+ "@dimina-kit/typescript-config": "0.1.0"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ },
65
+ "scripts": {
66
+ "build": "tsc -p tsconfig.build.json",
67
+ "check-types": "tsc --noEmit -p tsconfig.json",
68
+ "lint": "eslint . --max-warnings 0",
69
+ "test": "vitest run --reporter=default --reporter=json --outputFile.json=test-report.json --coverage.enabled --coverage.reporter=json-summary --coverage.reportsDirectory=coverage",
70
+ "test:dev": "vitest"
71
+ }
72
+ }