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(); 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;