diff --git a/packages/app/src/core/component-path.ts b/packages/app/src/core/component-path.ts index 8198c00..52451de 100644 --- a/packages/app/src/core/component-path.ts +++ b/packages/app/src/core/component-path.ts @@ -1,5 +1,34 @@ const jsxFilePattern = /\.(tsx|jsx)(\?.*)?$/u +/** + * Normalizes a module ID by stripping query parameters. + * + * Vite and other bundlers may append query parameters to module IDs + * (e.g., "src/App.tsx?import" or "src/App.tsx?v=123"). This function + * returns the clean file path without query string. + * + * @param id - Module ID (may include query parameters). + * @returns Clean path without query string. + * + * @pure true + * @invariant ∀ id: normalizeModuleId(id) does not contain '?' + * @complexity O(n) time / O(1) space where n = |id| + */ +// CHANGE: centralize query stripping as a pure function in core. +// WHY: unify module ID normalization in one place as requested in issue #18. +// QUOTE(ТЗ): "Вынести stripQuery() (или normalizeModuleId()) в core, использовать в Vite и (при желании) в isJsxFile." +// REF: REQ-18 (issue #18) +// SOURCE: n/a +// FORMAT THEOREM: ∀ id: normalizeModuleId(id) = id.split('?')[0] +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: result contains no query string +// COMPLEXITY: O(n)/O(1) +export const normalizeModuleId = (id: string): string => { + const queryIndex = id.indexOf("?") + return queryIndex === -1 ? id : id.slice(0, queryIndex) +} + // CHANGE: rename attribute from "path" to "data-path" for HTML5 compliance. // WHY: data-* attributes are standard HTML5 custom data attributes, improving compatibility. // QUOTE(issue-14): "Rename attribute path → data-path (breaking change)" @@ -24,7 +53,7 @@ export const componentPathAttributeName = "data-path" */ // CHANGE: centralize JSX file detection as a pure predicate. // WHY: keep file filtering in the functional core for testability. -// 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" +// QUOTE(TZ): "Сам компонент должен быть в текущем app но вот что бы его протестировать надо создать ещё один проект который наш текущий апп будет подключать" // REF: user-2026-01-14-frontend-consumer // SOURCE: n/a // FORMAT THEOREM: forall id in ModuleId: isJsxFile(id) -> matches(id, jsxFilePattern) @@ -48,7 +77,7 @@ export const isJsxFile = (id: string): boolean => jsxFilePattern.test(id) */ // CHANGE: provide a pure formatter for component location payloads. // WHY: reuse a single, deterministic encoding for UI metadata. -// 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" +// QUOTE(TZ): "Сам компонент должен быть в текущем app но вот что бы его протестировать надо создать ещё один проект который наш текущий апп будет подключать" // REF: user-2026-01-14-frontend-consumer // SOURCE: n/a // FORMAT THEOREM: forall p,l,c: formatComponentPathValue(p,l,c) = concat(p, ":", l, ":", c) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index eb188ee..97afcd9 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -8,7 +8,12 @@ // EFFECT: n/a // INVARIANT: exports remain stable for consumers // COMPLEXITY: O(1)/O(1) -export { componentPathAttributeName, formatComponentPathValue, isJsxFile } from "./core/component-path.js" +export { + componentPathAttributeName, + formatComponentPathValue, + isJsxFile, + normalizeModuleId +} from "./core/component-path.js" export { attrExists, createJsxTaggerVisitor, diff --git a/packages/app/src/shell/component-tagger.ts b/packages/app/src/shell/component-tagger.ts index 2558ca6..0d18ae2 100644 --- a/packages/app/src/shell/component-tagger.ts +++ b/packages/app/src/shell/component-tagger.ts @@ -3,7 +3,7 @@ import type { Path } from "@effect/platform/Path" import { Effect, pipe } from "effect" import type { PluginOption } from "vite" -import { componentPathAttributeName, isJsxFile } from "../core/component-path.js" +import { componentPathAttributeName, isJsxFile, normalizeModuleId } from "../core/component-path.js" import { createJsxTaggerVisitor, type JsxTaggerContext } from "../core/jsx-tagger.js" import { NodePathLayer, relativeFromRoot } from "../core/path-service.js" @@ -33,11 +33,6 @@ class ComponentTaggerError extends Error { } } -const stripQuery = (id: string): string => { - const queryIndex = id.indexOf("?") - return queryIndex === -1 ? id : id.slice(0, queryIndex) -} - const toViteResult = (result: BabelTransformResult): ViteTransformResult | null => { if (result === null || result.code === null || result.code === undefined) { return null @@ -105,7 +100,7 @@ const runTransform = ( rootDir: string, attributeName: string ): Effect.Effect => { - const cleanId = stripQuery(id) + const cleanId = normalizeModuleId(id) return pipe( relativeFromRoot(rootDir, cleanId), diff --git a/packages/app/tests/core/component-path.test.ts b/packages/app/tests/core/component-path.test.ts index 4e327c8..62b39d0 100644 --- a/packages/app/tests/core/component-path.test.ts +++ b/packages/app/tests/core/component-path.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" -import { componentPathAttributeName, formatComponentPathValue, isJsxFile } from "../../src/core/component-path.js" +import { + componentPathAttributeName, + formatComponentPathValue, + isJsxFile, + normalizeModuleId +} from "../../src/core/component-path.js" describe("component-path", () => { it.effect("exposes the data-path attribute name", () => @@ -21,4 +26,21 @@ describe("component-path", () => { expect(isJsxFile("src/App.jsx?import")).toBe(true) expect(isJsxFile("src/App.ts")).toBe(false) })) + + it.effect("normalizes module id by stripping query string", () => + Effect.sync(() => { + // With query parameter + expect(normalizeModuleId("src/App.tsx?import")).toBe("src/App.tsx") + expect(normalizeModuleId("src/App.jsx?v=123")).toBe("src/App.jsx") + expect(normalizeModuleId("src/App.tsx?import&v=abc")).toBe("src/App.tsx") + + // Without query parameter (idempotent) + expect(normalizeModuleId("src/App.tsx")).toBe("src/App.tsx") + expect(normalizeModuleId("src/App.jsx")).toBe("src/App.jsx") + + // Edge cases + expect(normalizeModuleId("")).toBe("") + expect(normalizeModuleId("?")).toBe("") + expect(normalizeModuleId("file?")).toBe("file") + })) })