# Dev Element Indices Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Vite plugin that injects `data-dx="N"` on every element in `.vue` templates + persistent `dev-indices.json` manifest + runtime `` (hover + Alt-keys + click-to-copy), enabling element-precision feedback («элемент 1030 измени цвет»). **Architecture:** Build-time AST walk via `@vue/compiler-sfc` + structural signature lookup against `dev-indices.json` (committed to repo). Runtime browser overlay listens to mousemove/keydown, renders Forest-palette badge with selected element's index. Production: plugin disabled, overlay tree-shaken via `import.meta.env.DEV`. **Tech Stack:** Vue 3 + Vuetify 3 + Vite 8 + Pinia + Vitest 4.1 + @vue/test-utils + jsdom. Plugin uses `@vue/compiler-sfc` and `@vue/compiler-dom` (Vue peer-deps; no new packages required for parsing). Uses `magic-string` (transitive dep via Vite/rollup) for source mutations. **Spec:** [docs/superpowers/specs/2026-05-12-dev-element-indices-design.md](../specs/2026-05-12-dev-element-indices-design.md) --- ## File Structure **Plugin (build-time, Node):** - `app/vite-plugins/dev-indices/types.ts` — shared TypeScript types - `app/vite-plugins/dev-indices/manifest.ts` — JSON IO, lastId, tombstones - `app/vite-plugins/dev-indices/signature.ts` — structural signature computation - `app/vite-plugins/dev-indices/index.ts` — Vite plugin (transform + buildEnd) - `app/vite-plugins/dev-indices/__tests__/manifest.test.ts` - `app/vite-plugins/dev-indices/__tests__/signature.test.ts` - `app/vite-plugins/dev-indices/__tests__/integration.test.ts` - `app/vite-plugins/dev-indices/__tests__/fixtures/sample.vue` **Manifest:** - `app/dev-indices.json` — committed to repo, source of truth - `app/dev-indices.schema.json` — JSON Schema validation **Runtime (browser):** - `app/resources/js/composables/useDevIndices.ts` — overlay state bus - `app/resources/js/components/DevIndexOverlay.vue` — UI badge, keyboard handlers - `app/resources/js/composables/__tests__/useDevIndices.test.ts` - `app/resources/js/components/__tests__/DevIndexOverlay.test.ts` **CLI:** - `app/scripts/dev-indices-lookup.mjs` — `npm run dx ` **Modified:** - `app/vite.config.ts` — register plugin under dev-guard - `app/resources/js/App.vue` — mount overlay under `import.meta.env.DEV` - `app/package.json` — add `"dx"` script > **Important first action for the engineer:** verify the project's Vitest test convention. If existing tests live in a different location (e.g., `tests/unit/`), move the planned `__tests__/` paths to match. The plan assumes co-located `__tests__/` because it is a common Vitest pattern, but project convention wins. --- ## Task 1: Types + Manifest IO module **Files:** - Create: `app/vite-plugins/dev-indices/types.ts` - Create: `app/vite-plugins/dev-indices/manifest.ts` - Test: `app/vite-plugins/dev-indices/__tests__/manifest.test.ts` - [ ] **Step 1: Create types.ts** ```typescript // app/vite-plugins/dev-indices/types.ts export interface ManifestEntry { file: string; // relative to app/, forward slashes line: number; // 1-based line in source tag: string; // 'v-btn', 'div', etc. parentChain: string[]; // ['AppSidebar', 'nav', 'v-list-item'] signature: string; // canonical signature text: string | null; // first 24 chars of static text key: string | null; // static :key attr value if present ref: string | null; // ref="..." attr value if present createdAt: string; // ISO-8601 } export interface DeletedEntry { lastSignature: string; lastFile: string; deletedAt: string; } export interface Manifest { $schema?: string; version: 1; lastId: number; entries: Record; // key: id-as-string deleted: Record; } ``` - [ ] **Step 2: Write failing test for manifest.test.ts** ```typescript // app/vite-plugins/dev-indices/__tests__/manifest.test.ts import { describe, it, expect } from 'vitest'; import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createEmpty, loadManifest, saveManifest, findBySignature, addEntry, markDeleted, } from '../manifest'; import type { ManifestEntry } from '../types'; const sampleEntry = (sig: string, file = 'resources/js/A.vue'): ManifestEntry => ({ file, line: 1, tag: 'v-btn', parentChain: ['A'], signature: sig, text: 'hello', key: null, ref: null, createdAt: '2026-05-12T00:00:00.000Z', }); describe('manifest', () => { it('createEmpty returns a fresh manifest with lastId=0', () => { const m = createEmpty(); expect(m.version).toBe(1); expect(m.lastId).toBe(0); expect(m.entries).toEqual({}); expect(m.deleted).toEqual({}); }); it('addEntry assigns next id and increments lastId', () => { const m = createEmpty(); const id1 = addEntry(m, sampleEntry('sig-1')); const id2 = addEntry(m, sampleEntry('sig-2')); expect(id1).toBe(1); expect(id2).toBe(2); expect(m.lastId).toBe(2); expect(m.entries['1'].signature).toBe('sig-1'); }); it('findBySignature returns matching id', () => { const m = createEmpty(); addEntry(m, sampleEntry('sig-a')); addEntry(m, sampleEntry('sig-b')); expect(findBySignature(m, 'sig-b')).toBe(2); expect(findBySignature(m, 'sig-x')).toBeNull(); }); it('markDeleted moves entry to deleted section, preserves lastId', () => { const m = createEmpty(); addEntry(m, sampleEntry('sig-x')); markDeleted(m, 1); expect(m.entries['1']).toBeUndefined(); expect(m.deleted['1'].lastSignature).toBe('sig-x'); expect(m.lastId).toBe(1); // monotonic, not decremented }); it('deleted ids are not reused by addEntry', () => { const m = createEmpty(); addEntry(m, sampleEntry('sig-x')); markDeleted(m, 1); const id = addEntry(m, sampleEntry('sig-y')); expect(id).toBe(2); }); it('saveManifest then loadManifest roundtrips data', () => { const dir = mkdtempSync(join(tmpdir(), 'dx-')); const path = join(dir, 'm.json'); const m = createEmpty(); addEntry(m, sampleEntry('sig-1')); saveManifest(path, m); const loaded = loadManifest(path); expect(loaded.lastId).toBe(1); expect(loaded.entries['1'].signature).toBe('sig-1'); rmSync(dir, { recursive: true }); }); it('loadManifest returns createEmpty() if file missing', () => { const m = loadManifest('/nonexistent/path-' + Math.random() + '.json'); expect(m.lastId).toBe(0); }); it('saveManifest writes atomically (no partial file on crash)', () => { const dir = mkdtempSync(join(tmpdir(), 'dx-')); const path = join(dir, 'm.json'); const m = createEmpty(); addEntry(m, sampleEntry('sig-x')); saveManifest(path, m); // Atomic write should leave no .tmp residue expect(() => readFileSync(path + '.tmp')).toThrow(); rmSync(dir, { recursive: true }); }); }); ``` - [ ] **Step 3: Run test, verify failure** Run: `cd app && npx vitest run vite-plugins/dev-indices/__tests__/manifest.test.ts` Expected: FAIL with `Cannot find module '../manifest'`. - [ ] **Step 4: Implement manifest.ts** ```typescript // app/vite-plugins/dev-indices/manifest.ts import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import type { Manifest, ManifestEntry } from './types'; export function createEmpty(): Manifest { return { $schema: './dev-indices.schema.json', version: 1, lastId: 0, entries: {}, deleted: {}, }; } export function loadManifest(path: string): Manifest { if (!existsSync(path)) return createEmpty(); try { const raw = readFileSync(path, 'utf8'); const parsed = JSON.parse(raw) as Manifest; if (parsed.version !== 1) { throw new Error(`dev-indices manifest version ${parsed.version} unsupported (expected 1)`); } parsed.entries ??= {}; parsed.deleted ??= {}; return parsed; } catch (err) { if (err instanceof SyntaxError) { throw new Error(`dev-indices manifest at ${path} is corrupt JSON: ${err.message}`); } throw err; } } export function saveManifest(path: string, manifest: Manifest): void { mkdirSync(dirname(path), { recursive: true }); const tmp = path + '.tmp'; const json = JSON.stringify(manifest, null, 2); writeFileSync(tmp, json, 'utf8'); renameSync(tmp, path); } export function findBySignature(manifest: Manifest, signature: string): number | null { for (const [id, entry] of Object.entries(manifest.entries)) { if (entry.signature === signature) return Number(id); } return null; } export function addEntry(manifest: Manifest, entry: ManifestEntry): number { const id = manifest.lastId + 1; manifest.lastId = id; manifest.entries[String(id)] = entry; return id; } export function markDeleted(manifest: Manifest, id: number): void { const key = String(id); const entry = manifest.entries[key]; if (!entry) return; manifest.deleted[key] = { lastSignature: entry.signature, lastFile: entry.file, deletedAt: new Date().toISOString(), }; delete manifest.entries[key]; } ``` - [ ] **Step 5: Run test, verify passing** Run: `cd app && npx vitest run vite-plugins/dev-indices/__tests__/manifest.test.ts` Expected: PASS (8 tests). - [ ] **Step 6: Commit** ```bash git add app/vite-plugins/dev-indices/types.ts \ app/vite-plugins/dev-indices/manifest.ts \ app/vite-plugins/dev-indices/__tests__/manifest.test.ts git commit -m "feat(dev-indices): manifest IO module (types + load/save/lookup/tombstones)" ``` --- ## Task 2: Signature module **Files:** - Create: `app/vite-plugins/dev-indices/signature.ts` - Test: `app/vite-plugins/dev-indices/__tests__/signature.test.ts` Signature shape: `${normalizedPath}::${ancestorChain}::${tag}[${attrs}]::${textSnippet}::${ordinalAmongSameTag}` - [ ] **Step 1: Write failing test for signature.test.ts** ```typescript // app/vite-plugins/dev-indices/__tests__/signature.test.ts import { describe, it, expect } from 'vitest'; import { computeSignature, normalizeFilePath, extractStaticText, extractDistinctiveAttrs, type SignatureNode, type SignatureContext, } from '../signature'; // Minimal node shape that matches what we extract from @vue/compiler-dom AST. // In production we adapt the parser's ElementNode → SignatureNode. const node = (overrides: Partial = {}): SignatureNode => ({ tag: 'div', props: [], children: [], ...overrides, }); const ctx = (overrides: Partial = {}): SignatureContext => ({ filePath: 'app/resources/js/components/AppSidebar.vue', appRoot: 'app/', ancestorChain: ['AppSidebar'], sameTagOrdinal: 0, ...overrides, }); describe('normalizeFilePath', () => { it('strips appRoot and .vue extension, uses forward slashes', () => { expect( normalizeFilePath('app/resources/js/components/A.vue', 'app/'), ).toBe('resources/js/components/A'); }); it('handles backslashes on Windows-style paths', () => { expect( normalizeFilePath('app\\resources\\js\\A.vue', 'app/'), ).toBe('resources/js/A'); }); }); describe('extractStaticText', () => { it('returns first 24 chars of plain text child', () => { const n = node({ children: [{ type: 'text', content: 'Создать проект сейчас же' }] }); expect(extractStaticText(n)).toBe('создать проект сейчас же'); }); it('truncates >24 chars', () => { const n = node({ children: [{ type: 'text', content: 'A'.repeat(50) }] }); expect(extractStaticText(n)).toHaveLength(24); }); it('ignores mustache interpolations', () => { const n = node({ children: [ { type: 'text', content: 'count is ' }, { type: 'interpolation', content: '{{ count }}' }, ], }); expect(extractStaticText(n)).toBe('count is'); }); it('returns null when no static text', () => { const n = node({ children: [{ type: 'interpolation', content: '{{ x }}' }] }); expect(extractStaticText(n)).toBeNull(); }); }); describe('extractDistinctiveAttrs', () => { it('extracts allowlisted static attrs sorted by name', () => { const n = node({ tag: 'v-btn', props: [ { type: 'attribute', name: 'icon', value: 'plus' }, { type: 'attribute', name: 'role', value: 'button' }, { type: 'attribute', name: 'class', value: 'mr-2' }, ], }); expect(extractDistinctiveAttrs(n)).toBe('icon=plus,role=button'); }); it('ignores v-bind / : attrs (dynamic)', () => { const n = node({ props: [ { type: 'directive', name: 'bind', arg: 'icon', exp: 'someVar' }, { type: 'attribute', name: 'id', value: 'static-id' }, ], }); expect(extractDistinctiveAttrs(n)).toBe('id=static-id'); }); it('returns empty string if no allowlisted attrs', () => { const n = node({ props: [{ type: 'attribute', name: 'class', value: 'foo' }] }); expect(extractDistinctiveAttrs(n)).toBe(''); }); it('escape-hatch: data-dev-name takes precedence', () => { const n = node({ tag: 'v-btn', props: [ { type: 'attribute', name: 'icon', value: 'plus' }, { type: 'attribute', name: 'data-dev-name', value: 'sidebar.create-btn' }, ], }); expect(extractDistinctiveAttrs(n)).toContain('data-dev-name=sidebar.create-btn'); }); }); describe('computeSignature', () => { it('canonical signature for a plain element', () => { const n = node({ tag: 'v-btn', props: [{ type: 'attribute', name: 'icon', value: 'plus' }], children: [{ type: 'text', content: 'Создать' }], }); expect(computeSignature(n, ctx({ ancestorChain: ['AppSidebar', 'nav', 'v-list-item'] }))).toBe( 'resources/js/components/AppSidebar::AppSidebar>nav>v-list-item::v-btn[icon=plus]::создать::0', ); }); it('identical templates produce identical signatures', () => { const a = node({ tag: 'span', children: [{ type: 'text', content: 'hi' }] }); const b = node({ tag: 'span', children: [{ type: 'text', content: 'hi' }] }); expect(computeSignature(a, ctx())).toBe(computeSignature(b, ctx())); }); it('different ordinalAmongSameTag yields different signatures', () => { const a = node({ tag: 'v-btn' }); const s0 = computeSignature(a, ctx({ sameTagOrdinal: 0 })); const s1 = computeSignature(a, ctx({ sameTagOrdinal: 1 })); expect(s0).not.toBe(s1); }); it('data-dev-name overrides structural pieces', () => { const a = node({ tag: 'v-btn', props: [{ type: 'attribute', name: 'data-dev-name', value: 'sidebar.create' }], }); const sigA = computeSignature(a, ctx({ sameTagOrdinal: 0 })); // Same name, different ordinal — signature MUST be identical (escape hatch) const sigB = computeSignature(a, ctx({ sameTagOrdinal: 7 })); expect(sigA).toBe(sigB); }); }); ``` - [ ] **Step 2: Run test, verify failure** Run: `cd app && npx vitest run vite-plugins/dev-indices/__tests__/signature.test.ts` Expected: FAIL with `Cannot find module '../signature'`. - [ ] **Step 3: Implement signature.ts** ```typescript // app/vite-plugins/dev-indices/signature.ts const ALLOWED_ATTRS = ['data-dev-name', 'key', 'id', 'name', 'type', 'icon', 'role']; const SNIPPET_MAX = 24; export interface SignatureNodeChild { type: 'text' | 'interpolation' | 'element' | 'comment'; content?: string; } export interface SignatureNodeProp { type: 'attribute' | 'directive'; name: string; value?: string; arg?: string; exp?: string; } export interface SignatureNode { tag: string; props: SignatureNodeProp[]; children: SignatureNodeChild[]; } export interface SignatureContext { filePath: string; // absolute or relative — anything load-time-stable appRoot: string; // e.g. 'app/', used by normalizeFilePath ancestorChain: string[]; // ['DeclaringComponent', 'div', 'nav'] sameTagOrdinal: number; // 0-based among siblings of same tag in same parent } export function normalizeFilePath(filePath: string, appRoot: string): string { const normalized = filePath.replace(/\\/g, '/').replace(/\.vue$/, ''); const root = appRoot.replace(/\\/g, '/').replace(/\/$/, '') + '/'; return normalized.startsWith(root) ? normalized.slice(root.length) : normalized; } export function extractStaticText(node: SignatureNode): string | null { const parts: string[] = []; for (const child of node.children) { if (child.type === 'text' && child.content) { parts.push(child.content); } } if (parts.length === 0) return null; const merged = parts.join(' ').replace(/\s+/g, ' ').trim().toLowerCase(); if (!merged) return null; return merged.slice(0, SNIPPET_MAX); } export function extractDistinctiveAttrs(node: SignatureNode): string { const pairs: string[] = []; for (const prop of node.props) { if (prop.type !== 'attribute') continue; // skip directives / v-bind if (!ALLOWED_ATTRS.includes(prop.name)) continue; if (prop.value == null) continue; pairs.push(`${prop.name}=${prop.value}`); } return pairs.sort().join(','); } function findDevName(node: SignatureNode): string | null { for (const prop of node.props) { if (prop.type === 'attribute' && prop.name === 'data-dev-name' && prop.value) { return prop.value; } } return null; } export function computeSignature(node: SignatureNode, ctx: SignatureContext): string { const path = normalizeFilePath(ctx.filePath, ctx.appRoot); const devName = findDevName(node); if (devName) { return `${path}::data-dev-name::${devName}`; } const ancestors = ctx.ancestorChain.join('>'); const attrs = extractDistinctiveAttrs(node); const text = extractStaticText(node) ?? ''; return `${path}::${ancestors}::${node.tag}[${attrs}]::${text}::${ctx.sameTagOrdinal}`; } ``` - [ ] **Step 4: Run test, verify passing** Run: `cd app && npx vitest run vite-plugins/dev-indices/__tests__/signature.test.ts` Expected: PASS (all tests green). - [ ] **Step 5: Commit** ```bash git add app/vite-plugins/dev-indices/signature.ts \ app/vite-plugins/dev-indices/__tests__/signature.test.ts git commit -m "feat(dev-indices): signature module (structural + data-dev-name escape hatch)" ``` --- ## Task 3: Vite plugin core **Files:** - Create: `app/vite-plugins/dev-indices/index.ts` - Test: `app/vite-plugins/dev-indices/__tests__/integration.test.ts` - Test fixture: `app/vite-plugins/dev-indices/__tests__/fixtures/sample.vue` The plugin uses `@vue/compiler-sfc` to parse the SFC, gets the template AST (which is already produced by `parse()` via `@vue/compiler-dom`), walks element nodes, computes signatures, injects `data-dx="N"` attributes via `magic-string`. - [ ] **Step 1: Create test fixture** ```vue ``` - [ ] **Step 2: Write failing integration test** ```typescript // app/vite-plugins/dev-indices/__tests__/integration.test.ts import { describe, it, expect, beforeEach } from 'vitest'; import { mkdtempSync, readFileSync, rmSync, copyFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { devIndicesPlugin } from '../index'; const FIXTURE = resolve(__dirname, 'fixtures/sample.vue'); function runTransform(filePath: string, manifestPath: string) { const plugin = devIndicesPlugin({ manifestPath, appRoot: resolve(__dirname, '..', '..', '..'), enabled: true, }); // buildStart loads existing manifest (or createEmpty() if missing) if (typeof plugin.buildStart === 'function') (plugin.buildStart as Function).call({}); const source = readFileSync(filePath, 'utf8'); const result = (plugin.transform as Function).call({}, source, filePath); // buildEnd flushes manifest to disk (in real Vite, called once at end of build) if (typeof plugin.buildEnd === 'function') (plugin.buildEnd as Function).call({}); return result?.code ?? result; } describe('vite-plugin-dev-indices integration', () => { let dir: string; let manifestPath: string; let workingFixture: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'dx-int-')); manifestPath = join(dir, 'dev-indices.json'); workingFixture = join(dir, 'sample.vue'); copyFileSync(FIXTURE, workingFixture); }); it('injects data-dx attributes into every element', () => { const code = runTransform(workingFixture, manifestPath); expect(code).toMatch(/
/); expect(code).toMatch(//); expect(code).toMatch(//); expect(code).toMatch(//); expect(code).toMatch(//); }); it('writes manifest with one entry per element', () => { runTransform(workingFixture, manifestPath); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); // 5 elements in fixture expect(Object.keys(manifest.entries)).toHaveLength(5); expect(manifest.lastId).toBe(5); }); it('second run on unchanged file produces identical IDs', () => { const code1 = runTransform(workingFixture, manifestPath); const code2 = runTransform(workingFixture, manifestPath); expect(code1).toBe(code2); const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); expect(manifest.lastId).toBe(5); }); it('returns null for non-.vue files', () => { const plugin = devIndicesPlugin({ manifestPath, appRoot: dir, enabled: true }); expect((plugin.transform as Function).call({}, '// js', '/foo/bar.ts')).toBeNull(); }); it('returns null when disabled', () => { const plugin = devIndicesPlugin({ manifestPath, appRoot: dir, enabled: false }); const source = readFileSync(workingFixture, 'utf8'); expect((plugin.transform as Function).call({}, source, workingFixture)).toBeNull(); }); afterEach(() => rmSync(dir, { recursive: true, force: true })); }); ``` Note: add `import { afterEach } from 'vitest';` at the top. - [ ] **Step 3: Run test, verify failure** Run: `cd app && npx vitest run vite-plugins/dev-indices/__tests__/integration.test.ts` Expected: FAIL with `Cannot find module '../index'`. - [ ] **Step 4: Implement index.ts (plugin core)** ```typescript // app/vite-plugins/dev-indices/index.ts import type { Plugin } from 'vite'; import { parse } from '@vue/compiler-sfc'; import { type ElementNode, NodeTypes } from '@vue/compiler-core'; import MagicString from 'magic-string'; import { relative, resolve } from 'node:path'; import type { Manifest, ManifestEntry } from './types'; import { createEmpty, loadManifest, saveManifest, findBySignature, addEntry, } from './manifest'; import { computeSignature, type SignatureNode, type SignatureNodeProp, type SignatureNodeChild, } from './signature'; export interface DevIndicesPluginOptions { manifestPath: string; appRoot: string; enabled: boolean; } interface ElementWalkContext { ancestorChain: string[]; sameTagOrdinalStack: Map[]; } function nodeToSignatureNode(el: ElementNode): SignatureNode { const props: SignatureNodeProp[] = el.props.map((p): SignatureNodeProp => { if (p.type === NodeTypes.ATTRIBUTE) { return { type: 'attribute', name: p.name, value: p.value?.content }; } // DIRECTIVE node return { type: 'directive', name: p.name, arg: 'arg' in p && p.arg && 'content' in p.arg ? p.arg.content : undefined, exp: 'exp' in p && p.exp && 'content' in p.exp ? p.exp.content : undefined, }; }); const children: SignatureNodeChild[] = el.children.map((c): 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 }; } function elementOpenTagEndOffset(el: ElementNode, templateContent: string, templateOffset: number): number { // Find the position just before the closing `>` of the opening tag // el.loc.start.offset is start of `` relative to templateContent const startAbs = templateOffset + el.loc.start.offset; // Walk forward in source to find unescaped `>` (skipping inside quotes) let i = startAbs; let inQuote: string | null = null; while (i < templateOffset + templateContent.length) { const ch = templateContent[i - templateOffset]; if (inQuote) { if (ch === inQuote) inQuote = null; } else if (ch === '"' || ch === "'") { inQuote = ch; } else if (ch === '>') { // self-closing: `/>` — insert before the `/` if (templateContent[i - templateOffset - 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, id) { 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 tplContent = descriptor.template.content; const tplOffset = descriptor.template.loc.start.offset; const ast = descriptor.template.ast; if (!ast) return null; const s = new MagicString(code); const relPath = relative(appRoot, id).replace(/\\/g, '/'); const walk = (node: ElementNode | (typeof ast), chain: string[], siblingOrdinals: Map) => { const children = ('children' in node ? node.children : []) as ElementNode['children']; const localOrdinals = new Map(); for (const child of children) { if (child.type !== NodeTypes.ELEMENT) continue; const tag = child.tag; const ord = localOrdinals.get(tag) ?? 0; localOrdinals.set(tag, ord + 1); const sig = computeSignature(nodeToSignatureNode(child), { filePath: id, appRoot, ancestorChain: chain, sameTagOrdinal: ord, }); let assignedId = findBySignature(manifest, sig); if (assignedId == null) { const entry: ManifestEntry = { file: relPath, line: child.loc.start.line, tag, parentChain: [...chain], signature: sig, text: child.children .filter((c) => c.type === NodeTypes.TEXT) .map((c) => (c as { content: string }).content) .join(' ') .trim() .slice(0, 24) || null, key: null, ref: null, createdAt: new Date().toISOString(), }; assignedId = addEntry(manifest, entry); dirty = true; } // Inject `data-dx="N"` into opening tag const insertPos = elementOpenTagEndOffset(child, tplContent, tplOffset); if (insertPos >= 0) { s.appendLeft(insertPos, ` data-dx="${assignedId}"`); } walk(child, [...chain, tag], localOrdinals); } }; // Top-level: declaring component name from file path const declaringName = id.split(/[\\/]/).pop()!.replace(/\.vue$/, ''); walk(ast, [declaringName], new Map()); // Flush manifest after every transform (idempotent if not dirty) 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; ``` > **Note on Vue parser API surface.** The exact import paths from `@vue/compiler-sfc` / `@vue/compiler-core` may shift across minor versions. If the test fails with a "module not found" or "NodeTypes undefined" error, the engineer should: > > 1. Run `node -e "console.log(Object.keys(require('@vue/compiler-sfc')))"` to inspect available exports > 2. `NodeTypes` may need to come from `@vue/compiler-core` directly or via `@vue/compiler-dom` > 3. `descriptor.template.ast` exists from Vue 3.4+; verify with `console.log(typeof descriptor.template.ast)` first > > Adjust imports inline. The algorithm itself doesn't change. - [ ] **Step 5: Run test, verify failure first (fixture missing or API issue)** Run: `cd app && npx vitest run vite-plugins/dev-indices/__tests__/integration.test.ts` Expected: integration FAIL initially with parser-API issues; iterate until PASS. If tests fail due to `magic-string` not installed: `cd app && npm install --save-dev magic-string` (Vite already ships with it transitively, but explicit dep is safer). - [ ] **Step 6: Iterate until tests pass** Common adjustments: - Use `import { NodeTypes } from '@vue/compiler-dom'` if compiler-core doesn't re-export - Use `descriptor.template.ast.children` directly if walk function shape is off - Add `console.log(descriptor.template.ast)` once to inspect actual structure, then remove - [ ] **Step 7: Run all dev-indices tests** Run: `cd app && npx vitest run vite-plugins/dev-indices/` Expected: all PASS. - [ ] **Step 8: Commit** ```bash git add app/vite-plugins/dev-indices/index.ts \ app/vite-plugins/dev-indices/__tests__/integration.test.ts \ app/vite-plugins/dev-indices/__tests__/fixtures/sample.vue \ app/package.json app/package-lock.json git commit -m "feat(dev-indices): Vite plugin core (transform + magic-string injection)" ``` --- ## Task 4: Wire plugin into vite.config.ts under dev-guard **Files:** - Modify: `app/vite.config.ts` - [ ] **Step 1: Read current vite.config.ts to determine integration point** Run: `cat app/vite.config.ts` — note where plugins array lives and existing plugin pattern. - [ ] **Step 2: Edit vite.config.ts to register plugin** Add import near top: ```typescript import { devIndicesPlugin } from './vite-plugins/dev-indices'; ``` Inside `defineConfig({ ... })`, locate the `plugins: [...]` array and insert (keep this addition first or near `vue()`): ```typescript const devIndicesEnabled = process.env.NODE_ENV !== 'production' && process.env.VITE_DEV_INDICES !== '0'; export default defineConfig({ // ... existing config ... plugins: [ devIndicesPlugin({ manifestPath: resolve(__dirname, 'dev-indices.json'), appRoot: __dirname, enabled: devIndicesEnabled, }), // ... existing plugins (vue(), vuetify(), etc) ... ], }); ``` (If `resolve` and `__dirname` aren't already imported, add `import { resolve } from 'node:path';` at top.) - [ ] **Step 3: Smoke-test dev server** Run: `cd app && npm run dev` Expected: - Server starts without errors - Open browser to `/login`, view source → `
` / `