@frmwrkd/snag-anchor
v0.1.3
Published
Build-time plugin that stamps data-snag="file:line:col" onto every element (Vite, webpack, Rspack, Rollup, esbuild).
Readme
@frmwrkd/snag-anchor
An unplugin that stamps
data-snag="path:line:col" onto DOM elements at build time.
<button data-snag="src/checkout/PromoField.tsx:42:7" data-snag-cmp="PromoField">Two attributes, self-contained — no global registry, no indirection.
LocatorJS's default data-locatorjs-id mode needs a window.__LOCATOR_DATA__
lookup table; this deliberately does not, because only data-* attributes cross
the ISOLATED-world boundary. Expando properties the page attaches to DOM nodes
(__reactFiber$*, __svelte_meta) are invisible to an ISOLATED content script,
so tier 0 is the only resolution path that needs no round trip into the page:
one getAttribute and you're done.
Licence: Apache-2.0.
Spec: docs/02-BACKEND-SPEC.md §2. Locked decisions live in
docs/00-MASTER-BUILD-PLAN.md §7 — don't relitigate them.
Install and wire up
// vite.config.ts
import { defineConfig } from 'vite';
import snagAnchor from '@frmwrkd/snag-anchor/vite';
export default defineConfig({ plugins: [snagAnchor()] });// webpack.config.js
const snagAnchor = require('@frmwrkd/snag-anchor/webpack').default;
module.exports = { plugins: [snagAnchor()] };Also available: @frmwrkd/snag-anchor/rollup, /rspack, /esbuild, /nuxt,
/babel (a plain Babel plugin) and /loader (a webpack-shaped loader, see
Next.js and Turbopack).
Options
Every default below is the one in docs/02-BACKEND-SPEC.md §2.3.
| Option | Type | Default | |
|---|---|---|---|
| enabled | boolean | process.env.NODE_ENV !== 'production' | Master switch. |
| attribute | string | 'data-snag' | |
| componentAttribute | string | 'data-snag-cmp' | |
| root | string | git root | Every emitted path is relative to this. |
| include | string[] | ['**/*.{jsx,tsx,vue}'] | |
| exclude | string[] | ['**/node_modules/**','**/*.test.*','**/*.stories.*'] | Beats include. |
| intrinsicOnly | boolean | true | Stamp DOM elements, not component tags. |
| emitRepoMap | boolean | true | Reserved — see Not yet wired. |
| devtoolsJson | boolean | true | Serve the well-known project-root file. |
| production | false \| 'minimal' | false | 'minimal' = component name only, no path. |
The information-disclosure tradeoff — read before turning production on
data-snag publishes a source path into the DOM. In development that is the
entire point. In a public production build it publishes your directory
layout, your internal component names, and — for anyone willing to read a few
hundred of them — a decent sketch of your architecture, to anyone with DevTools
open.
So the gate is two-layered and fails closed:
enableddefaults toprocess.env.NODE_ENV !== 'production', which switches the plugin off entirely in a production build.- Even with
enabled: trueforced on, a production build can only ever reach'minimal'. Theproductionoption's type isfalse | 'minimal'— there is no value that means "full paths in production", by design. - The Vite integration additionally re-reads
config.isProductioninconfigResolvedand downgrades on its own if Vite says "production" whileNODE_ENVsays nothing.
production: 'minimal' emits only data-snag-cmp="PromoField" — a
component name, never a path, never a line number. That is a real but much
smaller disclosure: it leaks your component vocabulary. It exists because a
staging screenshot with a component name still beats nothing, which is how
FullStory ships theirs. Use it on internal staging; think twice before shipping
it to the open internet.
If an element has no resolvable component name, 'minimal' emits nothing at all
for it. A missing attribute is honest; a made-up one is not.
What gets stamped, and what deliberately does not
Intrinsic elements only
data-snag goes on lowercase JSX elements — <div>, <button>,
<my-widget> — and never on <PromoField />. A data-snag prop on a component
element is silently swallowed unless that component happens to spread its props
onto a DOM node, and an attribute that lands nowhere is worse than no attribute:
the resolver cannot tell "not stamped" from "stamped and dropped".
The intrinsic test is Babel's own isCompatTag — the tag name starts with a
lowercase ASCII letter. <Foo.Bar> (a member expression) and <svg:circle> (a
namespaced name) are never stamped.
intrinsicOnly: false additionally stamps component elements. That is only
useful for frameworks that forward unknown props onto a DOM node by default;
leave it off unless you have measured that it helps.
Fragments
<>…</> parses to a different AST node entirely and is never visited.
<React.Fragment> is a member expression, so it is not intrinsic. Neither is
ever stamped.
An existing data-snag
User override wins. An element that already carries data-snag (or
data-snag-cmp) is left exactly as written.
Vue
The SFC template is transformed at enforce: 'pre', before
@vitejs/plugin-vue compiles it. Host elements are stamped; <MyChild/>,
<template> and <slot> are not — the last two are compiler constructs that
never reach the DOM. @vue/compiler-sfc reports template positions absolute
within the whole .vue file, so the emitted line counts from the top of the
file including the <script setup> block. The component name is the SFC's file
name.
Svelte — nothing, on purpose
.svelte is absent from the default include, deliberately. Svelte 5
already attaches
element.__svelte_meta = { loc: { file: 'src/App.svelte', line: 12, column: 5 } }to every element in dev, with zero setup, and @frmwrkd/snag-resolver reads it directly
at tier 1. Injecting an attribute there would duplicate framework metadata
that can only ever disagree with it. See docs/02-BACKEND-SPEC.md §2.2 and
docs/03-RESEARCH-APPENDIX.md §2.2, technique #3.
Plain HTML with no build step
Not supported at tier 0, by design. It falls to tier 4, where the resolver reports a route and a selector, zero candidates, and no fabricated path.
Line and column semantics
lineis 1-based and points at the<of the element's opening tag.colis 1-based. Babel reports columns 0-based; this package adds one, because the consumer isvscode://file/{path}:{line}:{col}and every editor's "go to line:column" is 1-based. Vue's parser already reports 1-based columns, so both languages emit the same convention.
The invariant, which packages/anchor/test/ asserts against every fixture by
reading the file back off disk:
sourceLines[line - 1].charAt(col - 1) === '<'Paths are repo-relative and POSIX-normalised, so the same attribute works locally, in a container, and on a preview deploy — and so a capture filed on Windows still matches a repo map built on Linux. The absolute root is published separately, below.
The absolute project root
With devtoolsJson: true (the default) the Vite dev server also serves:
GET /.well-known/appspecific/com.chrome.devtools.json
{ "workspace": { "root": "/Users/theo/dev/myapp", "uuid": "…" } }The extension fetches this from the page origin and joins it to the
repo-relative half, so src/App.tsx:42:7 becomes a path Claude Code can open.
It only works on localhost, which is exactly the case that matters.
The uuid is a UUID v5 derived from the absolute root, not a random one: Chrome DevTools binds a workspace folder to it, and a fresh uuid on every restart would silently unbind the folder each time the dev server restarts.
For bundlers other than Vite, mount the middleware yourself — register it
unmounted, or connect will rewrite req.url to / and it will swallow the
index route:
import { createDevtoolsJsonMiddleware } from '@frmwrkd/snag-anchor';
server.middlewares.use(createDevtoolsJsonMiddleware('/abs/project/root'));Next.js and Turbopack
create-next-app on Next 15 defaults next dev to Turbopack, and
turbopack.rules maps file extensions to webpack-shaped loaders, not to
unplugin plugins. So the transform also ships as a plain CommonJS loader:
// next.config.js
module.exports = {
turbopack: {
rules: {
'*.{jsx,tsx}': { loaders: ['@frmwrkd/snag-anchor/loader'], as: '*.{jsx,tsx}' },
},
},
};This integration is unverified. docs/03-RESEARCH-APPENDIX.md §2.7 marks it
[U], and no primary source confirms an unplugin-based transform can be
registered through turbopack.rules. It has not been run against a real
Turbopack dev server here. If Turbopack rejects the loader,
docs/02-BACKEND-SPEC.md §2.6 criterion 6 explicitly permits next dev
--no-turbo — which runs the webpack pipeline, where both
@frmwrkd/snag-anchor/webpack and this loader work — with the gap recorded as a known
limitation.
There is no native SWC plugin. A real one would have to be a Rust-compiled binary; the loader covers the same ground at the bundler level, for both the Turbopack and the webpack pipelines.
What this is NOT built on
React 19 removed fiber._debugSource. jsxDEV's source argument was
dropped in 19.0/19.1 and removed from the signature entirely in 19.2;
createElement explicitly strips __source/__self; @babel/preset-react now
defaults developmentSourceSelf to false for exactly that reason.
That break took out click-to-component,
vite-plugin-react-click-to-component, react-dev-inspector, code-inspector,
radon-ide and LocatorJS's React path simultaneously. This package depends
on none of them and reads none of those fields. The Babel visitor is written
from scratch and is about forty lines. See docs/03-RESEARCH-APPENDIX.md §2.1
(marked [V] — verified against React's source).
How the transform works
Parse, then splice — never re-print. @babel/parser produces the AST, the
insertion offsets are computed from it, and the attribute text is inserted into
the original source with magic-string. The file is never handed to a code
generator.
That matters three times over:
- Correctness. Re-printing a
.tsxfile round-trips syntax this plugin has no business touching. A splice cannot corrupt what it does not read. - Line accuracy. An insertion that never contains a newline cannot move a line — the emitted line number stays true to the original file.
- Build overhead.
docs/02-BACKEND-SPEC.md§2.6 criterion 4 caps overhead at 8%. Parse + splice is a fraction of parse + generate.
The attribute is inserted after the last existing attribute, so a
{...props} spread cannot overwrite the stamp with a value forwarded from a
parent.
A transform that throws breaks the developer's dev server, so every failure path here returns the source untouched instead: unparseable file, unsupported template language, no filename to derive a path from. The cost of a missed anchor is a tier-3 fallback. The cost of a thrown error is somebody's afternoon.
@frmwrkd/snag-anchor/babel is the same logic as a real Babel PluginObj, for wiring
into a babel.config.js (React Native, Storybook, Next's Babel path). Both
paths share one decision core, and test/parity.test.ts asserts they emit
identical attributes for the same input.
Not yet wired
emitRepoMap is accepted and carried on the resolved options, but nothing runs
yet — @frmwrkd/snag-repomap is milestone M3 and is not a dependency of this package.
The option exists so that a user's config does not need to change when it lands.
Tests
pnpm --filter @frmwrkd/snag-anchor testFixtures live in fixtures/ (see fixtures/README.md). The assertion that
matters is in packages/anchor/test/helpers/anchors.ts: every emitted
data-snag is read back out of the transformed output, the file it names is
opened at the line and column it names, and the element's opening tag has to
really be there. A snapshot can lock in an off-by-one for ever; reading the file
back cannot.
