Files
portal/app/vite-plugins/dev-indices/index.ts
T
Дмитрий 9a7615b257 fix(dev-indices): Esc pause-hover + skip inert Vue compiler tags
#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>
2026-05-12 12:32:15 +03:00

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;