Skip to content

Commit 64f9022

Browse files
konardclaude
andcommitted
Merge branch 'main' into issue-18-fe8f089190c9
Resolved conflicts: - packages/app/src/core/component-path.ts: kept both normalizeModuleId function and updated data-path attribute comment - packages/app/src/shell/component-tagger.ts: merged imports to include both normalizeModuleId and componentPathAttributeName Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2 parents ce21266 + 6fc5d2f commit 64f9022

11 files changed

Lines changed: 328 additions & 71 deletions

File tree

README.md

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,32 @@
11
# @prover-coder-ai/component-tagger
22

3-
Vite plugin that adds a single `path` attribute to every JSX opening tag.
3+
Vite and Babel plugin that adds a `data-path` attribute to every JSX opening tag, enabling component source location tracking.
44

5-
Example output:
5+
## Example output
66

77
```html
8-
<h1 path="src/App.tsx:22:4">Hello</h1>
8+
<h1 data-path="src/App.tsx:22:4">Hello</h1>
99
```
1010

1111
Format: `<relative-file-path>:<line>:<column>`
1212

13+
## Features
14+
15+
-**Idempotent**: adds `data-path` only if it doesn't already exist
16+
-**HTML5 compliant**: uses standard `data-*` attributes
17+
-**Configurable**: customize the attribute name via options
18+
-**Dual plugin support**: works with both Vite and Babel
19+
20+
## Installation
21+
22+
```bash
23+
npm install @prover-coder-ai/component-tagger
24+
```
25+
1326
## Usage
1427

28+
### Vite Plugin
29+
1530
```ts
1631
import { defineConfig, type PluginOption } from "vite"
1732
import { componentTagger } from "@prover-coder-ai/component-tagger"
@@ -23,3 +38,84 @@ export default defineConfig(({ mode }) => {
2338
return { plugins }
2439
})
2540
```
41+
42+
**With custom attribute name:**
43+
44+
```ts
45+
const plugins = [
46+
isDevelopment && componentTagger({ attributeName: "data-component-path" })
47+
].filter(Boolean) as PluginOption[]
48+
```
49+
50+
### Babel Plugin (e.g., Next.js)
51+
52+
Add to your `.babelrc`:
53+
54+
```json
55+
{
56+
"presets": ["next/babel"],
57+
"env": {
58+
"development": {
59+
"plugins": ["@prover-coder-ai/component-tagger/babel"]
60+
}
61+
}
62+
}
63+
```
64+
65+
**With options:**
66+
67+
```json
68+
{
69+
"presets": ["next/babel"],
70+
"env": {
71+
"development": {
72+
"plugins": [
73+
[
74+
"@prover-coder-ai/component-tagger/babel",
75+
{
76+
"rootDir": "/custom/root",
77+
"attributeName": "data-component-path"
78+
}
79+
]
80+
]
81+
}
82+
}
83+
}
84+
```
85+
86+
## Options
87+
88+
### Vite Plugin Options
89+
90+
```ts
91+
type ComponentTaggerOptions = {
92+
/**
93+
* Name of the attribute to add to JSX elements.
94+
* @default "data-path"
95+
*/
96+
attributeName?: string
97+
}
98+
```
99+
100+
### Babel Plugin Options
101+
102+
```ts
103+
type ComponentTaggerBabelPluginOptions = {
104+
/**
105+
* Root directory for computing relative paths.
106+
* @default process.cwd()
107+
*/
108+
rootDir?: string
109+
/**
110+
* Name of the attribute to add to JSX elements.
111+
* @default "data-path"
112+
*/
113+
attributeName?: string
114+
}
115+
```
116+
117+
## Behavior Guarantees
118+
119+
- **Idempotency**: If `data-path` (or custom attribute) already exists on an element, no duplicate is added
120+
- **Default attribute**: `data-path` is used when no `attributeName` is specified
121+
- **Standard compliance**: Uses HTML5 `data-*` custom attributes by default

packages/app/babel.cjs

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
* "plugins": ["@prover-coder-ai/component-tagger/babel"]
1212
* }
1313
*/
14-
// CHANGE: provide CommonJS entry point for Babel plugin.
15-
// WHY: Babel configuration often requires CommonJS modules.
16-
// REF: issue-12
14+
// CHANGE: provide CommonJS entry point for Babel plugin with configurable attributeName.
15+
// WHY: Babel configuration often requires CommonJS modules; support custom attribute names.
16+
// REF: issue-12, issue-14
1717
// FORMAT THEOREM: forall require: require(babel.cjs) -> PluginFactory
1818
// PURITY: SHELL
1919
// EFFECT: n/a
@@ -22,7 +22,7 @@
2222

2323
const path = require("node:path")
2424

25-
const componentPathAttributeName = "path"
25+
const componentPathAttributeName = "data-path"
2626
const jsxFilePattern = /\.(tsx|jsx)(\?.*)?$/u
2727

2828
const isJsxFile = (id) => jsxFilePattern.test(id)
@@ -58,21 +58,22 @@ module.exports = function componentTaggerBabelPlugin({ types: t }) {
5858
return
5959
}
6060

61-
// Skip if already has path attribute
62-
if (attrExists(node, componentPathAttributeName, t)) {
63-
return
64-
}
65-
66-
// Compute relative path from root
61+
// Compute relative path from root and get attribute name
6762
const opts = state.opts || {}
6863
const rootDir = opts.rootDir || state.cwd || process.cwd()
64+
const attributeName = opts.attributeName || componentPathAttributeName
6965
const relativeFilename = path.relative(rootDir, filename)
7066

67+
// Skip if already has the specified attribute (idempotency)
68+
if (attrExists(node, attributeName, t)) {
69+
return
70+
}
71+
7172
const { column, line } = node.loc.start
7273
const value = formatComponentPathValue(relativeFilename, line, column)
7374

7475
node.attributes.push(
75-
t.jsxAttribute(t.jsxIdentifier(componentPathAttributeName), t.stringLiteral(value))
76+
t.jsxAttribute(t.jsxIdentifier(attributeName), t.stringLiteral(value))
7677
)
7778
}
7879
}

packages/app/src/core/component-path.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,17 @@ export const normalizeModuleId = (id: string): string => {
2929
return queryIndex === -1 ? id : id.slice(0, queryIndex)
3030
}
3131

32-
// CHANGE: define canonical attribute name for component path tagging.
33-
// WHY: reduce metadata to a single attribute while keeping full source location.
34-
// QUOTE(TZ): "\u0421\u0430\u043c \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0432 \u0442\u0435\u043a\u0443\u0449\u0435\u043c app \u043d\u043e \u0432\u043e\u0442 \u0447\u0442\u043e \u0431\u044b \u0435\u0433\u043e \u043f\u0440\u043e\u0442\u0435\u0441\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043d\u0430\u0434\u043e \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u0435\u0449\u0451 \u043e\u0434\u0438\u043d \u043f\u0440\u043e\u0435\u043a\u0442 \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043d\u0430\u0448 \u0442\u0435\u043a\u0443\u0449\u0438\u0439 \u0430\u043f\u043f \u0431\u0443\u0434\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0442\u044c"
35-
// REF: user-2026-01-14-frontend-consumer
36-
// SOURCE: n/a
37-
// FORMAT THEOREM: forall a in AttributeName: a = "path"
32+
// CHANGE: rename attribute from "path" to "data-path" for HTML5 compliance.
33+
// WHY: data-* attributes are standard HTML5 custom data attributes, improving compatibility.
34+
// QUOTE(issue-14): "Rename attribute path → data-path (breaking change)"
35+
// REF: issue-14
36+
// SOURCE: https://html.spec.whatwg.org/multipage/dom.html#custom-data-attribute
37+
// FORMAT THEOREM: forall a in AttributeName: a = "data-path"
3838
// PURITY: CORE
3939
// EFFECT: n/a
4040
// INVARIANT: attribute name remains stable across transforms
4141
// COMPLEXITY: O(1)/O(1)
42-
export const componentPathAttributeName = "path"
42+
export const componentPathAttributeName = "data-path"
4343

4444
/**
4545
* Checks whether the Vite id represents a JSX or TSX module.
@@ -53,7 +53,7 @@ export const componentPathAttributeName = "path"
5353
*/
5454
// CHANGE: centralize JSX file detection as a pure predicate.
5555
// WHY: keep file filtering in the functional core for testability.
56-
// QUOTE(TZ): "\u0421\u0430\u043c \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0432 \u0442\u0435\u043a\u0443\u0449\u0435\u043c app \u043d\u043e \u0432\u043e\u0442 \u0447\u0442\u043e \u0431\u044b \u0435\u0433\u043e \u043f\u0440\u043e\u0442\u0435\u0441\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043d\u0430\u0434\u043e \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u0435\u0449\u0451 \u043e\u0434\u0438\u043d \u043f\u0440\u043e\u0435\u043a\u0442 \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043d\u0430\u0448 \u0442\u0435\u043a\u0443\u0449\u0438\u0439 \u0430\u043f\u043f \u0431\u0443\u0434\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0442\u044c"
56+
// QUOTE(TZ): "Сам компонент должен быть в текущем app но вот что бы его протестировать надо создать ещё один проект который наш текущий апп будет подключать"
5757
// REF: user-2026-01-14-frontend-consumer
5858
// SOURCE: n/a
5959
// FORMAT THEOREM: forall id in ModuleId: isJsxFile(id) -> matches(id, jsxFilePattern)
@@ -77,7 +77,7 @@ export const isJsxFile = (id: string): boolean => jsxFilePattern.test(id)
7777
*/
7878
// CHANGE: provide a pure formatter for component location payloads.
7979
// WHY: reuse a single, deterministic encoding for UI metadata.
80-
// QUOTE(TZ): "\u0421\u0430\u043c \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0432 \u0442\u0435\u043a\u0443\u0449\u0435\u043c app \u043d\u043e \u0432\u043e\u0442 \u0447\u0442\u043e \u0431\u044b \u0435\u0433\u043e \u043f\u0440\u043e\u0442\u0435\u0441\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043d\u0430\u0434\u043e \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u0435\u0449\u0451 \u043e\u0434\u0438\u043d \u043f\u0440\u043e\u0435\u043a\u0442 \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043d\u0430\u0448 \u0442\u0435\u043a\u0443\u0449\u0438\u0439 \u0430\u043f\u043f \u0431\u0443\u0434\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0442\u044c"
80+
// QUOTE(TZ): "Сам компонент должен быть в текущем app но вот что бы его протестировать надо создать ещё один проект который наш текущий апп будет подключать"
8181
// REF: user-2026-01-14-frontend-consumer
8282
// SOURCE: n/a
8383
// FORMAT THEOREM: forall p,l,c: formatComponentPathValue(p,l,c) = concat(p, ":", l, ":", c)

packages/app/src/core/jsx-tagger.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { types as t, Visitor } from "@babel/core"
22

3-
import { componentPathAttributeName, formatComponentPathValue } from "./component-path.js"
3+
import { formatComponentPathValue } from "./component-path.js"
44

55
/**
66
* Context required for JSX tagging.
@@ -12,6 +12,10 @@ export type JsxTaggerContext = {
1212
* Relative file path from the project root.
1313
*/
1414
readonly relativeFilename: string
15+
/**
16+
* Name of the attribute to add (defaults to "data-path").
17+
*/
18+
readonly attributeName: string
1519
}
1620

1721
/**
@@ -41,32 +45,34 @@ export const attrExists = (node: t.JSXOpeningElement, attrName: string, types: t
4145
/**
4246
* Creates a JSX attribute with the component path value.
4347
*
48+
* @param attributeName - Name of the attribute to create.
4449
* @param relativeFilename - Relative path to the file.
4550
* @param line - 1-based line number.
4651
* @param column - 0-based column number.
4752
* @param types - Babel types module.
4853
* @returns JSX attribute node with the path value.
4954
*
5055
* @pure true
51-
* @invariant attribute name is always componentPathAttributeName
56+
* @invariant attribute name matches the provided attributeName parameter
5257
* @complexity O(1)
5358
*/
54-
// CHANGE: extract attribute creation as a pure factory.
55-
// WHY: single point for attribute creation ensures consistency.
56-
// REF: issue-12 (unified interface request)
57-
// FORMAT THEOREM: ∀ f, l, c: createPathAttribute(f, l, c) = JSXAttribute(path, f:l:c)
59+
// CHANGE: add attributeName parameter for configurable attribute names.
60+
// WHY: support customizable attribute names while maintaining default "data-path".
61+
// REF: issue-14 (add attributeName option)
62+
// FORMAT THEOREM: ∀ n, f, l, c: createPathAttribute(n, f, l, c) = JSXAttribute(n, f:l:c)
5863
// PURITY: CORE
5964
// EFFECT: n/a
60-
// INVARIANT: output format is always path:line:column
65+
// INVARIANT: output format is always path:line:column with configurable attribute name
6166
// COMPLEXITY: O(1)/O(1)
6267
export const createPathAttribute = (
68+
attributeName: string,
6369
relativeFilename: string,
6470
line: number,
6571
column: number,
6672
types: typeof t
6773
): t.JSXAttribute => {
6874
const value = formatComponentPathValue(relativeFilename, line, column)
69-
return types.jsxAttribute(types.jsxIdentifier(componentPathAttributeName), types.stringLiteral(value))
75+
return types.jsxAttribute(types.jsxIdentifier(attributeName), types.stringLiteral(value))
7076
}
7177

7278
/**
@@ -76,12 +82,12 @@ export const createPathAttribute = (
7682
* Both the Vite plugin and standalone Babel plugin use this function.
7783
*
7884
* @param node - JSX opening element to process.
79-
* @param context - Tagging context with relative filename.
85+
* @param context - Tagging context with relative filename and attribute name.
8086
* @param types - Babel types module.
8187
* @returns true if attribute was added, false if skipped.
8288
*
8389
* @pure false (mutates node)
84-
* @invariant each JSX element has at most one path attribute after processing
90+
* @invariant each JSX element has at most one instance of the specified attribute after processing
8591
* @complexity O(n) where n = number of existing attributes
8692
*/
8793
// CHANGE: extract unified JSX element processing logic.
@@ -103,13 +109,13 @@ export const processJsxElement = (
103109
return false
104110
}
105111

106-
// Skip if already has path attribute (idempotency)
107-
if (attrExists(node, componentPathAttributeName, types)) {
112+
// Skip if already has the specified attribute (idempotency)
113+
if (attrExists(node, context.attributeName, types)) {
108114
return false
109115
}
110116

111117
const { column, line } = node.loc.start
112-
const attr = createPathAttribute(context.relativeFilename, line, column, types)
118+
const attr = createPathAttribute(context.attributeName, context.relativeFilename, line, column, types)
113119

114120
node.attributes.push(attr)
115121
return true

packages/app/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ export {
2222
processJsxElement
2323
} from "./core/jsx-tagger.js"
2424
export { componentTaggerBabelPlugin, type ComponentTaggerBabelPluginOptions } from "./shell/babel-plugin.js"
25-
export { componentTagger } from "./shell/component-tagger.js"
25+
export { componentTagger, type ComponentTaggerOptions } from "./shell/component-tagger.js"

packages/app/src/shell/babel-plugin.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type PluginObj, types as t } from "@babel/core"
22

3-
import { isJsxFile } from "../core/component-path.js"
3+
import { componentPathAttributeName, isJsxFile } from "../core/component-path.js"
44
import { createJsxTaggerVisitor, type JsxTaggerContext } from "../core/jsx-tagger.js"
55
import { computeRelativePath } from "../core/path-service.js"
66

@@ -13,6 +13,11 @@ export type ComponentTaggerBabelPluginOptions = {
1313
* Defaults to process.cwd().
1414
*/
1515
readonly rootDir?: string
16+
/**
17+
* Name of the attribute to add to JSX elements.
18+
* Defaults to "data-path".
19+
*/
20+
readonly attributeName?: string
1621
}
1722

1823
type BabelState = {
@@ -31,14 +36,14 @@ type BabelState = {
3136
* @invariant returns null when filename is undefined or not a JSX file
3237
* @complexity O(n) where n = path length
3338
*/
34-
// CHANGE: extract context creation for standalone Babel plugin.
35-
// WHY: enable unified visitor to work with Babel state.
36-
// QUOTE(TZ): "А ты можешь сделать что бы бизнес логика оставалось одной?"
37-
// REF: issue-12-comment (unified interface request)
39+
// CHANGE: add support for configurable attributeName from options.
40+
// WHY: enable unified visitor to work with Babel state and custom attribute names.
41+
// QUOTE(issue-14): "Add option attributeName (default: data-path) for both plugins"
42+
// REF: issue-14
3843
// FORMAT THEOREM: ∀ state: getContext(state) = context ↔ isValidState(state)
3944
// PURITY: CORE
4045
// EFFECT: n/a
41-
// INVARIANT: context contains valid relative path
46+
// INVARIANT: context contains valid relative path and attribute name
4247
// COMPLEXITY: O(n)/O(1)
4348
const getContextFromState = (state: BabelState): JsxTaggerContext | null => {
4449
const filename = state.filename
@@ -54,10 +59,11 @@ const getContextFromState = (state: BabelState): JsxTaggerContext | null => {
5459
}
5560

5661
// Compute relative path from root using Effect's Path service
57-
const rootDir = state.opts?.rootDir ?? state.cwd ?? ""
62+
const rootDir = state.opts?.rootDir ?? state.cwd ?? process.cwd()
5863
const relativeFilename = computeRelativePath(rootDir, filename)
64+
const attributeName = state.opts?.attributeName ?? componentPathAttributeName
5965

60-
return { relativeFilename }
66+
return { relativeFilename, attributeName }
6167
}
6268

6369
/**

0 commit comments

Comments
 (0)