fix(cem-plugin): exports module reference
Some checks are pending
Tests / Unit (push) Waiting to run
Tests / E2E (push) Waiting to run

The plugin correctly updates the modules own path, but left
exports[].declaration.module
This commit is contained in:
ayo 2026-07-25 11:41:57 +02:00
parent d86fd259e9
commit 283531b60b
3 changed files with 124 additions and 11 deletions

View file

@ -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

View file

@ -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)
}
},
}

View file

@ -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')
})
})
})