@yahoo/uds-create-loader 1.13.0 → 1.14.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.
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -3
- package/src/index.ts +12 -0
- package/src/inspect-transform.ts +79 -0
- package/src/loader/artifacts.ts +113 -0
- package/src/loader/babel-utils.ts +39 -0
- package/src/loader.test.ts +254 -0
- package/src/loader.ts +209 -0
- package/src/next.ts +118 -0
- package/src/transform-runner.ts +354 -0
- package/src/unplugin.ts +94 -0
package/src/loader.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import _traverse from '@babel/traverse';
|
|
3
|
+
import type { LoaderDefinitionFunction } from 'webpack';
|
|
4
|
+
import {
|
|
5
|
+
getLoaderProjection,
|
|
6
|
+
type LoaderOptions,
|
|
7
|
+
loadUdsConfig,
|
|
8
|
+
} from './loader/artifacts';
|
|
9
|
+
|
|
10
|
+
// Handle CJS/ESM interop for the babel CJS-shipped modules.
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: untyped babel CJS/ESM interop
|
|
12
|
+
const traverse = (_traverse as any).default ?? _traverse;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The UDS loader's only build-time job: rewrite alias-prefixed local
|
|
16
|
+
* imports of registered UDS components from `@/uds/components/<seg>`
|
|
17
|
+
* (the authoring file) to `'#uds/components/<seg>'` (the codegen-
|
|
18
|
+
* emitted per-source-file module at `.uds/components/<seg>.tsx`).
|
|
19
|
+
*
|
|
20
|
+
* The authoring file is read only by codegen — production renders the
|
|
21
|
+
* downleveled FC. Per-file targets keep Next.js Fast Refresh surgical:
|
|
22
|
+
* editing one authoring source only invalidates that one downleveled
|
|
23
|
+
* module.
|
|
24
|
+
*
|
|
25
|
+
* No JSX-site work: composite / variant / bool / style-prop resolution
|
|
26
|
+
* and motion wiring are owned by the codegen-emitted FC. The loader
|
|
27
|
+
* only changes import sources.
|
|
28
|
+
*/
|
|
29
|
+
const transformLoader: LoaderDefinitionFunction = function (source) {
|
|
30
|
+
const loaderContext =
|
|
31
|
+
(this as
|
|
32
|
+
| {
|
|
33
|
+
getOptions?: () => unknown;
|
|
34
|
+
addDependency?: (dep: string) => void;
|
|
35
|
+
resourcePath?: string;
|
|
36
|
+
}
|
|
37
|
+
| undefined) ?? undefined;
|
|
38
|
+
if (!loaderContext?.getOptions) return source;
|
|
39
|
+
|
|
40
|
+
const options = loaderContext.getOptions() as LoaderOptions;
|
|
41
|
+
const { dataPath, isDev, skipWatchDependencies } = options;
|
|
42
|
+
const resourcePath = loaderContext.resourcePath ?? '';
|
|
43
|
+
|
|
44
|
+
// Skip .uds/ generated files — they are codegen output and must
|
|
45
|
+
// not be re-transformed.
|
|
46
|
+
if (resourcePath.includes('.uds/')) {
|
|
47
|
+
return source;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Tell webpack to watch generated uds artifacts for changes.
|
|
51
|
+
// Skipped when the host app has its own reload mechanism (Studio
|
|
52
|
+
// WebSocket) to avoid full-page reloads on artifact regeneration.
|
|
53
|
+
if (!skipWatchDependencies) {
|
|
54
|
+
loaderContext.addDependency?.(dataPath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Config is required — without it we don't know which JSX tags
|
|
58
|
+
// belong to UDS. Returning early keeps non-UDS files untouched.
|
|
59
|
+
const config = loadUdsConfig(dataPath, isDev);
|
|
60
|
+
if (!config) return source;
|
|
61
|
+
const projection = getLoaderProjection(config);
|
|
62
|
+
|
|
63
|
+
// Cheap pre-filter: only parse files that look like they import or
|
|
64
|
+
// reference a UDS component. Avoids babel overhead on the bulk of
|
|
65
|
+
// application files.
|
|
66
|
+
let hasComponentReference = false;
|
|
67
|
+
for (const name of projection.componentNames) {
|
|
68
|
+
if (source.includes(name)) {
|
|
69
|
+
hasComponentReference = true;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!hasComponentReference) {
|
|
74
|
+
return source;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Determine parser plugins from file extension.
|
|
78
|
+
const isTS = resourcePath?.endsWith('.ts') || resourcePath?.endsWith('.tsx');
|
|
79
|
+
const isJSX =
|
|
80
|
+
resourcePath?.endsWith('.tsx') || resourcePath?.endsWith('.jsx');
|
|
81
|
+
const parserPlugins: Array<'jsx' | 'typescript'> = [];
|
|
82
|
+
if (isJSX || !isTS) parserPlugins.push('jsx');
|
|
83
|
+
if (isTS) parserPlugins.push('typescript');
|
|
84
|
+
|
|
85
|
+
let ast: ReturnType<typeof parse>;
|
|
86
|
+
try {
|
|
87
|
+
ast = parse(source, {
|
|
88
|
+
sourceType: 'module',
|
|
89
|
+
plugins: parserPlugins,
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
return source;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Collected source-literal rewrites, keyed by character offset.
|
|
96
|
+
// We splice these into the original `source` string rather than
|
|
97
|
+
// regenerating the file: the loader's only job is to swap import
|
|
98
|
+
// source strings, and re-printing the whole file through
|
|
99
|
+
// @babel/generator (esp. with `retainLines`) can mangle dense JSX
|
|
100
|
+
// into invalid output. Surgical splicing keeps every byte outside
|
|
101
|
+
// the rewritten literals identical, so the loader can never turn a
|
|
102
|
+
// valid file into a broken one.
|
|
103
|
+
const edits: Array<{ start: number; end: number; value: string }> = [];
|
|
104
|
+
|
|
105
|
+
traverse(ast, {
|
|
106
|
+
// -----------------------------------------------------------------------
|
|
107
|
+
// Import rewrite: alias-prefixed local imports of registered UDS
|
|
108
|
+
// components → `#uds/components/<seg>` (the per-source-file
|
|
109
|
+
// codegen-emitted module).
|
|
110
|
+
// -----------------------------------------------------------------------
|
|
111
|
+
// biome-ignore lint/suspicious/noExplicitAny: untyped babel traverse visitor
|
|
112
|
+
ImportDeclaration(nodePath: any) {
|
|
113
|
+
const importSource: string | undefined = nodePath.node.source?.value;
|
|
114
|
+
if (!importSource) return;
|
|
115
|
+
|
|
116
|
+
// Only rewrite alias-prefixed local imports (e.g.
|
|
117
|
+
// `@/uds/components/<name>`, `~/uds/components/<name>`).
|
|
118
|
+
// Relative imports (`./text`, `../box`) belong to the authoring
|
|
119
|
+
// chain where each `defineComponent(...)` may reference its
|
|
120
|
+
// siblings (`defineComponent(Text).config(...)`) — rewriting those
|
|
121
|
+
// would break codegen's read of the configs.
|
|
122
|
+
const isAliasedLocalImport =
|
|
123
|
+
!importSource.startsWith('.') && !importSource.startsWith('/');
|
|
124
|
+
if (!isAliasedLocalImport) return;
|
|
125
|
+
|
|
126
|
+
// First-pass match: any specifier name matches a registered
|
|
127
|
+
// component. The common case — `import { Box } from
|
|
128
|
+
// '@/uds/components/box'`.
|
|
129
|
+
let shouldRewriteSource = false;
|
|
130
|
+
for (const specifier of nodePath.node.specifiers) {
|
|
131
|
+
if (specifier.type !== 'ImportSpecifier') continue;
|
|
132
|
+
const importedName: string | undefined =
|
|
133
|
+
specifier.imported?.type === 'Identifier'
|
|
134
|
+
? specifier.imported.name
|
|
135
|
+
: undefined;
|
|
136
|
+
if (!importedName) continue;
|
|
137
|
+
|
|
138
|
+
if (projection.componentNames.has(importedName)) {
|
|
139
|
+
shouldRewriteSource = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Second-pass match: the import path points at the authoring
|
|
144
|
+
// file of a registered component (e.g. `@/uds/components/sidebar`)
|
|
145
|
+
// but the visible specifiers are co-located helpers like
|
|
146
|
+
// `SidebarProvider` / `useSidebar`. If we let this resolve to the
|
|
147
|
+
// source file, the helper sets up a different `SidebarContext`
|
|
148
|
+
// than the codegen-emitted `Sidebar` reads — provider and consumer
|
|
149
|
+
// end up with different context identities and `useSidebar()`
|
|
150
|
+
// throws. Funnel every export from that file through the codegen
|
|
151
|
+
// module so helper AND component share the same module.
|
|
152
|
+
//
|
|
153
|
+
// Constrain to `*/uds/components/<name>` paths so unrelated
|
|
154
|
+
// imports whose tail happens to match a registered name
|
|
155
|
+
// (`next/image` → `Image`) don't get rewritten.
|
|
156
|
+
const segmentMatch = importSource.match(/\/uds\/components\/([^/]+)$/);
|
|
157
|
+
if (!shouldRewriteSource && segmentMatch) {
|
|
158
|
+
const tail = segmentMatch[1];
|
|
159
|
+
const pascal = tail
|
|
160
|
+
.split('-')
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
163
|
+
.join('');
|
|
164
|
+
if (projection.componentNames.has(pascal)) {
|
|
165
|
+
shouldRewriteSource = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (shouldRewriteSource && segmentMatch) {
|
|
170
|
+
// Per-file rewrite: keep the trailing `<seg>` so each import
|
|
171
|
+
// resolves to its own downleveled module at
|
|
172
|
+
// `.uds/components/<seg>.tsx`. Splice over the literal's
|
|
173
|
+
// character range (quotes included) using the original quote
|
|
174
|
+
// char so the rest of the file is untouched.
|
|
175
|
+
const sourceNode = nodePath.node.source;
|
|
176
|
+
if (
|
|
177
|
+
typeof sourceNode.start !== 'number' ||
|
|
178
|
+
typeof sourceNode.end !== 'number'
|
|
179
|
+
) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const quote = source[sourceNode.start] ?? "'";
|
|
184
|
+
edits.push({
|
|
185
|
+
start: sourceNode.start,
|
|
186
|
+
end: sourceNode.end,
|
|
187
|
+
value: `${quote}#uds/components/${segmentMatch[1]}${quote}`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (edits.length === 0) return source;
|
|
194
|
+
|
|
195
|
+
// Apply edits from the end of the file backwards so earlier offsets
|
|
196
|
+
// stay valid as we splice.
|
|
197
|
+
edits.sort((a, b) => b.start - a.start);
|
|
198
|
+
|
|
199
|
+
let output = source;
|
|
200
|
+
for (const edit of edits) {
|
|
201
|
+
output = output.slice(0, edit.start) + edit.value + output.slice(edit.end);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return output;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// webpack loader-runner treats ESM loaders as valid when `normal` is exported.
|
|
208
|
+
export const normal = transformLoader;
|
|
209
|
+
export default transformLoader;
|
package/src/next.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { NextConfig } from 'next';
|
|
3
|
+
|
|
4
|
+
export interface WithUdsOptions {
|
|
5
|
+
/** Output directory for generated files (default: '.uds') */
|
|
6
|
+
outputDir?: string;
|
|
7
|
+
/** Skip addDependency calls to prevent webpack from watching .uds/ artifacts.
|
|
8
|
+
* Use this when the app has its own reload mechanism (e.g., Studio WebSocket). */
|
|
9
|
+
skipWatchDependencies?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resolveLoaderPath(): string {
|
|
13
|
+
return require.resolve('@yahoo/uds-create-loader/loader');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type NextConfigFn = (
|
|
17
|
+
phase: string,
|
|
18
|
+
ctx: { defaultConfig: NextConfig },
|
|
19
|
+
) => NextConfig | Promise<NextConfig>;
|
|
20
|
+
|
|
21
|
+
function applyUds(
|
|
22
|
+
nextConfig: NextConfig,
|
|
23
|
+
options?: WithUdsOptions,
|
|
24
|
+
): NextConfig {
|
|
25
|
+
const outputDir = options?.outputDir ?? '.uds';
|
|
26
|
+
const skipWatch = options?.skipWatchDependencies ?? false;
|
|
27
|
+
const dataPath = path.resolve(process.cwd(), outputDir, 'config.json');
|
|
28
|
+
const loaderPath = resolveLoaderPath();
|
|
29
|
+
const turbopack = nextConfig.turbopack ?? {};
|
|
30
|
+
const existingRules = turbopack.rules ?? {};
|
|
31
|
+
|
|
32
|
+
const udsLoader = {
|
|
33
|
+
loader: loaderPath,
|
|
34
|
+
options: {
|
|
35
|
+
dataPath,
|
|
36
|
+
isDev: true,
|
|
37
|
+
skipWatchDependencies: skipWatch,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Build turbopack rules that coexist with other plugins (e.g., Workflow
|
|
42
|
+
// DevKit). Other plugins may attach a `condition` to their rule entries
|
|
43
|
+
// which would incorrectly gate the UDS loader too. To avoid this, we
|
|
44
|
+
// keep existing rules intact and add the UDS loader as a separate
|
|
45
|
+
// unconditional rule under a distinct key (`**/*.ext`).
|
|
46
|
+
const udsRules: typeof existingRules = {};
|
|
47
|
+
for (const ext of ['tsx', 'ts', 'js'] as const) {
|
|
48
|
+
const key = `*.${ext}`;
|
|
49
|
+
const udsKey = `**/*.${ext}`;
|
|
50
|
+
// If another plugin already owns *.ext, use **/*.ext for UDS
|
|
51
|
+
if (existingRules[key]) {
|
|
52
|
+
udsRules[udsKey] = { loaders: [udsLoader] };
|
|
53
|
+
} else {
|
|
54
|
+
udsRules[key] = { loaders: [udsLoader] };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
...nextConfig,
|
|
60
|
+
turbopack: {
|
|
61
|
+
...turbopack,
|
|
62
|
+
rules: {
|
|
63
|
+
...existingRules,
|
|
64
|
+
...udsRules,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
webpack(config, webpackOptions) {
|
|
68
|
+
config.module?.rules?.push({
|
|
69
|
+
test: /\.(tsx|ts|js)$/,
|
|
70
|
+
use: [
|
|
71
|
+
{
|
|
72
|
+
loader: loaderPath,
|
|
73
|
+
options: {
|
|
74
|
+
dataPath,
|
|
75
|
+
isDev: webpackOptions.dev,
|
|
76
|
+
skipWatchDependencies: skipWatch,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (skipWatch) {
|
|
83
|
+
const existing = config.watchOptions?.ignored;
|
|
84
|
+
config.watchOptions = {
|
|
85
|
+
...config.watchOptions,
|
|
86
|
+
ignored: [
|
|
87
|
+
...(Array.isArray(existing)
|
|
88
|
+
? existing
|
|
89
|
+
: existing
|
|
90
|
+
? [existing]
|
|
91
|
+
: []),
|
|
92
|
+
'**/.uds/**',
|
|
93
|
+
'**/uds.config.ts',
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (typeof nextConfig.webpack === 'function') {
|
|
99
|
+
return nextConfig.webpack(config, webpackOptions);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return config;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function withUds(
|
|
108
|
+
nextConfigOrFn: NextConfig | NextConfigFn,
|
|
109
|
+
options?: WithUdsOptions,
|
|
110
|
+
): NextConfig | NextConfigFn {
|
|
111
|
+
if (typeof nextConfigOrFn === 'function') {
|
|
112
|
+
return async (phase, ctx) => {
|
|
113
|
+
const resolved = await nextConfigOrFn(phase, ctx);
|
|
114
|
+
return applyUds(resolved, options);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return applyUds(nextConfigOrFn, options);
|
|
118
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { gzipSync } from 'node:zlib';
|
|
3
|
+
import { parse as parseBabel } from '@babel/parser';
|
|
4
|
+
import * as t from '@babel/types';
|
|
5
|
+
import { VISITOR_KEYS } from '@babel/types';
|
|
6
|
+
import type { SerializedConfig } from '@yahoo/uds-create-config';
|
|
7
|
+
import transformLoader from './loader';
|
|
8
|
+
import { seedUdsConfig } from './loader/artifacts';
|
|
9
|
+
import { isCompiledJsxCallee } from './loader/babel-utils';
|
|
10
|
+
|
|
11
|
+
export interface LoaderTransformOutputSpecs {
|
|
12
|
+
outputGzipBytes: number;
|
|
13
|
+
classNameAssignments: number;
|
|
14
|
+
utilityClassTokens: number;
|
|
15
|
+
uniqueUtilityClassTokens: number;
|
|
16
|
+
runtimeStyleParserCalls: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface LoaderTransformSuccess {
|
|
20
|
+
output: string;
|
|
21
|
+
specs: LoaderTransformOutputSpecs;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface LoaderTransformFailure {
|
|
25
|
+
error: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type LoaderTransformResult =
|
|
29
|
+
| LoaderTransformSuccess
|
|
30
|
+
| LoaderTransformFailure;
|
|
31
|
+
|
|
32
|
+
export interface RunLoaderTransformOptions {
|
|
33
|
+
config: SerializedConfig;
|
|
34
|
+
resourcePath?: string;
|
|
35
|
+
isDev?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getClassTokens(value: string): string[] {
|
|
39
|
+
return value
|
|
40
|
+
.split(/\s+/)
|
|
41
|
+
.map((token) => token.trim())
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function collectClassTokensFromNode(
|
|
46
|
+
node: t.Node | null | undefined,
|
|
47
|
+
tokens: string[],
|
|
48
|
+
): void {
|
|
49
|
+
if (!node) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (t.isStringLiteral(node)) {
|
|
54
|
+
tokens.push(...getClassTokens(node.value));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (t.isTemplateLiteral(node)) {
|
|
59
|
+
for (const quasi of node.quasis) {
|
|
60
|
+
tokens.push(
|
|
61
|
+
...getClassTokens(quasi.value.cooked ?? quasi.value.raw ?? ''),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
for (const expression of node.expressions) {
|
|
65
|
+
collectClassTokensFromNode(expression, tokens);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (t.isJSXExpressionContainer(node)) {
|
|
71
|
+
if (!t.isJSXEmptyExpression(node.expression)) {
|
|
72
|
+
collectClassTokensFromNode(node.expression, tokens);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (
|
|
78
|
+
t.isParenthesizedExpression(node) ||
|
|
79
|
+
t.isTSAsExpression(node) ||
|
|
80
|
+
t.isTSTypeAssertion(node) ||
|
|
81
|
+
t.isTSNonNullExpression(node)
|
|
82
|
+
) {
|
|
83
|
+
collectClassTokensFromNode(node.expression, tokens);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (t.isUnaryExpression(node) || t.isUpdateExpression(node)) {
|
|
88
|
+
collectClassTokensFromNode(node.argument, tokens);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
t.isBinaryExpression(node) ||
|
|
94
|
+
t.isLogicalExpression(node) ||
|
|
95
|
+
t.isAssignmentExpression(node)
|
|
96
|
+
) {
|
|
97
|
+
collectClassTokensFromNode(node.left, tokens);
|
|
98
|
+
collectClassTokensFromNode(node.right, tokens);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (t.isConditionalExpression(node)) {
|
|
103
|
+
collectClassTokensFromNode(node.test, tokens);
|
|
104
|
+
collectClassTokensFromNode(node.consequent, tokens);
|
|
105
|
+
collectClassTokensFromNode(node.alternate, tokens);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (t.isSequenceExpression(node)) {
|
|
110
|
+
for (const expression of node.expressions) {
|
|
111
|
+
collectClassTokensFromNode(expression, tokens);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (t.isArrayExpression(node)) {
|
|
117
|
+
for (const element of node.elements) {
|
|
118
|
+
if (!element) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (t.isSpreadElement(element)) {
|
|
122
|
+
collectClassTokensFromNode(element.argument, tokens);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
collectClassTokensFromNode(element, tokens);
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (t.isObjectExpression(node)) {
|
|
131
|
+
for (const property of node.properties) {
|
|
132
|
+
if (t.isSpreadElement(property)) {
|
|
133
|
+
collectClassTokensFromNode(property.argument, tokens);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (t.isObjectProperty(property)) {
|
|
137
|
+
collectClassTokensFromNode(property.value, tokens);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
|
|
144
|
+
collectClassTokensFromNode(node.callee, tokens);
|
|
145
|
+
for (const argument of node.arguments) {
|
|
146
|
+
if (t.isSpreadElement(argument)) {
|
|
147
|
+
collectClassTokensFromNode(argument.argument, tokens);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
collectClassTokensFromNode(argument, tokens);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
|
|
156
|
+
collectClassTokensFromNode(node.object, tokens);
|
|
157
|
+
if (node.computed) {
|
|
158
|
+
collectClassTokensFromNode(node.property, tokens);
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (t.isNewExpression(node)) {
|
|
164
|
+
collectClassTokensFromNode(node.callee, tokens);
|
|
165
|
+
for (const argument of node.arguments ?? []) {
|
|
166
|
+
if (t.isSpreadElement(argument)) {
|
|
167
|
+
collectClassTokensFromNode(argument.argument, tokens);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
collectClassTokensFromNode(argument, tokens);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isClassNameObjectProperty(node: t.ObjectProperty): boolean {
|
|
176
|
+
if (node.computed) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
return (
|
|
180
|
+
(t.isIdentifier(node.key) && node.key.name === 'className') ||
|
|
181
|
+
(t.isStringLiteral(node.key) && node.key.value === 'className')
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isBabelNode(value: unknown): value is t.Node {
|
|
186
|
+
return (
|
|
187
|
+
typeof value === 'object' &&
|
|
188
|
+
value !== null &&
|
|
189
|
+
'type' in (value as Record<string, unknown>)
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isClassNamePropOnCompiledJsxCall(
|
|
194
|
+
node: t.ObjectProperty,
|
|
195
|
+
parent: t.Node | null,
|
|
196
|
+
grandParent: t.Node | null,
|
|
197
|
+
): boolean {
|
|
198
|
+
if (!isClassNameObjectProperty(node)) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (!parent || !t.isObjectExpression(parent)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
if (!grandParent || !t.isCallExpression(grandParent)) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
if (!isCompiledJsxCallee(grandParent.callee)) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
return grandParent.arguments.some((argument) => argument === parent);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getClassTokenStats(output: string): {
|
|
214
|
+
total: number;
|
|
215
|
+
unique: number;
|
|
216
|
+
assignments: number;
|
|
217
|
+
} {
|
|
218
|
+
try {
|
|
219
|
+
const ast = parseBabel(output, {
|
|
220
|
+
sourceType: 'module',
|
|
221
|
+
plugins: ['jsx', 'typescript'],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const uniqueTokens = new Set<string>();
|
|
225
|
+
let total = 0;
|
|
226
|
+
let assignments = 0;
|
|
227
|
+
|
|
228
|
+
const addTokens = (node: t.Node | null | undefined) => {
|
|
229
|
+
const nextTokens: string[] = [];
|
|
230
|
+
collectClassTokensFromNode(node, nextTokens);
|
|
231
|
+
total += nextTokens.length;
|
|
232
|
+
for (const token of nextTokens) {
|
|
233
|
+
uniqueTokens.add(token);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const visit = (
|
|
238
|
+
node: t.Node | null | undefined,
|
|
239
|
+
parent: t.Node | null,
|
|
240
|
+
grandParent: t.Node | null,
|
|
241
|
+
) => {
|
|
242
|
+
if (!node) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (
|
|
247
|
+
t.isJSXAttribute(node) &&
|
|
248
|
+
t.isJSXIdentifier(node.name) &&
|
|
249
|
+
node.name.name === 'className'
|
|
250
|
+
) {
|
|
251
|
+
assignments += 1;
|
|
252
|
+
addTokens(node.value);
|
|
253
|
+
} else if (t.isObjectProperty(node)) {
|
|
254
|
+
if (isClassNamePropOnCompiledJsxCall(node, parent, grandParent)) {
|
|
255
|
+
assignments += 1;
|
|
256
|
+
addTokens(node.value);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const visitorKeys = VISITOR_KEYS[node.type] ?? [];
|
|
261
|
+
const typedNode = node as unknown as Record<string, unknown>;
|
|
262
|
+
for (const key of visitorKeys) {
|
|
263
|
+
const childValue = typedNode[key];
|
|
264
|
+
if (Array.isArray(childValue)) {
|
|
265
|
+
for (const item of childValue) {
|
|
266
|
+
if (isBabelNode(item)) {
|
|
267
|
+
visit(item, node, parent);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (isBabelNode(childValue)) {
|
|
273
|
+
visit(childValue, node, parent);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
visit(ast.program, null, null);
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
total,
|
|
282
|
+
unique: uniqueTokens.size,
|
|
283
|
+
assignments,
|
|
284
|
+
};
|
|
285
|
+
} catch {
|
|
286
|
+
const fallbackAssignments = (output.match(/className\s*[:=]/g) ?? [])
|
|
287
|
+
.length;
|
|
288
|
+
return {
|
|
289
|
+
total: 0,
|
|
290
|
+
unique: 0,
|
|
291
|
+
assignments: fallbackAssignments,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function getRuntimeStyleParserCallCount(output: string): number {
|
|
297
|
+
const patterns = [/extractStyleProps\s*\(/g, /getStyles\s*\(/g];
|
|
298
|
+
return patterns.reduce(
|
|
299
|
+
(count, pattern) => count + (output.match(pattern) ?? []).length,
|
|
300
|
+
0,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function getLoaderTransformOutputSpecs(
|
|
305
|
+
output: string,
|
|
306
|
+
): LoaderTransformOutputSpecs {
|
|
307
|
+
const classTokenStats = getClassTokenStats(output);
|
|
308
|
+
return {
|
|
309
|
+
outputGzipBytes: gzipSync(output).byteLength,
|
|
310
|
+
classNameAssignments: classTokenStats.assignments,
|
|
311
|
+
utilityClassTokens: classTokenStats.total,
|
|
312
|
+
uniqueUtilityClassTokens: classTokenStats.unique,
|
|
313
|
+
runtimeStyleParserCalls: getRuntimeStyleParserCallCount(output),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const VIRTUAL_CONFIG_PATH = '/virtual/uds-config.json';
|
|
318
|
+
|
|
319
|
+
export function runLoaderTransform(
|
|
320
|
+
source: string,
|
|
321
|
+
options: RunLoaderTransformOptions,
|
|
322
|
+
): LoaderTransformResult {
|
|
323
|
+
if (typeof source !== 'string') {
|
|
324
|
+
return { error: '`source` must be a string.' };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Seed the loader's in-memory cache so it skips disk I/O
|
|
328
|
+
seedUdsConfig(VIRTUAL_CONFIG_PATH, options.config);
|
|
329
|
+
|
|
330
|
+
const context = {
|
|
331
|
+
getOptions: () => ({
|
|
332
|
+
dataPath: VIRTUAL_CONFIG_PATH,
|
|
333
|
+
isDev: options.isDev ?? false,
|
|
334
|
+
}),
|
|
335
|
+
addDependency: () => {},
|
|
336
|
+
resourcePath:
|
|
337
|
+
options.resourcePath ??
|
|
338
|
+
path.resolve(process.cwd(), 'playground-input.tsx'),
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const output = (
|
|
343
|
+
transformLoader as unknown as (this: unknown, code: string) => string
|
|
344
|
+
).call(context, source);
|
|
345
|
+
return { output, specs: getLoaderTransformOutputSpecs(output) };
|
|
346
|
+
} catch (error) {
|
|
347
|
+
return {
|
|
348
|
+
error:
|
|
349
|
+
error instanceof Error
|
|
350
|
+
? error.message
|
|
351
|
+
: 'Transform failed unexpectedly.',
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|