Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ jobs:
- name: Build the schematics package
run: pnpm schematics:build

# FSHSP-228: the only place every kit import goes through the rewrite that
# `ng add` applies. Without it, an import the table cannot re-address fails
# at the consumer's install, as `type UiIconSize` did in 0.11.0 and 0.12.0.
- name: Schematics tests
run: pnpm schematics:test

# `postinstall` has just regenerated everything `docs:config` produces, so
# this step looks redundant. It is not — it enforces two things nothing
# else does:
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr

## [Unreleased]

### Fixed

- **Schematics : sur un projet Angular 20, `ng add` s'arrêtait sur un ERESOLVE et laissait le
`package.json` à moitié migré** (FSHSP-228). Les `peerDependencies` du kit étaient recopiées
par-dessus les versions du projet : `@angular/core`, `common`, `forms`, `router` et
`platform-browser` passaient en `^22.0.0`, `compiler-cli`, `build` et `cli` restaient en 20, et
`@angular-devkit/build-angular@^22` faisait échouer npm.
- `ng add`, `add` et `update` vérifient d'abord le majeur d'Angular du projet. S'il n'est pas
celui du kit, ils s'arrêtent sans rien écrire et donnent les `ng update` à enchaîner.
- Une dépendance déjà déclarée garde sa plage. Un paquet Angular absent prend celle du
`@angular/core` du projet, les paquets du framework s'exigeant l'un l'autre à la version
exacte.
- **Schematics : copier `ui-input-date` faisait échouer `ng add`** (FSHSP-228), depuis la
0.11.0, sur « import(s) du kit non réadressé(s) … (symbole type UiIconSize) ». La réécriture
des imports prenait le modificateur `type` en ligne (`import { UiIcon, type UiIconSize }`)
pour une partie du nom. Il est ignoré pour la recherche et conservé dans l'import réécrit.
Les schematics ont maintenant des tests (`pnpm schematics:test`, en CI), dont un qui fait
passer chaque composant par la réécriture.

## [0.12.0] - 2026-09-25

### Added
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"schematics:assets": "node scripts/schematics-assets.build.mjs",
"schematics:build": "node scripts/schematics-package.build.mjs",
"schematics:pack": "pnpm schematics:build && pnpm -C dist/ui-kit-schematics pack --pack-destination ../..",
"schematics:test": "pnpm schematics:assets && vitest run --root projects/ui-kit-schematics",
"mcp:assets": "node scripts/mcp-assets.build.mjs",
"mcp:bundle": "node scripts/mcp-bundle.build.mjs",
"mcp:test": "vitest run --root projects/ui-kit-mcp"
Expand Down
6 changes: 6 additions & 0 deletions projects/ui-kit-schematics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ schematics that copy them into a consuming project.
ng add @4sh/ui-kit-schematics
```

**Requires an Angular 22 project** (the kit's `peerDependencies`: `@angular/core ^22.0.0`).
On any other major, `ng add`, `add` and `update` stop before writing anything and print
the `ng update` steps to run first, one major at a time. Your Angular, RxJS and CDK
versions are never rewritten: a missing Angular package is added at your
`@angular/core` range.

One command: it lays the foundation (styles, design tokens, `angular.json`), then
asks which components to copy and copies them, dependencies included. The prompt
is a checkbox list — <kbd>space</kbd> to pick, <kbd>a</kbd> for all,
Expand Down
3 changes: 3 additions & 0 deletions projects/ui-kit-schematics/src/add/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { resolveDependencies } from '../utils/dependency-graph';
import { copyUnit } from '../utils/copy';
import { emptyManifest, readManifest, today, writeManifest } from '../utils/manifest';
import { kitVersion as readKitVersion } from '../utils/kit-manifest';
import { checkAngularCompatibility } from '../utils/angular-version';

/**
* Sélection interactive : checkbox `@inquirer/prompts`, qui supporte déjà
Expand All @@ -29,6 +30,8 @@ async function promptComponentNames(): Promise<string[]> {

export function add(options: Schema): Rule {
return async (tree: Tree, context: SchematicContext) => {
// Avant le prompt : des sources Angular 22 dans un projet Angular 20 ne compilent pas.
checkAngularCompatibility(tree, context);
const available = listComponents();

let selected: string[];
Expand Down
17 changes: 15 additions & 2 deletions projects/ui-kit-schematics/src/ng-add/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { join } from 'node:path';
import type { Schema } from './schema';
import {
addDependency,
ensureDependency,
addNpmScript,
detectPackageManager,
readPackageJson,
Expand All @@ -36,6 +37,7 @@ import {
stylesFoundationDir,
} from '../utils/component-registry';
import { readKitManifestInfo } from '../utils/kit-manifest';
import { assertAngularCompatible } from '../utils/angular-version';
import { rewriteKitPaths } from '../utils/kit-paths';
import { add } from '../add';

Expand Down Expand Up @@ -740,7 +742,10 @@ function applicationIndexPaths(tree: Tree): string[] {
* Demander la même plage laisse npm les déduire ensemble.
*/
function angularRange(tree: Tree): string {
return readPackageJson(tree).dependencies?.['@angular/core'] ?? '^22.0.0';
const json = readPackageJson(tree);
return (
json.dependencies?.['@angular/core'] ?? json.devDependencies?.['@angular/core'] ?? '^22.0.0'
);
}

/** Dépendances et scripts du Storybook du consommateur. Versions alignées sur ce dépôt. */
Expand Down Expand Up @@ -816,8 +821,13 @@ function addRuntimeDependencies(): Rule {
// Toutes les peerDependencies déjà déclarées par @4sh/ui-kit (@angular/cdk, rxjs…)
// deviennent des dependencies directes chez le consommateur — pas @4sh/ui-kit
// lui-même, gardé en devDependency pour piloter la CLI (voir ticket).
// Seulement celles qui manquent : la plage du projet reste la sienne. Un
// paquet Angular absent prend celle de son `@angular/core`, pas le `^22.0.0`
// du kit : les paquets du framework s'exigent l'un l'autre à la version
// exacte (voir `angularRange`).
for (const [name, version] of Object.entries(peerDependencies)) {
addDependency(tree, name, version, 'dependencies');
const range = name.startsWith('@angular/') ? angularRange(tree) : version;
ensureDependency(tree, name, range, 'dependencies');
}
// FontAwesome n'est pas une peerDependency du kit (elle n'est pas importée
// par le TypeScript : `ui-icon` ne pose que des classes CSS), mais la
Expand Down Expand Up @@ -1138,6 +1148,9 @@ export function ngAdd(options: Schema): Rule {
const withMcp = !(options.skipMcp ?? false);

const foundation = [
// Avant tout le reste, prompts compris : un projet d'un autre majeur
// d'Angular est refusé sans que rien ne soit écrit ni demandé.
assertAngularCompatible(),
copyStylesFoundationRule(),
createStyleScaffolds(),
// Avant `updateAngularJson`, qui déclare `src/assets` au builder : la
Expand Down
7 changes: 5 additions & 2 deletions projects/ui-kit-schematics/src/update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { findUnit } from '../utils/component-registry';
import { renderUnitFiles } from '../utils/copy';
import { readManifest, today, writeManifest } from '../utils/manifest';
import { kitVersion as readKitVersion } from '../utils/kit-manifest';
import { checkAngularCompatibility } from '../utils/angular-version';

type Action = 'apply' | 'skip' | 'view-diff';

Expand Down Expand Up @@ -88,13 +89,15 @@ function logUncoveredConcerns(context: SchematicContext): void {
`d'une version plus ancienne du kit, \`ng add @4sh/ui-kit-schematics ` +
`--skip-components\` réapplique ce qui peut l'être sans risque : vos composants, ` +
`votre \`.mcp.json\`, votre config Prettier et vos styles retouchés sont ` +
`préservés. Seule réserve mesurée : une dépendance que vous auriez épinglée peut ` +
`y être ré-élargie vers la plage du kit.`,
`préservés. Seule réserve mesurée : une dépendance d'outillage que vous auriez épinglée ` +
`(Storybook, Prettier…) peut y être ré-élargie vers la plage du kit ; Angular et ` +
`RxJS gardent la vôtre.`,
);
}

export function update(options: Schema): Rule {
return async (tree: Tree, context: SchematicContext) => {
checkAngularCompatibility(tree, context);
const manifest = readManifest(tree);
if (!manifest) {
throw new SchematicsException(
Expand Down
105 changes: 105 additions & 0 deletions projects/ui-kit-schematics/src/utils/angular-version.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { HostTree, SchematicsException } from '@angular-devkit/schematics';
import type { SchematicContext } from '@angular-devkit/schematics';
import { describe, expect, it, vi } from 'vitest';
import { angularMismatch, checkAngularCompatibility, majorOf } from './angular-version';
import { readKitManifestInfo } from './kit-manifest';
import { ensureDependency, readPackageJson } from './package-json';

function projectTree(dependencies: Record<string, string>): HostTree {
const tree = new HostTree();
tree.create('/package.json', JSON.stringify({ name: 'test', dependencies }));
return tree;
}

function fakeContext() {
const warn = vi.fn();
return { context: { logger: { warn } } as unknown as SchematicContext, warn };
}

describe('majorOf', () => {
it.each([
['^20.3.0', 20],
['~20.3.0', 20],
['20.3.0', 20],
['>=20.0.0 <23', 20],
['20.x', 20],
['v22', 22],
['22.0.0-next.1', 22],
['^22.0.0 || ^23.0.0', 22],
])('%s → %i', (range, major) => {
expect(majorOf(range)).toBe(major);
});

it.each(['latest', '*', 'workspace:*', 'file:../core', 'npm:@angular/core@22', undefined])(
'%s → illisible',
(range) => {
expect(majorOf(range)).toBeUndefined();
},
);
});

describe('angularMismatch', () => {
it("laisse passer le majeur du kit, quelle qu'en soit la mineure", () => {
expect(angularMismatch('^22.2.0', '^22.0.0', '0.12.1')).toBeUndefined();
});

it('donne les montées de majeur une à une pour un projet en retard', () => {
const message = angularMismatch('^20.3.0', '^22.0.0', '0.12.1');
expect(message).toContain('Angular 22');
expect(message).toContain('Angular 20');
expect(message).toContain('ng update @angular/core@21 @angular/cli@21');
expect(message).toContain('ng update @angular/core@22 @angular/cli@22');
expect(message).toContain("n'a rien modifié");
});

it('refuse un majeur que le kit ne connaît pas encore, sans proposer de descendre', () => {
const message = angularMismatch('^23.0.0', '^22.0.0', '0.12.1');
expect(message).toContain('ne connaît pas encore Angular 23');
expect(message).not.toContain('ng update');
});
});

describe('checkAngularCompatibility', () => {
const kitRange = readKitManifestInfo().peerDependencies['@angular/core'];
const kitMajor = majorOf(kitRange)!;

it('refuse un projet Angular 20 (ERESOLVE du ng add de la 0.12.0)', () => {
const { context } = fakeContext();
expect(() =>
checkAngularCompatibility(projectTree({ '@angular/core': '^20.3.0' }), context),
).toThrow(SchematicsException);
});

it('accepte un projet du majeur du kit', () => {
const { context, warn } = fakeContext();
checkAngularCompatibility(projectTree({ '@angular/core': `^${kitMajor}.1.0` }), context);
expect(warn).not.toHaveBeenCalled();
});

it('avertit sans refuser quand la plage ne se lit pas', () => {
const { context, warn } = fakeContext();
checkAngularCompatibility(projectTree({ '@angular/core': 'latest' }), context);
expect(warn).toHaveBeenCalledOnce();
});
});

describe('ensureDependency', () => {
it("ne remplace jamais la plage d'un paquet déjà déclaré", () => {
const tree = projectTree({ '@angular/core': '^20.3.0' });
expect(ensureDependency(tree, '@angular/core', '^22.0.0')).toBe(false);
expect(readPackageJson(tree).dependencies?.['@angular/core']).toBe('^20.3.0');
});

it('ne le recopie pas en dependencies quand il est en devDependencies', () => {
const tree = new HostTree();
tree.create('/package.json', JSON.stringify({ devDependencies: { rxjs: '~7.8.0' } }));
expect(ensureDependency(tree, 'rxjs', '^7.8.0')).toBe(false);
expect(readPackageJson(tree).dependencies).toBeUndefined();
});

it('ajoute un paquet absent', () => {
const tree = projectTree({});
expect(ensureDependency(tree, '@angular/cdk', '^22.1.0')).toBe(true);
expect(readPackageJson(tree).dependencies?.['@angular/cdk']).toBe('^22.1.0');
});
});
107 changes: 107 additions & 0 deletions projects/ui-kit-schematics/src/utils/angular-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* angular-version — refuser un projet dont le majeur d'Angular n'est pas celui du kit.
*
* Les sources copiées et les dépendances que `ng add` inscrit visent le majeur
* des `peerDependencies` du kit. Posées dans un projet d'un autre majeur, elles
* ne compilent pas, et l'install s'arrête sur un ERESOLVE qui ne dit pas d'où il
* vient (Angular 20 : `@angular-devkit/build-angular@^22` contre le
* `compiler-cli@^20` du projet). Monter Angular d'un majeur est le travail
* d'`ng update`, avec ses migrations : un schematic de composants ne le fait pas
* à moitié. On s'arrête donc AVANT toute écriture, en disant quoi faire.
*/
import type { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { SchematicsException } from '@angular-devkit/schematics';
import { readKitManifestInfo } from './kit-manifest';
import { readPackageJson } from './package-json';

/**
* Majeur plancher d'une plage npm : `^20.3.0`, `~20.3.0`, `20.3.0`, `>=20.0.0`,
* `20.x`, `v20` → 20. `undefined` pour ce qui ne se lit pas ainsi (`latest`,
* `*`, `file:…`, `workspace:*`, `npm:…`) : l'appelant ne devine pas.
*/
export function majorOf(range: string | undefined): number | undefined {
const match = /^\s*(?:[\^~]|>=?)?\s*v?(\d+)(?:[.\sx*]|$)/.exec(range ?? '');
return match ? Number(match[1]) : undefined;
}

/** Plage de `@angular/core` du projet, dépendances puis devDependencies. */
function projectCoreRange(tree: Tree): string | undefined {
const json = readPackageJson(tree);
return json.dependencies?.['@angular/core'] ?? json.devDependencies?.['@angular/core'];
}

/** Version installée de `@angular/core`, quand la plage déclarée ne se lit pas. */
function installedCoreVersion(tree: Tree): string | undefined {
const buffer = tree.read('/node_modules/@angular/core/package.json');
if (!buffer) return undefined;
try {
return JSON.parse(buffer.toString('utf8')).version;
} catch {
return undefined;
}
}

/**
* Message d'échec, ou `undefined` si le projet est compatible ou illisible.
* Séparé de la règle pour être testé sans arbre.
*/
export function angularMismatch(
projectRange: string | undefined,
kitRange: string,
kitVersion: string,
): string | undefined {
const project = majorOf(projectRange);
const kit = majorOf(kitRange);
if (project === undefined || kit === undefined || project === kit) return undefined;

const found =
`@4sh/ui-kit ${kitVersion} demande Angular ${kit} (@angular/core ${kitRange}), ` +
`ce projet est en Angular ${project} (@angular/core ${projectRange}).`;
const untouched =
"Le schematic n'a rien modifié. Seul `@4sh/ui-kit-schematics`, ajouté par `ng add`, " +
'est à retirer du package.json si vous en restez là.';
if (project > kit) {
return `${found}\nCette version du kit ne connaît pas encore Angular ${project}.\n${untouched}`;
}
const steps = Array.from({ length: kit - project }, (_, i) => project + i + 1)
.map((major) => ` ng update @angular/core@${major} @angular/cli@${major}`)
.join('\n');
return (
`${found}\nMettre le projet à niveau d'abord, un majeur à la fois ` +
`(https://angular.dev/update-guide) :\n${steps}\n` +
`puis relancer \`ng add @4sh/ui-kit-schematics\`.\n${untouched}`
);
}

/**
* À appeler avant toute écriture ET avant tout prompt. Ne lit que
* `package.json` (et, à défaut, la version installée) : un projet dont la plage
* ne se lit pas passe, avec un avertissement, plutôt que d'être refusé sur une
* supposition.
*/
export function checkAngularCompatibility(tree: Tree, context: SchematicContext): void {
const { version, peerDependencies } = readKitManifestInfo();
const kitRange = peerDependencies['@angular/core'];
if (!kitRange) return;

const declared = projectCoreRange(tree);
const range = majorOf(declared) !== undefined ? declared : installedCoreVersion(tree);
if (majorOf(range) === undefined) {
context.logger.warn(
`Version d'Angular du projet illisible (@angular/core « ${declared ?? 'absent'} ») : ` +
`le kit demande ${kitRange}, vérification sautée.`,
);
return;
}

const mismatch = angularMismatch(range, kitRange, version);
if (mismatch) throw new SchematicsException(mismatch);
}

/** {@link checkAngularCompatibility} en règle, pour la tête d'une chaîne. */
export function assertAngularCompatible(): Rule {
return (tree, context) => {
checkAngularCompatibility(tree, context);
return tree;
};
}
24 changes: 24 additions & 0 deletions projects/ui-kit-schematics/src/utils/copy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { listComponents, listSharedBases } from './component-registry';
import { renderUnitFiles } from './copy';

// Le seul endroit où TOUS les imports du kit passent par la réécriture. Sans
// lui, un import que la table ne sait pas réadresser n'échoue que chez le
// consommateur, au `ng add` : c'est ainsi que `type UiIconSize` (ui-input-date)
// est parti en 0.11.0 et 0.12.0.
describe('renderUnitFiles, sur chaque unité du kit', () => {
const units = [...listComponents(), ...listSharedBases()];

it('trouve les unités (assets/ généré)', () => {
// Une liste vide ferait passer la suite sans rien vérifier.
expect(listComponents().length).toBeGreaterThan(40);
expect(listSharedBases().length).toBeGreaterThan(0);
});

it.each(units.map((unit) => [unit.name, unit] as const))(
'%s : aucun import du kit non réadressé',
(_name, unit) => {
expect(() => renderUnitFiles(unit, '0.0.0-test', { withStorybook: true })).not.toThrow();
},
);
});
Loading
Loading