9a7615b257
#1 (review-Important) — Esc now also calls pauseHover(2000) so the next mousemove doesn't re-target the cursor element within 16ms. User gets 2 seconds to move off before hover re-engages. #4 (review-Important) — Plugin walker now skips data-dx injection for inert Vue compiler tags (template / slot / component / Transition / TransitionGroup / Suspense / KeepAlive) but still recurses into their children with the tag preserved in ancestor chain (keeps descendant signatures stable). Manifest regenerated — no more phantom IDs that reference no-DOM-element nodes. Other review findings (CI integration, save-amplification, code-style polish) skipped: this feature is temporary, will be removed at final release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.2 KiB
TypeScript
214 lines
7.2 KiB
TypeScript
import type { Plugin } from 'vite';
|
|
import { parse } from '@vue/compiler-sfc';
|
|
import { NodeTypes } from '@vue/compiler-core';
|
|
import type { ElementNode, TemplateChildNode, AttributeNode, DirectiveNode } from '@vue/compiler-core';
|
|
import MagicString from 'magic-string';
|
|
import { relative } from 'node:path';
|
|
|
|
import type { Manifest, ManifestEntry } from './types';
|
|
import {
|
|
createEmpty,
|
|
loadManifest,
|
|
saveManifest,
|
|
findBySignature,
|
|
addEntry,
|
|
} from './manifest';
|
|
import {
|
|
computeSignature,
|
|
} from './signature';
|
|
import type {
|
|
SignatureNode,
|
|
SignatureNodeProp,
|
|
SignatureNodeChild,
|
|
} from './signature';
|
|
|
|
const INERT_TAGS = new Set([
|
|
'template',
|
|
'slot',
|
|
'component',
|
|
'Transition',
|
|
'TransitionGroup',
|
|
'Suspense',
|
|
'KeepAlive',
|
|
]);
|
|
|
|
export interface DevIndicesPluginOptions {
|
|
manifestPath: string;
|
|
appRoot: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
function nodeToSignatureNode(el: ElementNode): SignatureNode {
|
|
const props: SignatureNodeProp[] = el.props.map((p): SignatureNodeProp => {
|
|
if (p.type === NodeTypes.ATTRIBUTE) {
|
|
const attr = p as AttributeNode;
|
|
return { type: 'attribute', name: attr.name, value: attr.value?.content };
|
|
}
|
|
// DIRECTIVE node
|
|
const dir = p as DirectiveNode;
|
|
const argContent =
|
|
dir.arg && 'content' in dir.arg ? (dir.arg as { content: string }).content : undefined;
|
|
const expContent =
|
|
dir.exp && 'content' in dir.exp ? (dir.exp as { content: string }).content : undefined;
|
|
return {
|
|
type: 'directive',
|
|
name: dir.name,
|
|
arg: argContent,
|
|
exp: expContent,
|
|
};
|
|
});
|
|
|
|
const children: SignatureNodeChild[] = el.children.map((c: TemplateChildNode): SignatureNodeChild => {
|
|
if (c.type === NodeTypes.TEXT) return { type: 'text', content: c.content };
|
|
if (c.type === NodeTypes.INTERPOLATION) return { type: 'interpolation' };
|
|
if (c.type === NodeTypes.ELEMENT) return { type: 'element' };
|
|
if (c.type === NodeTypes.COMMENT) return { type: 'comment' };
|
|
return { type: 'element' };
|
|
});
|
|
|
|
return { tag: el.tag, props, children };
|
|
}
|
|
|
|
/**
|
|
* Returns the offset in `source` where we should inject the attribute —
|
|
* immediately before `>` (or before `/` in self-closing `/>`) of the opening tag.
|
|
*/
|
|
function elementOpenTagEndOffset(el: ElementNode, source: string): number {
|
|
let i = el.loc.start.offset;
|
|
let inQuote: string | null = null;
|
|
while (i < source.length) {
|
|
const ch = source[i];
|
|
if (inQuote) {
|
|
if (ch === inQuote) inQuote = null;
|
|
} else if (ch === '"' || ch === "'") {
|
|
inQuote = ch;
|
|
} else if (ch === '>') {
|
|
// self-closing `/>` — insert before the `/`
|
|
if (source[i - 1] === '/') return i - 1;
|
|
return i;
|
|
}
|
|
i++;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
export function devIndicesPlugin(options: DevIndicesPluginOptions): Plugin {
|
|
const { manifestPath, appRoot, enabled } = options;
|
|
let manifest: Manifest = createEmpty();
|
|
let dirty = false;
|
|
|
|
return {
|
|
name: 'vite-plugin-dev-indices',
|
|
|
|
buildStart() {
|
|
if (!enabled) return;
|
|
manifest = loadManifest(manifestPath);
|
|
},
|
|
|
|
transform(code: string, id: string) {
|
|
if (!enabled) return null;
|
|
if (!id.endsWith('.vue')) return null;
|
|
// Skip query-suffixed Vue requests (e.g. ?type=style)
|
|
if (id.includes('?')) return null;
|
|
|
|
const { descriptor } = parse(code, { filename: id });
|
|
if (!descriptor.template) return null;
|
|
|
|
const ast = descriptor.template.ast;
|
|
if (!ast) return null;
|
|
|
|
const s = new MagicString(code);
|
|
const relPath = relative(appRoot, id).replace(/\\/g, '/');
|
|
|
|
const walk = (
|
|
children: TemplateChildNode[],
|
|
chain: string[],
|
|
): void => {
|
|
const localOrdinals = new Map<string, number>();
|
|
for (const child of children) {
|
|
if (child.type !== NodeTypes.ELEMENT) continue;
|
|
const el = child as ElementNode;
|
|
const tag = el.tag;
|
|
|
|
// Skip inert Vue compiler tags (no DOM presence) but still walk
|
|
// children with the tag preserved in ancestor chain so that
|
|
// descendant signatures remain stable.
|
|
if (INERT_TAGS.has(tag)) {
|
|
walk(el.children, [...chain, tag]);
|
|
continue;
|
|
}
|
|
|
|
const ord = localOrdinals.get(tag) ?? 0;
|
|
localOrdinals.set(tag, ord + 1);
|
|
|
|
const sigNode = nodeToSignatureNode(el);
|
|
const sig = computeSignature(sigNode, {
|
|
filePath: id,
|
|
appRoot,
|
|
ancestorChain: chain,
|
|
sameTagOrdinal: ord,
|
|
});
|
|
|
|
let assignedId = findBySignature(manifest, sig);
|
|
if (assignedId == null) {
|
|
const text =
|
|
el.children
|
|
.filter((c) => c.type === NodeTypes.TEXT)
|
|
.map((c) => (c as { content: string }).content)
|
|
.join(' ')
|
|
.trim()
|
|
.slice(0, 24) || null;
|
|
|
|
const entry: ManifestEntry = {
|
|
file: relPath,
|
|
line: el.loc.start.line,
|
|
tag,
|
|
parentChain: [...chain],
|
|
signature: sig,
|
|
text,
|
|
key: null,
|
|
ref: null,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
assignedId = addEntry(manifest, entry);
|
|
dirty = true;
|
|
}
|
|
|
|
// Inject `data-dx="N"` into opening tag
|
|
const insertPos = elementOpenTagEndOffset(el, code);
|
|
if (insertPos >= 0) {
|
|
s.appendLeft(insertPos, ` data-dx="${assignedId}"`);
|
|
}
|
|
|
|
walk(el.children, [...chain, tag]);
|
|
}
|
|
};
|
|
|
|
// Top-level ancestor = declaring component name (derived from file path)
|
|
const declaringName = id.split(/[\\/]/).pop()!.replace(/\.vue$/, '');
|
|
walk(ast.children, [declaringName]);
|
|
|
|
// Flush manifest when dirty (after each transform to ensure safety)
|
|
if (dirty) {
|
|
saveManifest(manifestPath, manifest);
|
|
dirty = false;
|
|
}
|
|
|
|
return {
|
|
code: s.toString(),
|
|
map: s.generateMap({ hires: true, source: id }),
|
|
};
|
|
},
|
|
|
|
buildEnd() {
|
|
if (!enabled) return;
|
|
if (dirty) {
|
|
saveManifest(manifestPath, manifest);
|
|
dirty = false;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export default devIndicesPlugin;
|