Skip to content

Commit 0f7e65f

Browse files
committed
adding support for publishing
1 parent a11a102 commit 0f7e65f

File tree

4 files changed

+354
-6
lines changed

4 files changed

+354
-6
lines changed

packages/docusaurus/docs/features/catalogs.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,26 @@ Reference named catalogs by specifying the name:
6464
}
6565
```
6666

67+
## Publishing
68+
69+
When publishing packages, the `catalog:` protocol is automatically replaced with actual version ranges, ensuring compatibility with other package managers:
70+
71+
```json
72+
// Source package.json
73+
{
74+
"dependencies": {
75+
"react": "catalog:react18"
76+
}
77+
}
78+
79+
// Published package.json
80+
{
81+
"dependencies": {
82+
"react": "^18.3.1"
83+
}
84+
}
85+
```
86+
6787
## Supported fields
6888

6989
The `catalog:` protocol works in `dependencies`, `devDependencies`, `peerDependencies`, and `optionalDependencies`.

packages/plugin-catalog/sources/index.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import {type Descriptor, type Locator, type Plugin, type Project, type Resolver, type ResolveOptions, SettingsType} from '@yarnpkg/core';
1+
import {type Descriptor, type Locator, type Plugin, type Project, type Resolver, type ResolveOptions, type Workspace, SettingsType, structUtils} from '@yarnpkg/core';
2+
import {Hooks as CoreHooks} from '@yarnpkg/core';
3+
import {DEPENDENCY_TYPES, Hooks as PackHooks} from '@yarnpkg/plugin-pack';
24

3-
import {isCatalogReference, resolveDescriptorFromCatalog} from './utils';
5+
import {isCatalogReference, resolveDescriptorFromCatalog} from './utils';
46

57
declare module '@yarnpkg/core' {
68
interface ConfigurationValueMap {
@@ -9,8 +11,17 @@ declare module '@yarnpkg/core' {
911
}
1012
}
1113

12-
const plugin: Plugin = {
14+
15+
const plugin: Plugin<CoreHooks & PackHooks> = {
1316
configuration: {
17+
/**
18+
* Example:
19+
* ```yaml
20+
* catalog:
21+
* react: ^18.3.1
22+
* lodash: ^4.17.21
23+
* ```
24+
*/
1425
catalog: {
1526
description: `The default catalog of packages`,
1627
type: SettingsType.MAP,
@@ -19,6 +30,18 @@ const plugin: Plugin = {
1930
type: SettingsType.STRING,
2031
},
2132
},
33+
/**
34+
* Example:
35+
* ```yaml
36+
* catalogs:
37+
* react18:
38+
* react: ^18.3.1
39+
* react-dom: ^18.3.1
40+
* react17:
41+
* react: ^17.0.2
42+
* react-dom: ^17.0.2
43+
* ```
44+
*/
2245
catalogs: {
2346
description: `Named catalogs of packages`,
2447
type: SettingsType.MAP,
@@ -33,9 +56,44 @@ const plugin: Plugin = {
3356
},
3457
},
3558
hooks: {
59+
/**
60+
* To allow publishing packages with catalog references, we need to replace the
61+
* catalog references with the actual version ranges during the packing phase.
62+
*/
63+
beforeWorkspacePacking: (workspace: Workspace, rawManifest: any) => {
64+
const project = workspace.project;
65+
66+
for (const dependencyType of DEPENDENCY_TYPES) {
67+
const dependencies = rawManifest[dependencyType];
68+
if (!dependencies) continue;
69+
70+
for (const [identStr, range] of Object.entries(dependencies)) {
71+
if (typeof range !== `string` || !isCatalogReference(range)) continue;
72+
73+
try {
74+
// Create a descriptor to resolve from catalog
75+
const ident = structUtils.parseIdent(identStr);
76+
const descriptor = structUtils.makeDescriptor(ident, range);
77+
78+
// Resolve the catalog reference to get the actual version range
79+
const resolvedDescriptor = resolveDescriptorFromCatalog(project, descriptor);
80+
81+
// Replace the catalog reference with the resolved range
82+
dependencies[identStr] = resolvedDescriptor.range;
83+
} catch {
84+
// If resolution fails, leave the catalog reference as-is
85+
// This will allow the error to be caught during normal resolution
86+
continue;
87+
}
88+
}
89+
}
90+
},
3691

37-
reduceDependency: (dependency: Descriptor, project: Project, locator: Locator, initialDependency: Descriptor, {resolver, resolveOptions}: {resolver: Resolver, resolveOptions: ResolveOptions}) => {
38-
// On this hook, we will check if the dependency is a catalog reference, and if so, we will replace the range with the actual range defined in the catalog
92+
/**
93+
* On this hook, we will check if the dependency is a catalog reference, and if so,
94+
* we will replace the range with the actual range defined in the catalog.
95+
*/
96+
reduceDependency: async (dependency: Descriptor, project: Project, locator: Locator, initialDependency: Descriptor, {resolver, resolveOptions}: {resolver: Resolver, resolveOptions: ResolveOptions}) => {
3997
if (isCatalogReference(dependency.range)) {
4098
const resolvedDescriptor = resolveDescriptorFromCatalog(project, dependency);
4199
return resolvedDescriptor;
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import {Configuration, Project, structUtils, Plugin} from '@yarnpkg/core';
2+
import {PortablePath, xfs, ppath, Filename} from '@yarnpkg/fslib';
3+
import {packUtils} from '@yarnpkg/plugin-pack';
4+
5+
import catalogPlugin from '../sources/index';
6+
7+
describe(`Catalog publishing behavior`, () => {
8+
let tmpDir: PortablePath;
9+
let configuration: Configuration;
10+
let project: Project;
11+
12+
beforeEach(async () => {
13+
tmpDir = await xfs.mktempPromise();
14+
15+
// Create a workspace structure
16+
await xfs.writeJsonPromise(ppath.join(tmpDir, Filename.manifest), {
17+
name: `test-workspace`,
18+
version: `1.0.0`,
19+
workspaces: [`packages/*`],
20+
});
21+
22+
const packageDir = ppath.join(tmpDir, `packages`, `test-package`);
23+
await xfs.mkdirpPromise(packageDir);
24+
25+
// Create a package with catalog dependencies
26+
await xfs.writeJsonPromise(ppath.join(packageDir, Filename.manifest), {
27+
name: `@test/package`,
28+
version: `1.0.0`,
29+
dependencies: {
30+
react: `catalog:`,
31+
vue: `catalog:vue3`,
32+
lodash: `catalog:`,
33+
},
34+
devDependencies: {
35+
typescript: `catalog:`,
36+
eslint: `catalog:linting`,
37+
},
38+
peerDependencies: {
39+
rxjs: `catalog:rx`,
40+
},
41+
});
42+
43+
// Create plugins map with catalog plugin
44+
const plugins = new Map<string, Plugin<any>>();
45+
plugins.set(`catalog`, catalogPlugin);
46+
47+
configuration = Configuration.create(tmpDir, tmpDir, plugins);
48+
49+
// Set up catalog configuration directly
50+
const defaultCatalog = new Map([
51+
[`react`, `^18.3.1`],
52+
[`lodash`, `^4.17.21`],
53+
[`typescript`, `~5.2.0`],
54+
]);
55+
configuration.values.set(`catalog`, defaultCatalog);
56+
57+
const namedCatalogs = new Map([
58+
[`vue3`, new Map([
59+
[`vue`, `^3.4.0`],
60+
])],
61+
[`linting`, new Map([
62+
[`eslint`, `^8.57.0`],
63+
])],
64+
[`rx`, new Map([
65+
[`rxjs`, `^7.8.0`],
66+
])],
67+
]);
68+
configuration.values.set(`catalogs`, namedCatalogs);
69+
70+
const {project: foundProject} = await Project.find(configuration, tmpDir);
71+
project = foundProject;
72+
});
73+
74+
afterEach(async () => {
75+
await xfs.removePromise(tmpDir);
76+
});
77+
78+
it(`should replace catalog: protocol with actual version ranges during packaging`, async () => {
79+
// Find the test package workspace
80+
const workspace = project.getWorkspaceByIdent(structUtils.makeIdent(`test`, `package`));
81+
82+
if (!workspace)
83+
throw new Error(`Test workspace not found`);
84+
85+
86+
// Generate the package manifest (this is what gets published)
87+
const publishedManifest = await packUtils.genPackageManifest(workspace);
88+
const publishedDeps = (publishedManifest as any);
89+
90+
// Verify that catalog: protocol has been replaced with actual version ranges
91+
expect(publishedDeps.dependencies.react).toBe(`npm:^18.3.1`);
92+
expect(publishedDeps.dependencies.vue).toBe(`npm:^3.4.0`);
93+
expect(publishedDeps.dependencies.lodash).toBe(`npm:^4.17.21`);
94+
95+
expect(publishedDeps.devDependencies.typescript).toBe(`npm:~5.2.0`);
96+
expect(publishedDeps.devDependencies.eslint).toBe(`npm:^8.57.0`);
97+
98+
expect(publishedDeps.peerDependencies.rxjs).toBe(`npm:^7.8.0`);
99+
100+
101+
// Verify that no catalog: protocol remains
102+
const allDeps = {
103+
...publishedDeps.dependencies,
104+
...publishedDeps.devDependencies,
105+
...publishedDeps.peerDependencies,
106+
};
107+
108+
for (const [depName, depRange] of Object.entries(allDeps)) {
109+
expect(typeof depRange).toBe(`string`);
110+
expect((depRange as string).startsWith(`catalog:`)).toBe(false);
111+
}
112+
});
113+
114+
it(`should handle packages without catalog dependencies`, async () => {
115+
// Create a package without catalog dependencies
116+
const noCatalogDir = ppath.join(tmpDir, `packages`, `no-catalog`);
117+
await xfs.mkdirpPromise(noCatalogDir);
118+
119+
await xfs.writeJsonPromise(ppath.join(noCatalogDir, Filename.manifest), {
120+
name: `@test/no-catalog`,
121+
version: `1.0.0`,
122+
dependencies: {
123+
react: `^17.0.0`,
124+
lodash: `~4.16.0`,
125+
},
126+
});
127+
128+
// Re-find the project to include the new package
129+
const {project: updatedProject} = await Project.find(configuration, tmpDir);
130+
const workspace = updatedProject.getWorkspaceByIdent(structUtils.makeIdent(`test`, `no-catalog`));
131+
132+
if (!workspace)
133+
throw new Error(`No-catalog workspace not found`);
134+
135+
136+
const publishedManifest = await packUtils.genPackageManifest(workspace);
137+
const publishedDeps = (publishedManifest as any);
138+
139+
// Verify that regular version ranges are unchanged
140+
expect(publishedDeps.dependencies.react).toBe(`^17.0.0`);
141+
expect(publishedDeps.dependencies.lodash).toBe(`~4.16.0`);
142+
});
143+
144+
it(`should handle packages with mixed catalog and regular dependencies`, async () => {
145+
// Create a package with mixed dependencies
146+
const mixedDir = ppath.join(tmpDir, `packages`, `mixed-deps`);
147+
await xfs.mkdirpPromise(mixedDir);
148+
149+
await xfs.writeJsonPromise(ppath.join(mixedDir, Filename.manifest), {
150+
name: `@test/mixed-deps`,
151+
version: `1.0.0`,
152+
dependencies: {
153+
react: `catalog:`, // From catalog
154+
express: `^4.18.0`, // Regular version
155+
vue: `catalog:vue3`, // From named catalog
156+
moment: `>=2.29.0 <3.0.0`, // Complex version range
157+
},
158+
});
159+
160+
// Re-find the project to include the new package
161+
const {project: updatedProject} = await Project.find(configuration, tmpDir);
162+
const workspace = updatedProject.getWorkspaceByIdent(structUtils.makeIdent(`test`, `mixed-deps`));
163+
164+
if (!workspace)
165+
throw new Error(`Mixed-deps workspace not found`);
166+
167+
168+
const publishedManifest = await packUtils.genPackageManifest(workspace);
169+
const publishedDeps = (publishedManifest as any);
170+
171+
// Catalog dependencies should be resolved
172+
expect(publishedDeps.dependencies.react).toBe(`npm:^18.3.1`);
173+
expect(publishedDeps.dependencies.vue).toBe(`npm:^3.4.0`);
174+
175+
// Regular dependencies should remain unchanged
176+
expect(publishedDeps.dependencies.express).toBe(`^4.18.0`);
177+
expect(publishedDeps.dependencies.moment).toBe(`>=2.29.0 <3.0.0`);
178+
});
179+
180+
it(`should gracefully handle missing catalog entries`, async () => {
181+
// Create a package with missing catalog entry
182+
const missingDir = ppath.join(tmpDir, `packages`, `missing-catalog`);
183+
await xfs.mkdirpPromise(missingDir);
184+
185+
await xfs.writeJsonPromise(ppath.join(missingDir, Filename.manifest), {
186+
name: `@test/missing-catalog`,
187+
version: `1.0.0`,
188+
dependencies: {
189+
react: `catalog:`, // Valid entry
190+
nonexistent: `catalog:missing`, // Invalid named catalog
191+
missing: `catalog:`, // Missing entry in default catalog
192+
},
193+
});
194+
195+
// Re-find the project to include the new package
196+
const {project: updatedProject} = await Project.find(configuration, tmpDir);
197+
const workspace = updatedProject.getWorkspaceByIdent(structUtils.makeIdent(`test`, `missing-catalog`));
198+
199+
if (!workspace)
200+
throw new Error(`Missing-catalog workspace not found`);
201+
202+
203+
const publishedManifest = await packUtils.genPackageManifest(workspace);
204+
const publishedDeps = (publishedManifest as any);
205+
206+
// Valid catalog entry should be resolved
207+
expect(publishedDeps.dependencies.react).toBe(`npm:^18.3.1`);
208+
209+
// Invalid entries should remain as catalog: references
210+
// This allows the error to be caught during normal resolution
211+
expect(publishedDeps.dependencies.nonexistent).toBe(`catalog:missing`);
212+
expect(publishedDeps.dependencies.missing).toBe(`catalog:`);
213+
});
214+
215+
it(`should handle scoped package names in catalogs`, async () => {
216+
// Update catalog configuration for scoped packages
217+
// Note: For scoped packages like @types/node, the catalog key should be just the package name "node"
218+
const scopedDefaultCatalog = new Map([
219+
[`react`, `^18.3.1`],
220+
[`node`, `^20.0.0`], // @types/node -> node
221+
[`core`, `^7.24.0`], // @babel/core -> core
222+
]);
223+
configuration.values.set(`catalog`, scopedDefaultCatalog);
224+
225+
const scopedNamedCatalogs = new Map([
226+
[`types`, new Map([
227+
[`react`, `^18.2.0`], // @types/react -> react
228+
[`lodash`, `^4.14.0`], // @types/lodash -> lodash
229+
])],
230+
]);
231+
configuration.values.set(`catalogs`, scopedNamedCatalogs);
232+
233+
// Create a package with scoped dependencies
234+
const scopedDir = ppath.join(tmpDir, `packages`, `scoped-deps`);
235+
await xfs.mkdirpPromise(scopedDir);
236+
237+
await xfs.writeJsonPromise(ppath.join(scopedDir, Filename.manifest), {
238+
name: `@test/scoped-deps`,
239+
version: `1.0.0`,
240+
dependencies: {
241+
react: `catalog:`,
242+
"@types/node": `catalog:`,
243+
"@babel/core": `catalog:`,
244+
},
245+
devDependencies: {
246+
"@types/react": `catalog:types`,
247+
"@types/lodash": `catalog:types`,
248+
},
249+
});
250+
251+
// Re-find the project to include the new package
252+
const {project: updatedProject} = await Project.find(configuration, tmpDir);
253+
const workspace = updatedProject.getWorkspaceByIdent(structUtils.makeIdent(`test`, `scoped-deps`));
254+
255+
if (!workspace)
256+
throw new Error(`Scoped-deps workspace not found`);
257+
258+
259+
const publishedManifest = await packUtils.genPackageManifest(workspace);
260+
const publishedDeps = (publishedManifest as any);
261+
262+
// Verify scoped packages are properly resolved
263+
expect(publishedDeps.dependencies.react).toBe(`npm:^18.3.1`);
264+
expect(publishedDeps.dependencies[`@types/node`]).toBe(`npm:^20.0.0`);
265+
expect(publishedDeps.dependencies[`@babel/core`]).toBe(`npm:^7.24.0`);
266+
267+
expect(publishedDeps.devDependencies[`@types/react`]).toBe(`npm:^18.2.0`);
268+
expect(publishedDeps.devDependencies[`@types/lodash`]).toBe(`npm:^4.14.0`);
269+
});
270+
});

0 commit comments

Comments
 (0)