Skip to content

Commit 0d595c2

Browse files
feat: add source-map support for editor DOM mapping (#347)
--------- Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com> Co-authored-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
1 parent efb069b commit 0d595c2

7 files changed

Lines changed: 194 additions & 7 deletions

File tree

packages/appkit/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
"express": "4.22.0",
8181
"get-port": "7.2.0",
8282
"js-yaml": "4.1.1",
83+
"magic-string": "0.30.21",
8384
"obug": "2.1.1",
8485
"pg": "8.18.0",
8586
"picocolors": "1.1.1",
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import path from "node:path";
2+
import { Lang, parse, type SgNode } from "@ast-grep/napi";
3+
import MagicString from "magic-string";
4+
import type { Plugin } from "vite";
5+
6+
const JSX_ELEMENT_MATCHER = {
7+
rule: {
8+
any: [
9+
{ kind: "jsx_opening_element" },
10+
{ kind: "jsx_self_closing_element" },
11+
],
12+
},
13+
};
14+
15+
interface ReactSourceLocPluginOptions {
16+
/** Absolute app root used for data-source relative paths (typically `process.cwd()`). */
17+
projectRoot: string;
18+
}
19+
20+
function cleanModuleId(id: string): string {
21+
return id.split("?")[0].split("#")[0];
22+
}
23+
24+
function shouldTransform(id: string): boolean {
25+
if (id.includes("\0")) return false;
26+
if (id.includes("node_modules")) return false;
27+
return /\.[jt]sx$/.test(cleanModuleId(id));
28+
}
29+
30+
function isNativeJsxTag(name: SgNode): boolean {
31+
const kind = name.kind();
32+
if (kind === "member_expression") return false;
33+
if (kind === "jsx_namespace_name") return false;
34+
if (kind === "identifier") {
35+
const tagName = name.text();
36+
if (!tagName) return false;
37+
return /^[a-z]/.test(tagName);
38+
}
39+
return false;
40+
}
41+
42+
function hasDataSourceAttribute(node: SgNode): boolean {
43+
for (const attr of node.fieldChildren("attribute")) {
44+
if (!attr.is("jsx_attribute")) continue;
45+
for (const child of attr.children()) {
46+
if (child.is("property_identifier") && child.text() === "data-source") {
47+
return true;
48+
}
49+
}
50+
}
51+
return false;
52+
}
53+
54+
/**
55+
* Injects `data-source="<file>:<line>:<col>"` on native JSX elements so editors
56+
* can map DOM nodes back to source locations.
57+
*/
58+
export function reactSourceLocPlugin(
59+
options: ReactSourceLocPluginOptions,
60+
): Plugin {
61+
const projectRoot = path.resolve(options.projectRoot);
62+
63+
return {
64+
name: "react-source-loc",
65+
enforce: "pre",
66+
apply: "serve",
67+
68+
transform(code, id) {
69+
if (!shouldTransform(id)) return;
70+
71+
const cleanId = cleanModuleId(id);
72+
const root = parse(Lang.Tsx, code).root();
73+
const s = new MagicString(code);
74+
const relPath = path.relative(projectRoot, cleanId);
75+
76+
for (const node of root.findAll(JSX_ELEMENT_MATCHER)) {
77+
const name = node.field("name");
78+
if (!name || !isNativeJsxTag(name)) continue;
79+
if (hasDataSourceAttribute(node)) continue;
80+
81+
const nodeRange = node.range();
82+
const value = `${relPath}:${nodeRange.start.line + 1}:${nodeRange.start.column}`;
83+
s.appendLeft(name.range().end.index, ` data-source="${value}"`);
84+
}
85+
86+
if (!s.hasChanged()) return;
87+
88+
return {
89+
code: s.toString(),
90+
map: s.generateMap({ hires: true }),
91+
};
92+
},
93+
};
94+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import path from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
import { describe, expect, it } from "vitest";
4+
import { reactSourceLocPlugin } from "../react-source-loc-vite-plugin";
5+
6+
const testDir = path.dirname(fileURLToPath(import.meta.url));
7+
const clientRoot = path.join(testDir, "client");
8+
const projectRoot = testDir;
9+
const moduleId = path.join(clientRoot, "src", "Example.tsx");
10+
11+
interface TestableHooks {
12+
transform?: (
13+
code: string,
14+
id: string,
15+
) =>
16+
| { code: string }
17+
| string
18+
| null
19+
| undefined
20+
| Promise<{ code: string } | string | null | undefined>;
21+
}
22+
23+
async function transformSource(
24+
code: string,
25+
root: string = clientRoot,
26+
id: string = moduleId,
27+
rootForPaths: string = projectRoot,
28+
): Promise<string> {
29+
const { transform } = reactSourceLocPlugin({
30+
projectRoot: rootForPaths,
31+
}) as unknown as TestableHooks;
32+
33+
const result = await transform?.(code, id);
34+
if (!result) return code;
35+
return typeof result === "string" ? result : result.code;
36+
}
37+
38+
describe("reactSourceLocPlugin", () => {
39+
it("injects data-source on native opening and self-closing tags", async () => {
40+
const code = `export function App() {
41+
return (
42+
<motion.div>
43+
<div className="a">
44+
<span />
45+
</div>
46+
</motion.div>
47+
);
48+
}
49+
`;
50+
const output = await transformSource(code);
51+
expect(output).toContain('data-source="client/src/Example.tsx:');
52+
expect(output).toMatch(/<motion\.div>/);
53+
expect(output).toMatch(/<div data-source="[^"]+" className="a">/);
54+
expect(output).toMatch(/<span data-source="[^"]+" \/>/);
55+
expect(output).not.toContain("motion.div data-source");
56+
});
57+
58+
it("skips components, fragments, namespaced tags, and existing data-source", async () => {
59+
const code = `export function App() {
60+
return (
61+
<>
62+
<Foo />
63+
<Foo.Bar />
64+
<svg:circle />
65+
<motion.div data-source="manual" />
66+
</>
67+
);
68+
}
69+
`;
70+
const output = await transformSource(code);
71+
expect(output).not.toMatch(/<Foo data-source=/);
72+
expect(output).not.toMatch(/<Foo\.Bar data-source=/);
73+
expect(output).not.toMatch(/<svg:circle data-source=/);
74+
expect(output).toContain('<motion.div data-source="manual"');
75+
expect(output).not.toMatch(/data-source="[^"]+" data-source=/);
76+
});
77+
78+
it("resolves paths from app root when vite root is the app root", async () => {
79+
const appRoot = path.join(testDir, "flat-app");
80+
const flatModuleId = path.join(appRoot, "src", "Page.tsx");
81+
const code = `export const Page = () => <div className="x" />;`;
82+
83+
const output = await transformSource(code, appRoot, flatModuleId, appRoot);
84+
85+
expect(output).toMatch(/<div data-source="src\/Page\.tsx:/);
86+
expect(output).not.toMatch(/data-source="[^"]*\.\./);
87+
});
88+
});

packages/appkit/src/plugins/server/vite-dev-server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { appKitServingTypesPlugin } from "../../type-generator/serving/vite-plug
88
import { appKitTypesPlugin } from "../../type-generator/vite-plugin";
99
import { mergeConfigDedup } from "../../utils";
1010
import { BaseServer } from "./base-server";
11+
import { reactSourceLocPlugin } from "./react-source-loc-vite-plugin";
1112
import type { PluginClientConfigs, PluginEndpoints } from "./utils";
1213

1314
const logger = createLogger("server:vite");
@@ -52,6 +53,7 @@ export class ViteDevServer extends BaseServer {
5253
const react = await import("@vitejs/plugin-react");
5354

5455
const clientRoot = this.findClientRoot();
56+
const projectRoot = process.cwd();
5557

5658
const loadedConfig = await loadConfigFromFile(
5759
{
@@ -81,6 +83,7 @@ export class ViteDevServer extends BaseServer {
8183
},
8284
plugins: [
8385
react.default(),
86+
reactSourceLocPlugin({ projectRoot }),
8487
appKitTypesPlugin(),
8588
appKitServingTypesPlugin(),
8689
],

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

template/client/vite.config.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,14 @@ import path from 'node:path';
66
// https://vite.dev/config/
77
export default defineConfig({
88
root: __dirname,
9-
plugins: [
10-
react(),
11-
tailwindcss(),
12-
],
9+
plugins: [react(), tailwindcss()],
1310
server: {
1411
middlewareMode: true,
1512
},
1613
build: {
1714
outDir: path.resolve(__dirname, './dist'),
1815
emptyOutDir: true,
16+
sourcemap: process.env.NODE_ENV === 'development',
1917
},
2018
optimizeDeps: {
2119
include: ['react', 'react-dom', 'react/jsx-dev-runtime', 'react/jsx-runtime', 'recharts'],

template/package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)