@harness-fe/unplugin 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/core.d.ts +19 -0
  4. package/dist/core.js +211 -0
  5. package/dist/esbuild.d.ts +9 -0
  6. package/dist/esbuild.js +9 -0
  7. package/dist/index.d.ts +20 -0
  8. package/dist/index.js +20 -0
  9. package/dist/internal/buildIdentity.d.ts +19 -0
  10. package/dist/internal/buildIdentity.js +49 -0
  11. package/dist/internal/log-capture.d.ts +7 -0
  12. package/dist/internal/log-capture.js +22 -0
  13. package/dist/internal/mcp-client.d.ts +11 -0
  14. package/dist/internal/mcp-client.js +165 -0
  15. package/dist/internal/types.d.ts +61 -0
  16. package/dist/internal/types.js +4 -0
  17. package/dist/resolveBuildId.d.ts +32 -0
  18. package/dist/resolveBuildId.js +88 -0
  19. package/dist/resolveProjectId.d.ts +9 -0
  20. package/dist/resolveProjectId.js +44 -0
  21. package/dist/rollup.d.ts +9 -0
  22. package/dist/rollup.js +9 -0
  23. package/dist/rspack.d.ts +9 -0
  24. package/dist/rspack.js +9 -0
  25. package/dist/transform.d.ts +27 -0
  26. package/dist/transform.js +150 -0
  27. package/dist/vite.d.ts +10 -0
  28. package/dist/vite.js +10 -0
  29. package/dist/vue-transform.d.ts +90 -0
  30. package/dist/vue-transform.js +350 -0
  31. package/package.json +75 -0
  32. package/src/core.ts +230 -0
  33. package/src/esbuild.ts +12 -0
  34. package/src/index.ts +34 -0
  35. package/src/internal/buildIdentity.ts +66 -0
  36. package/src/internal/log-capture.ts +26 -0
  37. package/src/internal/mcp-client.ts +181 -0
  38. package/src/internal/types.ts +66 -0
  39. package/src/resolveBuildId.test.ts +63 -0
  40. package/src/resolveBuildId.ts +125 -0
  41. package/src/resolveProjectId.test.ts +99 -0
  42. package/src/resolveProjectId.ts +48 -0
  43. package/src/rollup.ts +12 -0
  44. package/src/rspack.ts +12 -0
  45. package/src/transform.test.ts +89 -0
  46. package/src/transform.ts +188 -0
  47. package/src/vite.ts +13 -0
  48. package/src/vue-transform.test.ts +398 -0
  49. package/src/vue-transform.ts +455 -0
@@ -0,0 +1,48 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+
5
+ const HARNESS_ID_FILE = '.harness-id';
6
+
7
+ /**
8
+ * Resolves the project ID for a given project root directory.
9
+ *
10
+ * Priority:
11
+ * 1. `userConfig` — if provided, return immediately without touching `.harness-id`
12
+ * 2. Read `{root}/.harness-id` — if readable, return trimmed content
13
+ * 3. Generate UUID v4, write to `{root}/.harness-id` (UTF-8, no BOM, no trailing whitespace), return it
14
+ */
15
+ export async function resolveProjectId(root: string, userConfig?: string): Promise<string> {
16
+ // Priority 1: explicit user config value
17
+ if (userConfig !== undefined && userConfig !== '') {
18
+ return userConfig;
19
+ }
20
+
21
+ const idFilePath = join(root, HARNESS_ID_FILE);
22
+
23
+ // Priority 2: read existing .harness-id file
24
+ try {
25
+ const content = await readFile(idFilePath, 'utf-8');
26
+ const trimmed = content.trim();
27
+ if (trimmed.length > 0) {
28
+ return trimmed;
29
+ }
30
+ } catch (err) {
31
+ // ENOENT or other read error — fall through to generate a new UUID
32
+ const code = (err as NodeJS.ErrnoException).code;
33
+ if (code !== 'ENOENT') {
34
+ // Log unexpected errors but still proceed to generate a new ID
35
+ console.warn(`[harness] Could not read ${idFilePath}: ${(err as Error).message}`);
36
+ }
37
+ }
38
+
39
+ // Priority 3: generate UUID v4, write to .harness-id, return it
40
+ const newId = randomUUID();
41
+ try {
42
+ await writeFile(idFilePath, newId, { encoding: 'utf-8' });
43
+ } catch (err) {
44
+ console.warn(`[harness] Could not write ${idFilePath}: ${(err as Error).message}`);
45
+ }
46
+
47
+ return newId;
48
+ }
package/src/rollup.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Rollup-specific export.
3
+ *
4
+ * Usage:
5
+ * import { harnessFE } from '@harness-fe/unplugin/rollup'
6
+ */
7
+
8
+ import { unplugin } from './core.js';
9
+ export type { HarnessFEOptions } from './core.js';
10
+
11
+ export const harnessFE = unplugin.rollup;
12
+ export default harnessFE;
package/src/rspack.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Rspack-specific export.
3
+ *
4
+ * Usage:
5
+ * import { harnessFE } from '@harness-fe/unplugin/rspack'
6
+ */
7
+
8
+ import { unplugin } from './core.js';
9
+ export type { HarnessFEOptions } from './core.js';
10
+
11
+ export const harnessFE = unplugin.rspack;
12
+ export default harnessFE;
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { transformJsx } from './transform.js';
3
+
4
+ describe('transformJsx', () => {
5
+ it('tags JSX inside a function component with data-morphix-comp + data-morphix-loc', () => {
6
+ const src = `
7
+ export function SubmitButton() {
8
+ return <button>Submit</button>;
9
+ }
10
+ `;
11
+ const map = new Map();
12
+ const out = transformJsx(src, 'src/Submit.tsx', map);
13
+ expect(out).not.toBeNull();
14
+ expect(out!.code).toContain('data-morphix-comp="SubmitButton"');
15
+ expect(out!.code).toMatch(/data-morphix-loc="src\/Submit\.tsx:\d+:\d+"/);
16
+ expect(map.get('SubmitButton')).toBeDefined();
17
+ expect(map.get('SubmitButton')!.length).toBeGreaterThan(0);
18
+ });
19
+
20
+ it('tags arrow-function component assigned to PascalCase variable', () => {
21
+ const src = `
22
+ const Card = () => <div>hello</div>;
23
+ `;
24
+ const map = new Map();
25
+ const out = transformJsx(src, 'src/Card.tsx', map);
26
+ expect(out!.code).toContain('data-morphix-comp="Card"');
27
+ expect(map.get('Card')).toBeDefined();
28
+ });
29
+
30
+ it('only adds data-morphix-loc on JSX outside any component (e.g. helper)', () => {
31
+ const src = `
32
+ function helper() {
33
+ return <span>x</span>;
34
+ }
35
+ `;
36
+ const map = new Map();
37
+ const out = transformJsx(src, 'src/h.tsx', map);
38
+ // JSX is tagged with loc but not with comp (helper is lowercase).
39
+ expect(out!.code).toContain('data-morphix-loc=');
40
+ expect(out!.code).not.toContain('data-morphix-comp=');
41
+ expect(map.size).toBe(0);
42
+ });
43
+
44
+ it('returns null for files without JSX', () => {
45
+ const src = `export const x = 1;\n`;
46
+ const map = new Map();
47
+ const out = transformJsx(src, 'src/a.tsx', map);
48
+ expect(out).toBeNull();
49
+ });
50
+
51
+ it('preserves existing data-morphix-comp if author already wrote one', () => {
52
+ const src = `
53
+ export function Foo() {
54
+ return <button data-morphix-comp="OverrideName">x</button>;
55
+ }
56
+ `;
57
+ const map = new Map();
58
+ const out = transformJsx(src, 'src/Foo.tsx', map);
59
+ // Did not add a duplicate.
60
+ const matches = out!.code.match(/data-morphix-comp/g) ?? [];
61
+ expect(matches).toHaveLength(1);
62
+ expect(out!.code).toContain('data-morphix-comp="OverrideName"');
63
+ // Both the enclosing component and the hand-written tag land in the map.
64
+ expect(map.get('Foo')).toBeDefined();
65
+ expect(map.get('OverrideName')).toBeDefined();
66
+ expect(map.get('OverrideName')![0].file).toBe('src/Foo.tsx');
67
+ });
68
+
69
+ it('collects hand-written data-morphix-comp tags inside an enclosing component', () => {
70
+ const src = `
71
+ export function App() {
72
+ return (
73
+ <main>
74
+ <button data-morphix-comp="IncrementBtn">+</button>
75
+ <input data-morphix-comp="EchoInput" />
76
+ <p data-morphix-comp="EchoDisplay">x</p>
77
+ </main>
78
+ );
79
+ }
80
+ `;
81
+ const map = new Map();
82
+ const out = transformJsx(src, 'src/App.tsx', map);
83
+ expect(out).not.toBeNull();
84
+ expect(map.get('App')).toBeDefined();
85
+ expect(map.get('IncrementBtn')).toBeDefined();
86
+ expect(map.get('EchoInput')).toBeDefined();
87
+ expect(map.get('EchoDisplay')).toBeDefined();
88
+ });
89
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * JSX transform: parse a .tsx / .jsx file and inject:
3
+ * - `data-morphix-loc="<relPath>:<line>:<col>"` on every JSX opening element
4
+ * - `data-morphix-comp="<ComponentName>"` on JSX opening elements that are
5
+ * enclosed (transitively) by a top-level component definition. The
6
+ * component name comes from the nearest enclosing function/class/variable
7
+ * declaration whose name starts with an uppercase letter (PascalCase).
8
+ *
9
+ * Output is the original source with the attribute strings spliced in via
10
+ * MagicString — keeps source maps intact and avoids re-generating the file.
11
+ *
12
+ * Side effect: every successfully scanned file contributes entries to the
13
+ * supplied `componentMap`: name → list of locations (file:line:col).
14
+ */
15
+
16
+ import { parse } from '@babel/parser';
17
+ import traverseDefault from '@babel/traverse';
18
+ import type { NodePath } from '@babel/traverse';
19
+ import * as t from '@babel/types';
20
+ import MagicString from 'magic-string';
21
+
22
+ // @babel/traverse exposes its default as `default` only when published as ESM
23
+ // in older versions; later versions also expose a named export. Pick whichever
24
+ // is callable.
25
+ const traverse: typeof traverseDefault =
26
+ typeof traverseDefault === 'function'
27
+ ? traverseDefault
28
+ : ((traverseDefault as unknown as { default: typeof traverseDefault }).default ?? traverseDefault);
29
+
30
+ export interface ComponentLocation {
31
+ file: string;
32
+ line: number;
33
+ col: number;
34
+ }
35
+
36
+ export type ComponentMap = Map<string, ComponentLocation[]>;
37
+
38
+ export interface TransformResult {
39
+ code: string;
40
+ map?: object;
41
+ /** Number of JSX elements that got attributes. */
42
+ taggedCount: number;
43
+ }
44
+
45
+ const ATTR_COMP = 'data-morphix-comp';
46
+ const ATTR_LOC = 'data-morphix-loc';
47
+
48
+ const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
49
+
50
+ export function transformJsx(
51
+ source: string,
52
+ relPath: string,
53
+ componentMap: ComponentMap,
54
+ ): TransformResult | null {
55
+ let ast;
56
+ try {
57
+ ast = parse(source, {
58
+ sourceType: 'module',
59
+ plugins: ['jsx', 'typescript'],
60
+ errorRecovery: true,
61
+ });
62
+ } catch {
63
+ return null;
64
+ }
65
+
66
+ const magic = new MagicString(source);
67
+ let taggedCount = 0;
68
+
69
+ traverse(ast, {
70
+ JSXOpeningElement(path: NodePath<t.JSXOpeningElement>) {
71
+ const node = path.node;
72
+ const start = node.start;
73
+ if (start == null) return;
74
+ // Position attribute insertion right after the tag name token.
75
+ const name = node.name;
76
+ const nameEnd = name.end;
77
+ if (nameEnd == null) return;
78
+
79
+ const loc = node.loc?.start;
80
+ if (!loc) return;
81
+ const locValue = `${relPath}:${loc.line}:${loc.column}`;
82
+ const enclosingName = findEnclosingComponentName(path);
83
+ const explicitName = getStringAttribute(node, ATTR_COMP);
84
+
85
+ const attrs: string[] = [];
86
+ if (!hasAttribute(node, ATTR_LOC)) {
87
+ attrs.push(`${ATTR_LOC}="${escapeAttr(locValue)}"`);
88
+ }
89
+ if (!hasAttribute(node, ATTR_COMP) && enclosingName) {
90
+ attrs.push(`${ATTR_COMP}="${escapeAttr(enclosingName)}"`);
91
+ }
92
+
93
+ // Register every name that ends up on this element into the map —
94
+ // both enclosing component (so e.g. App resolves) and any
95
+ // hand-written tag (so IncrementBtn / EchoInput resolve too).
96
+ const names = new Set<string>();
97
+ if (enclosingName) names.add(enclosingName);
98
+ if (explicitName) names.add(explicitName);
99
+ for (const name of names) {
100
+ const entries = componentMap.get(name) ?? [];
101
+ entries.push({ file: relPath, line: loc.line, col: loc.column });
102
+ componentMap.set(name, entries);
103
+ }
104
+
105
+ if (!attrs.length) return;
106
+ // Insert: <Foo ATTRS … >
107
+ magic.appendLeft(nameEnd, ' ' + attrs.join(' '));
108
+ taggedCount++;
109
+ },
110
+ });
111
+
112
+ if (taggedCount === 0) return null;
113
+
114
+ return {
115
+ code: magic.toString(),
116
+ map: magic.generateMap({ hires: true, source: relPath, includeContent: true }),
117
+ taggedCount,
118
+ };
119
+ }
120
+
121
+ function getStringAttribute(node: t.JSXOpeningElement, name: string): string | undefined {
122
+ for (const attr of node.attributes) {
123
+ if (
124
+ attr.type === 'JSXAttribute' &&
125
+ attr.name.type === 'JSXIdentifier' &&
126
+ attr.name.name === name &&
127
+ attr.value &&
128
+ attr.value.type === 'StringLiteral'
129
+ ) {
130
+ return attr.value.value;
131
+ }
132
+ }
133
+ return undefined;
134
+ }
135
+
136
+ function hasAttribute(node: t.JSXOpeningElement, name: string): boolean {
137
+ for (const attr of node.attributes) {
138
+ if (
139
+ attr.type === 'JSXAttribute' &&
140
+ attr.name.type === 'JSXIdentifier' &&
141
+ attr.name.name === name
142
+ ) {
143
+ return true;
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+
149
+ function findEnclosingComponentName(path: NodePath<t.JSXOpeningElement>): string | undefined {
150
+ let current: NodePath | null = path.parentPath;
151
+ while (current) {
152
+ const node = current.node;
153
+ // function Foo() { return <jsx/> }
154
+ if (t.isFunctionDeclaration(node) && node.id && PASCAL_CASE.test(node.id.name)) {
155
+ return node.id.name;
156
+ }
157
+ // const Foo = (...) => <jsx/>
158
+ // const Foo = function (...) { return <jsx/> }
159
+ if (
160
+ (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node)) &&
161
+ current.parentPath &&
162
+ t.isVariableDeclarator(current.parentPath.node) &&
163
+ t.isIdentifier(current.parentPath.node.id) &&
164
+ PASCAL_CASE.test(current.parentPath.node.id.name)
165
+ ) {
166
+ return current.parentPath.node.id.name;
167
+ }
168
+ // class Foo extends Component { render() { return <jsx/> } }
169
+ if (t.isClassDeclaration(node) && node.id && PASCAL_CASE.test(node.id.name)) {
170
+ return node.id.name;
171
+ }
172
+ // export default function Foo() ...
173
+ if (
174
+ t.isExportDefaultDeclaration(node) &&
175
+ t.isFunctionDeclaration(node.declaration) &&
176
+ node.declaration.id &&
177
+ PASCAL_CASE.test(node.declaration.id.name)
178
+ ) {
179
+ return node.declaration.id.name;
180
+ }
181
+ current = current.parentPath;
182
+ }
183
+ return undefined;
184
+ }
185
+
186
+ function escapeAttr(value: string): string {
187
+ return value.replace(/"/g, '&quot;');
188
+ }
package/src/vite.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Vite-specific export.
3
+ *
4
+ * Usage:
5
+ * import { harnessFE } from '@harness-fe/unplugin/vite'
6
+ * export default defineConfig({ plugins: [harnessFE()] })
7
+ */
8
+
9
+ import { unplugin } from './core.js';
10
+ export type { HarnessFEOptions } from './core.js';
11
+
12
+ export const harnessFE = unplugin.vite;
13
+ export default harnessFE;