From 283531b60b8cb21afe7fecf4ae42ce592f4b7211 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sat, 25 Jul 2026 11:41:57 +0200 Subject: [PATCH] fix(cem-plugin): exports module reference The plugin correctly updates the modules own path, but left exports[].declaration.module --- docs/src/content/docs/api/cem-plugin.md | 5 ++ src/cem-plugin.js | 46 ++++++++++---- test/cem-plugin.test.mjs | 84 +++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/docs/src/content/docs/api/cem-plugin.md b/docs/src/content/docs/api/cem-plugin.md index 3754be7..1065536 100644 --- a/docs/src/content/docs/api/cem-plugin.md +++ b/docs/src/content/docs/api/cem-plugin.md @@ -82,6 +82,11 @@ extensions to their emitted JS form — `.ts` → `.js`, `.mts` → `.mjs`, `.cts` → `.cjs`. Other extensions pass through, so a plain `.js` source only has its directory swapped. `options.ext` merges over that default map. +References that point back at a module — `exports[].declaration`, `superclass`, +`mixins[]` — are rewritten along with the paths, so the `module` a consumer +follows still resolves to a module in the manifest. A reference carrying a +`package` names another package's layout and is left untouched. + It runs in the analyzer's `packageLinkPhase` (after `wcbStaticProps`'s `analyzePhase`), so ordering the two in the `plugins` array does not matter. Run `cem analyze` after your build so the files the rewritten paths point at diff --git a/src/cem-plugin.js b/src/cem-plugin.js index 04fa225..b0d537b 100644 --- a/src/cem-plugin.js +++ b/src/cem-plugin.js @@ -215,6 +215,11 @@ export function wcbStaticProps() { * extensions to their emitted JS form (`.ts`→`.js`, `.mts`→`.mjs`, * `.cts`→`.cjs`); other extensions pass through untouched. Override `rootDir` / * `outDir` (named to mirror `tsconfig`) or extend `ext` for other layouts. + * + * Every internal reference that points back at a module (`exports[].declaration`, + * `superclass`, `mixins[]`, …) is rewritten with the module paths, so the + * `module` a consumer follows still resolves. References carrying a `package` + * name another package's layout and are left alone. * @param {object} [options] * @param {string} [options.rootDir] source directory prefix to replace (default `'src'`) * @param {string} [options.outDir] built-output directory to point at (default `'dist'`) @@ -233,21 +238,40 @@ export function distPaths(options = {}) { const from = withSlash(options.rootDir ?? 'src') const to = withSlash(options.outDir ?? 'dist') const ext = { '.ts': '.js', '.mts': '.mjs', '.cts': '.cjs', ...options.ext } + + /** + * Maps one scanned source path to the built file that ships in its place. + * @param {string} path the path the analyzer stamped + * @returns {string} the built-output path + */ + const rewrite = (path) => { + const swapped = path.startsWith(from) ? to + path.slice(from.length) : path + for (const [srcExt, outExt] of Object.entries(ext)) + if (swapped.endsWith(srcExt)) + return swapped.slice(0, -srcExt.length) + outExt + return swapped + } + + /** + * Walks a manifest node and rewrites `module` on every reference to a module + * in this package, so references keep resolving after the paths move. + * @param {any} node a manifest node, array, or leaf value + * @returns {void} + */ + const rewriteRefs = (node) => { + if (Array.isArray(node)) return node.forEach(rewriteRefs) + if (!node || typeof node !== 'object') return + if (typeof node.module === 'string' && !node.package) + node.module = rewrite(node.module) + for (const value of Object.values(node)) rewriteRefs(value) + } + return { name: 'wcb-dist-paths', packageLinkPhase({ customElementsManifest }) { for (const mod of customElementsManifest.modules ?? []) { - if (!mod.path) continue - let path = mod.path.startsWith(from) - ? to + mod.path.slice(from.length) - : mod.path - for (const [srcExt, outExt] of Object.entries(ext)) { - if (path.endsWith(srcExt)) { - path = path.slice(0, -srcExt.length) + outExt - break - } - } - mod.path = path + if (mod.path) mod.path = rewrite(mod.path) + rewriteRefs(mod) } }, } diff --git a/test/cem-plugin.test.mjs b/test/cem-plugin.test.mjs index 6187c58..6bd3082 100644 --- a/test/cem-plugin.test.mjs +++ b/test/cem-plugin.test.mjs @@ -205,6 +205,24 @@ function modulePaths(fileNames, plugins) { return manifest.modules.map((m) => m.path) } +/** + * Analyzes one source file with `wcbStaticProps` + `distPaths` installed and + * returns the resulting module doc — `packageLinkPhase` included. + * @param {string} fileName the source path stamped on the scanned module + * @param {string} source component source to analyze + * @returns {any} the module doc from the generated manifest + */ +function analyzedModule(fileName, source) { + const manifest = create({ + modules: [ + ts.createSourceFile(fileName, source, ts.ScriptTarget.ES2015, true), + ], + plugins: [wcbStaticProps(), distPaths()], + context: { dev: false, thirdPartyCEMs: [] }, + }) + return manifest.modules[0] +} + describe('cem-plugin: distPaths', () => { it('rewrites src/*.ts paths to dist/*.js by default', () => { expect(modulePaths(['src/wcb-button.ts'], [distPaths()])).toEqual([ @@ -255,4 +273,70 @@ describe('cem-plugin: distPaths', () => { const doc = mod.declarations.find((d) => d.name === 'CozyButton') expect(doc.attributes.map((a) => a.name)).toContain('max-count') }) + + describe('references back to the rewritten modules', () => { + const mod = analyzedModule('src/cozy-button.ts', COZY_BUTTON) + + it('rewrites the module every export declaration points at', () => { + // A reference left on `src/cozy-button.ts` resolves to no module in the + // manifest — and to a file the package does not publish. + expect(mod.exports.length).toBeGreaterThan(0) + for (const exported of mod.exports) + expect(exported.declaration.module).toBe('dist/cozy-button.js') + }) + + it('keeps every internal reference pointing at a module in the manifest', () => { + const paths = [mod.path] + for (const exported of mod.exports) + expect(paths).toContain(exported.declaration.module) + }) + + it('leaves references into another package alone', () => { + const doc = mod.declarations.find((d) => d.name === 'CozyButton') + // `WebComponent` lives in web-component-base, whose layout this plugin + // knows nothing about — rewriting it would invent a path. + expect(doc.superclass).toMatchObject({ + name: 'WebComponent', + package: 'web-component-base', + }) + expect(doc.superclass.module).toBeUndefined() + }) + + it('rewrites a same-package reference but not a cross-package one', () => { + const manifest = create({ + modules: [ + ts.createSourceFile('src/x.ts', '', ts.ScriptTarget.ES2015, true), + ], + plugins: [ + // Seeds before distPaths: plugins run a phase in array order. + { + name: 'seed-refs', + packageLinkPhase({ customElementsManifest }) { + customElementsManifest.modules[0].declarations = [ + { + kind: 'class', + name: 'X', + superclass: { name: 'Base', module: 'src/base.ts' }, + mixins: [ + { name: 'Ext', module: 'src/mix.ts' }, + { + name: 'Vendor', + module: 'src/v.ts', + package: 'vendor-ui', + }, + ], + }, + ] + }, + }, + distPaths(), + ], + context: { dev: false, thirdPartyCEMs: [] }, + }) + const [doc] = manifest.modules[0].declarations + expect(doc.superclass.module).toBe('dist/base.js') + expect(doc.mixins[0].module).toBe('dist/mix.js') + expect(doc.mixins[1].module).toBe('src/v.ts') + }) + }) })