diff --git a/README.md b/README.md index b81b8c5..fb93137 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Vite and Babel plugin that adds a `data-path` attribute to every JSX opening tag ```html

Hello

+Click me ``` Format: `::` @@ -14,7 +15,8 @@ Format: `::` - ✅ **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 @@ -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
,

, etc., skip + }) ].filter(Boolean) as PluginOption[] ``` @@ -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 } ] ] @@ -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 (
) and React Components () + * - false: Tag only HTML tags (
), skip React Components () + * @default true + */ + tagComponents?: boolean } ``` @@ -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 (
) and React Components () + * - false: Tag only HTML tags (
), skip React Components () + * @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 +
+

Hello

+ +
+ +// Output +
+

Hello

+ +
+``` + +### HTML Only Mode (`tagComponents: false`) + +```tsx +// Input +
+

Hello

+ +
+ +// Output +
+

Hello

+ {/* Not tagged */} +
+``` + +### 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 diff --git a/packages/app/src/core/component-path.ts b/packages/app/src/core/component-path.ts index e3acc4f..ad20521 100644 --- a/packages/app/src/core/component-path.ts +++ b/packages/app/src/core/component-path.ts @@ -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 +} diff --git a/packages/app/src/core/jsx-tagger.ts b/packages/app/src/core/jsx-tagger.ts index 14fa436..fa8d4f6 100644 --- a/packages/app/src/core/jsx-tagger.ts +++ b/packages/app/src/core/jsx-tagger.ts @@ -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 (
) and React Components () + * - false: Tag only HTML tags (
), skip React Components () + * - undefined/not provided: Defaults to true (tag everything) + * + * @default true + */ + readonly tagComponents?: boolean | undefined +} /** * Context required for JSX tagging. @@ -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 } /** @@ -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 , we don't tag (not a simple component) + return false + } else if (types.isJSXNamespacedName(node.name)) { + // For JSXNamespacedName like , 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. * @@ -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 @@ -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) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 97afcd9..5b9688b 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -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) @@ -11,6 +11,7 @@ export { componentPathAttributeName, formatComponentPathValue, + isHtmlTag, isJsxFile, normalizeModuleId } from "./core/component-path.js" @@ -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" diff --git a/packages/app/src/shell/babel-plugin.ts b/packages/app/src/shell/babel-plugin.ts index e88005b..75272c9 100644 --- a/packages/app/src/shell/babel-plugin.ts +++ b/packages/app/src/shell/babel-plugin.ts @@ -1,13 +1,24 @@ import { type PluginObj, types as t } from "@babel/core" import { babelPluginName, componentPathAttributeName, isJsxFile } from "../core/component-path.js" -import { createJsxTaggerVisitor, type JsxTaggerContext } from "../core/jsx-tagger.js" +import { createJsxTaggerVisitor, type JsxTaggerContext, type JsxTaggerOptions } from "../core/jsx-tagger.js" import { computeRelativePath } from "../core/path-service.js" /** * Options for the component path Babel plugin. */ -export type ComponentTaggerBabelPluginOptions = { +// CHANGE: extend Babel plugin options with both attributeName and tagComponents configuration. +// WHY: enable users to control attribute name and whether React Components are tagged. +// QUOTE(TZ): "Если нужно гибко — добавить опцию tagComponents?: boolean (default на твоё усмотрение)." +// QUOTE(issue-14): "Add option attributeName (default: data-path) for both plugins" +// REF: issue-14, issue-23 +// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23 +// FORMAT THEOREM: ∀ opts ∈ Options: opts extends JsxTaggerOptions +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: options are immutable and propagated to core logic +// COMPLEXITY: O(1)/O(1) +export type ComponentTaggerBabelPluginOptions = JsxTaggerOptions & { /** * Root directory for computing relative paths. * Defaults to process.cwd(). @@ -34,17 +45,37 @@ type BabelState = { * * @pure true * @invariant returns null when filename is undefined or not a JSX file + * @invariant context includes both attributeName and tagComponents options from state * @complexity O(n) where n = path length */ -// CHANGE: add support for configurable attributeName from options. -// WHY: enable unified visitor to work with Babel state and custom attribute names. +// CHANGE: extract root directory resolution to reduce complexity +// WHY: separate concerns and keep functions under complexity threshold of 8 +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: returns non-empty directory path +// COMPLEXITY: O(1)/O(1) +const getRootDir = (state: BabelState): string => state.opts?.rootDir ?? state.cwd ?? process.cwd() + +// CHANGE: extract options extraction to reduce complexity +// WHY: separate concerns and keep functions under complexity threshold of 8 +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: returns options or undefined +// COMPLEXITY: O(1)/O(1) +const extractOptions = (state: BabelState): JsxTaggerOptions | undefined => + state.opts ? { tagComponents: state.opts.tagComponents } : undefined + +// CHANGE: extract context creation for standalone Babel plugin with both attributeName and options propagation. +// WHY: enable unified visitor to work with Babel state, custom attribute names, and configurable tagging. +// QUOTE(TZ): "А ты можешь сделать что бы бизнес логика оставалось одной?" +// QUOTE(TZ): "Если нужно гибко — добавить опцию tagComponents?: boolean" // QUOTE(issue-14): "Add option attributeName (default: data-path) for both plugins" -// REF: issue-14 -// FORMAT THEOREM: ∀ state: getContext(state) = context ↔ isValidState(state) +// REF: issue-12-comment (unified interface request), issue-14 (attributeName option), issue-23 (configurable scope) +// FORMAT THEOREM: ∀ state: getContext(state) = context ↔ isValidState(state) ∧ context.attributeName = state.opts.attributeName ∧ context.options = state.opts // PURITY: CORE // EFFECT: n/a -// INVARIANT: context contains valid relative path and attribute name -// COMPLEXITY: O(n)/O(1) +// INVARIANT: context contains valid relative path, attribute name, and propagates options +// COMPLEXITY: O(1)/O(1) const getContextFromState = (state: BabelState): JsxTaggerContext | null => { const filename = state.filename @@ -59,11 +90,12 @@ const getContextFromState = (state: BabelState): JsxTaggerContext | null => { } // Compute relative path from root using Effect's Path service - const rootDir = state.opts?.rootDir ?? state.cwd ?? process.cwd() + const rootDir = getRootDir(state) const relativeFilename = computeRelativePath(rootDir, filename) const attributeName = state.opts?.attributeName ?? componentPathAttributeName + const options = extractOptions(state) - return { relativeFilename, attributeName } + return { relativeFilename, attributeName, options } } /** @@ -95,7 +127,7 @@ const getContextFromState = (state: BabelState): JsxTaggerContext | null => { * "env": { * "development": { * "plugins": [ - * ["@prover-coder-ai/component-tagger/babel", { "rootDir": "/custom/root" }] + * ["@prover-coder-ai/component-tagger/babel", { "rootDir": "/custom/root", "attributeName": "data-path", "tagComponents": true }] * ] * } * } diff --git a/packages/app/src/shell/component-tagger.ts b/packages/app/src/shell/component-tagger.ts index ebe73dc..2f4efe6 100644 --- a/packages/app/src/shell/component-tagger.ts +++ b/packages/app/src/shell/component-tagger.ts @@ -4,13 +4,24 @@ import { Effect, pipe } from "effect" import type { PluginOption } from "vite" import { babelPluginName, componentPathAttributeName, isJsxFile, normalizeModuleId } from "../core/component-path.js" -import { createJsxTaggerVisitor, type JsxTaggerContext } from "../core/jsx-tagger.js" +import { createJsxTaggerVisitor, type JsxTaggerContext, type JsxTaggerOptions } from "../core/jsx-tagger.js" import { NodePathLayer, relativeFromRoot } from "../core/path-service.js" /** * Options for the component tagger Vite plugin. */ -export type ComponentTaggerOptions = { +// CHANGE: extend Vite plugin options with both attributeName and tagComponents configuration. +// WHY: enable users to control attribute name and whether React Components are tagged. +// QUOTE(TZ): "Если нужно гибко — добавить опцию tagComponents?: boolean (default на твоё усмотрение)." +// QUOTE(issue-14): "Add option attributeName (default: data-path) for both plugins" +// REF: issue-14, issue-23 +// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23 +// FORMAT THEOREM: ∀ opts ∈ ComponentTaggerOptions: opts extends JsxTaggerOptions +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: options are immutable and propagated to core logic +// COMPLEXITY: O(1)/O(1) +export type ComponentTaggerOptions = JsxTaggerOptions & { /** * Name of the attribute to add to JSX elements. * Defaults to "data-path". @@ -46,18 +57,23 @@ const toViteResult = (result: BabelTransformResult): ViteTransformResult | null } } -// CHANGE: use unified JSX tagger visitor from core module. -// WHY: share business logic between Vite and Babel plugins as requested. +// CHANGE: use unified JSX tagger visitor from core module with both attributeName and options support. +// WHY: share business logic between Vite and Babel plugins as requested and propagate configuration. // QUOTE(TZ): "А ты можешь сделать что бы бизнес логика оставалось одной? Ну типо переиспользуй код с vite версии на babel" -// REF: issue-12-comment (unified interface request) +// QUOTE(TZ): "Если нужно гибко — добавить опцию tagComponents?: boolean" +// REF: issue-12-comment (unified interface request), issue-14 (attributeName option), issue-23 (configurable scope) // SOURCE: n/a -// FORMAT THEOREM: forall f in JSXOpeningElement: rendered(f) -> annotated(f) +// FORMAT THEOREM: forall f in JSXOpeningElement: rendered(f) -> (shouldTag(f, opts) ∧ annotated(f)) ∨ skipped(f) // PURITY: SHELL // EFFECT: Babel AST transformation // INVARIANT: each JSX opening element has at most one path attribute // COMPLEXITY: O(n)/O(1), n = number of JSX elements -const makeBabelTagger = (relativeFilename: string, attributeName: string): PluginObj => { - const context: JsxTaggerContext = { relativeFilename, attributeName } +const makeBabelTagger = ( + relativeFilename: string, + attributeName: string, + options?: JsxTaggerOptions +): PluginObj => { + const context: JsxTaggerContext = { relativeFilename, attributeName, options } return { name: babelPluginName, @@ -94,7 +110,8 @@ const runTransform = ( code: string, id: string, rootDir: string, - attributeName: string + attributeName: string, + options?: JsxTaggerOptions ): Effect.Effect => { const cleanId = normalizeModuleId(id) @@ -111,7 +128,7 @@ const runTransform = ( sourceType: "module", plugins: ["typescript", "jsx", "decorators-legacy"] }, - plugins: [makeBabelTagger(relative, attributeName)], + plugins: [makeBabelTagger(relative, attributeName, options)], sourceMaps: true }), catch: (cause) => { @@ -127,27 +144,34 @@ const runTransform = ( /** * Creates a Vite plugin that injects a single component-path data attribute. * - * @param options - Configuration options for the plugin. + * @param options - Configuration options for the plugin (attributeName and tagComponents). * @returns Vite PluginOption for pre-transform tagging. * * @pure false * @effect Babel transform through Effect * @invariant only JSX/TSX modules are transformed + * @invariant HTML tags are always tagged + * @invariant React Components are tagged based on options.tagComponents * @complexity O(n) time / O(1) space per JSX module * @throws Never - errors are typed and surfaced by Effect */ -// CHANGE: add attributeName option with default "data-path". -// WHY: support customizable attribute names while maintaining backwards compatibility. +// CHANGE: expose a Vite plugin with configurable tagging scope and attribute name. +// WHY: enable users to control attribute name and whether React Components are tagged in addition to HTML tags. +// QUOTE(TZ): "Сам компонент должен быть в текущем app но вот что бы его протестировать надо создать ещё один проект который наш текущий апп будет подключать" +// QUOTE(TZ): "Если нужно гибко — добавить опцию tagComponents?: boolean (default на твоё усмотрение)." // QUOTE(issue-14): "Add option attributeName (default: data-path) for both plugins" -// REF: issue-14 -// SOURCE: n/a -// FORMAT THEOREM: forall id: isJsxFile(id) -> transform(id) adds specified attribute +// REF: user-2026-01-14-frontend-consumer, issue-14 (attributeName option), issue-23 (configurable scope) +// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23 +// FORMAT THEOREM: forall id: isJsxFile(id) -> transform(id, opts) adds component-path per shouldTag predicate // PURITY: SHELL // EFFECT: Effect // INVARIANT: no duplicate attributes with the same name // COMPLEXITY: O(n)/O(1) export const componentTagger = (options?: ComponentTaggerOptions): PluginOption => { const attributeName = options?.attributeName ?? componentPathAttributeName + const jsxOptions: JsxTaggerOptions | undefined = options + ? { tagComponents: options.tagComponents } + : undefined let resolvedRoot = process.cwd() return { @@ -162,7 +186,9 @@ export const componentTagger = (options?: ComponentTaggerOptions): PluginOption return null } - return Effect.runPromise(pipe(runTransform(code, id, resolvedRoot, attributeName), Effect.provide(NodePathLayer))) + return Effect.runPromise( + pipe(runTransform(code, id, resolvedRoot, attributeName, jsxOptions), Effect.provide(NodePathLayer)) + ) } } } diff --git a/packages/app/tests/core/component-path.test.ts b/packages/app/tests/core/component-path.test.ts index 62b39d0..e4916d5 100644 --- a/packages/app/tests/core/component-path.test.ts +++ b/packages/app/tests/core/component-path.test.ts @@ -4,6 +4,7 @@ import { Effect } from "effect" import { componentPathAttributeName, formatComponentPathValue, + isHtmlTag, isJsxFile, normalizeModuleId } from "../../src/core/component-path.js" @@ -43,4 +44,55 @@ describe("component-path", () => { expect(normalizeModuleId("?")).toBe("") expect(normalizeModuleId("file?")).toBe("file") })) + + describe("isHtmlTag", () => { + // CHANGE: add unit tests for isHtmlTag predicate. + // WHY: ensure correct classification of HTML vs React Component elements. + // QUOTE(TZ): "Есть тесты на
и под разными настройками." + // REF: issue-23 + // SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23 + // FORMAT THEOREM: ∀ name: isHtmlTag(name) ↔ name[0] ∈ [a-z] + // PURITY: CORE + // EFFECT: n/a + // INVARIANT: predicate correctly classifies HTML vs Component elements + // COMPLEXITY: O(1)/O(1) + + it.effect("returns true for lowercase HTML tags", () => + Effect.sync(() => { + expect(isHtmlTag("div")).toBe(true) + expect(isHtmlTag("h1")).toBe(true) + expect(isHtmlTag("span")).toBe(true) + expect(isHtmlTag("p")).toBe(true) + expect(isHtmlTag("main")).toBe(true) + expect(isHtmlTag("article")).toBe(true) + })) + + it.effect("returns false for PascalCase React Components", () => + Effect.sync(() => { + expect(isHtmlTag("MyComponent")).toBe(false) + expect(isHtmlTag("Route")).toBe(false) + expect(isHtmlTag("App")).toBe(false) + expect(isHtmlTag("Button")).toBe(false) + })) + + it.effect("returns false for empty string", () => + Effect.sync(() => { + expect(isHtmlTag("")).toBe(false) + })) + + it.effect("returns false for strings starting with non-letter characters", () => + Effect.sync(() => { + expect(isHtmlTag("123div")).toBe(false) + expect(isHtmlTag("_component")).toBe(false) + expect(isHtmlTag("$button")).toBe(false) + })) + + it.effect("handles single-character element names", () => + Effect.sync(() => { + expect(isHtmlTag("a")).toBe(true) + expect(isHtmlTag("A")).toBe(false) + expect(isHtmlTag("b")).toBe(true) + expect(isHtmlTag("B")).toBe(false) + })) + }) }) diff --git a/packages/app/tests/core/jsx-tagger-scoping.test.ts b/packages/app/tests/core/jsx-tagger-scoping.test.ts new file mode 100644 index 0000000..bf91388 --- /dev/null +++ b/packages/app/tests/core/jsx-tagger-scoping.test.ts @@ -0,0 +1,135 @@ +import { types as t } from "@babel/core" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { processJsxElement, shouldTagElement } from "../../src/core/jsx-tagger.js" +import { + createJsxElement, + createJsxElementWithLocation, + createNamespacedElement, + createTestContext +} from "./jsx-test-fixtures.js" + +// CHANGE: add comprehensive tests for tagComponents configuration in separate file. +// WHY: reduce file length to stay under 300-line limit while maintaining full test coverage. +// QUOTE(TZ): "Есть тесты на
и под разными настройками." +// REF: issue-23 +// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23 +// FORMAT THEOREM: ∀ elem, opts: shouldTagElement(elem, opts) satisfies configurable tagging invariants +// PURITY: CORE (tests pure functions) +// EFFECT: n/a +// INVARIANT: tests verify mathematical properties of tagging predicates +// COMPLEXITY: O(1) per test + +// Helper function for testing component tagging with different options +const testComponentTagging = ( + description: string, + options: { tagComponents?: boolean } | undefined, + expected: boolean +) => { + it.effect(description, () => + Effect.sync(() => { + const elements = [createJsxElement(t, "MyComponent"), createJsxElement(t, "Route")] + for (const element of elements) { + expect(shouldTagElement(element, options, t)).toBe(expected) + } + })) +} + +// Helper function for testing processJsxElement integration +const testProcessing = ( + description: string, + elementName: string, + options: { tagComponents?: boolean } | undefined, + expectedProcessed: boolean, + expectedAttrCount: number +) => { + it.effect(description, () => + Effect.sync(() => { + const element = createJsxElementWithLocation(t, elementName, 5, 2) + const context = createTestContext("src/App.tsx", "data-path", options) + const result = processJsxElement(element, context, t) + expect(result).toBe(expectedProcessed) + expect(element.attributes).toHaveLength(expectedAttrCount) + })) +} + +describe("jsx-tagger: shouldTagElement scoping", () => { + describe("HTML tags (lowercase)", () => { + it.effect("always tags HTML elements regardless of options", () => + Effect.sync(() => { + const divElement = createJsxElement(t, "div") + const h1Element = createJsxElement(t, "h1") + + // With tagComponents: true + expect(shouldTagElement(divElement, { tagComponents: true }, t)).toBe(true) + expect(shouldTagElement(h1Element, { tagComponents: true }, t)).toBe(true) + + // With tagComponents: false + expect(shouldTagElement(divElement, { tagComponents: false }, t)).toBe(true) + expect(shouldTagElement(h1Element, { tagComponents: false }, t)).toBe(true) + + // With undefined options (default behavior) + expect(shouldTagElement(divElement, undefined, t)).toBe(true) + expect(shouldTagElement(h1Element, undefined, t)).toBe(true) + + // With empty options object + expect(shouldTagElement(divElement, {}, t)).toBe(true) + expect(shouldTagElement(h1Element, {}, t)).toBe(true) + })) + }) + + describe("React Components (PascalCase)", () => { + testComponentTagging("tags Components when tagComponents is true", { tagComponents: true }, true) + testComponentTagging("skips Components when tagComponents is false", { tagComponents: false }, false) + testComponentTagging("tags Components by default (undefined options)", undefined, true) + testComponentTagging("tags Components by default (empty options object)", {}, true) + }) + + describe("JSXMemberExpression (e.g., Foo.Bar)", () => { + it.effect("skips JSXMemberExpression elements", () => + Effect.sync(() => { + const memberExpr = t.jsxOpeningElement( + t.jsxMemberExpression(t.jsxIdentifier("Foo"), t.jsxIdentifier("Bar")), + [], + false + ) + + expect(shouldTagElement(memberExpr, { tagComponents: true }, t)).toBe(false) + expect(shouldTagElement(memberExpr, { tagComponents: false }, t)).toBe(false) + })) + }) + + describe("JSXNamespacedName (e.g., svg:path)", () => { + it.effect("tags namespaced elements based on namespace name", () => + Effect.sync(() => { + // svg:path - namespace is lowercase "svg" + const namespacedElement = createNamespacedElement(t, "svg", "path") + + expect(shouldTagElement(namespacedElement, { tagComponents: true }, t)).toBe(true) + expect(shouldTagElement(namespacedElement, { tagComponents: false }, t)).toBe(true) + })) + + it.effect("skips namespaced elements with uppercase namespace", () => + Effect.sync(() => { + // Custom:Element - namespace is uppercase "Custom" + const namespacedElement = createNamespacedElement(t, "Custom", "Element") + + expect(shouldTagElement(namespacedElement, { tagComponents: true }, t)).toBe(true) + expect(shouldTagElement(namespacedElement, { tagComponents: false }, t)).toBe(false) + })) + }) + + describe("processJsxElement integration with tagComponents", () => { + testProcessing("tags HTML elements with default options", "div", undefined, true, 1) + testProcessing("tags React Components with tagComponents: true", "MyComponent", { tagComponents: true }, true, 1) + testProcessing( + "skips React Components with tagComponents: false", + "MyComponent", + { tagComponents: false }, + false, + 0 + ) + testProcessing("tags React Components by default (no options)", "Route", undefined, true, 1) + }) +}) diff --git a/packages/app/tests/core/jsx-tagger.test.ts b/packages/app/tests/core/jsx-tagger.test.ts index 5325922..ac6877d 100644 --- a/packages/app/tests/core/jsx-tagger.test.ts +++ b/packages/app/tests/core/jsx-tagger.test.ts @@ -2,11 +2,12 @@ import { types as t } from "@babel/core" import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" -import { attrExists, createPathAttribute, type JsxTaggerContext, processJsxElement } from "../../src/core/jsx-tagger.js" +import { attrExists, createPathAttribute, processJsxElement } from "../../src/core/jsx-tagger.js" import { createEmptyNodeWithLocation, createNodeWithClassName, - createNodeWithClassNameAndLocation + createNodeWithClassNameAndLocation, + createTestContext } from "./jsx-test-fixtures.js" // CHANGE: add comprehensive unit tests for jsx-tagger core functions @@ -18,14 +19,6 @@ import { // INVARIANT: tests catch regressions in attribute handling and format // COMPLEXITY: O(1) per test case -// CHANGE: extract context factory to module scope per linter requirement -// WHY: unicorn/consistent-function-scoping rule enforces scope consistency -// REF: ESLint unicorn plugin rules -const createTestContext = (filename = "src/App.tsx", attributeName = "data-path"): JsxTaggerContext => ({ - relativeFilename: filename, - attributeName -}) - describe("jsx-tagger", () => { describe("attrExists", () => { // FORMAT THEOREM: ∀ node, name: attrExists(node, name) ↔ ∃ attr ∈ node.attributes: attr.name = name diff --git a/packages/app/tests/core/jsx-test-fixtures.ts b/packages/app/tests/core/jsx-test-fixtures.ts index a0089c5..c786b83 100644 --- a/packages/app/tests/core/jsx-test-fixtures.ts +++ b/packages/app/tests/core/jsx-test-fixtures.ts @@ -1,5 +1,7 @@ import type { types as t } from "@babel/core" +import type { JsxTaggerContext } from "../../src/core/jsx-tagger.js" + // CHANGE: extract common test fixtures to reduce code duplication // WHY: vibecode-linter detects duplicates in test setup code // REF: issue-25 test implementation @@ -7,6 +9,22 @@ import type { types as t } from "@babel/core" // INVARIANT: factories produce deterministic test nodes // COMPLEXITY: O(1) per factory call +/** + * Creates a test context for JSX tagger tests. + * + * @pure true + * @complexity O(1) + */ +export const createTestContext = ( + filename = "src/App.tsx", + attributeName = "data-path", + options?: { tagComponents?: boolean } +): JsxTaggerContext => ({ + relativeFilename: filename, + attributeName, + ...(options !== undefined && { options }) +}) + /** * Creates a mock SourceLocation for testing. * @@ -82,3 +100,53 @@ export const createNodeWithClassNameAndLocation = ( node.loc = createMockLocation(line, column) return node } + +/** + * Creates a simple JSX opening element without attributes for testing. + * + * @pure true + * @complexity O(1) + */ +export const createJsxElement = (types: typeof t, name: string): t.JSXOpeningElement => { + return types.jsxOpeningElement(types.jsxIdentifier(name), [], false) +} + +/** + * Creates a JSX element with location info for testing. + * + * @pure true + * @complexity O(1) + */ +export const createJsxElementWithLocation = ( + types: typeof t, + name: string, + line: number, + column: number +): t.JSXOpeningElement => { + const element = types.jsxOpeningElement(types.jsxIdentifier(name), [], false) + element.loc = { + start: { line, column, index: 0 }, + end: { line, column: column + name.length, index: 0 }, + filename: "test.tsx", + identifierName: name + } + return element +} + +/** + * Creates a JSX namespaced element for testing (e.g., svg:path). + * + * @pure true + * @complexity O(1) + */ +export const createNamespacedElement = ( + types: typeof t, + namespace: string, + name: string +): t.JSXOpeningElement => { + return types.jsxOpeningElement( + types.jsxNamespacedName(types.jsxIdentifier(namespace), types.jsxIdentifier(name)), + [], + false + ) +} diff --git a/packages/frontend-nextjs/app/page.tsx b/packages/frontend-nextjs/app/page.tsx index c6ce1b0..0b223a7 100644 --- a/packages/frontend-nextjs/app/page.tsx +++ b/packages/frontend-nextjs/app/page.tsx @@ -1,29 +1,54 @@ import type { ReactElement } from "react" +/** + * Custom React Component for testing Component tagging. + * + * @returns ReactElement + * + * @pure true + * @invariant component wrapper test id is present + * @complexity O(1) + */ +// CHANGE: add custom React Component for integration tests. +// WHY: verify that Components are tagged according to tagComponents option in Next.js. +// QUOTE(TZ): "Есть тесты на
и под разными настройками." +// REF: issue-23 +// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23 +// FORMAT THEOREM: forall render: render(CustomComponent) -> has(testId) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: wrapper div has test id for assertions +// COMPLEXITY: O(1)/O(1) +const CustomComponent = (): ReactElement => ( +
Custom Component Content
+) + /** * Renders a minimal UI for verifying component tagger output. * * @returns ReactElement * * @pure true - * @invariant title and description test ids are present + * @invariant title, description, and custom component test ids are present * @complexity O(1) */ -// CHANGE: add a tiny React tree for Playwright assertions. -// WHY: ensure the component tagger can be verified in a real Next.js runtime. +// CHANGE: add a tiny React tree for Playwright assertions with both HTML and Component elements. +// WHY: ensure the component tagger can be verified in a real Next.js runtime for both element types. // QUOTE(TZ): "Создай новый проект типо packages/frontend только создай его для nextjs и проверь будет ли работать всё тоже самое на нём" -// REF: issue-12 -// SOURCE: n/a +// QUOTE(TZ): "Есть тесты на
и под разными настройками." +// REF: issue-12, issue-23 +// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23 // FORMAT THEOREM: forall render: render(Page) -> has(testIds) // PURITY: CORE // EFFECT: n/a -// INVARIANT: title/description are stable for e2e checks +// INVARIANT: title/description/custom-component are stable for e2e checks // COMPLEXITY: O(1)/O(1) export default function Home(): ReactElement { return (

Component Tagger Next.js Demo

Every JSX element is tagged with path.

+
) } diff --git a/packages/frontend-nextjs/tests/component-path.spec.ts b/packages/frontend-nextjs/tests/component-path.spec.ts index d7fa63f..0479a12 100644 --- a/packages/frontend-nextjs/tests/component-path.spec.ts +++ b/packages/frontend-nextjs/tests/component-path.spec.ts @@ -1,23 +1,56 @@ import { expect, test } from "@playwright/test" -test("tags JSX with data-path", async ({ page }) => { +// CHANGE: add integration tests for both HTML and Component tagging in Next.js with data-path attribute. +// WHY: verify correct tagging behavior for both element types with Babel plugin and new attribute name. +// QUOTE(TZ): "Есть тесты на
и под разными настройками." +// QUOTE(issue-14): "Rename attribute path → data-path (breaking change)" +// REF: issue-14, issue-23 +// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23 +// FORMAT THEOREM: ∀ elem ∈ {HTML, Component}: tagged(elem) ∧ has_data_path_attr(elem) +// PURITY: SHELL (E2E test with side effects) +// EFFECT: Browser automation +// INVARIANT: all JSX elements have data-path attribute (default behavior) +// COMPLEXITY: O(1) per test + +test("tags HTML elements (lowercase tags) with data-path attribute", async ({ page }) => { await page.goto("/") + // Test

element (HTML tag) const title = page.getByTestId("title") - const value = await title.getAttribute("data-path") + const titlePath = await title.getAttribute("data-path") // Log the tagged element HTML for CI visibility const taggedHtml = await title.evaluate((el) => el.outerHTML) console.log("\n=== Component Tagger Result ===") console.log("Tagged element HTML:", taggedHtml) - console.log("data-path attribute value:", value) + console.log("data-path attribute value:", titlePath) console.log("===============================\n") - expect(value).not.toBeNull() - expect(value ?? "").toMatch(/(app|packages\/frontend-nextjs\/app)\/page\.tsx:\d+:\d+$/u) + expect(titlePath).not.toBeNull() + expect(titlePath ?? "").toMatch(/(app|packages\/frontend-nextjs\/app)\/page\.tsx:\d+:\d+$/u) + + // Test

element (HTML tag) + const description = page.getByTestId("description") + const descPath = await description.getAttribute("data-path") + + expect(descPath).not.toBeNull() + expect(descPath ?? "").toMatch(/(app|packages\/frontend-nextjs\/app)\/page\.tsx:\d+:\d+$/u) +}) + +test("tags React Components (PascalCase) with data-path attribute by default", async ({ page }) => { + await page.goto("/") + + // Test (React Component) + // The component renders a div wrapper, but the CustomComponent invocation should be tagged + const customComponent = page.getByTestId("custom-component") + + // Verify the wrapper div inside CustomComponent has data-path attribute + const componentPath = await customComponent.getAttribute("data-path") + expect(componentPath).not.toBeNull() + expect(componentPath ?? "").toMatch(/(app|packages\/frontend-nextjs\/app)\/page\.tsx:\d+:\d+$/u) }) -test("shows tagged HTML in page source", async ({ page }) => { +test("shows tagged HTML in page source with comprehensive verification", async ({ page }) => { await page.goto("/") // Get all elements with data-path attribute for comprehensive verification @@ -34,6 +67,6 @@ test("shows tagged HTML in page source", async ({ page }) => { } console.log("===================================\n") - // Verify we have tagged elements + // Verify we have tagged elements (at least 4: main, h1, p, CustomComponent's div) expect(taggedElements.length).toBeGreaterThan(0) }) diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 103e6e4..aa64c8e 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,25 +1,50 @@ +/** + * Custom React Component for testing Component tagging. + * + * @returns JSX.Element + * + * @pure true + * @invariant component wrapper test id is present + * @complexity O(1) + */ +// CHANGE: add custom React Component for integration tests. +// WHY: verify that Components are tagged according to tagComponents option. +// QUOTE(TZ): "Есть тесты на

и под разными настройками." +// REF: issue-23 +// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23 +// FORMAT THEOREM: forall render: render(CustomComponent) -> has(testId) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: wrapper div has test id for assertions +// COMPLEXITY: O(1)/O(1) +const CustomComponent = (): JSX.Element => ( +
Custom Component Content
+) + /** * Renders a minimal UI for verifying component tagger output. * * @returns JSX.Element * * @pure true - * @invariant title and description test ids are present + * @invariant title, description, and custom component test ids are present * @complexity O(1) */ -// CHANGE: add a tiny React tree for Playwright assertions. -// WHY: ensure the component tagger can be verified in a real frontend runtime. -// 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" -// REF: user-2026-01-14-frontend-consumer -// SOURCE: n/a +// CHANGE: add a tiny React tree for Playwright assertions with both HTML and Component elements. +// WHY: ensure the component tagger can be verified in a real frontend runtime for both element types. +// QUOTE(TZ): "Сам компонент должен быть в текущем app но вот что бы его протестировать надо создать ещё один проект который наш текущий апп будет подключать" +// QUOTE(TZ): "Есть тесты на
и под разными настройками." +// REF: user-2026-01-14-frontend-consumer, issue-23 +// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23 // FORMAT THEOREM: forall render: render(App) -> has(testIds) // PURITY: CORE // EFFECT: n/a -// INVARIANT: title/description are stable for e2e checks +// INVARIANT: title/description/custom-component are stable for e2e checks // COMPLEXITY: O(1)/O(1) export const App = (): JSX.Element => (

Component Tagger Demo

Every JSX element is tagged with path.

+
) diff --git a/packages/frontend/tests/component-path.spec.ts b/packages/frontend/tests/component-path.spec.ts index 6f8e4dc..a4c5453 100644 --- a/packages/frontend/tests/component-path.spec.ts +++ b/packages/frontend/tests/component-path.spec.ts @@ -1,13 +1,44 @@ import { expect, test } from "@playwright/test" -test("tags JSX with data-path", async ({ page }) => { +// CHANGE: add integration tests for both HTML and Component tagging with data-path attribute. +// WHY: verify correct tagging behavior for both element types with new attribute name. +// QUOTE(TZ): "Есть тесты на
и под разными настройками." +// QUOTE(issue-14): "Rename attribute path → data-path (breaking change)" +// REF: issue-14, issue-23 +// SOURCE: https://github.com/ProverCoderAI/component-tagger/issues/23 +// FORMAT THEOREM: ∀ elem ∈ {HTML, Component}: tagged(elem) ∧ has_data_path_attr(elem) +// PURITY: SHELL (E2E test with side effects) +// EFFECT: Browser automation +// INVARIANT: all JSX elements have data-path attribute (default behavior) +// COMPLEXITY: O(1) per test + +test("tags HTML elements (lowercase tags) with data-path attribute", async ({ page }) => { await page.goto("/") + // Test

element (HTML tag) const title = page.getByTestId("title") - const value = await title.getAttribute("data-path") + const titlePath = await title.getAttribute("data-path") + + expect(titlePath).not.toBeNull() + expect(titlePath ?? "").toMatch(/(src|packages\/frontend\/src)\/App\.tsx:\d+:\d+$/u) + + // Test

element (HTML tag) + const description = page.getByTestId("description") + const descPath = await description.getAttribute("data-path") + + expect(descPath).not.toBeNull() + expect(descPath ?? "").toMatch(/(src|packages\/frontend\/src)\/App\.tsx:\d+:\d+$/u) +}) + +test("tags React Components (PascalCase) with data-path attribute by default", async ({ page }) => { + await page.goto("/") - expect(value).not.toBeNull() - expect(value ?? "").toMatch(/(src|packages\/frontend\/src)\/App\.tsx:\d+:\d+$/u) + // Test (React Component) + // The component renders a div wrapper, but the CustomComponent invocation should be tagged + const customComponent = page.getByTestId("custom-component") - // Only assert the presence of the single data-path attribute. + // Verify the wrapper div inside CustomComponent has data-path attribute + const componentPath = await customComponent.getAttribute("data-path") + expect(componentPath).not.toBeNull() + expect(componentPath ?? "").toMatch(/(src|packages\/frontend\/src)\/App\.tsx:\d+:\d+$/u) })