@yahoo/uds-create-loader 5.88.0 → 5.88.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yahoo/uds-create-loader",
3
- "version": "5.88.0",
3
+ "version": "5.88.1",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "files": [
@@ -38,7 +38,7 @@
38
38
  "test:update": "bun test src/ --update-snapshots"
39
39
  },
40
40
  "dependencies": {
41
- "@yahoo/uds-create-config": "5.88.0",
41
+ "@yahoo/uds-create-config": "5.88.1",
42
42
  "@babel/parser": "^7.29.0",
43
43
  "@babel/traverse": "^7.29.0",
44
44
  "@babel/types": "^7.29.0",
@@ -154,6 +154,101 @@ export function Demo() {
154
154
  });
155
155
  });
156
156
 
157
+ describe('loader — re-export rewrite', () => {
158
+ it("re-exports a linked system's sources as the modules the build emitted", () => {
159
+ const source = `
160
+ export { Button } from '@/uds/linked/yos-v3/button';
161
+ export { createToast, ToastPortal } from "@/uds/linked/yos-v3/toast-portal";
162
+ export * from '@/uds/linked/yos-v3/box';
163
+ `;
164
+ const output = runLoader(source, makeEmptyConfig());
165
+
166
+ expect(output).toBe(`
167
+ export { Button } from '#uds/components/_linked/yos-v3/button';
168
+ export { createToast, ToastPortal } from "#uds/components/_linked/yos-v3/toast-portal";
169
+ export * from '#uds/components/_linked/yos-v3/box';
170
+ `);
171
+ });
172
+
173
+ it('re-exports `@/uds/components/<seg>` as `#uds/components/<seg>`, whatever the case of the segment', () => {
174
+ const source = `
175
+ export { Box } from '@/uds/components/Box';
176
+ export { Box as Panel } from '@/uds/components/box';
177
+ `;
178
+ const output = runLoader(source, makeBoxConfig());
179
+
180
+ expect(output).toBe(`
181
+ export { Box } from '#uds/components/Box';
182
+ export { Box as Panel } from '#uds/components/box';
183
+ `);
184
+ });
185
+
186
+ it("re-exports a component file's helpers through the same generated module", () => {
187
+ const source = `
188
+ export { BoxProvider, useBox } from '@/uds/components/box';
189
+ export { default as BoxDefault } from '@/uds/components/box';
190
+ `;
191
+ const output = runLoader(source, makeBoxConfig());
192
+
193
+ expect(output).toBe(`
194
+ export { BoxProvider, useBox } from '#uds/components/box';
195
+ export { default as BoxDefault } from '#uds/components/box';
196
+ `);
197
+ });
198
+
199
+ it("re-exports everything from a component's file through its generated module", () => {
200
+ const source = `
201
+ export * from '@/uds/components/box';
202
+ export * as box from '@/uds/components/box';
203
+ export * from '@/uds/components/utils';
204
+ `;
205
+ const output = runLoader(source, makeBoxConfig());
206
+
207
+ expect(output).toBe(`
208
+ export * from '#uds/components/box';
209
+ export * as box from '#uds/components/box';
210
+ export * from '@/uds/components/utils';
211
+ `);
212
+ });
213
+
214
+ it('leaves type-only re-exports pointed at the source', () => {
215
+ const source = `
216
+ export type { BoxProps } from '@/uds/components/box';
217
+ export type { ButtonProps } from '@/uds/linked/yos-v3/button';
218
+ export type * from '@/uds/components/box';
219
+ `;
220
+ const output = runLoader(source, makeBoxConfig());
221
+
222
+ expect(output).toBe(source);
223
+ });
224
+
225
+ it('rewrites a re-export that mixes values and types, for its values', () => {
226
+ const source = `
227
+ export { Box, type BoxProps } from '@/uds/components/box';
228
+ export { type Box as BoxType } from '@/uds/components/layout';
229
+ `;
230
+ const output = runLoader(source, makeBoxConfig());
231
+
232
+ expect(output).toBe(`
233
+ export { Box, type BoxProps } from '#uds/components/box';
234
+ export { type Box as BoxType } from '@/uds/components/layout';
235
+ `);
236
+ });
237
+
238
+ it('leaves non-UDS and relative re-exports alone', () => {
239
+ const source = `
240
+ export { Box } from './box';
241
+ export * from '../uds/components/box';
242
+ export { Image } from 'next/image';
243
+ export { Box as Other } from '@/components/box';
244
+ export const local = 1;
245
+ `;
246
+ const output = runLoader(source, makeBoxConfig());
247
+
248
+ expect(output).toBe(source);
249
+ });
250
+ });
251
+
157
252
  describe('loader — JSX passes through unchanged', () => {
158
253
  it('leaves JSX call sites alone while rewriting the import', () => {
159
254
  const config = makeBoxConfig();
package/src/loader.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parse } from '@babel/parser';
2
2
  import _traverse from '@babel/traverse';
3
+ import type * as t from '@babel/types';
3
4
  import {
4
5
  resolveLinkedAlias,
5
6
  resolveLinkedDir,
@@ -19,9 +20,10 @@ const traverse = (_traverse as any).default ?? _traverse;
19
20
 
20
21
  /**
21
22
  * The UDS loader's only build-time job: rewrite alias-prefixed local
22
- * imports of registered UDS components from `@/uds/components/<seg>`
23
- * (the authoring file) to `'#uds/components/<seg>'` (the codegen-
24
- * emitted per-source-file module at `.uds/components/<seg>.tsx`).
23
+ * imports and re-exports of registered UDS components from
24
+ * `@/uds/components/<seg>` (the authoring file) to
25
+ * `'#uds/components/<seg>'` (the codegen-emitted per-source-file module
26
+ * at `.uds/components/<seg>.tsx`).
25
27
  *
26
28
  * The authoring file is read only by codegen — production renders the
27
29
  * downleveled FC. Per-file targets keep Next.js Fast Refresh surgical:
@@ -102,8 +104,10 @@ const transformLoader: LoaderDefinitionFunction = function (source) {
102
104
 
103
105
  // Cheap pre-filter: only parse files that look like they import or
104
106
  // reference a UDS component. Avoids babel overhead on the bulk of
105
- // application files.
106
- let hasComponentReference = source.includes(linkedAlias);
107
+ // application files. An `export * from '@/uds/components/box'` names no
108
+ // component, so the path itself also counts.
109
+ let hasComponentReference =
110
+ source.includes(linkedAlias) || source.includes('/uds/components/');
107
111
  for (const name of projection.componentNames) {
108
112
  if (source.includes(name)) {
109
113
  hasComponentReference = true;
@@ -134,7 +138,7 @@ const transformLoader: LoaderDefinitionFunction = function (source) {
134
138
 
135
139
  // Collected source-literal rewrites, keyed by character offset.
136
140
  // We splice these into the original `source` string rather than
137
- // regenerating the file: the loader's only job is to swap import
141
+ // regenerating the file: the loader's only job is to swap module
138
142
  // source strings, and re-printing the whole file through
139
143
  // @babel/generator (esp. with `retainLines`) can mangle dense JSX
140
144
  // into invalid output. Surgical splicing keeps every byte outside
@@ -142,110 +146,127 @@ const transformLoader: LoaderDefinitionFunction = function (source) {
142
146
  // valid file into a broken one.
143
147
  const edits: Array<{ start: number; end: number; value: string }> = [];
144
148
 
149
+ /**
150
+ * Rewrites one import or re-export source to the generated module it
151
+ * runs as. `names` are the value bindings the statement takes from that
152
+ * module, as the module spells them.
153
+ */
154
+ const rewriteSource = ({
155
+ sourceNode,
156
+ names,
157
+ }: {
158
+ sourceNode: t.StringLiteral;
159
+ names: string[];
160
+ }) => {
161
+ const { start, end, value: moduleSource } = sourceNode;
162
+ if (typeof start !== 'number' || typeof end !== 'number') return;
163
+
164
+ // Splice over the literal's character range (quotes included) using
165
+ // the original quote char so the rest of the file is untouched.
166
+ const quote = source[start] ?? "'";
167
+ const splice = (target: string) => {
168
+ edits.push({ start, end, value: `${quote}${target}${quote}` });
169
+ };
170
+
171
+ // A linked system's source, imported by its alias for the types (`@/uds/linked/yos/avatar`),
172
+ // runs as the module the build emitted from it — the same path under `components/_linked/`.
173
+ if (moduleSource.startsWith(linkedAlias)) {
174
+ splice(
175
+ `${outputAlias}/components/_linked/${moduleSource.slice(linkedAlias.length)}`,
176
+ );
177
+ return;
178
+ }
179
+
180
+ // Only rewrite alias-prefixed local imports (e.g.
181
+ // `@/uds/components/<name>`, `~/uds/components/<name>`).
182
+ // Relative imports (`./text`, `../box`) belong to the authoring
183
+ // chain where each `defineComponent(...)` may reference its
184
+ // siblings (`defineComponent(Text).config(...)`) — rewriting those
185
+ // would break codegen's read of the configs.
186
+ if (moduleSource.startsWith('.') || moduleSource.startsWith('/')) return;
187
+
188
+ // Constrain to `*/uds/components/<name>` paths so unrelated
189
+ // imports whose tail happens to match a registered name
190
+ // (`next/image` → `Image`) don't get rewritten.
191
+ const segmentMatch = moduleSource.match(/\/uds\/components\/([^/]+)$/);
192
+ if (!segmentMatch) return;
193
+
194
+ const segment = segmentMatch[1];
195
+
196
+ // First-pass match: any name matches a registered component. The
197
+ // common case — `import { Box } from '@/uds/components/box'`.
198
+ //
199
+ // Second-pass match: the path points at the authoring file of a
200
+ // registered component (e.g. `@/uds/components/sidebar`) but the
201
+ // visible names are co-located helpers like `SidebarProvider` /
202
+ // `useSidebar`. If we let this resolve to the source file, the helper
203
+ // sets up a different `SidebarContext` than the codegen-emitted
204
+ // `Sidebar` reads — provider and consumer end up with different
205
+ // context identities and `useSidebar()` throws. Funnel every export
206
+ // from that file through the codegen module so helper and component
207
+ // share the same module.
208
+ const pascal = segment
209
+ .split('-')
210
+ .filter(Boolean)
211
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
212
+ .join('');
213
+ const namesComponent = names.some((name) =>
214
+ projection.componentNames.has(name),
215
+ );
216
+
217
+ if (!namesComponent && !projection.componentNames.has(pascal)) return;
218
+
219
+ // Per-file rewrite: keep the trailing `<seg>` so each import resolves
220
+ // to its own downleveled module at `.uds/components/<seg>.tsx`.
221
+ splice(`${outputAlias}/components/${segment}`);
222
+ };
223
+
145
224
  traverse(ast, {
146
- // -----------------------------------------------------------------------
147
- // Import rewrite: alias-prefixed local imports of registered UDS
148
- // components → `#uds/components/<seg>` (the per-source-file
149
- // codegen-emitted module).
150
- // -----------------------------------------------------------------------
151
225
  // biome-ignore lint/suspicious/noExplicitAny: untyped babel traverse visitor
152
226
  ImportDeclaration(nodePath: any) {
153
- const importSource: string | undefined = nodePath.node.source?.value;
154
- if (!importSource) return;
227
+ const names: string[] = [];
155
228
 
156
- // A linked system's source, imported by its alias for the types (`@/uds/linked/yos/avatar`),
157
- // runs as the module the build emitted from it — the same path under `components/_linked/`.
158
- if (importSource.startsWith(linkedAlias)) {
159
- const sourceNode = nodePath.node.source;
229
+ for (const specifier of nodePath.node.specifiers) {
160
230
  if (
161
- typeof sourceNode.start !== 'number' ||
162
- typeof sourceNode.end !== 'number'
231
+ specifier.type === 'ImportSpecifier' &&
232
+ specifier.imported?.type === 'Identifier'
163
233
  ) {
164
- return;
234
+ names.push(specifier.imported.name);
165
235
  }
166
- const quote = source[sourceNode.start] ?? "'";
167
- edits.push({
168
- start: sourceNode.start,
169
- end: sourceNode.end,
170
- value: `${quote}${outputAlias}/components/_linked/${importSource.slice(linkedAlias.length)}${quote}`,
171
- });
172
- return;
173
236
  }
174
237
 
175
- // Only rewrite alias-prefixed local imports (e.g.
176
- // `@/uds/components/<name>`, `~/uds/components/<name>`).
177
- // Relative imports (`./text`, `../box`) belong to the authoring
178
- // chain where each `defineComponent(...)` may reference its
179
- // siblings (`defineComponent(Text).config(...)`) — rewriting those
180
- // would break codegen's read of the configs.
181
- const isAliasedLocalImport =
182
- !importSource.startsWith('.') && !importSource.startsWith('/');
183
- if (!isAliasedLocalImport) return;
184
-
185
- // First-pass match: any specifier name matches a registered
186
- // component. The common case — `import { Box } from
187
- // '@/uds/components/box'`.
188
- let shouldRewriteSource = false;
189
- for (const specifier of nodePath.node.specifiers) {
190
- if (specifier.type !== 'ImportSpecifier') continue;
191
- const importedName: string | undefined =
192
- specifier.imported?.type === 'Identifier'
193
- ? specifier.imported.name
194
- : undefined;
195
- if (!importedName) continue;
238
+ rewriteSource({ sourceNode: nodePath.node.source, names });
239
+ },
196
240
 
197
- if (projection.componentNames.has(importedName)) {
198
- shouldRewriteSource = true;
199
- }
200
- }
241
+ // `export { Box } from '…'` runs the same module an import of `Box`
242
+ // would. `export type { … } from '…'` is erased before it runs, and
243
+ // stays pointed at the source its types come from.
244
+ // biome-ignore lint/suspicious/noExplicitAny: untyped babel traverse visitor
245
+ ExportNamedDeclaration(nodePath: any) {
246
+ const { node } = nodePath;
247
+ if (!node.source || node.exportKind === 'type') return;
201
248
 
202
- // Second-pass match: the import path points at the authoring
203
- // file of a registered component (e.g. `@/uds/components/sidebar`)
204
- // but the visible specifiers are co-located helpers like
205
- // `SidebarProvider` / `useSidebar`. If we let this resolve to the
206
- // source file, the helper sets up a different `SidebarContext`
207
- // than the codegen-emitted `Sidebar` reads — provider and consumer
208
- // end up with different context identities and `useSidebar()`
209
- // throws. Funnel every export from that file through the codegen
210
- // module so helper AND component share the same module.
211
- //
212
- // Constrain to `*/uds/components/<name>` paths so unrelated
213
- // imports whose tail happens to match a registered name
214
- // (`next/image` → `Image`) don't get rewritten.
215
- const segmentMatch = importSource.match(/\/uds\/components\/([^/]+)$/);
216
- if (!shouldRewriteSource && segmentMatch) {
217
- const tail = segmentMatch[1];
218
- const pascal = tail
219
- .split('-')
220
- .filter(Boolean)
221
- .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
222
- .join('');
223
- if (projection.componentNames.has(pascal)) {
224
- shouldRewriteSource = true;
225
- }
226
- }
249
+ const names: string[] = [];
227
250
 
228
- if (shouldRewriteSource && segmentMatch) {
229
- // Per-file rewrite: keep the trailing `<seg>` so each import
230
- // resolves to its own downleveled module at
231
- // `.uds/components/<seg>.tsx`. Splice over the literal's
232
- // character range (quotes included) using the original quote
233
- // char so the rest of the file is untouched.
234
- const sourceNode = nodePath.node.source;
251
+ for (const specifier of node.specifiers) {
235
252
  if (
236
- typeof sourceNode.start !== 'number' ||
237
- typeof sourceNode.end !== 'number'
253
+ specifier.type === 'ExportSpecifier' &&
254
+ specifier.exportKind !== 'type' &&
255
+ specifier.local?.type === 'Identifier'
238
256
  ) {
239
- return;
257
+ names.push(specifier.local.name);
240
258
  }
241
-
242
- const quote = source[sourceNode.start] ?? "'";
243
- edits.push({
244
- start: sourceNode.start,
245
- end: sourceNode.end,
246
- value: `${quote}${outputAlias}/components/${segmentMatch[1]}${quote}`,
247
- });
248
259
  }
260
+
261
+ rewriteSource({ sourceNode: node.source, names });
262
+ },
263
+
264
+ // biome-ignore lint/suspicious/noExplicitAny: untyped babel traverse visitor
265
+ ExportAllDeclaration(nodePath: any) {
266
+ const { node } = nodePath;
267
+ if (node.exportKind === 'type') return;
268
+
269
+ rewriteSource({ sourceNode: node.source, names: [] });
249
270
  },
250
271
  });
251
272