Skip to content

Commit 087cc8d

Browse files
iOvergaardclaude
andauthored
Backoffice NPM: use looser peerDependencies version ranges for plugin compatibility (#21644)
* feat(backoffice): use looser version ranges for peerDependencies Convert hoisted dependencies to peerDependencies with more permissive version ranges that allow plugin developers to use different versions without npm conflicts. Version range strategy: - Pre-release (0.x.y): >=X.Y.Z <1.0.0 Example: @hey-api/openapi-ts 0.85.0 → >=0.85.0 <1.0.0 Allows plugins to use 0.85.0, 0.91.1, 0.99.99 without conflicts - Stable (major.x.y where major ≥1): major.x.x Example: lit ^3.3.1 → 3.x.x Allows any patch/minor within the major version This allows plugin developers to: - Use @hey-api/openapi-ts 0.91.1 while backoffice uses 0.85.0 - Install compatible deduplicated versions when available - Override versions when needed for their specific use case Types remain available from peerDependencies (automatically installed by npm 7+). When @hey-api reaches 1.0.0, the range will automatically become ^1.0.0. https://claude.ai/code/session_01CBpcwXYZjzexKkM9Cf57Kb * refactor(backoffice): use semver package for version parsing in cleanse script Replace regex-based version parsing with the semver package used by npm itself. This ensures version parsing is consistent with npm's own semver handling and is more robust for edge cases. Also update the version range logic to be more explicit and correct: - Pre-release (0.x.y): >=X.Y.Z <1.0.0 - Stable (1+.x.y): >=X.Y.Z <NEXT_MAJOR.0.0 This ensures plugin developers use at least the tested version and prevents accidental downgrades to incompatible minor versions. https://claude.ai/code/session_01CBpcwXYZjzexKkM9Cf57Kb * chore: formats file * chore: lockfile * fix(backoffice): use semver.minVersion to parse version ranges Fix parsing of version ranges like ^0.85.0 by using semver.minVersion() instead of semver.parse(). The parse() function only handles exact versions, while minVersion() extracts the minimum version from a range. Example transformations: - ^0.85.0 → 0.85.0 → >=0.85.0 <1.0.0 - ^3.3.1 → 3.3.1 → >=3.3.1 <4.0.0 https://claude.ai/code/session_01CBpcwXYZjzexKkM9Cf57Kb * refactor(backoffice): keep caret ranges for stable package versions Optimize the version range conversion logic: - Stable versions (major ≥ 1) with caret (e.g., ^3.3.1): Keep as-is The caret already implements the desired range: >=3.3.1 <4.0.0 - Pre-release versions (0.x.y): Convert to explicit range ^0.85.0 → >=0.85.0 <1.0.0 (caret only allows 0.85.z, not 0.91.z) - Exact versions (e.g., 3.16.0): Convert to range 3.16.0 → >=3.16.0 <4.0.0 This simplifies the published package.json while maintaining the same semantics and is more explicit about the intent. Examples of published peerDependencies: - lit: ^3.3.1 (unchanged, already has correct range) - rxjs: ^7.8.2 (unchanged) - @hey-api/openapi-ts: >=0.85.0 <1.0.0 (converted from ^0.85.0) - @tiptap/core: >=3.16.0 <4.0.0 (converted from 3.16.0) https://claude.ai/code/session_01CBpcwXYZjzexKkM9Cf57Kb * refactor(backoffice): use caret for stable exact versions Simplify stable exact versions (e.g., 3.16.0) by adding a caret prefix (^3.16.0) instead of explicit range (>=3.16.0 <4.0.0). Both are semantically identical for stable versions but caret is more concise and conventional. Updated version range logic: - Stable with caret (^3.3.1): Keep as-is - Pre-release with caret (^0.85.0): Convert to >=0.85.0 <1.0.0 - Stable exact version (3.16.0): Convert to ^3.16.0 Examples of published peerDependencies: - lit: ^3.3.1 - rxjs: ^7.8.2 - @hey-api/openapi-ts: >=0.85.0 <1.0.0 - @tiptap/core: ^3.16.0 (now with caret) https://claude.ai/code/session_01CBpcwXYZjzexKkM9Cf57Kb * refactor(backoffice): ensure all pre-release versions get explicit range Reorganize version conversion logic for clarity: 1. All pre-release (0.x.y) versions → explicit range: >=X.Y.Z <1.0.0 - Examples: ^0.85.0 → >=0.85.0 <1.0.0, 0.85.0 → >=0.85.0 <1.0.0 2. Stable versions with caret (^3.3.1) → keep as-is 3. Stable versions exact (3.16.0) → add caret: ^3.16.0 This ensures pre-release version constraints are properly loosened for plugins while maintaining stability guarantees. https://claude.ai/code/session_01CBpcwXYZjzexKkM9Cf57Kb * treat all modifiers the same * docs: add backoffice npm package structure documentation Add comprehensive section to CLAUDE.md explaining: - Backoffice npm package architecture and plugin model - Dependency hoisting strategy and version range logic - How pre-release versions are handled vs stable versions - Importmap as single source of truth for runtime - Plugin development implications and expectations Clarifies that while npm versions constrain types, the actual runtime comes from importmap, and plugin developers should declare explicit dependencies rather than relying on transitive deps. https://claude.ai/code/session_01CBpcwXYZjzexKkM9Cf57Kb * docs: add npm package publishing guide to backoffice CLAUDE.md Add comprehensive section explaining: - Why backoffice uses peerDependencies (importmap provides runtime) - Dependency hoisting strategy and version range conversion logic - How pre-release versions are handled differently from stable versions - Example published peerDependencies showing final output - Plugin developer guide with dos and don'ts - Key files involved in the publishing process Provides clear guidance for plugin developers on version compatibility and explains the importmap-as-single-source-of-truth architecture. https://claude.ai/code/session_01CBpcwXYZjzexKkM9Cf57Kb --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0045691 commit 087cc8d

File tree

5 files changed

+227
-76
lines changed

5 files changed

+227
-76
lines changed

CLAUDE.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,65 @@ APIs use `Asp.Versioning.Mvc`:
313313
- Delivery API: `/umbraco/delivery/api/v{version}/*`
314314
- OpenAPI/Swagger docs per version
315315

316+
### Backoffice npm Package Structure
317+
318+
The backoffice (`Umbraco.Web.UI.Client`) is published to npm as **`@umbraco-cms/backoffice`** with a plugin architecture:
319+
320+
#### Architecture Overview
321+
322+
- **Multi-workspace structure**: Subprojects in `src/libs/*`, `src/packages/*`, `src/external/*`
323+
- **Export model**: All exports defined in root `package.json``./exports` field
324+
- **Importmap-driven runtime**: Dependencies provided at runtime via importmap (single source of truth)
325+
- **Build-time types**: TypeScript types come from npm peerDependencies
326+
- **Plugin model**: Developers create plugins that import from `@umbraco-cms/backoffice/*` exports
327+
328+
#### Dependency Hoisting Strategy
329+
330+
When building for npm (`npm pack`), the `cleanse-pkg.js` script hoists subproject dependencies to root `peerDependencies` with intelligent version range conversion:
331+
332+
**Version Range Logic** (uses `semver` package):
333+
334+
1. **Pre-release (0.x.y)**: Convert to explicit range
335+
- Input: `^0.85.0` or `0.85.0`
336+
- Output: `>=0.85.0 <1.0.0`
337+
- Rationale: Pre-release caret only allows patch updates, explicit range allows minor upgrades within 0.x.x
338+
- Example: Plugin can use `@hey-api/openapi-ts@0.91.1` while backoffice uses `0.85.0`
339+
340+
2. **Stable with caret (^X.Y.Z where X ≥ 1)**: Keep as-is
341+
- Input: `^3.3.1`
342+
- Output: `^3.3.1` (unchanged)
343+
- Rationale: Caret already implements correct semantics for stable versions
344+
345+
3. **Stable exact versions (X.Y.Z where X ≥ 1)**: Add caret
346+
- Input: `3.16.0`
347+
- Output: `^3.16.0`
348+
- Rationale: Normalizes to conventional semver format
349+
350+
#### Key Dependencies
351+
352+
**Runtime via importmap** (types available from peerDependencies):
353+
- `lit`, `rxjs`, `@umbraco-ui/uui` - Core framework
354+
- `monaco-editor`, `@tiptap/*` - Feature-specific editors
355+
- `@hey-api/openapi-ts` - HTTP client type generation
356+
357+
**Build-time only** (not hoisted):
358+
- `vite`, `typescript`, `eslint` - Dev tooling
359+
360+
#### Plugin Development Implications
361+
362+
Plugin developers should:
363+
- **Declare explicit dependencies** in their own `package.json` (avoid relying on transitive deps)
364+
- **Understand the version ranges**: `>=0.85.0 <1.0.0` means they can use newer pre-release versions
365+
- **Know that types match npm ranges**, but runtime comes from importmap (managed by backoffice)
366+
- **When `@hey-api` hits 1.0.0**: Published constraint will automatically become `^1.0.0`
367+
368+
#### Implementation Details
369+
370+
- Script location: `src/Umbraco.Web.UI.Client/devops/publish/cleanse-pkg.js`
371+
- Runs as `prepack` hook before npm pack
372+
- Uses `semver.minVersion()` for robust version range parsing
373+
- Generates single source of truth for importmap versions
374+
316375
### Known Limitations
317376

318377
1. **Circular Dependencies**: Avoided via `Lazy<T>` or event notifications

src/Umbraco.Web.UI.Client/CLAUDE.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,87 @@ See **[Commands](./docs/commands.md)** for all available commands.
9292

9393
---
9494

95+
## npm Package Publishing
96+
97+
### Overview
98+
99+
The backoffice is published to npm as `@umbraco-cms/backoffice` with a plugin-first architecture. All dependencies are **peerDependencies** because:
100+
101+
1. **Importmap provides runtime**: The actual code at runtime comes from importmap, not npm
102+
2. **Types are the primary need**: Plugin developers need types for development, but runtime is managed centrally
103+
3. **Version flexibility**: Allows plugins to use different versions of pre-release packages (e.g., `@hey-api/openapi-ts`)
104+
105+
### Dependency Hoisting & Version Ranges
106+
107+
The `npm pack` process (prepack hook) runs `devops/publish/cleanse-pkg.js` which:
108+
109+
1. **Collects dependencies** from all workspace subpackages
110+
2. **Converts to peerDependencies** at the root level
111+
3. **Intelligently adjusts version ranges** based on stability
112+
113+
#### Version Range Conversion Logic
114+
115+
Uses the `semver` package (npm's own semver library) for robust parsing:
116+
117+
**Pre-release packages (0.x.y)**
118+
```
119+
Input: ^0.85.0 or 0.85.0
120+
Output: >=0.85.0 <1.0.0
121+
122+
Why: Pre-release caret (^0.85.0) only allows patch updates (0.85.x).
123+
Explicit range allows plugins to use 0.91.1 without conflicts.
124+
```
125+
126+
**Stable packages with caret (major ≥ 1)**
127+
```
128+
Input: ^3.3.1
129+
Output: ^3.3.1 (kept as-is)
130+
131+
Why: Caret already implements the correct range: >=3.3.1 <4.0.0
132+
```
133+
134+
**Stable exact versions (major ≥ 1)**
135+
```
136+
Input: 3.16.0 (from @tiptap/*)
137+
Output: ^3.16.0
138+
139+
Why: Normalizes to conventional semver format
140+
```
141+
142+
#### Example Published peerDependencies
143+
144+
```json
145+
{
146+
"peerDependencies": {
147+
"lit": "^3.3.1",
148+
"rxjs": "^7.8.2",
149+
"@umbraco-ui/uui": "^1.17.0-rc.5",
150+
"monaco-editor": "^0.55.1",
151+
"@tiptap/core": "^3.16.0",
152+
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
153+
}
154+
}
155+
```
156+
157+
### Plugin Developer Guide
158+
159+
When using `@umbraco-cms/backoffice`:
160+
161+
- **Declare dependencies explicitly** in your `package.json` (don't rely on transitive deps from backoffice)
162+
- **Version ranges are flexible**: `>=0.85.0 <1.0.0` means you can use `0.85.0`, `0.91.1`, or `0.99.99`
163+
- **Types come from npm**: TypeScript gets types from your declared versions
164+
- **Runtime comes from importmap**: The actual code at runtime is managed by the backoffice (importmap)
165+
- **Future compatibility**: When `@hey-api` hits `1.0.0`, the published range will automatically become `^1.0.0`
166+
167+
### Key Files
168+
169+
| File | Purpose |
170+
|------|---------|
171+
| `package.json` | Root package with exports and workspace references |
172+
| `devops/publish/cleanse-pkg.js` | Script that runs during `npm pack` to hoist and convert versions |
173+
| `src/external/*` | Dependency wrapper packages |
174+
| `src/packages/core` | Contains `@hey-api/openapi-ts` and other utilities |
175+
176+
---
177+
95178
**This project follows a modular package architecture with strict TypeScript, Lit web components, and an extensible manifest system. Each package is independent but follows consistent patterns. For extension development, use the Context API for dependency injection, controllers for logic, and manifests for registration.**

src/Umbraco.Web.UI.Client/devops/publish/cleanse-pkg.js

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
/* eslint-disable local-rules/enforce-umbraco-external-imports */
12
import { readFileSync, writeFileSync, existsSync } from 'fs';
23
import { join } from 'path';
3-
import glob from 'tiny-glob'
4+
import glob from 'tiny-glob';
5+
import semver from 'semver';
46

57
console.log('[Prepublish] Cleansing package.json');
68

@@ -10,18 +12,45 @@ const packageJson = JSON.parse(readFileSync(packageFile, 'utf8'));
1012
// Remove all DevDependencies
1113
delete packageJson.devDependencies;
1214

13-
// Rename dependencies to peerDependencies
14-
packageJson.peerDependencies = { ...packageJson.dependencies };
15+
// Convert version to a looser range that allows plugin developers to use newer versions
16+
// while still enforcing a minimum version and safety ceiling
17+
const looseVersionRange = (version) => {
18+
// Extract minimum version from a range (e.g., ^0.85.0 -> 0.85.0)
19+
const minVersion = semver.minVersion(version);
20+
if (!minVersion) {
21+
console.warn('Could not parse version:', version, 'keeping original');
22+
return version;
23+
}
24+
25+
const major = minVersion.major;
26+
const minor = minVersion.minor;
27+
const patch = minVersion.patch;
28+
29+
// For pre-release (0.x.y), always use floor at current version and ceiling at 1.0.0
30+
if (major === 0) {
31+
return `>=${major}.${minor}.${patch} <1.0.0`;
32+
}
33+
34+
// Exact version without caret, add caret (e.g., 3.16.0 -> ^3.16.0 and ^3.16.0 -> ^3.16.0 and ~3.16.0 -> ^3.16.0)
35+
return `^${major}.${minor}.${patch}`;
36+
};
37+
38+
// Rename dependencies to peerDependencies with looser version ranges
39+
packageJson.peerDependencies = {};
40+
Object.entries(packageJson.dependencies || {}).forEach(([key, value]) => {
41+
packageJson.peerDependencies[key] = looseVersionRange(value);
42+
console.log('Converting to peer dependency:', key, 'from', value, 'to', packageJson.peerDependencies[key]);
43+
});
1544
delete packageJson.dependencies;
1645

1746
// Iterate all workspaces and hoist the dependencies to the root package.json
1847
const workspaces = packageJson.workspaces || [];
19-
const workspacePromises = workspaces.map(async workspaceGlob => {
48+
const workspacePromises = workspaces.map(async (workspaceGlob) => {
2049
// Use glob to find the workspace path
2150
const localWorkspace = workspaceGlob.replace(/\.\/src/, './dist-cms');
2251
const workspacePaths = await glob(localWorkspace, { cwd: './', absolute: true });
2352

24-
workspacePaths.forEach(workspace => {
53+
workspacePaths.forEach((workspace) => {
2554
const workspacePackageFile = join(workspace, 'package.json');
2655

2756
// Ensure the workspace package.json exists
@@ -36,11 +65,21 @@ const workspacePromises = workspaces.map(async workspaceGlob => {
3665
// Move dependencies from the workspace to the root package.json
3766
if (workspacePackageJson.dependencies) {
3867
Object.entries(workspacePackageJson.dependencies).forEach(([key, value]) => {
39-
console.log('Hoisting dependency:', key, 'from workspace:', workspace, 'with version:', value);
40-
packageJson.peerDependencies[key] = value;
68+
const loosenedVersion = looseVersionRange(value);
69+
console.log(
70+
'Hoisting dependency:',
71+
key,
72+
'from workspace:',
73+
workspace,
74+
'with version:',
75+
value,
76+
'loosened to:',
77+
loosenedVersion,
78+
);
79+
packageJson.peerDependencies[key] = loosenedVersion;
4180
});
4281
}
43-
})
82+
});
4483
});
4584

4685
// Wait for all workspace processing to complete

0 commit comments

Comments
 (0)