Skip to content

Commit 3735691

Browse files
authored
Merge pull request #13 from konard/issue-12-13dbad4c3a6a
feat(app): export standalone Babel plugin for Next.js integration
2 parents ce5218e + 05b4e7d commit 3735691

20 files changed

Lines changed: 1227 additions & 88 deletions

.github/workflows/check.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,23 @@ jobs:
7272
- name: Install dependencies
7373
uses: ./.github/actions/setup
7474
- run: pnpm lint:effect
75+
76+
e2e-nextjs:
77+
name: E2E Next.js
78+
runs-on: ubuntu-latest
79+
timeout-minutes: 15
80+
steps:
81+
- uses: actions/checkout@v6
82+
- name: Install dependencies
83+
uses: ./.github/actions/setup
84+
- name: Install Playwright browsers
85+
run: pnpm --filter frontend-nextjs exec playwright install --with-deps chromium
86+
- name: Run Next.js E2E tests
87+
run: pnpm --filter frontend-nextjs test:e2e
88+
- name: Upload test results
89+
uses: actions/upload-artifact@v4
90+
if: always()
91+
with:
92+
name: nextjs-e2e-results
93+
path: packages/frontend-nextjs/test-results/
94+
retention-days: 7

packages/app/babel.cjs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* CommonJS entry point for the component-tagger Babel plugin.
3+
*
4+
* This file provides a CommonJS-compatible wrapper for the Babel plugin,
5+
* allowing it to be used with Next.js and other tools that require CJS modules.
6+
*
7+
* @example
8+
* // .babelrc
9+
* {
10+
* "presets": ["next/babel"],
11+
* "plugins": ["@prover-coder-ai/component-tagger/babel"]
12+
* }
13+
*/
14+
// CHANGE: provide CommonJS entry point for Babel plugin.
15+
// WHY: Babel configuration often requires CommonJS modules.
16+
// REF: issue-12
17+
// FORMAT THEOREM: forall require: require(babel.cjs) -> PluginFactory
18+
// PURITY: SHELL
19+
// EFFECT: n/a
20+
// INVARIANT: exports match Babel plugin signature
21+
// COMPLEXITY: O(1)/O(1)
22+
23+
const path = require("node:path")
24+
25+
const componentPathAttributeName = "path"
26+
const jsxFilePattern = /\.(tsx|jsx)(\?.*)?$/u
27+
28+
const isJsxFile = (id) => jsxFilePattern.test(id)
29+
30+
const formatComponentPathValue = (relativeFilename, line, column) =>
31+
`${relativeFilename}:${line}:${column}`
32+
33+
const attrExists = (node, attrName, t) =>
34+
node.attributes.some(
35+
(attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: attrName })
36+
)
37+
38+
module.exports = function componentTaggerBabelPlugin({ types: t }) {
39+
return {
40+
name: "component-path-babel-tagger",
41+
visitor: {
42+
JSXOpeningElement(nodePath, state) {
43+
const { node } = nodePath
44+
const filename = state.filename
45+
46+
// Skip if no filename
47+
if (filename === undefined) {
48+
return
49+
}
50+
51+
// Skip if no location info
52+
if (node.loc === null || node.loc === undefined) {
53+
return
54+
}
55+
56+
// Skip if not a JSX/TSX file
57+
if (!isJsxFile(filename)) {
58+
return
59+
}
60+
61+
// Skip if already has path attribute
62+
if (attrExists(node, componentPathAttributeName, t)) {
63+
return
64+
}
65+
66+
// Compute relative path from root
67+
const opts = state.opts || {}
68+
const rootDir = opts.rootDir || state.cwd || process.cwd()
69+
const relativeFilename = path.relative(rootDir, filename)
70+
71+
const { column, line } = node.loc.start
72+
const value = formatComponentPathValue(relativeFilename, line, column)
73+
74+
node.attributes.push(
75+
t.jsxAttribute(t.jsxIdentifier(componentPathAttributeName), t.stringLiteral(value))
76+
)
77+
}
78+
}
79+
}
80+
}

packages/app/package.json

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
{
22
"name": "@prover-coder-ai/component-tagger",
3-
"version": "1.0.22",
4-
"description": "Component tagger Vite plugin for JSX metadata",
3+
"version": "1.0.23",
4+
"description": "Component tagger Vite plugin and Babel plugin for JSX metadata",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
7+
"exports": {
8+
".": {
9+
"types": "./dist/index.d.ts",
10+
"import": "./dist/index.js"
11+
},
12+
"./babel": {
13+
"types": "./dist/shell/babel-plugin.d.ts",
14+
"require": "./babel.cjs",
15+
"import": "./dist/shell/babel-plugin.js"
16+
}
17+
},
718
"files": [
8-
"dist"
19+
"dist",
20+
"babel.cjs"
921
],
1022
"directories": {
1123
"doc": "doc"
@@ -31,6 +43,8 @@
3143
"keywords": [
3244
"effect",
3345
"vite",
46+
"babel",
47+
"nextjs",
3448
"plugin",
3549
"tagger"
3650
],
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import type { types as t, Visitor } from "@babel/core"
2+
3+
import { componentPathAttributeName, formatComponentPathValue } from "./component-path.js"
4+
5+
/**
6+
* Context required for JSX tagging.
7+
*
8+
* @pure true
9+
*/
10+
export type JsxTaggerContext = {
11+
/**
12+
* Relative file path from the project root.
13+
*/
14+
readonly relativeFilename: string
15+
}
16+
17+
/**
18+
* Checks if a JSX attribute with the given name already exists on the element.
19+
*
20+
* @param node - JSX opening element to check.
21+
* @param attrName - Name of the attribute to look for.
22+
* @returns true if attribute exists, false otherwise.
23+
*
24+
* @pure true
25+
* @invariant returns true iff attribute with exact name exists
26+
* @complexity O(n) where n = number of attributes
27+
*/
28+
// CHANGE: extract attribute existence check as a pure utility.
29+
// WHY: enable reuse across Vite and Babel plugin implementations.
30+
// REF: issue-12 (unified interface request)
31+
// FORMAT THEOREM: ∀ node, name: attrExists(node, name) ↔ ∃ attr ∈ node.attributes: attr.name = name
32+
// PURITY: CORE
33+
// EFFECT: n/a
34+
// INVARIANT: predicate is deterministic for fixed inputs
35+
// COMPLEXITY: O(n)/O(1)
36+
export const attrExists = (node: t.JSXOpeningElement, attrName: string, types: typeof t): boolean =>
37+
node.attributes.some(
38+
(attr) => types.isJSXAttribute(attr) && types.isJSXIdentifier(attr.name, { name: attrName })
39+
)
40+
41+
/**
42+
* Creates a JSX attribute with the component path value.
43+
*
44+
* @param relativeFilename - Relative path to the file.
45+
* @param line - 1-based line number.
46+
* @param column - 0-based column number.
47+
* @param types - Babel types module.
48+
* @returns JSX attribute node with the path value.
49+
*
50+
* @pure true
51+
* @invariant attribute name is always componentPathAttributeName
52+
* @complexity O(1)
53+
*/
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)
58+
// PURITY: CORE
59+
// EFFECT: n/a
60+
// INVARIANT: output format is always path:line:column
61+
// COMPLEXITY: O(1)/O(1)
62+
export const createPathAttribute = (
63+
relativeFilename: string,
64+
line: number,
65+
column: number,
66+
types: typeof t
67+
): t.JSXAttribute => {
68+
const value = formatComponentPathValue(relativeFilename, line, column)
69+
return types.jsxAttribute(types.jsxIdentifier(componentPathAttributeName), types.stringLiteral(value))
70+
}
71+
72+
/**
73+
* Processes a single JSX opening element and adds path attribute if needed.
74+
*
75+
* This is the unified business logic for tagging JSX elements with source location.
76+
* Both the Vite plugin and standalone Babel plugin use this function.
77+
*
78+
* @param node - JSX opening element to process.
79+
* @param context - Tagging context with relative filename.
80+
* @param types - Babel types module.
81+
* @returns true if attribute was added, false if skipped.
82+
*
83+
* @pure false (mutates node)
84+
* @invariant each JSX element has at most one path attribute after processing
85+
* @complexity O(n) where n = number of existing attributes
86+
*/
87+
// CHANGE: extract unified JSX element processing logic.
88+
// WHY: satisfy user request for single business logic shared by Vite and Babel.
89+
// QUOTE(TZ): "А ты можешь сделать что бы бизнес логика оставалось одной? Ну типо переиспользуй код с vite версии на babel"
90+
// REF: issue-12-comment (unified interface request)
91+
// FORMAT THEOREM: ∀ jsx ∈ JSXOpeningElement: processElement(jsx) → tagged(jsx) ∨ skipped(jsx)
92+
// PURITY: SHELL (mutates AST)
93+
// EFFECT: AST mutation
94+
// INVARIANT: idempotent - processing same element twice produces same result
95+
// COMPLEXITY: O(n)/O(1)
96+
export const processJsxElement = (
97+
node: t.JSXOpeningElement,
98+
context: JsxTaggerContext,
99+
types: typeof t
100+
): boolean => {
101+
// Skip if no location info
102+
if (node.loc === null || node.loc === undefined) {
103+
return false
104+
}
105+
106+
// Skip if already has path attribute (idempotency)
107+
if (attrExists(node, componentPathAttributeName, types)) {
108+
return false
109+
}
110+
111+
const { column, line } = node.loc.start
112+
const attr = createPathAttribute(context.relativeFilename, line, column, types)
113+
114+
node.attributes.push(attr)
115+
return true
116+
}
117+
118+
/**
119+
* Creates a Babel visitor for JSX elements that uses the unified tagging logic.
120+
*
121+
* This is the shared visitor factory used by both:
122+
* - Vite plugin (componentTagger) - passes relative filename directly
123+
* - Standalone Babel plugin - computes relative filename from state
124+
*
125+
* @param getContext - Function to extract context from Babel state.
126+
* @param types - Babel types module.
127+
* @returns Babel visitor object for JSXOpeningElement.
128+
*
129+
* @pure true (returns immutable visitor object)
130+
* @invariant visitor applies processJsxElement to all JSX opening elements
131+
* @complexity O(1) for visitor creation
132+
*/
133+
// CHANGE: create shared visitor factory for both plugin types.
134+
// WHY: single unified interface as requested by user.
135+
// QUOTE(TZ): "Сделай единный интерфейс для этого"
136+
// REF: issue-12-comment (unified interface request)
137+
// FORMAT THEOREM: ∀ visitor = createVisitor(ctx): visitor processes all JSX elements uniformly
138+
// PURITY: CORE
139+
// EFFECT: n/a (visitor application has effects)
140+
// INVARIANT: visitor behavior is consistent across plugin implementations
141+
// COMPLEXITY: O(1)/O(1)
142+
export const createJsxTaggerVisitor = <TState>(
143+
getContext: (state: TState) => JsxTaggerContext | null,
144+
types: typeof t
145+
): Visitor<TState> => ({
146+
JSXOpeningElement(nodePath, state) {
147+
const context = getContext(state)
148+
if (context === null) {
149+
return
150+
}
151+
processJsxElement(nodePath.node, context, types)
152+
}
153+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Path service utilities using Effect-TS.
3+
*
4+
* PURITY: SHELL (uses Effect for file system operations)
5+
* PURPOSE: Centralize Effect-based path operations to avoid code duplication.
6+
*/
7+
8+
import { layer as NodePathLayer } from "@effect/platform-node/NodePath"
9+
import { Path } from "@effect/platform/Path"
10+
import { Effect, pipe } from "effect"
11+
12+
/**
13+
* Computes relative path using Effect's Path service.
14+
*
15+
* @param rootDir - Root directory for relative path calculation.
16+
* @param absolutePath - Absolute file path to convert.
17+
* @returns Effect that produces relative path string.
18+
*
19+
* @pure false
20+
* @effect Path service access
21+
* @invariant result is a valid relative path
22+
* @complexity O(n)/O(1) where n = path length
23+
*/
24+
// CHANGE: extract common path calculation logic from both plugins.
25+
// WHY: eliminate code duplication detected by vibecode-linter.
26+
// REF: lint error DUPLICATE #1
27+
// FORMAT THEOREM: ∀ (root, path): relativePath(root, path) = Path.relative(root, path)
28+
// PURITY: SHELL
29+
// EFFECT: Effect<string, never, Path>
30+
// INVARIANT: always returns a valid relative path for valid inputs
31+
// COMPLEXITY: O(n)/O(1)
32+
export const relativeFromRoot = (
33+
rootDir: string,
34+
absolutePath: string
35+
): Effect.Effect<string, never, Path> =>
36+
pipe(
37+
Path,
38+
Effect.map((pathService) => pathService.relative(rootDir, absolutePath))
39+
)
40+
41+
/**
42+
* Synchronously computes relative path using Effect's Path service.
43+
*
44+
* @param rootDir - Root directory for relative path calculation.
45+
* @param absolutePath - Absolute file path to convert.
46+
* @returns Relative path string.
47+
*
48+
* @pure false
49+
* @effect Path service access (synchronous)
50+
* @invariant result is a valid relative path
51+
* @complexity O(n)/O(1) where n = path length
52+
*/
53+
// CHANGE: provide synchronous variant for Babel plugin (which requires sync operations).
54+
// WHY: Babel plugins must operate synchronously; Effect.runSync bridges Effect-style code.
55+
// REF: babel-plugin.ts:65-71
56+
// FORMAT THEOREM: ∀ (root, path): computeRelativePath(root, path) = runSync(relativePath(root, path))
57+
// PURITY: SHELL
58+
// EFFECT: Path service (executed synchronously)
59+
// INVARIANT: always returns a valid string for valid inputs
60+
// COMPLEXITY: O(n)/O(1)
61+
export const computeRelativePath = (rootDir: string, absolutePath: string): string =>
62+
pipe(relativeFromRoot(rootDir, absolutePath), Effect.provide(NodePathLayer), Effect.runSync)
63+
64+
/**
65+
* Re-export NodePathLayer for plugins that need to provide it explicitly.
66+
*/
67+
68+
export { layer as NodePathLayer } from "@effect/platform-node/NodePath"

packages/app/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,12 @@
99
// INVARIANT: exports remain stable for consumers
1010
// COMPLEXITY: O(1)/O(1)
1111
export { componentPathAttributeName, formatComponentPathValue, isJsxFile } from "./core/component-path.js"
12+
export {
13+
attrExists,
14+
createJsxTaggerVisitor,
15+
createPathAttribute,
16+
type JsxTaggerContext,
17+
processJsxElement
18+
} from "./core/jsx-tagger.js"
19+
export { componentTaggerBabelPlugin, type ComponentTaggerBabelPluginOptions } from "./shell/babel-plugin.js"
1220
export { componentTagger } from "./shell/component-tagger.js"

0 commit comments

Comments
 (0)