diff --git a/CHANGELOG.md b/CHANGELOG.md index 55390e9..54ee0db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr ## [Unreleased] ### Added +- **Un projet en sources copiées a désormais son propre Storybook** (FSHSP-125) : `ng add @4sh/ui-kit-schematics --with-storybook` copie, à côté de chaque composant, sa story et sa page MDX, pose la configuration Storybook (`storybook/`, cibles `angular.json`, devDependencies, scripts npm) et la chaîne qui alimente la doc (`scripts/docs.config.mjs`, bloc ``). Un `npm run storybook` suffit ensuite. Jusqu'ici le projet possédait son code mais aucune doc : il lui restait un Storybook hébergé qui décrit *nos* composants, pas ses copies éditées. + + Deux choses font que cette doc appartient au projet. Les tables *Theming* sont lues sur ses propres `.scss` au build, donc elles décrivent ses valeurs, rebranding compris. Et les globs couvrent tout `src/app/shared/components/**` : une story écrite à côté d'un composant maison apparaît sans toucher à la configuration. + + Les imports suivent les copies, comme pour les sources — mais les extraits de code des pages ne sont pas réécrits : ce sont des exemples destinés au lecteur, pas des imports à résoudre. Les liens `parameters.design` vers notre fichier Figma sont retirés à la copie : le consommateur n'y a pas accès. Les pages transverses embarquées se limitent à *Foundations*, *Spécifications* et *Configuration* ; `Introduction`, `NpmPackage` et `Overview` parlent du kit lui-même et restent ici. + + Désactivé par défaut : sans Storybook, story et MDX sont deux fichiers qui importent des packages absents. Le choix est retenu dans `ui-kit.json` et `update` s'y tient. - **`@4sh/ui-kit-schematics` embarque son `LICENSE` et ses README** (EN + FR). Le package déclarait `Apache-2.0` sans en fournir le texte, alors que la licence (§4a) demande qu'une redistribution en joigne une copie — d'autant plus ici que ce package existe pour que son contenu soit recopié ailleurs. Sa page npmjs, jusque-là vide, redirige maintenant vers `@4sh/ui-kit` : le compagnon ne s'installe jamais directement. ### Fixed diff --git a/projects/ui-kit-schematics/README.fr.md b/projects/ui-kit-schematics/README.fr.md index 0cee2cf..686fd88 100644 --- a/projects/ui-kit-schematics/README.fr.md +++ b/projects/ui-kit-schematics/README.fr.md @@ -19,7 +19,8 @@ ng add @4sh/ui-kit-schematics Une seule commande : elle pose la fondation (styles, design tokens, `angular.json`), puis demande quels composants copier et les copie, dépendances -comprises. +comprises. La sélection se fait à la case à cocher — espace pour +choisir, a pour tout, i pour inverser. ``` src/app/shared/ @@ -37,9 +38,38 @@ jamais une fusion automatique. | `ng add @4sh/ui-kit-schematics` | fondation **et** composants, d'un coup | | `ng add @4sh/ui-kit-schematics --skip-components` | fondation seule, composants choisis plus tard | | `ng add @4sh/ui-kit-schematics --skip-install` | ne pas lancer `npm install` (projet qui pilote son lockfile) | +| `ng add @4sh/ui-kit-schematics --with-storybook` | copier aussi la story et le MDX de chaque composant (voir ci-dessous) | | `ng generate @4sh/ui-kit-schematics:add` | copier d'autres composants (interactif, ou `--components`, ou `--all`) | | `ng generate @4sh/ui-kit-schematics:update` | diff des composants copiés face aux sources publiées | +### Votre propre Storybook : `--with-storybook` + +Désactivé par défaut. Activé, vous obtenez un Storybook qui tourne, sur les +composants que vous avez copiés : + +```bash +npm run storybook +``` + +Chaque composant arrive avec sa story et sa page MDX, à côté de ses sources. La +configuration atterrit dans `storybook/` — `main.js`, `preview.ts`, le thème du +manager, le sélecteur de marque, et les pages transverses *Foundations*, +*Spécifications* et *Configuration*. Les cibles `storybook` et `build-storybook` +sont ajoutées à `angular.json`, les devDependencies au `package.json`. + +Deux choses font que cette doc est **la vôtre**, et non une photo de la nôtre. +Les tables *Theming* sont lues sur vos propres `.scss` au build +(`scripts/docs.config.mjs` en extrait les rôles `///`) : elles décrivent vos +valeurs, rebranding compris. Et les globs couvrent tout +`src/app/shared/components/**` : une story écrite à côté de votre propre +composant apparaît sans toucher à la configuration. + +Le choix est retenu dans `ui-kit.json`, et `update` s'y tient. + +Non repris : les liens `parameters.design` vers notre fichier Figma — vous ne +pouvez pas l'ouvrir, ils sont retirés à la copie. Remettez votre `node-id` si +vous en avez un. + ### `@4sh/ui-kit` n'est délibérément **pas** installé Cette voie ne met jamais le kit dans `node_modules`, et c'est précisément le but : diff --git a/projects/ui-kit-schematics/README.md b/projects/ui-kit-schematics/README.md index ed68645..31e589f 100644 --- a/projects/ui-kit-schematics/README.md +++ b/projects/ui-kit-schematics/README.md @@ -18,7 +18,9 @@ ng add @4sh/ui-kit-schematics ``` One command: it lays the foundation (styles, design tokens, `angular.json`), then -asks which components to copy and copies them, dependencies included. +asks which components to copy and copies them, dependencies included. The prompt +is a checkbox list — space to pick, a for all, +i to invert. ``` src/app/shared/ @@ -35,9 +37,37 @@ against newer sources — never an automatic merge. | `ng add @4sh/ui-kit-schematics` | foundation **and** components, in one go | | `ng add @4sh/ui-kit-schematics --skip-components` | foundation only, pick components later | | `ng add @4sh/ui-kit-schematics --skip-install` | skip `npm install` (project drives its own lockfile) | +| `ng add @4sh/ui-kit-schematics --with-storybook` | also copy each component's story and MDX (see below) | | `ng generate @4sh/ui-kit-schematics:add` | copy more components (interactive, or `--components`, or `--all`) | | `ng generate @4sh/ui-kit-schematics:update` | diff copied components against the published sources | +### Your own Storybook: `--with-storybook` + +Off by default. Turned on, you get a working Storybook of the components you +copied: + +```bash +npm run storybook +``` + +Each component arrives with its story and its MDX page, next to its sources. The +config lands in `storybook/` — `main.js`, `preview.ts`, the manager theme, the +brand switcher, and the shared *Foundations* / *Specifications* / *Configuration* +pages. The `storybook` and `build-storybook` targets are added to `angular.json`, +the devDependencies to `package.json`. + +Two things make the doc *yours* rather than a snapshot of ours. The *Theming* +tables are read off your own `.scss` at build time (`scripts/docs.config.mjs` +collects the `///` roles), so they describe your values, rebranding included. And +the globs cover `src/app/shared/components/**` whole: a story you write next to +your own component shows up with no config change. + +The choice is recorded in `ui-kit.json`, and `update` honours it. + +Not carried over: the `parameters.design` links to our Figma file — you cannot +open it, so it is stripped at copy time. Put your own `node-id` back if you have +one. + ### `@4sh/ui-kit` is deliberately **not** installed This path never puts the kit in `node_modules`, and that is the point: with it diff --git a/projects/ui-kit-schematics/src/add/index.ts b/projects/ui-kit-schematics/src/add/index.ts index b78ddb3..9e23c8c 100644 --- a/projects/ui-kit-schematics/src/add/index.ts +++ b/projects/ui-kit-schematics/src/add/index.ts @@ -51,18 +51,24 @@ export function add(options: Schema): Rule { const kitVersion = readKitVersion(); const units = resolveDependencies(selected); - const manifest = readManifest(tree) ?? emptyManifest(kitVersion); + const existing = readManifest(tree); + // `--with-storybook` n'est passé qu'à l'installation : sur un `add` isolé, + // c'est le manifeste qui porte le choix du projet. + const withStorybook = options.withStorybook ?? existing?.storybook ?? false; + const manifest = existing ?? emptyManifest(kitVersion, withStorybook); for (const unit of units) { - copyUnit(tree, unit, kitVersion); + copyUnit(tree, unit, kitVersion, { withStorybook }); manifest.components[unit.name] = { version: kitVersion, installedAt: today() }; } manifest.kitVersion = kitVersion; + manifest.storybook = withStorybook; writeManifest(tree, manifest); const extra = units.filter((u) => !selected.includes(u.name)).map((u) => u.name); context.logger.info( - `✔ ${units.length} unité(s) copiée(s)${extra.length ? ` (dont dépendances : ${extra.join(', ')})` : ''}.`, + `✔ ${units.length} unité(s) copiée(s)${extra.length ? ` (dont dépendances : ${extra.join(', ')})` : ''}` + + `${withStorybook ? ', story et MDX inclus' : ''}.`, ); return tree; }; diff --git a/projects/ui-kit-schematics/src/add/schema.d.ts b/projects/ui-kit-schematics/src/add/schema.d.ts index e07334f..b0f8f0d 100644 --- a/projects/ui-kit-schematics/src/add/schema.d.ts +++ b/projects/ui-kit-schematics/src/add/schema.d.ts @@ -3,4 +3,7 @@ export interface Schema { components?: string[]; /** Copie tous les composants disponibles, sans prompt. */ all?: boolean; + /** Copie aussi la story et le MDX de chaque composant. Omis → on reconduit le + * choix mémorisé dans `ui-kit.json` à l'installation. */ + withStorybook?: boolean; } diff --git a/projects/ui-kit-schematics/src/add/schema.json b/projects/ui-kit-schematics/src/add/schema.json index a33f73c..be35cd0 100644 --- a/projects/ui-kit-schematics/src/add/schema.json +++ b/projects/ui-kit-schematics/src/add/schema.json @@ -14,6 +14,10 @@ "type": "boolean", "default": false, "description": "Copie tous les composants disponibles, sans prompt." + }, + "withStorybook": { + "type": "boolean", + "description": "Copie aussi la story et le MDX de chaque composant. Omis → choix mémorisé dans ui-kit.json." } } } diff --git a/projects/ui-kit-schematics/src/ng-add/files/storybook/main.js b/projects/ui-kit-schematics/src/ng-add/files/storybook/main.js new file mode 100644 index 0000000..7f09524 --- /dev/null +++ b/projects/ui-kit-schematics/src/ng-add/files/storybook/main.js @@ -0,0 +1,31 @@ +/** + * Configuration Storybook posée par `ng add @4sh/ui-kit-schematics + * --with-storybook`. Fichier à vous : modifiez-le librement. + * + * Les globs couvrent `components/` en entier — les composants copiés du kit + * comme les vôtres : une story écrite à côté de votre propre composant est + * ramassée sans rien changer ici. + */ +module.exports = { + stories: [ + './docs/**/*.mdx', + '../src/app/shared/components/**/*.mdx', + '../src/app/shared/components/**/*.stories.@(js|jsx|mjs|ts|tsx)', + ], + addons: ['@storybook/addon-docs', '@storybook/addon-a11y', '@storybook-community/storybook-dark-mode'], + framework: { + name: '@storybook/angular', + options: {}, + }, + webpackFinal: async (config) => { + // Angular définit déjà `process.env.NODE_ENV` ; la garder ici la déclare + // deux fois et webpack échoue sur le conflit. + const definePlugin = config.plugins.find( + (p) => p.constructor.name === 'DefinePlugin' && p.definitions['process.env.NODE_ENV'], + ); + if (definePlugin) { + delete definePlugin.definitions['process.env.NODE_ENV']; + } + return config; + }, +}; diff --git a/projects/ui-kit-schematics/src/ng-add/files/storybook/myTheme.ts b/projects/ui-kit-schematics/src/ng-add/files/storybook/myTheme.ts new file mode 100644 index 0000000..e5ed48f --- /dev/null +++ b/projects/ui-kit-schematics/src/ng-add/files/storybook/myTheme.ts @@ -0,0 +1,56 @@ +/** + * Thème du manager Storybook (barre latérale, barre d'outils) posé par + * `ng add @4sh/ui-kit-schematics --with-storybook`. Fichier à vous. + * + * Ce sont les couleurs du CHÂSSIS Storybook, pas celles de vos composants : + * elles ne peuvent pas venir des design tokens, le manager étant rendu hors de + * votre application. Mettez-y votre marque — et `brandImage` pour votre logo. + */ +import { create } from 'storybook/theming'; + +const brandContent = { + brandTitle: 'Design System', + brandTarget: '_self', + fontBase: '"Inter", sans-serif', + fontCode: 'monospace', + appBorderRadius: 8, + inputBorderRadius: 4, +}; + +export const lightTheme = create({ + base: 'light', + ...brandContent, + appBg: '#f6f7f9', + appContentBg: '#ffffff', + appBorderColor: '#dfe2e7', + textColor: '#111827', + textInverseColor: '#ffffff', + barTextColor: '#6b7280', + barSelectedColor: '#9747ff', + barBg: '#ffffff', + inputBg: '#ffffff', + inputBorder: '#dfe2e7', + inputTextColor: '#111827', + colorPrimary: '#9747ff', + colorSecondary: '#9747ff', +}); + +export const darkTheme = create({ + base: 'dark', + ...brandContent, + appBg: '#0f172a', + appContentBg: '#111827', + appBorderColor: '#1f2937', + textColor: '#f3f4f6', + textInverseColor: '#111827', + barTextColor: '#9ca3af', + barSelectedColor: '#ac78ff', + barBg: '#111827', + inputBg: '#1f2937', + inputBorder: '#374151', + inputTextColor: '#f6f7f9', + colorPrimary: '#9747ff', + colorSecondary: '#9747ff', +}); + +export default lightTheme; diff --git a/projects/ui-kit-schematics/src/ng-add/files/storybook/preview.ts b/projects/ui-kit-schematics/src/ng-add/files/storybook/preview.ts new file mode 100644 index 0000000..6a38409 --- /dev/null +++ b/projects/ui-kit-schematics/src/ng-add/files/storybook/preview.ts @@ -0,0 +1,83 @@ +/** + * Preview Storybook posée par `ng add @4sh/ui-kit-schematics --with-storybook`. + * Fichier à vous : modifiez-le librement. + * + * Il fait trois choses que les stories du kit supposent : brancher le thème + * clair/sombre sur `data-theme` (comme `ThemeService`), offrir le sélecteur de + * marque de la barre d'outils (comme `BrandService`), et fournir les providers + * Angular dont les stories ont besoin. + */ +import { applicationConfig, Preview } from '@storybook/angular'; +import { setCompodocJson } from '@storybook/addon-docs/angular'; +import { provideRouter } from '@angular/router'; +import { addons } from 'storybook/preview-api'; +import { lightTheme, darkTheme } from './myTheme'; +import { brandGlobalTypes, withBrand, DEFAULT_BRAND } from './brand-toolbar'; +// Descriptions des inputs dans l'onglet API : produites par Compodoc, que le +// builder `@storybook/angular` lance lui-même (`compodoc: true` dans angular.json). +import docJson from '../documentation.json'; +// +import { provideUiImageAssets, UiImageAssetsMap } from '../src/app/shared/components/ui/base/ui-image/ui-image'; +// `ui-image` lit ses images locales dans une map injectée : le composant ne peut +// pas deviner l'arborescence d'assets d'un projet. Remplissez +// `src/assets/assets-map.json` pour que ses stories affichent vos visuels. +import assetsMap from '../src/assets/assets-map.json'; +// + +setCompodocJson(docJson); + +/** Applique le mode sombre comme le fait `ThemeService` : un attribut sur . */ +const syncTheme = (isDark: boolean) => { + if (typeof document === 'undefined') return; + const root = document.documentElement; + if (isDark) root.setAttribute('data-theme', 'dark'); + else root.removeAttribute('data-theme'); +}; + +const channel = addons.getChannel(); +channel.on('DARK_MODE', (isDark) => { + setTimeout(() => syncTheme(isDark), 0); +}); + +const preview: Preview = { + initialGlobals: { brand: DEFAULT_BRAND }, + globalTypes: brandGlobalTypes, + decorators: [ + withBrand, + (Story, context) => { + syncTheme(context.globals['darkMode']); + return Story(); + }, + applicationConfig({ + providers: [ + // `provideRouter` : plusieurs composants du kit ont des liens + // (`ui-link`, `ui-breadcrumb`, `ui-menu`…) qui exigent un Router. + // Pas de `provideAnimations` ici : le kit anime en CSS (`ui-motion`). + // Le package `@angular/animations` est installé (le renderer Storybook + // l'exige) : si VOS composants animent avec Angular, ajoutez le + // provider, rien d'autre à installer. + provideRouter([]), + // + provideUiImageAssets(assetsMap as UiImageAssetsMap), + // + ], + }), + ], + parameters: { + layout: 'centered', + docs: { + story: { inline: true }, + }, + darkMode: { + dark: darkTheme, + light: lightTheme, + stylePreview: true, + classTarget: 'html', + darkClass: 'dark-mode', + lightClass: 'light-mode', + }, + backgrounds: { disable: true }, + }, +}; + +export default preview; diff --git a/projects/ui-kit-schematics/src/ng-add/files/storybook/tsconfig.doc.json b/projects/ui-kit-schematics/src/ng-add/files/storybook/tsconfig.doc.json new file mode 100644 index 0000000..69921a3 --- /dev/null +++ b/projects/ui-kit-schematics/src/ng-add/files/storybook/tsconfig.doc.json @@ -0,0 +1,8 @@ +/* tsconfig lu par Compodoc, qui produit les descriptions de l'onglet API. + Les stories en sont exclues : ce sont des exemples, pas de l'API. */ +{ + "extends": "./tsconfig.json", + "exclude": ["../src/**/*.spec.ts", "../src/**/*.stories.ts"], + "include": ["../src/**/*"], + "files": ["./typings.d.ts"] +} diff --git a/projects/ui-kit-schematics/src/ng-add/files/storybook/tsconfig.json b/projects/ui-kit-schematics/src/ng-add/files/storybook/tsconfig.json new file mode 100644 index 0000000..baec217 --- /dev/null +++ b/projects/ui-kit-schematics/src/ng-add/files/storybook/tsconfig.json @@ -0,0 +1,14 @@ +/* tsconfig de la configuration Storybook (posé par `ng add + @4sh/ui-kit-schematics --with-storybook`). Il étend celui de l'application + pour compiler stories et preview avec les mêmes règles que votre code. */ +{ + "extends": "../tsconfig.app.json", + "compilerOptions": { + "types": ["node"], + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true + }, + "exclude": ["../src/**/*.spec.ts", "../src/main.ts"], + "include": ["../src/**/*", "./preview.ts"], + "files": ["./typings.d.ts"] +} diff --git a/projects/ui-kit-schematics/src/ng-add/index.ts b/projects/ui-kit-schematics/src/ng-add/index.ts index 336da37..211aae7 100644 --- a/projects/ui-kit-schematics/src/ng-add/index.ts +++ b/projects/ui-kit-schematics/src/ng-add/index.ts @@ -18,8 +18,9 @@ import { join } from 'node:path'; import type { Schema } from './schema'; import { addDependency, addNpmScript, readPackageJson } from '../utils/package-json'; import { emptyManifest, MANIFEST_PATH, writeManifest } from '../utils/manifest'; -import { stylesFoundationDir } from '../utils/component-registry'; +import { CONFIG_TABLE_PATH, docsPipelineDir, stylesFoundationDir } from '../utils/component-registry'; import { readKitManifestInfo } from '../utils/kit-manifest'; +import { rewriteKitPaths } from '../utils/kit-paths'; import { add } from '../add'; /** Copie la fondation de styles selon l'arborescence validée avec le designer @@ -146,6 +147,231 @@ function copyTokensPipeline(): Rule { }; } +/** + * Chaîne de doc (FSHSP-125), pendant de `copyTokensPipeline` : le bloc + * `` qu'importe chaque MDX copié, et le script qui lui produit son + * manifeste depuis les `///` des `.scss` du projet. + * + * Sans elle, la section « Theming » de chaque page décrirait les valeurs du kit + * au jour de la copie, pas celles du projet — or c'est précisément ce que ce + * système existe pour éviter. Le script est copié tel quel : il reconnaît seul + * la disposition d'un projet consommateur. + */ +function copyDocsPipeline(): Rule { + return (tree: Tree, context: SchematicContext) => { + const dir = docsPipelineDir(); + for (const [name, targetPath] of [ + ['docs.config.mjs', 'scripts/docs.config.mjs'], + ['config-table.js', CONFIG_TABLE_PATH], + ] as const) { + if (tree.exists(targetPath)) continue; // éditable : jamais réécrasé + tree.create(targetPath, readFileSync(join(dir, name), 'utf8')); + } + addNpmScript(tree, 'docs:config', 'node scripts/docs.config.mjs'); + context.logger.info(`✔ Chaîne de doc copiée (scripts/docs.config.mjs, ${CONFIG_TABLE_PATH}).`); + return tree; + }; +} + +/** Chemin de la copie de `ui-image` : la preview ne câble ses providers que s'il est là. */ +const UI_IMAGE_PATH = 'src/app/shared/components/ui/base/ui-image/ui-image.ts'; + +/** Bornes du bloc conditionnel de `preview.ts` (voir {@link scaffoldStorybook}). */ +const UI_IMAGE_BLOCK_RE = /^[ \t]*\/\/ \n[\s\S]*?^[ \t]*\/\/ <\/ui-image>\n/gm; +const UI_IMAGE_MARKER_RE = /^[ \t]*\/\/ <\/?ui-image>\n/gm; + +/** + * Pose la configuration Storybook (FSHSP-125). + * + * Deux natures de fichier, deux provenances : ce qui vaut tel quel ici comme + * là-bas (`manager.ts`, `brand-toolbar.ts`, `preview-head.html`, les pages de + * doc transverses) vient des assets, synchronisé depuis ce dépôt ; ce qui doit + * diverger (`main.js` et ses globs, `preview.ts` et ses couplages, `myTheme.ts` + * et sa marque, les tsconfig) est un scaffold écrit pour le consommateur. + * + * La règle tourne APRÈS la copie des composants, et pas avant : c'est l'arbre + * qui dit si `ui-image` en fait partie. Sa preview a besoin de providers que + * lui seul utilise — les écrire sans lui casserait tout le Storybook sur un + * import mort. + */ +function scaffoldStorybook(): Rule { + return (tree: Tree, context: SchematicContext) => { + const assets = join(stylesFoundationDir(), '..', 'storybook'); + const scaffolds = join(__dirname, 'files', 'storybook'); + + const create = (targetPath: string, content: string) => { + if (tree.exists(targetPath)) return; // fichier du consommateur dès la première pose + tree.create(targetPath, content); + }; + + const copyAll = (srcDir: string, targetDir: string) => { + for (const entry of readdirSync(srcDir, { withFileTypes: true })) { + const full = join(srcDir, entry.name); + if (entry.isDirectory()) { + copyAll(full, `${targetDir}/${entry.name}`); + continue; + } + const source = readFileSync(full, 'utf8'); + // Les pages transverses citent la fondation de styles — `Colors.mdx` + // va jusqu'à importer le manifeste de tokens. Aux chemins du monorepo, + // le build échoue sur un module introuvable. + create(`${targetDir}/${entry.name}`, entry.name.endsWith('.mdx') ? rewriteKitPaths(source) : source); + } + }; + + copyAll(assets, 'storybook'); + + for (const name of ['main.js', 'myTheme.ts', 'tsconfig.json', 'tsconfig.doc.json']) { + create(`storybook/${name}`, readFileSync(join(scaffolds, name), 'utf8')); + } + + const hasUiImage = tree.exists(UI_IMAGE_PATH); + const preview = readFileSync(join(scaffolds, 'preview.ts'), 'utf8'); + create('storybook/preview.ts', hasUiImage ? preview.replace(UI_IMAGE_MARKER_RE, '') : preview.replace(UI_IMAGE_BLOCK_RE, '')); + + // `ui-image` résout ses images dans cette map. Vide, ses stories affichent + // le placeholder plutôt que de faire échouer la compilation de la preview. + if (hasUiImage) create('src/assets/assets-map.json', '{}\n'); + + context.logger.info('✔ Configuration Storybook posée (storybook/).'); + return tree; + }; +} + +/** + * Cibles `storybook` / `build-storybook`, calquées sur les options de `build` + * du projet — mêmes feuilles de style et mêmes `includePaths`, sans quoi les + * `@use 'utils'` des composants copiés ne résolvent pas dans la preview. + * + * `compodoc: true` : le builder lance Compodoc lui-même avant de démarrer, et + * c'est ce `documentation.json` qui donne aux `ArgTypes` leurs descriptions. + */ +function addStorybookTargets(): Rule { + return updateWorkspace((workspace) => { + for (const [name, project] of workspace.projects) { + const build = project.targets.get('build'); + if (!build || project.extensions['projectType'] !== 'application') continue; + if (project.targets.has('storybook')) continue; // déjà câblé : on ne réécrit pas + + // Le builder Storybook valide les options du `browserTarget` contre SON + // schéma, plus ancien : il y exige `output` sur chaque motif d'asset, + // qu'`ng new` n'écrit plus depuis que `@angular/build` lui donne `.` + // pour défaut. Sans ce complément, `ng run …:build-storybook` s'arrête + // sur « must have required property 'output' » en désignant un fichier + // que le consommateur n'a pas écrit. La valeur ajoutée est celle que le + // builder d'application applique déjà : le comportement ne change pas. + const assets = build.options?.['assets']; + if (Array.isArray(assets)) { + build.options!['assets'] = assets.map((asset) => + asset && typeof asset === 'object' && !('output' in asset) ? { ...asset, output: '.' } : asset, + ); + } + + const shared = { + configDir: 'storybook', + browserTarget: `${name}:build`, + assets: [{ glob: '**/*', input: 'src/assets', output: './assets/' }], + styles: build.options?.['styles'], + stylePreprocessorOptions: build.options?.['stylePreprocessorOptions'], + compodoc: true, + compodocArgs: ['-e', 'json', '-d', '.'], + }; + + project.targets.add({ + name: 'storybook', + builder: '@storybook/angular:start-storybook', + options: { ...shared, port: 6006 }, + }); + project.targets.add({ + name: 'build-storybook', + builder: '@storybook/angular:build-storybook', + options: { ...shared, outputDir: 'storybook-static' }, + }); + } + }); +} + +/** + * Nom du projet que les scripts npm doivent viser : la première application + * d'`angular.json`, celle à laquelle `addStorybookTargets` a ajouté ses cibles. + */ +function firstApplicationName(tree: Tree): string | null { + const buffer = tree.read('/angular.json'); + if (!buffer) return null; + const workspace = JSON.parse(buffer.toString('utf8')) as { + projects?: Record }>; + }; + for (const [name, project] of Object.entries(workspace.projects ?? {})) { + if (project.projectType === 'application') return name; + } + return null; +} + +/** + * Plage de version d'Angular déclarée par le projet (`^22.1.0`), telle quelle. + * + * Reprise mot pour mot, et non normalisée en `^22.0.0` : les paquets Angular + * s'exigent l'un l'autre à la version EXACTE, donc un `platform-browser-dynamic` + * résolu plus haut que le `@angular/common` déjà verrouillé casse l'install. + * 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'; +} + +/** Dépendances et scripts du Storybook du consommateur. Versions alignées sur ce dépôt. */ +function addStorybookDependencies(): Rule { + return (tree: Tree, context: SchematicContext) => { + // Deux peerDependencies de `@storybook/angular` qu'un projet Angular récent + // n'a plus : il bâtit avec `@angular/build`, et ne rend plus en JIT. Sans + // elles au `package.json`, npm les résout seul — il tombe sur le majeur + // précédent, dont les peers entrent en conflit avec l'Angular du projet, et + // l'install s'arrête sur un ERESOLVE qui ne dit pas d'où il vient. On les + // déclare au millésime du projet, comme ce dépôt le fait pour lui-même. + // `@angular/animations` est de la partie bien que le kit ne s'en serve pas : + // le renderer de `@storybook/angular` fait un `import()` dynamique de + // `@angular/platform-browser/animations` pour reconnaître + // `BrowserAnimationsModule`. Absent, le build PASSE — webpack se contente + // d'un stub — et c'est au premier rendu d'une story que tout s'arrête sur + // « Cannot find module ». Le package doit être là, pas son provider. + for (const name of [ + '@angular-devkit/build-angular', + '@angular/platform-browser-dynamic', + '@angular/animations', + ]) { + addDependency(tree, name, angularRange(tree), 'devDependencies'); + } + for (const [name, version] of [ + ['storybook', '^10.5.0'], + ['@storybook/angular', '^10.5.0'], + ['@storybook/addon-docs', '^10.5.0'], + ['@storybook/addon-a11y', '^10.5.0'], + ['@storybook-community/storybook-dark-mode', '^7.1.0'], + // Lancé par le builder (`compodoc: true`), pas par un script à nous. + ['@compodoc/compodoc', '^1.1.26'], + ] as const) { + addDependency(tree, name, version, 'devDependencies'); + } + // Les deux générations d'abord, dans cet ordre : la page « Colors » importe + // `tokens.manifest.json` (produit par `tokens:build`) et le bloc + // `` lit `ui-config.json` (produit par `docs:config`). Les + // chaîner ici plutôt que de compter sur le `postinstall` : sur une + // installation fraîche, le build tomberait sinon sur un module introuvable. + const project = firstApplicationName(tree); + if (project) { + const generate = 'npm run tokens:build && npm run docs:config'; + addNpmScript(tree, 'storybook', `${generate} && ng run ${project}:storybook`); + addNpmScript(tree, 'build-storybook', `${generate} && ng run ${project}:build-storybook`); + } else { + context.logger.warn( + "Aucune application trouvée dans angular.json : scripts npm `storybook` non écrits (les cibles, elles, n'ont pas pu être ajoutées non plus).", + ); + } + context.logger.info('✔ Dépendances et scripts Storybook ajoutés.'); + return tree; + }; +} + function addRuntimeDependencies(): Rule { return (tree: Tree, context: SchematicContext) => { const { peerDependencies } = readKitManifestInfo(); @@ -179,11 +405,11 @@ function addRuntimeDependencies(): Rule { }; } -function createManifest(): Rule { +function createManifest(withStorybook: boolean): Rule { return (tree: Tree, context: SchematicContext) => { if (tree.exists(MANIFEST_PATH)) return tree; // ré-exécution de `ng add` : on ne réinitialise pas const { version } = readKitManifestInfo(); - writeManifest(tree, emptyManifest(version)); + writeManifest(tree, emptyManifest(version, withStorybook)); context.logger.info(`✔ ${MANIFEST_PATH} créé (kitVersion: ${version}).`); return tree; }; @@ -207,15 +433,28 @@ function installRuntimeDependencies(): Rule { } export function ngAdd(options: Schema): Rule { + const withStorybook = options.withStorybook ?? false; + const foundation = [ copyStylesFoundationRule(), createStyleScaffolds(), copyTokensPipeline(), + // La chaîne de doc ne sert qu'aux MDX copiés : sans eux, ce sont deux + // fichiers morts dans le dépôt du consommateur. + ...(withStorybook ? [copyDocsPipeline()] : []), addRuntimeDependencies(), updateAngularJson(), - createManifest(), + createManifest(withStorybook), ]; + // Après la fondation ET après la copie : le scaffold lit l'arbre pour savoir + // si `ui-image` est là. Les cibles et les dépendances peuvent, elles, être + // écrites n'importe quand — on les groupe ici pour n'avoir qu'un seul bloc + // Storybook dans la chaîne. + const storybook = withStorybook + ? [scaffoldStorybook(), addStorybookTargets(), addStorybookDependencies()] + : []; + // En queue de chaîne : la tâche s'exécute après application de l'arbre, donc // une fois `package.json` écrit. `--skip-install` la retire, pour un projet qui // pilote son lockfile lui-même (CI, monorepo). @@ -228,6 +467,7 @@ export function ngAdd(options: Schema): Rule { if (options.skipComponents) { return chain([ ...foundation, + ...storybook, (_tree: Tree, context: SchematicContext) => { context.logger.info( 'Fondation posée. Composants à copier ensuite : `ng generate @4sh/ui-kit-schematics:add`.', @@ -237,5 +477,14 @@ export function ngAdd(options: Schema): Rule { ]); } - return chain([...foundation, add({ components: options.components, all: options.all }), ...install]); + return chain([ + ...foundation, + // Pas de `components` ici : `ng add` enchaîne sur le prompt, où l'on coche + // à la barre d'espace (`a` = tout). Une liste en ligne de commande ne + // rendrait pas le service que rend déjà cet écran, deux secondes plus tard. + // Elle reste sur `ng generate …:add`, pour un usage scripté. + add({ all: options.all, withStorybook }), + ...storybook, + ...install, + ]); } diff --git a/projects/ui-kit-schematics/src/ng-add/schema.d.ts b/projects/ui-kit-schematics/src/ng-add/schema.d.ts index 4607aae..f4fd3cb 100644 --- a/projects/ui-kit-schematics/src/ng-add/schema.d.ts +++ b/projects/ui-kit-schematics/src/ng-add/schema.d.ts @@ -1,9 +1,9 @@ export interface Schema { skipInstall?: boolean; - /** Noms explicites (`ui-button`, `ui-select`…) — court-circuite le prompt interactif. */ - components?: string[]; /** Copie tous les composants disponibles, sans prompt. */ all?: boolean; /** Pose la fondation seule, sans copier de composant. */ skipComponents?: boolean; + /** Copie la doc des composants (story + MDX) et la chaîne qui l'alimente. */ + withStorybook?: boolean; } diff --git a/projects/ui-kit-schematics/src/ng-add/schema.json b/projects/ui-kit-schematics/src/ng-add/schema.json index 2c845b1..32578b4 100644 --- a/projects/ui-kit-schematics/src/ng-add/schema.json +++ b/projects/ui-kit-schematics/src/ng-add/schema.json @@ -9,11 +9,6 @@ "default": false, "description": "Ne pas lancer `npm install` après avoir modifié package.json." }, - "components": { - "type": "array", - "items": { "type": "string" }, - "description": "Composants à copier (ex. ui-button ui-select). Omis → sélection interactive." - }, "all": { "type": "boolean", "default": false, @@ -23,6 +18,11 @@ "type": "boolean", "default": false, "description": "Pose la fondation seule. Les composants se copient ensuite avec `ng generate @4sh/ui-kit-schematics:add`." + }, + "withStorybook": { + "type": "boolean", + "default": false, + "description": "Copie la story et le MDX de chaque composant, plus la chaîne de doc (docs.config.mjs, bloc ConfigTable). La configuration Storybook elle-même reste à poser." } } } diff --git a/projects/ui-kit-schematics/src/update/index.ts b/projects/ui-kit-schematics/src/update/index.ts index c92b00b..b7a2674 100644 --- a/projects/ui-kit-schematics/src/update/index.ts +++ b/projects/ui-kit-schematics/src/update/index.ts @@ -58,7 +58,10 @@ export function update(options: Schema): Rule { context.logger.warn(`${name} : présent dans ui-kit.json mais introuvable dans le kit installé — ignoré.`); continue; } - const files = renderUnitFiles(unit, kitVersion); + // Le choix fait à l'installation, pas une valeur par défaut : une mise à + // jour ne doit ni introduire de la doc chez qui n'en a pas demandé, ni + // laisser périmée celle du projet qui en a (FSHSP-125). + const files = renderUnitFiles(unit, kitVersion, { withStorybook: manifest.storybook ?? false }); while (true) { diff --git a/projects/ui-kit-schematics/src/utils/component-registry.ts b/projects/ui-kit-schematics/src/utils/component-registry.ts index c96dfbc..3b06741 100644 --- a/projects/ui-kit-schematics/src/utils/component-registry.ts +++ b/projects/ui-kit-schematics/src/utils/component-registry.ts @@ -24,6 +24,11 @@ const COMPONENTS_ROOT = 'src/app/shared/components/ui'; /** Directives de base, services, utilitaires et types transverses : hors de * `components/`, qui ne doit contenir que des composants (FSHSP-121). */ const CORE_ROOT = 'src/app/shared/ui-core'; +/** Bloc de doc `` chez le consommateur (FSHSP-125) : sa place + * décide du chemin relatif réécrit dans chaque MDX copié, d'où la constante + * partagée plutôt qu'un littéral des deux côtés. Même disposition qu'ici, pour + * que le scaffold Storybook à venir tombe juste autour. */ +export const CONFIG_TABLE_PATH = 'storybook/blocks/config-table.js'; function listDirs(dir: string): string[] { if (!existsSync(dir)) return []; @@ -82,6 +87,16 @@ export function stylesFoundationDir(): string { return join(ASSETS_ROOT, 'styles'); } +/** Chaîne de doc embarquée : `docs.config.mjs` + le bloc `` (FSHSP-125). */ +export function docsPipelineDir(): string { + return join(ASSETS_ROOT, 'docs-pipeline'); +} + +/** Story et MDX d'un composant : sa doc, copiée seulement sur `--with-storybook`. */ +export function isStorybookFile(path: string): boolean { + return path.endsWith('.stories.ts') || path.endsWith('.mdx'); +} + /** Tous les fichiers d'une unité, chemins absolus. */ export function unitSourceFiles(unit: AssetUnit): string[] { const out: string[] = []; diff --git a/projects/ui-kit-schematics/src/utils/copy.ts b/projects/ui-kit-schematics/src/utils/copy.ts index 9312e0d..155f138 100644 --- a/projects/ui-kit-schematics/src/utils/copy.ts +++ b/projects/ui-kit-schematics/src/utils/copy.ts @@ -8,14 +8,22 @@ import { SchematicsException } from '@angular-devkit/schematics'; import { readFileSync } from 'node:fs'; import { basename } from 'node:path'; import type { AssetUnit } from './component-registry'; -import { flattenedRelPath, unitSourceFiles } from './component-registry'; +import { flattenedRelPath, isStorybookFile, unitSourceFiles } from './component-registry'; import { BARREL_FILENAME } from './export-map'; -import { rewriteKitImports } from './rewrite-imports'; +import { rewriteDocImports, rewriteKitImports } from './rewrite-imports'; +import { stripFigmaDesign } from './strip-figma'; const HEADER_STYLE: Record string> = { '.ts': (lines) => lines.map((l) => `// ${l}`).join('\n') + '\n', '.scss': (lines) => lines.map((l) => `// ${l}`).join('\n') + '\n', '.html': (lines) => `\n`, + // Une constante exportée, et non le commentaire `{/* … */}` qu'on attendrait + // ici : c'est la seule forme de commentaire que le MDX accepte, et elle ne + // survit pas au passage de Prettier que le CLI Angular applique aux fichiers + // écrits par un schematic (`{/*` en ressort en `{/_`, et l'indexeur de + // Storybook ne parse plus la page). Un `export const` traverse ce formatage + // sans dommage, reste invisible au rendu, et porte la même mention. + '.mdx': (lines) => `export const uiKitOrigin = ${JSON.stringify(lines.join(' '))};\n\n`, }; function traceabilityHeader(unit: AssetUnit, relPath: string, kitVersion: string, ext: string): string { @@ -37,9 +45,17 @@ export interface RenderedFile { content: string; } +export interface RenderOptions { + /** Poser aussi la story et le MDX du composant (FSHSP-125). Par défaut non : + * sans Storybook dans le projet, ce sont deux fichiers qui importent des + * packages absents. Le choix est mémorisé dans `ui-kit.json`, pour qu'`update` + * le reconduise au lieu de le redemander. */ + withStorybook?: boolean; +} + /** Calcule le contenu final (en-tête inclus) de chaque fichier d'une unité, * sans rien écrire — réutilisé par `copyUnit` et par le diff d'`update`. */ -export function renderUnitFiles(unit: AssetUnit, kitVersion: string): RenderedFile[] { +export function renderUnitFiles(unit: AssetUnit, kitVersion: string, options: RenderOptions = {}): RenderedFile[] { const files: RenderedFile[] = []; const unresolved: string[] = []; @@ -55,6 +71,7 @@ export function renderUnitFiles(unit: AssetUnit, kitVersion: string): RenderedFi // Comparaison sur le nom EXACT : un `endsWith` écarterait aussi un // `ui-table-public-api.ts`, qui est un fichier de composant ordinaire. if (basename(absSrc) === BARREL_FILENAME) continue; + if (!options.withStorybook && isStorybookFile(absSrc)) continue; const relPath = flattenedRelPath(unit, absSrc); const ext = absSrc.slice(absSrc.lastIndexOf('.')); @@ -70,8 +87,9 @@ export function renderUnitFiles(unit: AssetUnit, kitVersion: string): RenderedFi claimedBy.set(targetPath, absSrc); let source = readFileSync(absSrc, 'utf8'); - if (ext === '.ts') { - const result = rewriteKitImports(source, targetPath); + if (absSrc.endsWith('.stories.ts')) source = stripFigmaDesign(source); + if (ext === '.ts' || ext === '.mdx') { + const result = ext === '.mdx' ? rewriteDocImports(source, targetPath) : rewriteKitImports(source, targetPath); source = result.content; unresolved.push(...result.unresolved.map((item) => `${relPath} → ${item}`)); } @@ -94,9 +112,9 @@ export function renderUnitFiles(unit: AssetUnit, kitVersion: string): RenderedFi } /** Copie tous les fichiers d'une unité dans l'arbre, avec en-tête de traçabilité. */ -export function copyUnit(tree: Tree, unit: AssetUnit, kitVersion: string): string[] { +export function copyUnit(tree: Tree, unit: AssetUnit, kitVersion: string, options: RenderOptions = {}): string[] { const written: string[] = []; - for (const { targetPath, content } of renderUnitFiles(unit, kitVersion)) { + for (const { targetPath, content } of renderUnitFiles(unit, kitVersion, options)) { if (tree.exists(targetPath)) tree.overwrite(targetPath, content); else tree.create(targetPath, content); written.push(targetPath); diff --git a/projects/ui-kit-schematics/src/utils/export-map.ts b/projects/ui-kit-schematics/src/utils/export-map.ts index 32d0c18..d8b46fa 100644 --- a/projects/ui-kit-schematics/src/utils/export-map.ts +++ b/projects/ui-kit-schematics/src/utils/export-map.ts @@ -80,6 +80,10 @@ export function buildExportMap(unit: AssetUnit): Map { continue; } if (!absPath.endsWith('.ts')) continue; + // Une story exporte des symboles (`export const High: Story`) qui ne sont + // pas de l'API : les indexer ferait réadresser un import du kit vers un + // fichier de doc si les deux noms se croisaient (FSHSP-125). + if (absPath.endsWith('.stories.ts')) continue; // Chemin aplati, sans extension — c'est ce qu'un import doit désigner. const flattened = flattenedRelPath(unit, absPath).replace(/\.ts$/, ''); diff --git a/projects/ui-kit-schematics/src/utils/kit-paths.ts b/projects/ui-kit-schematics/src/utils/kit-paths.ts new file mode 100644 index 0000000..5e02214 --- /dev/null +++ b/projects/ui-kit-schematics/src/utils/kit-paths.ts @@ -0,0 +1,25 @@ +/** + * kit-paths — réadresse les chemins du monorepo cités par les pages de doc + * transverses (FSHSP-125). + * + * Ces pages sont copiées telles quelles depuis ce dépôt, où la fondation de + * styles vit sous `projects/ui-kit/styles/`. Chez le consommateur elle est + * ailleurs, et la citation n'est pas décorative : `Colors.mdx` IMPORTE le + * manifeste de tokens, et le build s'arrête net sur un module introuvable. + * Les mentions en prose passent par la même table — un chemin faux dans une + * doc coûte au lecteur le temps de le chercher. + */ + +/** Ordre significatif : `base/` sort de `ui-kit/`, il doit être testé avant le préfixe général. */ +const PATH_MAP: readonly (readonly [string, string])[] = [ + ['projects/ui-kit/styles/base/', 'src/styles/base/'], + ['projects/ui-kit/styles/', 'src/styles/ui-kit/'], +]; + +export function rewriteKitPaths(content: string): string { + let out = content; + for (const [from, to] of PATH_MAP) { + out = out.split(from).join(to); + } + return out; +} diff --git a/projects/ui-kit-schematics/src/utils/manifest.ts b/projects/ui-kit-schematics/src/utils/manifest.ts index 8a0dabd..1c29226 100644 --- a/projects/ui-kit-schematics/src/utils/manifest.ts +++ b/projects/ui-kit-schematics/src/utils/manifest.ts @@ -24,6 +24,11 @@ export interface ManifestComponentEntry { export interface UiKitManifest { kitVersion: string; + /** Le projet reçoit-il la story et le MDX de chaque composant copié + * (FSHSP-125) ? Mémorisé ici parce qu'`update` doit reconduire le choix fait + * à l'installation : sans lui, une mise à jour poserait de la doc chez qui + * n'en veut pas, ou l'effacerait chez qui en a. */ + storybook?: boolean; components: Record; } @@ -42,8 +47,8 @@ export function writeManifest(tree: Tree, manifest: UiKitManifest): void { } } -export function emptyManifest(kitVersion: string): UiKitManifest { - return { kitVersion, components: {} }; +export function emptyManifest(kitVersion: string, storybook = false): UiKitManifest { + return { kitVersion, storybook, components: {} }; } /** Horodatage ISO, tronqué au jour — cohérent avec l'exemple du ticket (`"2026-08-13"`). */ diff --git a/projects/ui-kit-schematics/src/utils/rewrite-imports.ts b/projects/ui-kit-schematics/src/utils/rewrite-imports.ts index 8bc4352..e076b92 100644 --- a/projects/ui-kit-schematics/src/utils/rewrite-imports.ts +++ b/projects/ui-kit-schematics/src/utils/rewrite-imports.ts @@ -7,6 +7,10 @@ * aucun effet sur les composants qui l'utilisent, et le modèle « les sources * t'appartiennent » ne tiendrait pas. C'est la raison d'être du starter. * + * Les MDX passent par la même porte ({@link rewriteDocImports}, FSHSP-125), + * avec deux différences : ils importent en plus le bloc ``, et + * leurs blocs de code sont de la prose — on n'y touche pas. + * * Un import peut devoir être SCINDÉ : les symboles d'un même barrel ne vivent * pas forcément dans le même fichier (`{ UiIcon, UiIconType }` → * `ui-icon.ts` + `ui-icon-families.ts`). Une réécriture qui se contenterait de @@ -14,7 +18,7 @@ */ import { dirname, relative } from 'node:path'; import type { AssetUnit } from './component-registry'; -import { resolveSpecifier } from './component-registry'; +import { CONFIG_TABLE_PATH, resolveSpecifier } from './component-registry'; import { buildExportMap } from './export-map'; /** @@ -116,3 +120,72 @@ export function rewriteKitImports(content: string, fileTargetPath: string): Rewr return { content: rewritten, unresolved }; } + +/** `import { ConfigTable } from '../../../../storybook/blocks/config-table';` */ +const CONFIG_TABLE_IMPORT_RE = + /^([ \t]*import\s+(?:type\s+)?\{[^}]*\}\s+from\s+)['"](?:\.\.\/)+storybook\/blocks\/config-table['"](\s*;?[ \t]*)$/gm; + +/** Délimiteur de bloc de code MDX : ``` ou ~~~, éventuellement indenté. */ +const FENCE_RE = /^\s*(```|~~~)/; + +/** + * Applique une réécriture aux seules lignes HORS bloc de code. + * + * Un MDX mêle deux natures de code : ses propres imports ESM, qui doivent + * suivre les copies, et des extraits pédagogiques entre ``` qui montrent au + * lecteur ce qu'il écrira dans SON application. Réécrire les seconds + * produirait des chemins relatifs faux — ils dépendent de l'emplacement du + * fichier du lecteur, que nous ne connaissons pas. + */ +function outsideFencedBlocks(content: string, rewrite: (chunk: string) => string): string { + const lines = content.split('\n'); + const out: string[] = []; + let buffer: string[] = []; + let inFence = false; + + const flush = () => { + if (buffer.length) out.push(rewrite(buffer.join('\n'))); + buffer = []; + }; + + for (const line of lines) { + if (FENCE_RE.test(line)) { + if (!inFence) flush(); + inFence = !inFence; + out.push(line); + continue; + } + if (inFence) out.push(line); + else buffer.push(line); + } + flush(); + + return out.join('\n'); +} + +/** + * Réécrit les imports d'un MDX copié : ceux du kit comme dans un `.ts`, plus + * l'import du bloc `` — qui pointe ici, depuis le kit, vers + * `storybook/blocks/` du monorepo (49 MDX sur 54), et doit désigner chez le + * consommateur la copie que `ng add` y a posée. + * + * Ce que ce passage NE corrige PAS : les extraits de code pédagogiques qui + * importent `@4sh/ui-kit/…` (aujourd'hui `ui-icon.mdx`) restent tels quels — + * voir {@link outsideFencedBlocks}. Le lecteur y verra un package qu'il n'a + * pas ; c'est de la prose à reprendre, pas un import à résoudre. + */ +export function rewriteDocImports(content: string, fileTargetPath: string): RewriteResult { + const unresolved: string[] = []; + + const rewritten = outsideFencedBlocks(content, (chunk) => { + const kit = rewriteKitImports(chunk, fileTargetPath); + unresolved.push(...kit.unresolved); + return kit.content.replace( + CONFIG_TABLE_IMPORT_RE, + (_whole, prefix: string, suffix: string) => + `${prefix}'${relativeSpecifier(fileTargetPath, CONFIG_TABLE_PATH.replace(/\.js$/, ''))}'${suffix}`, + ); + }); + + return { content: rewritten, unresolved }; +} diff --git a/projects/ui-kit-schematics/src/utils/strip-figma.ts b/projects/ui-kit-schematics/src/utils/strip-figma.ts new file mode 100644 index 0000000..e71da3e --- /dev/null +++ b/projects/ui-kit-schematics/src/utils/strip-figma.ts @@ -0,0 +1,65 @@ +/** + * strip-figma — retire le bloc `design: { type: 'figma', url: … }` des stories + * copiées (FSHSP-125). + * + * 48 des 53 stories du kit pointent, via `parameters.design.url`, vers NOTRE + * fichier Figma. Chez le consommateur, l'onglet « Design » n'ouvrirait qu'un + * lien qu'il ne peut pas suivre : on le retire plutôt que de livrer une porte + * fermée. À lui de rebrancher son propre `node-id` s'il en a un. + * + * La suppression compte les accolades au lieu de faire confiance à une + * expression régulière : une URL Figma porte `?node-id=…&t=…`, et un futur + * bloc pourrait gagner une clé. Un compteur ne se trompe pas de fermeture. + */ + +/** Ligne d'ouverture du bloc, seule sur sa ligne : ` design: {`. */ +const DESIGN_OPEN_RE = /^([ \t]*)design:\s*\{[ \t]*$/; + +/** `parameters: {}` devenu vide après retrait — plus rien à y lire. */ +const EMPTY_PARAMETERS_RE = /^[ \t]*parameters:\s*\{\s*\},?[ \t]*$/; + +/** + * Indice de la ligne qui ferme le bloc ouvert en `start`, ou `-1` si la + * fermeture n'est jamais atteinte (source malformée : on préfère ne rien + * toucher). Les accolades des chaînes de caractères ne comptent pas. + */ +function findBlockEnd(lines: string[], start: number): number { + let depth = 0; + for (let i = start; i < lines.length; i++) { + let inString: string | null = null; + for (const char of lines[i]) { + if (inString) { + if (char === inString) inString = null; + continue; + } + if (char === "'" || char === '"' || char === '`') inString = char; + else if (char === '{') depth++; + else if (char === '}') { + depth--; + if (depth === 0) return i; + } + } + } + return -1; +} + +/** Retire chaque bloc `design: { … }` d'une story, et le `parameters: {}` qu'il laisserait vide. */ +export function stripFigmaDesign(content: string): string { + const lines = content.split('\n'); + const kept: string[] = []; + + for (let i = 0; i < lines.length; i++) { + if (!DESIGN_OPEN_RE.test(lines[i])) { + kept.push(lines[i]); + continue; + } + const end = findBlockEnd(lines, i); + if (end === -1) { + kept.push(lines[i]); + continue; + } + i = end; // les lignes du bloc, fermeture comprise, ne sont pas reprises + } + + return kept.filter((line) => !EMPTY_PARAMETERS_RE.test(line)).join('\n'); +} diff --git a/projects/ui-kit-schematics/tsconfig.json b/projects/ui-kit-schematics/tsconfig.json index 288dd72..a00f410 100644 --- a/projects/ui-kit-schematics/tsconfig.json +++ b/projects/ui-kit-schematics/tsconfig.json @@ -18,5 +18,9 @@ "paths": {} }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts"] + /* `files/` porte des scaffolds destinés au projet consommateur (preview + Storybook, thème du manager…). Ils sont copiés tels quels, jamais + compilés ici : leurs imports (`@storybook/angular`, `../documentation.json`) + n'existent que là-bas. */ + "exclude": ["src/**/*.spec.ts", "src/**/files/**"] } diff --git a/scripts/docs.config.mjs b/scripts/docs.config.mjs index 8419782..a3cd7e0 100644 --- a/scripts/docs.config.mjs +++ b/scripts/docs.config.mjs @@ -40,13 +40,32 @@ import { dirname, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const SHARED_CONFIG = join(ROOT, 'projects/ui-kit/styles/settings/_ui-config.scss'); -const COMPONENTS_DIRS = [ - join(ROOT, 'src/app/shared/components'), - // Components already migrated to the `ui-kit` npm package (`ng-packagr` secondary - // entry points) — their `.scss` still documents the same public contract. - join(ROOT, 'projects/ui-kit'), -]; + +/** + * Le script est embarqué tel quel dans `@4sh/ui-kit-schematics` et tourne donc + * dans deux dispositions (FSHSP-125) : ce monorepo, où le kit vit sous + * `projects/ui-kit/`, et un projet consommateur, où il n'existe que des copies + * sous `src/`. Ce sont les mêmes `.scss` et le même contrat `///` : seuls les + * chemins changent. On les déduit du disque plutôt que de maintenir deux + * versions du script, ou de réécrire ses constantes à la copie. + */ +const IS_KIT_MONOREPO = existsSync(join(ROOT, 'projects/ui-kit/styles/settings/_ui-config.scss')); + +const SHARED_CONFIG = IS_KIT_MONOREPO + ? join(ROOT, 'projects/ui-kit/styles/settings/_ui-config.scss') + : // Chez le consommateur, la fondation de styles est posée par `ng add`. + join(ROOT, 'src/styles/ui-kit/settings/_ui-config.scss'); + +const COMPONENTS_DIRS = IS_KIT_MONOREPO + ? [ + join(ROOT, 'src/app/shared/components'), + // Components already migrated to the `ui-kit` npm package (`ng-packagr` secondary + // entry points) — their `.scss` still documents the same public contract. + join(ROOT, 'projects/ui-kit'), + ] + : // Les composants copiés (`components/ui/`) et ceux du projet cohabitent ici. + [join(ROOT, 'src/app/shared/components')]; + const OUT_FILE = join(ROOT, 'storybook/generated/ui-config.json'); // Sections de `_ui-config.scss` → page de doc du groupe partagé. @@ -362,6 +381,9 @@ function resolveBinding(rawValue, { shared, local }) { // --- Collecte --------------------------------------------------------- function walk(dir, out = []) { + // Un projet consommateur peut n'avoir encore copié aucun composant : le + // dossier n'existe pas, ce n'est pas une erreur (la doc est alors vide). + if (!existsSync(dir)) return out; for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name); if (entry.isDirectory()) walk(full, out); @@ -371,6 +393,15 @@ function walk(dir, out = []) { } function parseSharedConfig() { + if (!existsSync(SHARED_CONFIG)) { + // Chez le consommateur, ce fichier arrive avec la fondation de styles. Sans + // lui, chaque composant perdrait ses constantes partagées sans qu'on sache + // dire pourquoi : on nomme la cause plutôt que de produire une doc amputée. + throw new Error( + `${relative(ROOT, SHARED_CONFIG)} introuvable — pose la fondation de styles ` + + `(\`ng add @4sh/ui-kit-schematics\`) avant de générer la doc.`, + ); + } const text = readFileSync(SHARED_CONFIG, 'utf8'); const map = new Map(); for (const decl of parseDeclarations(text)) { diff --git a/scripts/schematics-assets.build.mjs b/scripts/schematics-assets.build.mjs index 99c9fc1..75a91d6 100644 --- a/scripts/schematics-assets.build.mjs +++ b/scripts/schematics-assets.build.mjs @@ -14,8 +14,11 @@ * `ng-package.json`) → `assets/components/{catégorie}/ui-{nom}/` * - une base PARTAGÉE (transverse à une catégorie, ex. `forms/src` qui * porte `BaseFormField`) → `assets/shared/{catégorie}/` - * Seuls `*.ts`, `*.html`, `*.scss` sont copiés — jamais `*.stories.ts`, - * `*.spec.ts`, `*.mdx` (contrat repris de `components.check.mjs`). + * Seuls `*.ts`, `*.html`, `*.scss`, `*.mdx` sont copiés — jamais `*.spec.ts`. + * La story et le MDX en font partie depuis FSHSP-125 : ils sont la doc du + * composant, et un composant copié sans sa doc laisse le consommateur avec un + * Storybook hébergé qui décrit NOS composants, pas ses copies éditées. C'est + * `add --with-storybook` qui décide de les poser ou non chez lui, pas ce script. * * La fondation de styles (`styles/base`, `styles/utils`, `styles/settings`, * `styles/generated`) part elle aussi dans `assets/styles/`, pour `ng-add`. @@ -31,8 +34,10 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const KIT = join(ROOT, 'projects/ui-kit'); const ASSETS = join(ROOT, 'projects/ui-kit-schematics/assets'); -const SOURCE_EXTENSIONS = new Set(['.ts', '.html', '.scss']); -const EXCLUDED_SUFFIXES = ['.stories.ts', '.spec.ts', '.mdx']; +const SOURCE_EXTENSIONS = new Set(['.ts', '.html', '.scss', '.mdx']); +// Les tests restent au kit : ils s'appuient sur son harnais, pas sur celui du +// consommateur, chez qui ils échoueraient sans rien lui apprendre. +const EXCLUDED_SUFFIXES = ['.spec.ts']; function isSourceFile(name) { const ext = name.slice(name.lastIndexOf('.')); @@ -40,7 +45,7 @@ function isSourceFile(name) { return !EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix)); } -/** Copie récursivement les fichiers source (.ts/.html/.scss) d'un dossier vers un autre. */ +/** Copie récursivement les fichiers source (.ts/.html/.scss/.mdx) d'un dossier vers un autre. */ function copySourceTree(srcDir, destDir) { return copyTree(srcDir, destDir, isSourceFile); } @@ -172,10 +177,58 @@ function main() { tokenFiles++; } + // Chaîne de doc (FSHSP-125) — pendant exact de la chaîne de tokens, pour la + // même raison : la section « Theming » de chaque MDX n'est pas écrite à la + // main, elle est lue dans `ui-config.json` que `docs.config.mjs` extrait des + // `///` des `.scss`. Sans le script chez le consommateur, ses tables gèlent + // sur NOS valeurs à la première copie — l'inverse de ce que la doc promet. + // `docs.config.mjs` est copié tel quel : il détecte lui-même sa disposition + // (monorepo ou projet consommateur), aucun chemin à réécrire ici. + let docFiles = 0; + const docsDest = join(ASSETS, 'docs-pipeline'); + for (const [src, name] of [ + [join(ROOT, 'scripts/docs.config.mjs'), 'docs.config.mjs'], + [join(ROOT, 'storybook/blocks/config-table.js'), 'config-table.js'], + ]) { + if (!existsSync(src)) continue; + mkdirSync(docsDest, { recursive: true }); + copyFileSync(src, join(docsDest, name)); + docFiles++; + } + + // Configuration Storybook — la part qui vaut TELLE QUELLE chez le + // consommateur, donc copiée d'ici pour rester en phase avec ce dépôt sans + // recopie manuelle. Ce qui doit diverger (`main.js` et ses globs, `preview.ts` + // et ses couplages, `myTheme.ts` et sa marque, les tsconfig) est écrit en + // scaffold dans `src/ng-add/files/storybook/` : deux natures, deux endroits. + // + // `restore-component-metadata.ts` n'est PAS du lot : il répare les + // annotations que le linker retire du package *compilé* que nos stories + // importent. Chez le consommateur les stories visent des sources, compilées + // avec le reste de son app — le décorateur n'aurait rien à réparer. + for (const name of ['manager.ts', 'brand-toolbar.ts', 'preview-head.html', 'typings.d.ts']) { + const src = join(ROOT, 'storybook', name); + if (!existsSync(src)) continue; + mkdirSync(join(ASSETS, 'storybook'), { recursive: true }); + copyFileSync(src, join(ASSETS, 'storybook', name)); + docFiles++; + } + + // Pages de doc transverses. `Introduction`, `NpmPackage` et `Overview` restent + // ici : les deux premières racontent l'installation de `@4sh/ui-kit`, la + // troisième est validée par `components.check.mjs` contre NOTRE inventaire. + // Les pages `config/` sont, elles, la cible des liens `?path=/docs/…` que + // portent les MDX des composants : sans elles, ces liens tombent dans le vide. + for (const group of ['foundations', 'specifications', 'config']) { + const src = join(ROOT, 'storybook/docs', group); + if (!existsSync(src)) continue; + docFiles += copyTree(src, join(ASSETS, 'storybook/docs', group), (n) => n.endsWith('.mdx')); + } + console.log( `[schematics-assets] ${componentCount} composant(s), ${sharedCount} base(s) partagée(s), ` + - `${styleFiles} fichier(s) de style, ${tokenFiles} fichier(s) de pipeline de tokens ` + - `→ ${relative(ROOT, ASSETS)}`, + `${styleFiles} fichier(s) de style, ${tokenFiles} fichier(s) de pipeline de tokens, ` + + `${docFiles} fichier(s) de chaîne de doc → ${relative(ROOT, ASSETS)}`, ); } diff --git a/scripts/schematics-package.build.mjs b/scripts/schematics-package.build.mjs index a80a06f..4ebd9d6 100644 --- a/scripts/schematics-package.build.mjs +++ b/scripts/schematics-package.build.mjs @@ -16,7 +16,7 @@ import { execSync } from 'node:child_process'; import { cpSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -56,9 +56,13 @@ for (const name of ['README.md', 'README.fr.md', 'LICENSE']) { if (existsSync(src)) copyFileSync(src, join(DEST, name)); } // `schema.json` n'est pas un `.ts` : tsc ne le copie pas tout seul. +// Les `files/` échappent au filtre : ce sont des scaffolds pour le projet +// consommateur (`preview.ts`, `myTheme.ts`…), exclus de la compilation +// justement parce qu'ils doivent partir tels quels — les filtrer ici les +// ferait disparaître du package sans que rien ne le signale. cpSync(join(PKG, 'src'), join(DEST, 'src'), { recursive: true, - filter: (path) => !path.endsWith('.ts'), + filter: (path) => !path.endsWith('.ts') || path.includes(`${sep}files${sep}`), }); cpSync(join(PKG, 'assets'), join(DEST, 'assets'), { recursive: true });