@macaly/static-tagger
v0.1.0
Published
Vite plugin that injects source-location data attributes onto JSX elements at build time, so production HTML/JS carries data-macaly-loc / data-macaly-name pointing back at the original source coordinate.
Maintainers
Readme
@macaly/static-tagger
Vite plugin. Rewrites .jsx / .tsx source so every JSX element gains two static attributes:
data-macaly-loc="<relative/path.tsx>:<line>:<col>"— points back at the source coordinate where the element was authored.data-macaly-name="<TagName>"—div,Button,MyLib.Card, …
The rewrite happens before JSX is lowered to _jsx() / React.createElement(), so the attributes survive into the bundled JS as ordinary string props on regular HTML elements. They land in:
- the production DOM for SPA builds (after React mounts client-side);
- the prerendered HTML for SSG/SSR builds (TanStack Start, Vike, Astro React islands, Remix/React Router SSR, etc.) — visible without JS.
Intended use: click an element in the browser → jump to the exact source line in your editor. This package only writes the attributes; a separate runtime/extension reads them.
Vite-only. Build-time only. No webpack, no Turbopack, no dev-server tagging.
Installation
npm install --save-dev @macaly/static-tagger
# or: pnpm add -D @macaly/static-taggerUsage
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { macalyTagger } from '@macaly/static-tagger';
export default defineConfig({
plugins: [
macalyTagger({
// ignorePackages: ['@react-three/fiber', '@react-three/drei'],
// disableSourceMaps: false,
// debug: false,
}),
react(),
],
});macalyTagger() must come before @vitejs/plugin-react. The plugin sets enforce: 'pre' so Vite always runs it before non-pre plugins, but keeping it first in the array is the obvious form.
How it works
your src/Foo.tsx <Foo data-macaly-loc=... data-macaly-name=...>
↓ ↑
[macalyTagger plugin] parse → walk AST → magic-string │
↓ injects 2 string attrs onto each JSXOpeningElement │
│
[@vitejs/plugin-react] JSX → _jsx("Foo", { ... }) │
↓ attrs become regular string-valued object props │
│
[esbuild / rollup] bundle, tree-shake, minify │
↓ │
dist/assets/*.js contains data-macaly-loc="…" ───┘
↓
SPA: browser loads → React mounts → DOM has the attrs
SSG/SSR: prerender writes them straight into dist/**/*.htmlMechanics:
enforce: 'pre'— Vite's transform pipeline runspreplugins before user plugins.@vitejs/plugin-reactis a user plugin, so we get the file whileJSXOpeningElementnodes still exist. After plugin-react has run, JSX is lowered to_jsx(...)calls and there is nothing tag-shaped left to rewrite.apply: 'build'— only runs duringvite build. The dev server is left untouched. (Dev-mode tagging is out of scope.)- AST rewrite, not codegen — parses with
@babel/parser(jsx+typescriptplugins), walks withestree-walker, splices two attributes after the tag name withmagic-string. The rest of the source is byte-identical, including indentation, comments, and trailing commas. A high-resolution sourcemap is returned alongside the code. - What ends up in the bundle — for a host element:
the plugin produces:<button onClick={...}>Click</button>
plugin-react/esbuild lowers that to:<button data-macaly-loc="src/App.tsx:42:8" data-macaly-name="button" onClick={...}>Click</button>
At render time React forwards both_jsx("button", { "data-macaly-loc": "src/App.tsx:42:8", "data-macaly-name": "button", onClick: ..., children: "Click" })data-*props to the DOM, exactly as it would for any other prop. - Skipped automatically — virtual modules (ids starting with
\0), files outside.jsx/.tsx, anything undernode_modules, React Fragments, lowercase non-HTML/SVG tags (custom-renderer elements like R3F's<mesh>,<boxGeometry>), and any element already carryingdata-macaly-loc. Components imported from packages listed inignorePackagesare also skipped — see below.
SPA vs SSG/SSR
Plugin behavior is identical. The difference is where the attributes become visible:
- SPA (
vite buildwith defaultindex.html):dist/index.htmlis an empty shell. Attributes appear in the DOM only after React mounts. View via DevTools → Elements. - SSG / SSR (TanStack Start, Vike, Astro React islands, Remix/React Router SSR, etc.): the prerenderer runs your components on the server, React's HTML serializer emits the
data-macaly-*attributes into the markup, and they sit directly in the static.htmloutput — visible without JavaScript.
No configuration changes between the two modes.
Options
macalyTagger({
debug: false, // verbose console logging
disableSourceMaps: false, // skip generating the high-res sourcemap
ignorePackages: [], // see "Custom renderers" section
});| Option | Type | Default | Description |
|--------|------|---------|-------------|
| debug | boolean | false | Log each file processed and each tag decision. |
| disableSourceMaps | boolean | false | Don't return a sourcemap from the transform. |
| ignorePackages | string[] | [] | Don't tag components imported from these packages. |
Programmatic API
import { transformJSX } from '@macaly/static-tagger';
const result = transformJSX(sourceCode, '/abs/path/src/App.tsx', '/abs/path', {
ignorePackages: ['@react-three/fiber'],
});
// result === null → file skipped or no taggable JSX
// result === { code, map }transformJSX is the framework-agnostic core; the Vite plugin is a thin shell around it.
Example
Input:
export default function TestComponent() {
return (
<div className="container">
<h1>Hello Macaly!</h1>
<button onClick={() => console.log('clicked')}>Click me</button>
<MyLib.SpecialButton />
</div>
);
}After build, in the rendered DOM (or prerendered HTML):
<div data-macaly-loc="components/TestComponent.tsx:3:4" data-macaly-name="div" class="container">
<h1 data-macaly-loc="components/TestComponent.tsx:4:6" data-macaly-name="h1">Hello Macaly!</h1>
<button data-macaly-loc="components/TestComponent.tsx:5:6" data-macaly-name="button">Click me</button>
<!-- MyLib.SpecialButton renders whatever it renders, with data-macaly-* forwarded onto its root -->
</div>Custom renderers (React Three Fiber, etc.)
Renderers that map JSX onto non-DOM hosts (R3F's <mesh>, <boxGeometry>, …) cannot accept data-* props. Two layers of filtering keep them clean:
- Heuristic — lowercase tag names that aren't standard HTML/SVG are never tagged. R3F's
<mesh>,<boxGeometry>,<meshStandardMaterial>,<ambientLight>, etc. fall out automatically. ignorePackages— for capitalized React components from these renderers (e.g.<Canvas>from@react-three/fiber,<OrbitControls>from@react-three/drei), list the package and the plugin skips every component imported from it.
macalyTagger({
ignorePackages: [
'@react-three/fiber',
'@react-three/drei',
'@react-three/postprocessing',
'three',
],
});import { Canvas } from '@react-three/fiber'; // Canvas → skipped
import { OrbitControls, Html } from '@react-three/drei'; // skipped
import * as Fiber from '@react-three/fiber'; // Fiber.* → skipped
function Scene() {
return (
<div> {/* tagged */}
<Canvas> {/* skipped (imported from ignored pkg) */}
<OrbitControls /> {/* skipped */}
<Html> {/* skipped */}
<span>Label</span> {/* tagged (HTML element) */}
</Html>
<mesh> {/* skipped (lowercase non-HTML) */}
<boxGeometry /> {/* skipped */}
</mesh>
</Canvas>
</div>
);
}All three import styles are handled:
import { Canvas } from '...'(named) →Canvasis skipped.import Drei from '...'(default) →Drei.*is skipped.import * as Fiber from '...'(namespace) →Fiber.*is skipped.
Troubleshooting
No data-macaly-* in the DOM. Confirm macalyTagger() is listed before @vitejs/plugin-react. If plugin-react sees the file first, JSX has already been lowered and there's nothing to rewrite.
Plugin doesn't run during vite dev. By design — apply: 'build'. Tagging is a build-time-only feature.
Element type is invalid from a custom renderer. That renderer doesn't accept data-* props. Add its package to ignorePackages.
Loud logging? Set debug: true.
License
MIT — see LICENSE.
