npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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:

  • enabled defaults to process.env.NODE_ENV !== 'production', which switches the plugin off entirely in a production build.
  • Even with enabled: true forced on, a production build can only ever reach 'minimal'. The production option's type is false | 'minimal' — there is no value that means "full paths in production", by design.
  • The Vite integration additionally re-reads config.isProduction in configResolved and downgrades on its own if Vite says "production" while NODE_ENV says 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

  • line is 1-based and points at the < of the element's opening tag.
  • col is 1-based. Babel reports columns 0-based; this package adds one, because the consumer is vscode://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:

  1. Correctness. Re-printing a .tsx file round-trips syntax this plugin has no business touching. A splice cannot corrupt what it does not read.
  2. Line accuracy. An insertion that never contains a newline cannot move a line — the emitted line number stays true to the original file.
  3. 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 test

Fixtures 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.