diff --git a/packages/appkit/package.json b/packages/appkit/package.json index 2349e06b..9d0c946e 100644 --- a/packages/appkit/package.json +++ b/packages/appkit/package.json @@ -80,6 +80,7 @@ "express": "4.22.0", "get-port": "7.2.0", "js-yaml": "4.1.1", + "magic-string": "0.30.21", "obug": "2.1.1", "pg": "8.18.0", "picocolors": "1.1.1", diff --git a/packages/appkit/src/plugins/server/react-source-loc-vite-plugin.ts b/packages/appkit/src/plugins/server/react-source-loc-vite-plugin.ts new file mode 100644 index 00000000..a32a768f --- /dev/null +++ b/packages/appkit/src/plugins/server/react-source-loc-vite-plugin.ts @@ -0,0 +1,102 @@ +import path from "node:path"; +import { Lang, parse, type SgNode } from "@ast-grep/napi"; +import MagicString from "magic-string"; +import type { Plugin } from "vite"; + +const JSX_ELEMENT_MATCHER = { + rule: { + any: [ + { kind: "jsx_opening_element" }, + { kind: "jsx_self_closing_element" }, + ], + }, +}; + +/** Matches nested client dirs from ViteDevServer.findClientRoot() (not "."). */ +const NESTED_CLIENT_DIRS = new Set(["client", "src", "app", "frontend"]); + +function resolveProjectRoot(clientRoot: string): string { + const resolved = path.resolve(clientRoot); + if (NESTED_CLIENT_DIRS.has(path.basename(resolved))) { + return path.resolve(resolved, ".."); + } + return resolved; +} + +function cleanModuleId(id: string): string { + return id.split("?")[0].split("#")[0]; +} + +function shouldTransform(id: string): boolean { + if (id.includes("\0")) return false; + if (id.includes("node_modules")) return false; + return /\.[jt]sx$/.test(cleanModuleId(id)); +} + +function isNativeJsxTag(name: SgNode): boolean { + const kind = name.kind(); + if (kind === "member_expression") return false; + if (kind === "jsx_namespace_name") return false; + if (kind === "identifier") { + const tagName = name.text(); + if (!tagName) return false; + return /^[a-z]/.test(tagName); + } + return false; +} + +function hasDataSourceAttribute(node: SgNode): boolean { + for (const attr of node.fieldChildren("attribute")) { + if (!attr.is("jsx_attribute")) continue; + for (const child of attr.children()) { + if (child.is("property_identifier") && child.text() === "data-source") { + return true; + } + } + } + return false; +} + +/** + * Injects `data-source="::"` on native JSX elements so editors + * can map DOM nodes back to source locations. + */ +export function reactSourceLocPlugin(): Plugin { + let projectRoot: string; + + return { + name: "react-source-loc", + enforce: "pre", + apply: "serve", + + configResolved(config) { + projectRoot = resolveProjectRoot(config.root); + }, + + transform(code, id) { + if (!shouldTransform(id)) return; + + const cleanId = cleanModuleId(id); + const root = parse(Lang.Tsx, code).root(); + const s = new MagicString(code); + const relPath = path.relative(projectRoot, cleanId); + + for (const node of root.findAll(JSX_ELEMENT_MATCHER)) { + const name = node.field("name"); + if (!name || !isNativeJsxTag(name)) continue; + if (hasDataSourceAttribute(node)) continue; + + const nodeRange = node.range(); + const value = `${relPath}:${nodeRange.start.line + 1}:${nodeRange.start.column}`; + s.appendLeft(name.range().end.index, ` data-source="${value}"`); + } + + if (!s.hasChanged()) return; + + return { + code: s.toString(), + map: s.generateMap({ hires: true }), + }; + }, + }; +} diff --git a/packages/appkit/src/plugins/server/tests/react-source-loc-vite-plugin.test.ts b/packages/appkit/src/plugins/server/tests/react-source-loc-vite-plugin.test.ts new file mode 100644 index 00000000..9688fc4e --- /dev/null +++ b/packages/appkit/src/plugins/server/tests/react-source-loc-vite-plugin.test.ts @@ -0,0 +1,95 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { ResolvedConfig } from "vite"; +import { describe, expect, it } from "vitest"; +import { reactSourceLocPlugin } from "../react-source-loc-vite-plugin"; + +const clientRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "client", +); +const moduleId = path.join(clientRoot, "src", "Example.tsx"); + +interface TestableHooks { + configResolved?: (config: ResolvedConfig) => void | Promise; + transform?: ( + code: string, + id: string, + ) => + | { code: string } + | string + | null + | undefined + | Promise<{ code: string } | string | null | undefined>; +} + +async function transformSource( + code: string, + root: string = clientRoot, + id: string = moduleId, +): Promise { + const { configResolved, transform } = + reactSourceLocPlugin() as unknown as TestableHooks; + const config = { root } as ResolvedConfig; + + await configResolved?.(config); + + const result = await transform?.(code, id); + if (!result) return code; + return typeof result === "string" ? result : result.code; +} + +describe("reactSourceLocPlugin", () => { + it("injects data-source on native opening and self-closing tags", async () => { + const code = `export function App() { + return ( + +
+ +
+
+ ); +} +`; + const output = await transformSource(code); + expect(output).toContain('data-source="client/src/Example.tsx:'); + expect(output).toMatch(//); + expect(output).toMatch(/
/); + expect(output).toMatch(//); + expect(output).not.toContain("motion.div data-source"); + }); + + it("skips components, fragments, namespaced tags, and existing data-source", async () => { + const code = `export function App() { + return ( + <> + + + + + + ); +} +`; + const output = await transformSource(code); + expect(output).not.toMatch(/ { + const appRoot = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "flat-app", + ); + const flatModuleId = path.join(appRoot, "src", "Page.tsx"); + const code = `export const Page = () =>
;`; + + const output = await transformSource(code, appRoot, flatModuleId); + + expect(output).toMatch(/