Skip to content
81 changes: 77 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Vite and Babel plugin that adds a `data-path` attribute to every JSX opening tag

```html
<h1 data-path="src/App.tsx:22:4">Hello</h1>
<CustomButton data-path="src/App.tsx:25:6">Click me</CustomButton>
```

Format: `<relative-file-path>:<line>:<column>`
Expand All @@ -14,7 +15,8 @@ Format: `<relative-file-path>:<line>:<column>`

- ✅ **Idempotent**: adds `data-path` only if it doesn't already exist
- ✅ **HTML5 compliant**: uses standard `data-*` attributes
- ✅ **Configurable**: customize the attribute name via options
- ✅ **Configurable attribute**: customize the attribute name via options
- ✅ **Configurable scope**: control whether to tag HTML elements only or all JSX (HTML + React Components)
- ✅ **Dual plugin support**: works with both Vite and Babel

## Installation
Expand All @@ -39,11 +41,24 @@ export default defineConfig(({ mode }) => {
})
```

**With custom attribute name:**
**With options:**

```ts
const plugins = [
isDevelopment && componentTagger({
attributeName: "data-component-path", // Custom attribute name
tagComponents: true // Tag both HTML and React Components (default)
})
].filter(Boolean) as PluginOption[]
```

**Tag HTML elements only:**

```ts
const plugins = [
isDevelopment && componentTagger({ attributeName: "data-component-path" })
isDevelopment && componentTagger({
tagComponents: false // Only tag <div>, <h1>, etc., skip <MyComponent>
})
].filter(Boolean) as PluginOption[]
```

Expand Down Expand Up @@ -74,7 +89,8 @@ Add to your `.babelrc`:
"@prover-coder-ai/component-tagger/babel",
{
"rootDir": "/custom/root",
"attributeName": "data-component-path"
"attributeName": "data-component-path",
"tagComponents": true
}
]
]
Expand All @@ -94,6 +110,13 @@ type ComponentTaggerOptions = {
* @default "data-path"
*/
attributeName?: string
/**
* Whether to tag React Components (PascalCase elements) in addition to HTML tags.
* - true: Tag both HTML tags (<div>) and React Components (<MyComponent>)
* - false: Tag only HTML tags (<div>), skip React Components (<MyComponent>)
* @default true
*/
tagComponents?: boolean
}
```

Expand All @@ -111,11 +134,61 @@ type ComponentTaggerBabelPluginOptions = {
* @default "data-path"
*/
attributeName?: string
/**
* Whether to tag React Components (PascalCase elements) in addition to HTML tags.
* - true: Tag both HTML tags (<div>) and React Components (<MyComponent>)
* - false: Tag only HTML tags (<div>), skip React Components (<MyComponent>)
* @default true
*/
tagComponents?: boolean
}
```

## Tagging Scope: HTML vs React Components

By default, the plugin tags **all JSX elements** (both HTML tags and React Components). You can customize this behavior with the `tagComponents` option.

### Default Behavior (tag everything)

```tsx
// Input
<div>
<h1>Hello</h1>
<MyComponent />
</div>

// Output
<div data-path="src/App.tsx:5:0">
<h1 data-path="src/App.tsx:6:2">Hello</h1>
<MyComponent data-path="src/App.tsx:7:2" />
</div>
```

### HTML Only Mode (`tagComponents: false`)

```tsx
// Input
<div>
<h1>Hello</h1>
<MyComponent />
</div>

// Output
<div data-path="src/App.tsx:5:0">
<h1 data-path="src/App.tsx:6:2">Hello</h1>
<MyComponent /> {/* Not tagged */}
</div>
```

### Classification Rules

- **HTML elements** (lowercase, e.g., `div`, `h1`, `span`): Always tagged
- **React Components** (PascalCase, e.g., `MyComponent`, `Button`): Tagged only when `tagComponents !== false`

## Behavior Guarantees

- **Idempotency**: If `data-path` (or custom attribute) already exists on an element, no duplicate is added
- **Default attribute**: `data-path` is used when no `attributeName` is specified
- **Default scope**: All JSX elements are tagged when `tagComponents` is not specified
- **HTML always tagged**: HTML elements (lowercase tags) are always tagged regardless of `tagComponents` setting
- **Standard compliance**: Uses HTML5 `data-*` custom attributes by default
32 changes: 32 additions & 0 deletions packages/app/src/core/component-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,35 @@ export const formatComponentPathValue = (
line: number,
column: number
): string => `${relativeFilename}:${line}:${column}`

/**
* Determines whether a JSX element name represents an HTML tag (lowercase) vs a React Component (PascalCase).
*
* @param elementName - The name of the JSX element (e.g., "div", "MyComponent").
* @returns true if the element is an HTML tag (starts with lowercase), false for React Components (starts with uppercase).
*
* @pure true
* @invariant isHtmlTag(name) = true <-> name[0] in [a-z]
* @complexity O(1) time / O(1) space
*/
// CHANGE: add pure predicate to distinguish HTML tags from React Components.
// WHY: enable configurable tagging scope (DOM-only vs all JSX).
// QUOTE(TZ): "Определиться: метить только lowercase-теги (div, h1) или вообще всё (MyComponent, Route тоже)."
// REF: issue-23
// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23
// FORMAT THEOREM: ∀ name ∈ JSXElementName: isHtmlTag(name) ↔ name[0] ∈ [a-z]
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: classification is deterministic and based only on first character
// COMPLEXITY: O(1)/O(1)
export const isHtmlTag = (elementName: string): boolean => {
if (elementName.length === 0) {
return false
}
const firstChar = elementName.codePointAt(0)
if (firstChar === undefined) {
return false
}
// Check if first character is lowercase ASCII letter (a-z: 97-122)
return firstChar >= 97 && firstChar <= 122
}
108 changes: 102 additions & 6 deletions packages/app/src/core/jsx-tagger.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import type { types as t, Visitor } from "@babel/core"

import { formatComponentPathValue } from "./component-path.js"
import { formatComponentPathValue, isHtmlTag } from "./component-path.js"

/**
* Configuration options for JSX tagging behavior.
*
* @pure true
*/
// CHANGE: add configuration type for tagging scope control.
// WHY: enable flexible choice between DOM-only tagging vs all JSX elements.
// QUOTE(TZ): "Если нужно гибко — добавить опцию tagComponents?: boolean (default на твоё усмотрение)."
// REF: issue-23
// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23
// FORMAT THEOREM: ∀ config ∈ JsxTaggerOptions: config.tagComponents ∈ {true, false, undefined}
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: configuration is immutable
// COMPLEXITY: O(1)/O(1)
export type JsxTaggerOptions = {
/**
* Whether to tag React Components (PascalCase elements) in addition to HTML tags.
* - true: Tag both HTML tags (<div>) and React Components (<MyComponent>)
* - false: Tag only HTML tags (<div>), skip React Components (<MyComponent>)
* - undefined/not provided: Defaults to true (tag everything)
*
* @default true
*/
readonly tagComponents?: boolean | undefined
}

/**
* Context required for JSX tagging.
Expand All @@ -16,6 +43,10 @@ export type JsxTaggerContext = {
* Name of the attribute to add (defaults to "data-path").
*/
readonly attributeName: string
/**
* Configuration options for tagging behavior.
*/
readonly options?: JsxTaggerOptions | undefined
}

/**
Expand All @@ -42,6 +73,63 @@ export const attrExists = (node: t.JSXOpeningElement, attrName: string, types: t
(attr) => types.isJSXAttribute(attr) && types.isJSXIdentifier(attr.name, { name: attrName })
)

/**
* Determines whether a JSX element should be tagged based on configuration.
*
* @param node - JSX opening element to check.
* @param options - Tagging configuration options.
* @param types - Babel types module.
* @returns true if element should be tagged, false otherwise.
*
* @pure true
* @invariant HTML tags are always tagged regardless of options
* @invariant React Components are tagged only when tagComponents !== false
* @complexity O(1)
*/
// CHANGE: add pure predicate for tagging eligibility.
// WHY: implement configurable tagging scope as per issue requirements.
// QUOTE(TZ): "Определиться: метить только lowercase-теги (div, h1) или вообще всё (MyComponent, Route тоже)."
// QUOTE(TZ): "(МЕтить надо всё)" - default should tag everything
// REF: issue-23
// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23
// FORMAT THEOREM: ∀ elem ∈ JSX: shouldTagElement(elem, opts) = isHtml(elem) ∨ (opts.tagComponents ≠ false)
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: ∀ html ∈ HTML: shouldTagElement(html, _) = true
// INVARIANT: ∀ comp ∈ Component: shouldTagElement(comp, {tagComponents: false}) = false
// INVARIANT: ∀ comp ∈ Component: shouldTagElement(comp, {tagComponents: true}) = true
// COMPLEXITY: O(1)/O(1)
export const shouldTagElement = (
node: t.JSXOpeningElement,
options: JsxTaggerOptions | undefined,
types: typeof t
): boolean => {
// Extract element name
let elementName: string
if (types.isJSXIdentifier(node.name)) {
elementName = node.name.name
} else if (types.isJSXMemberExpression(node.name)) {
// For JSXMemberExpression like <Foo.Bar>, we don't tag (not a simple component)
return false
} else if (types.isJSXNamespacedName(node.name)) {
// For JSXNamespacedName like <svg:path>, treat namespace as lowercase
elementName = node.name.namespace.name
} else {
// Unknown node type, skip
return false
}

// Always tag HTML elements (lowercase)
if (isHtmlTag(elementName)) {
return true
}

// For React Components (PascalCase), check tagComponents option
// Default: true (tag everything, as per issue comment)
const tagComponents = options?.tagComponents ?? true
return tagComponents
}

/**
* Creates a JSX attribute with the component path value.
*
Expand Down Expand Up @@ -82,19 +170,22 @@ export const createPathAttribute = (
* Both the Vite plugin and standalone Babel plugin use this function.
*
* @param node - JSX opening element to process.
* @param context - Tagging context with relative filename and attribute name.
* @param context - Tagging context with relative filename, attribute name, and options.
* @param types - Babel types module.
* @returns true if attribute was added, false if skipped.
*
* @pure false (mutates node)
* @invariant each JSX element has at most one instance of the specified attribute after processing
* @invariant HTML elements are always tagged when eligible
* @invariant React Components are tagged based on options.tagComponents
* @complexity O(n) where n = number of existing attributes
*/
// CHANGE: extract unified JSX element processing logic.
// WHY: satisfy user request for single business logic shared by Vite and Babel.
// CHANGE: extract unified JSX element processing logic with configurable scope and attribute name.
// WHY: satisfy user request for single business logic shared by Vite and Babel + configurable tagging + custom attribute names.
// QUOTE(TZ): "А ты можешь сделать что бы бизнес логика оставалось одной? Ну типо переиспользуй код с vite версии на babel"
// REF: issue-12-comment (unified interface request)
// FORMAT THEOREM: ∀ jsx ∈ JSXOpeningElement: processElement(jsx) → tagged(jsx) ∨ skipped(jsx)
// QUOTE(TZ): "Если нужно гибко — добавить опцию tagComponents?: boolean (default на твоё усмотрение)."
// REF: issue-12-comment (unified interface request), issue-14 (attributeName option), issue-23 (configurable scope)
// FORMAT THEOREM: ∀ jsx ∈ JSXOpeningElement: processElement(jsx, ctx) → (shouldTag(jsx, ctx.options) ∧ tagged(jsx)) ∨ skipped(jsx)
// PURITY: SHELL (mutates AST)
// EFFECT: AST mutation
// INVARIANT: idempotent - processing same element twice produces same result
Expand All @@ -114,6 +205,11 @@ export const processJsxElement = (
return false
}

// Skip if element should not be tagged based on configuration
if (!shouldTagElement(node, context.options, types)) {
return false
}

const { column, line } = node.loc.start
const attr = createPathAttribute(context.attributeName, context.relativeFilename, line, column, types)

Expand Down
7 changes: 5 additions & 2 deletions packages/app/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// CHANGE: expose the component tagger as the library entrypoint.
// WHY: provide a single import surface for consumers.
// QUOTE(TZ): "\u0423\u0431\u0435\u0440\u0438 \u0442\u0435\u043f\u0435\u0440\u044c \u0432\u0441\u0451 \u043b\u0438\u0448\u043d\u0438\u0435. \u0415\u0441\u043b\u0438 \u0447\u0442\u043e \u043c\u044b \u0434\u0435\u043b\u0430\u0435\u043c \u0431\u0438\u0431\u043b\u0438\u043e\u0442\u0435\u0447\u043d\u044b\u0439 \u043c\u043e\u0434\u0443\u043b\u044c \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043f\u0440\u043e\u0441\u0442\u043e \u0431\u0443\u0434\u0435\u043c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0442\u044c \u0432 \u043f\u0440\u043e\u0435\u043a\u0442\u044b"
// QUOTE(TZ): "Убери теперь всё лишние. Если что мы делаем библиотечный модуль который просто будем подключать в проекты"
// REF: user-2026-01-14-library-cleanup
// SOURCE: n/a
// FORMAT THEOREM: forall consumer: import(index) -> available(componentTagger)
Expand All @@ -11,6 +11,7 @@
export {
componentPathAttributeName,
formatComponentPathValue,
isHtmlTag,
isJsxFile,
normalizeModuleId
} from "./core/component-path.js"
Expand All @@ -19,7 +20,9 @@ export {
createJsxTaggerVisitor,
createPathAttribute,
type JsxTaggerContext,
processJsxElement
type JsxTaggerOptions,
processJsxElement,
shouldTagElement
} from "./core/jsx-tagger.js"
export { componentTaggerBabelPlugin, type ComponentTaggerBabelPluginOptions } from "./shell/babel-plugin.js"
export { componentTagger, type ComponentTaggerOptions } from "./shell/component-tagger.js"
Loading
Loading