@junro/component-tagger
v0.0.1
Published
Vite plugin that tags JSX elements with data-dev-* attributes (source path, line, enclosing component) so Junro's web plugin can point a report at the right file.
Maintainers
Readme
@junro/component-tagger
A Vite plugin that stamps every JSX element with data-dev-* attributes at build time — the
source file, line, column and the React component that rendered it — so that a picked DOM node can
be traced back to apps/web/src/features/pricing/PricingCard.tsx:42:8.
It is the tagging half of the Junro web plugin: the widget reads these attributes off whatever the reporter clicks and puts the file and component into the report. It works on its own too — as stable selectors for E2E tests, or for "which component rendered this?" in DevTools.
Forked from vite-plugin-component-debugger (MIT). See LICENSE.
Install
pnpm add -D @junro/component-tagger
# npm install --save-dev @junro/component-taggerQuick start
// vite.config.ts
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import componentTagger from '@junro/component-tagger'
export default defineConfig({
plugins: [
// FIRST. It must see the untouched source: @vitejs/plugin-react prepends HMR code (line
// numbers would shift) and TanStack Router's code splitter regenerates route modules.
componentTagger({
// undefined → tag the dev server only. Set to true to ship tags in a production build.
enabled: process.env.ENABLE_COMPONENT_TAGGER === 'true' || undefined,
// Directory that data-dev-path is relative to (default: Vite's root).
projectRoot: fileURLToPath(new URL('.', import.meta.url)),
}),
react(),
],
})What it does
// src/components/PricingCard.tsx
export function PricingCard({ plan }: Props) {
return (
<section className="card">
<button onClick={upgrade}>Upgrade</button>
</section>
)
}becomes
<section
data-dev-id="src/components/PricingCard.tsx:4:4"
data-dev-name="section"
data-dev-path="src/components/PricingCard.tsx"
data-dev-line="4"
data-dev-file="PricingCard.tsx"
data-dev-component="PricingCard"
className="card"
>
<button data-dev-id="src/components/PricingCard.tsx:5:6" data-dev-name="button" … data-dev-component="PricingCard">| Attribute | Value |
| -------------------- | ----------------------------------------------------------------------- |
| data-dev-id | path:line:column of the opening tag — unique per source element |
| data-dev-name | The JSX tag as written: section, Card, Menu.Item |
| data-dev-path | Source path relative to projectRoot |
| data-dev-line | Line of the opening tag |
| data-dev-file | Basename of the source file |
| data-dev-component | The React component whose body renders the element (see below) |
| data-dev-metadata | URL-encoded JSON of string props / text content (opt-in, see includeProps) |
data-dev-component — the enclosing component
The component name is resolved from the AST, not from the tag. Every common way of declaring a component is recognised:
function Card() {} // Card
export default function () {} // file basename (index.tsx → parent directory)
const Card = () => … // Card
const Card = memo(forwardRef(() => …)) // Card (any call wrapper: memo, forwardRef, observer, …)
const Card = (() => …) as FC // Card (as / satisfies)
Menu.Item = () => … // Menu.Item
class Table extends Component {} // Table, for every method and class-property arrowCallbacks inherit the component they live in: elements rendered from items.map(item => <li />),
render props, useMemo(() => <p />) and lowercase render helpers such as renderRow inside Table
all report Table. A lowercase function with no capitalized ancestor reports itself
(renderIcon), and JSX outside any function reports ''.
Caveat — components that do not forward props. Tags are written on the JSX call site. When
<Card className="x"> is rendered by PricingPage, the <Card> tag carries
data-dev-component="PricingPage", and whether it reaches the DOM depends on Card spreading its
props onto a host element. A Card that ignores unknown props drops the call-site tag; what
survives in the DOM is the tag on Card's own root element, which reads data-dev-component="Card"
with Card's source path. Both are true statements about the element — the tag on the DOM always
names the component whose file contains that line.
Which modules are tagged
- Files whose extension is in
extensions(default.jsx,.tsx) after stripping any query or hash from the module id.route.tsx?tsr-split=component---x(TanStack Router's code splitter),Comp.tsx?v=abc(Vite's cache buster) andComp.tsx#hashall resolve to the.tsxfile. \0-prefixed virtual modules are never tagged.- Anything under a
node_modulesdirectory is skipped unlessincludeNodeModules: true. - An element that already carries
<prefix>-idis left alone, so a second pass is a no-op. <svg:rect>(namespaced names) and fragments are never tagged;<A.B.C>is tagged asA.B.C.
Monorepos and prebuilt packages
projectRoot is what makes data-dev-path meaningful across packages. Point every Vite config in
the workspace at the repository root and the app reports apps/web/src/… while a design-system
component reports packages/design-system/src/primitives/dialog.tsx.
A package consumed as a prebuilt dist/ (a component library built with vite build) is never
seen by the consuming app's Vite pipeline, so add the tagger to that package's own Vite config.
Because it is always a build, opt in explicitly:
componentTagger({
enabled: process.env.ENABLE_COMPONENT_TAGGER === 'true',
projectRoot: fileURLToPath(new URL('../..', import.meta.url)),
excludePaths: ['**/*.stories.tsx'],
})and run ENABLE_COMPONENT_TAGGER=true vite build for the development artefact only.
Configuration
Core
| Option | Type | Default | Description |
| -------------------- | ---------- | ------------------ | ----------------------------------------------------------------------------------------------------- |
| enabled | boolean | undefined | undefined → only when Vite's command is 'serve' (dev server). An explicit boolean always wins. |
| projectRoot | string | Vite config.root | Directory data-dev-path / data-dev-id are relative to. process.cwd() when used outside Vite. |
| includeNodeModules | boolean | false | Also tag modules under node_modules. |
| tagComponents | boolean | true | Tag component call sites (<Card>, <Menu.Item>, <motion.div>) as well as host elements. |
| attributePrefix | string | 'data-dev' | Prefix for every attribute. |
| extensions | string[] | ['.jsx', '.tsx'] | File extensions to process. |
| preset | Preset | undefined | 'minimal' (id only), 'testing' (id, name, component), 'debugging' (everything), 'production' |
Attribute control
| Option | Type | Default | Description |
| ------------------- | ----------------- | ----------- | ------------------------------------------------------ |
| includeAttributes | AttributeName[] | undefined | Allowlist — only these attributes are written. |
| excludeAttributes | AttributeName[] | undefined | Disallowlist. Ignored when includeAttributes is set. |
| transformers | object | undefined | Per-attribute value transformers (path, id, name, line, file, component). |
| groupAttributes | boolean | false | Emit one <prefix> attribute holding all values as JSON. |
| metadataEncoding | 'json' \| 'base64' \| 'none' | 'json' | Encoding of data-dev-metadata (and of the grouped attribute). |
| includeProps | boolean | false | Put string / boolean props into data-dev-metadata. |
| includeContent | boolean | false | Put the element's text content into data-dev-metadata. |
AttributeName is 'id' | 'name' | 'path' | 'line' | 'file' | 'component' | 'metadata'.
Filtering
| Option | Type | Default | Description |
| ----------------- | --------------------------- | -------------------------------- | ------------------------------------------------------------ |
| includePaths | string[] | undefined | Globs (matched against the projectRoot-relative path) to include. |
| excludePaths | string[] | undefined | Globs to exclude. |
| excludeElements | string[] | ['Fragment', 'React.Fragment'] | Tag names (full dotted form) to skip. |
| customExcludes | Set<string> | Three.js / react-three-fiber elements | More tag names to skip. |
| shouldTag | (info: ComponentInfo) => boolean | undefined | Per-element veto. info has elementName, component, filePath, line, column, props, content. |
| customAttributes| (info: ComponentInfo) => Record<string, string> | undefined | Extra <prefix>-* attributes per element. |
| maxDepth / minDepth / tagOnlyRoots | number / number / boolean | 0 / 0 / false | Limit tagging by JSX nesting depth. |
Statistics and debugging
| Option | Type | Default | Description |
| ----------------------- | ----------------------------- | ----------- | -------------------------------------------------------- |
| onTransform | (stats: TransformStats) => void | undefined | Called after each tagged file. |
| onComplete | (stats: CompletionStats) => void | undefined | Called from buildEnd. |
| exportStats | string | undefined | Write the completion stats to this JSON file (inside projectRoot). |
| includeSourceMapHints | boolean | false | Add a <prefix>-sourcemap attribute. |
| debug | boolean | false | Log every processed file and tagged element. |
Troubleshooting
Line numbers are off by ~19. The tagger is running after @vitejs/plugin-react. Put it first.
Route components are not tagged. With @tanstack/router-plugin and autoCodeSplitting the
tagger must be listed before tanstackRouter(); the split modules are regenerated from the AST
and would otherwise lose their source positions.
Nothing is tagged in a build. That is the default: leave enabled undefined for dev-only tags,
or pass enabled: true for the build that should carry them.
Design-system elements have no tags. The package is consumed prebuilt — add the tagger to its own Vite config (see Monorepos and prebuilt packages).
Development
pnpm --filter @junro/component-tagger test # vitest
pnpm --filter @junro/component-tagger build # tsup → dist (cjs + esm + d.ts)
pnpm --filter @junro/component-tagger check # test + buildLicense
MIT. Forked from vite-plugin-component-debugger (Copyright (c) 2024); portions Copyright (c) 2026
Junro.
