Bootstrap monorepo pnpm + migration de web-component #1

Merged
kyuno merged 2 commits from feat/monorepo-bootstrap into main 2026-09-18 11:36:45 +02:00
32 changed files with 3120 additions and 1 deletions

8
.changeset/README.md Normal file
View file

@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).

11
.changeset/config.json Normal file
View file

@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View file

@ -0,0 +1,5 @@
---
"@lightweight-sdk/web-component": patch
---
Migration de `web-component` dans le monorepo lightweight-sdk. Aucun changement de comportement : le code source est repris tel quel depuis l'ancien repo `lightweight-sdk-web-component`, seuls l'outillage (Biome, Changesets, publication sur le registre npm privé) et l'organisation du dépôt changent.

18
.editorconfig Normal file
View file

@ -0,0 +1,18 @@
root = true
[*]
indent_style = tab
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yml,yaml}]
indent_style = space
indent_size = 2
[*.md]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false

51
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,51 @@
name: CI
on:
pull_request:
branches:
- main
jobs:
build-lint-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint (Biome)
run: pnpm lint
- name: Build (tsc -b)
run: pnpm build
- name: Test (Vitest)
run: pnpm test
- name: Docs check (TypeDoc validation)
run: pnpm docs:check

View file

@ -0,0 +1,51 @@
# Publie les packages modifiés sur le registre npm privé Forgejo
# (https://git.dev.kyuno.fr/api/packages/kyuno/npm/) via Changesets.
#
# - déclenché sur push vers `main` (typiquement après le merge d'une PR
# "Version Packages" générée par `pnpm changeset version`)
# - `changeset publish` est idempotent par package : republie uniquement
# les packages dont la version locale n'existe pas encore sur le registre
# - prérequis côté instance : secret de repo REGISTRY_TOKEN, un jeton
# Forgejo avec les scopes write:package + read:package (Settings ->
# Applications) - le jeton automatique des Actions n'a pas ce droit
# sur cette instance (voir spec/ci-cd.md)
name: Release
on:
push:
branches:
- main
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Configure registry authentication
run: echo "//git.dev.kyuno.fr/api/packages/kyuno/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Publish changed packages
run: pnpm changeset:publish
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
node_modules/
dist/
*.tsbuildinfo
docs/api/
coverage/
*.log
.DS_Store
Thumbs.db
.vscode/
.env
.env.*.local
# Claude Code (règle CLAUDE.md : ne jamais commit ceci)
.claude/
CLAUDE.md

1
.npmrc Normal file
View file

@ -0,0 +1 @@
@lightweight-sdk:registry=https://git.dev.kyuno.fr/api/packages/kyuno/npm/

View file

@ -1,3 +1,36 @@
# lightweight-sdk # lightweight-sdk
Bibliothèque personnelle de packages TypeScript (monorepo pnpm), publiée sur le registre npm privé Forgejo. Bibliothèque personnelle de packages TypeScript, organisée en monorepo pnpm et publiée sur le registre npm privé hébergé sur [git.dev.kyuno.fr](https://git.dev.kyuno.fr/kyuno).
## Structure
Les packages sont répartis en trois catégories sous `packages/` :
- `client/` — packages destinés au navigateur (DOM, composants UI).
- `common/` — code isomorphe partagé entre client et server.
- `server/` — packages Node/backend, structurés en services modulaires autonomes.
Voir [`spec/architecture.md`](./spec/architecture.md) pour le détail des choix d'outillage, et [`spec/packages.md`](./spec/packages.md) pour la liste des packages existants.
## Démarrage
```bash
pnpm install
pnpm build
pnpm lint
pnpm test
pnpm docs:check
```
## Créer un nouveau package
```bash
pnpm create-package <client|common|server> <nom-du-package>
```
## Documentation
- [`spec/architecture.md`](./spec/architecture.md) — vue d'ensemble et choix techniques.
- [`spec/conventions.md`](./spec/conventions.md) — conventions de code et traçabilité avec les directives du projet.
- [`spec/ci-cd.md`](./spec/ci-cd.md) — CI, versioning (Changesets) et publication sur le registre.
- [`spec/packages.md`](./spec/packages.md) — registre des packages du monorepo.

62
biome.json Normal file
View file

@ -0,0 +1,62 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"style": {
"useConsistentMemberAccessibility": {
"level": "error",
"options": { "accessibility": "explicit" }
}
},
"nursery": {
"noFloatingPromises": "error",
"noMisusedPromises": "error",
"useAwaitThenable": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always",
"trailingCommas": "all"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"overrides": [
{
"includes": ["packages/client/web-component/src/**"],
"formatter": { "enabled": false },
"linter": {
"rules": {
"suspicious": {
"noShadowRestrictedNames": "off"
}
}
}
}
]
}

34
package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "lightweight-sdk",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@12.4.2",
"engines": {
"node": ">=22"
},
"scripts": {
"build": "tsc -b",
"build:clean": "tsc -b --clean",
"dev": "tsc -b --watch",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"test": "vitest run",
"test:watch": "vitest",
"docs:check": "typedoc --options typedoc.json --emit none --treatWarningsAsErrors --disableGit",
"docs:build": "typedoc --options typedoc.json",
"changeset": "changeset",
"changeset:version": "changeset version",
"changeset:publish": "changeset publish",
"create-package": "node ./scripts/create-package.mjs",
"ci": "pnpm lint && pnpm build && pnpm test && pnpm docs:check"
},
"devDependencies": {
"@biomejs/biome": "catalog:",
"@changesets/cli": "catalog:",
"@types/node": "catalog:",
"typedoc": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}

View file

@ -0,0 +1,48 @@
# lightweight-sdk-web-component
A lightweight library to build web components easily.
## Installation
```bash
pnpm add @lightweight-sdk/web-component
```
## Prérequis côté consommateur
`@LinkToDom` et `@CustomComponent` sont des décorateurs TypeScript "legacy" (signature `(target, key)` / `(constructor)`). Pour les utiliser, ton propre `tsconfig.json` doit activer :
```jsonc
{
"compilerOptions": {
"experimentalDecorators": true
}
}
```
## Usage
```ts
import { AbstractComponent, CustomComponent, LinkToDom } from "@lightweight-sdk/web-component";
@CustomComponent({
tagName: "my-widget",
template: "<span ref=\"label\"></span>",
})
class MyWidget extends AbstractComponent {
@LinkToDom("label")
private label!: HTMLElement;
public override init(): void {
this.label.textContent = "Hello";
}
}
```
## API
- `AbstractComponent` — classe de base à étendre pour déclarer un web component (cycle de vie `init`/`refresh`).
- `CustomComponent(params)` — décorateur de classe qui enregistre le custom element et branche le template/Shadow DOM.
- `LinkToDom(ref)` — décorateur de propriété qui lie un membre de la classe à l'élément DOM portant l'attribut `ref="..."` correspondant.
- `findRefElement(ref, parent?)` — retrouve un élément par son attribut `ref` dans un conteneur donné (`document.body` par défaut).
- `ComponentParams`, `LinkedProperty`, `AcceptedHTMLContent` — types associés.

View file

@ -0,0 +1,28 @@
{
"name": "@lightweight-sdk/web-component",
"version": "0.0.2",
"description": "A lightweight library to build web components easily",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"engines": {
"node": ">=22"
},
"publishConfig": {
"access": "restricted",
"registry": "https://git.dev.kyuno.fr/api/packages/kyuno/npm/"
},
"typedocOptions": {
"entryPoints": [
"src/index.ts"
],
"readme": "README.md",
"validation": {
"notExported": false,
"invalidLink": false,
"notDocumented": false
}
}
}

View file

@ -0,0 +1,30 @@
import { ComponentParams, CUSTOM_COMPONENT_CONFIG, LinkedProperty } from "./component.type";
/** Basic class used to declare web components */
export class AbstractComponent extends HTMLElement {
/** List of all the class members linked to a dom element */
public __linkedProperties?: LinkedProperty[]
/** All the component's params */
public [CUSTOM_COMPONENT_CONFIG]?: ComponentParams;
public connectedCallback(): void {
this.init();
this.refresh();
}
public disconnectedCallback(): void { }
/**
* Function called in the component's life cycle
* Used to init the DOM and the sub components
*/
public init(): void { }
/**
* Function called in the component's life cycle
* Used to refresh the datas, and re render the component
* Please be conscious that if you clean THIS as a dom element, the template won't be re rendered again
*/
public refresh(): void { }
}

View file

@ -0,0 +1,110 @@
import { AbstractComponent } from "./abstractComponent";
import { ComponentParams, CUSTOM_COMPONENT_CONFIG } from "./component.type";
import { findRefElement, parseModuleToHtml } from "./dom.tools";
// Declare a unique key to store the flag on the prototype.
// This symbol is used to uniquely identify and access the DOM link flag,
// ensuring there are no conflicts with other properties on the prototype.
export const REQUEST_DOM_LINK_KEY = Symbol("requestDomLink");
interface ConstructorFunction<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
new(...args: any[]): T
}
/**
* Define a custom web component using a decorator.
* The decorator configures the component's template, styles, and any additional
* properties, as well as handling Shadow DOM attachment if specified.
* @param p @see {@link ComponentParams}
* @returns a constructor factory
*/
export function CustomComponent<T extends ConstructorFunction<AbstractComponent>>(p: ComponentParams): (constructor: T) => void {
return (constructor: T): void => {
// Create a template element to contain the component's HTML structure, parsed for use.
const templateElement = document.createElement('template');
templateElement.innerHTML = parseModuleToHtml(p.template);
// Attach component configuration as a non-writable property on the prototype
Object.defineProperty(constructor.prototype, CUSTOM_COMPONENT_CONFIG, {
value: p
});
// Save the original connectedCallback, if it exists, to allow for extension.
const originalConnectedCallback = constructor.prototype.connectedCallback;
// Define a custom connectedCallback for the component
constructor.prototype.connectedCallback = function (this: AbstractComponent) {
// If Shadow DOM is specified, attach it and add the template to it
if (p.attachToShadow) {
// Determine Shadow DOM mode, defaulting to 'open' if boolean true is given.
const mode: ShadowRootMode = typeof p.attachToShadow === "boolean" ? "open" : p.attachToShadow;
// Attach a Shadow DOM if not already attached
if (!this.shadowRoot) {
this.attachShadow({ mode });
}
// Append the template content to the Shadow DOM
if (this.shadowRoot) {
this.shadowRoot.appendChild(templateElement.content.cloneNode(true));
}
} else {
// If not using Shadow DOM, insert template content directly into Light DOM
this.innerHTML = "";
this.appendChild(templateElement.content.cloneNode(true));
}
// Apply any specified CSS classes to the component root element
if (p.cssClasses) {
this.classList.add(...p.cssClasses);
}
// Process and link properties specified in __linkedProperties to their DOM elements
for (const requested of this.__linkedProperties ?? []) {
const elt = findRefElement(requested.ref, this);
if (elt == null) {
throw new Error(`ref ${requested.ref} not found`);
}
// Attach the found element as a property on the component instance
Object.defineProperty(this, requested.property, { value: elt });
}
// Call the original connectedCallback if it exists
if (originalConnectedCallback) {
originalConnectedCallback.call(this);
}
};
// Register the custom element with the specified tag name
window.customElements.define(p.tagName, constructor);
};
}
/**
* Decorator that flags the class member as a link to a dom element
* @param ref
*/
export function LinkToDom(ref: string) {
return function <T extends AbstractComponent>(target: T, key: string | symbol): void {
// Si target est indéfini, renvoyer un avertissement
if (!target) {
throw new Error(`LinkToDom: 'target' is undefined for property ${String(key)}`);
}
// Ajouter un flag `request` pour marquer cette propriété comme liée au DOM
if (!target.__linkedProperties) {
target.__linkedProperties = [];
}
// Ajouter la propriété à la liste des propriétés liées
target.__linkedProperties.push({
property: String(key),
ref
});
}
}

View file

@ -0,0 +1,28 @@
export const CUSTOM_COMPONENT_CONFIG = "__customComponentConfig"
/** Abstract component params*/
export interface ComponentParams {
/**
* A tag name that will be used to register the component as a custom element
* Should always follow the following format "my-name"
* ex: "custom-menu-component"
*/
tagName: string;
/** HTML Template */
template?: string;
/** Specific style of the component */
style?: string;
/** Css classes to append */
cssClasses?: string[];
/** Define if the component should be atatched to the shadow dom */
attachToShadow?: boolean | ShadowRootMode
}
/** Map a property key to a html element by it's ref attribute */
export interface LinkedProperty {
/** The ref attribute value */
ref: string;
/** The object's property */
property: string;
}

View file

@ -0,0 +1,26 @@
/** The attriute used to bind dom to component's members */
const REF_ATTRIBUTE = "ref"
/**
* Find an element with the attribute ref= in the given parent (or the entire body)
* @param ref the ref attribute's value of the target dom element
* @param parent the container used to search the element. document.body by default
* @returns the founded html element or undefined
*/
export function findRefElement(ref: string, parent?: HTMLElement): HTMLElement | undefined {
const parentElement = parent ?? document.body;
const elt = parentElement.querySelector(`[${REF_ATTRIBUTE}=${ref}]`);
return elt == null ? undefined : elt as HTMLElement
}
/**
* Parse the value of ComponentParams.template into a string if it was fetched with required function
* @param mod the template value
* @returns the template as a string
*/
export function parseModuleToHtml(mod: any): string {
if (mod == null) return "";
return "default" in mod ? mod.default as string : "";
}

View file

@ -0,0 +1,2 @@
/** Anything that could be append in html*/
export type AcceptedHTMLContent = HTMLElement | string

View file

@ -0,0 +1,6 @@
export { AbstractComponent } from "./abstractComponent";
export { CustomComponent, LinkToDom } from "./component.tools";
export { ComponentParams, LinkedProperty } from "./component.type";
export { findRefElement } from "./dom.tools";
export { AcceptedHTMLContent } from "./dom.types";

View file

@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}

2145
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

18
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,18 @@
packages:
- "packages/*/*"
allowBuilds:
esbuild: true
# human-id@4.1+ est publié en ESM pur ; @changesets/write le require() en CJS
# et casse (ERR_REQUIRE_ESM). 4.0.0 est la dernière version CommonJS.
overrides:
human-id: "4.0.0"
catalog:
typescript: ^5.7.0
"@types/node": ^22.10.0
vitest: ^2.1.0
"@biomejs/biome": ^2.4.0
typedoc: ^0.28.0
"@changesets/cli": ^2.27.0

153
scripts/create-package.mjs Normal file
View file

@ -0,0 +1,153 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const CATEGORIES = ['client', 'common', 'server'];
/**
* Prints usage information and exits with an error status.
* @param {string} message the reason the script is aborting
*/
function fail(message) {
console.error(`Erreur : ${message}`);
console.error('Usage : pnpm create-package <client|common|server> <nom-du-package>');
process.exitCode = 1;
process.exit(1);
}
const [, , category, name] = process.argv;
if (!category || !CATEGORIES.includes(category)) {
fail(`catégorie invalide (attendu : ${CATEGORIES.join(', ')})`);
}
if (!name || !/^[a-z][a-z0-9-]*[a-z0-9]$/.test(name)) {
fail('nom de package invalide (attendu : kebab-case, ex. "my-widget")');
}
const packageDir = join(ROOT, 'packages', category, name);
if (existsSync(packageDir)) {
fail(`packages/${category}/${name} existe déjà`);
}
const npmName = `@lightweight-sdk/${name}`;
const isClient = category === 'client';
mkdirSync(join(packageDir, 'src'), { recursive: true });
const packageJson = {
name: npmName,
version: '0.0.1',
description: `TODO: décrire le package ${name}.`,
main: 'dist/index.js',
types: 'dist/index.d.ts',
files: ['dist'],
engines: { node: '>=22' },
publishConfig: {
access: 'restricted',
registry: 'https://git.dev.kyuno.fr/api/packages/kyuno/npm/',
},
typedocOptions: {
entryPoints: ['src/index.ts'],
readme: 'README.md',
},
};
writeFileSync(join(packageDir, 'package.json'), `${JSON.stringify(packageJson, null, '\t')}\n`);
const tsconfig = {
extends: '../../../tsconfig.base.json',
compilerOptions: isClient
? { lib: ['ES2022', 'DOM', 'DOM.Iterable'], outDir: './dist', rootDir: './src' }
: { outDir: './dist', rootDir: './src' },
include: ['src/**/*'],
};
writeFileSync(join(packageDir, 'tsconfig.json'), `${JSON.stringify(tsconfig, null, '\t')}\n`);
const indexTs = `/**
* Placeholder function for the ${npmName} package — replace with real exports.
* @param subject the name to greet
* @returns a greeting for the given subject
*/
export function greet(subject: string): string {
return \`Hello, \${subject}!\`;
}
`;
writeFileSync(join(packageDir, 'src', 'index.ts'), indexTs);
const indexTest = `import { describe, expect, it } from 'vitest';
import { greet } from './index';
describe('greet', () => {
it('greets the given subject', () => {
expect(greet('world')).toBe('Hello, world!');
});
});
`;
writeFileSync(join(packageDir, 'src', 'index.test.ts'), indexTest);
const commonNote =
category === 'common'
? "\n> `common/` doit rester isomorphe : pas d'API exclusivement DOM ou exclusivement Node.\n"
: '';
const readme = `# ${npmName}
${commonNote}
TODO: décrire le package.
## Installation
\`\`\`bash
pnpm add ${npmName}
\`\`\`
## Usage
TODO
`;
writeFileSync(join(packageDir, 'README.md'), readme);
// Édite le tsconfig.json racine (project references)
const rootTsconfigPath = join(ROOT, 'tsconfig.json');
const rootTsconfig = JSON.parse(readFileSync(rootTsconfigPath, 'utf8'));
rootTsconfig.references ??= [];
rootTsconfig.references.push({ path: `./packages/${category}/${name}` });
writeFileSync(rootTsconfigPath, `${JSON.stringify(rootTsconfig, null, '\t')}\n`);
// Ajoute une ligne au registre des packages
const packagesSpecPath = join(ROOT, 'spec', 'packages.md');
const packagesSpec = readFileSync(packagesSpecPath, 'utf8');
const newRow = `| \`${npmName}\` | ${category} | 0.0.1 | brouillon |\n`;
writeFileSync(packagesSpecPath, packagesSpec.trimEnd() + '\n' + newRow);
console.log(`Package ${npmName} créé dans packages/${category}/${name}/.`);
try {
execFileSync('pnpm', ['install'], { cwd: ROOT, stdio: 'inherit' });
execFileSync(
'pnpm',
[
'exec',
'biome',
'check',
'--write',
`packages/${category}/${name}`,
'tsconfig.json',
'spec/packages.md',
],
{ cwd: ROOT, stdio: 'inherit' },
);
} catch (error) {
console.error('Étape automatique échouée, à relancer toi-même :');
console.error(' pnpm install');
console.error(
` pnpm exec biome check --write packages/${category}/${name} tsconfig.json spec/packages.md`,
);
console.error(String(error));
}
console.log('\nProchaines étapes :');
console.log(` cd packages/${category}/${name}`);
console.log(' - compléter la description dans package.json et README.md');
console.log(` - pnpm --filter ${npmName} test`);
console.log(' - pnpm changeset (une fois prêt à publier)');

39
spec/architecture.md Normal file
View file

@ -0,0 +1,39 @@
# Architecture
## Objectif
`lightweight-sdk` est un monorepo pnpm destiné à héberger, au fil du temps, des packages TypeScript réutilisables développés par l'auteur pour ses projets personnels. Chaque package est publié indépendamment sur le registre npm privé hébergé sur l'instance Forgejo personnelle (`git.dev.kyuno.fr`), sous le scope `@lightweight-sdk`.
## Organisation des packages
Les packages sont répartis en trois catégories sous `packages/`, qui reflètent la distinction Frontend/Backend du [CLAUDE.md](../CLAUDE.md) du projet :
- **`client/`** — packages destinés au navigateur (DOM, composants UI). Peuvent dépendre de `lib: DOM` dans leur `tsconfig.json`.
- **`common/`** — code isomorphe partagé entre `client` et `server` (types, utilitaires purs, constantes). Convention : ne doit dépendre d'aucune API exclusivement DOM ou exclusivement Node, pour rester portable des deux côtés.
- **`server/`** — packages Node/backend. Chaque package doit être structuré comme un service modulaire autonome exposant sa propre API (règle "Architecture Backend" du CLAUDE.md).
Le nom npm d'un package ne reprend pas le préfixe de catégorie (`@lightweight-sdk/web-component`, pas `@lightweight-sdk/client-web-component`) — la catégorie est une convention d'organisation du dépôt, pas une convention de nommage public.
Un nouveau package se crée avec `pnpm create-package <client|common|server> <nom>` (voir `scripts/create-package.mjs`).
## Choix d'outillage
| Aspect | Choix | Pourquoi |
|---|---|---|
| Gestionnaire de packages | pnpm workspaces + `catalog:` | Centralise les versions des devDependencies partagées dans `pnpm-workspace.yaml` |
| Orchestrateur de build | Aucun (pas de Turborepo/Nx) | Un seul package réel au démarrage ; à reconsidérer si le temps de build devient un problème |
| Build | `tsc -b` (project references), pas de bundler | Les packages sont consommés via pnpm par d'autres projets Node/TS de l'auteur ; pas besoin de bundle |
| Modules | **CommonJS**, résolution `Node10` | Décision explicite : pas d'extension `.js` requise dans les imports relatifs, et le code migré depuis l'ancien repo `web-component` compile sans modification |
| Lint + format | [Biome.js](https://biomejs.dev/) | Imposé par le CLAUDE.md du projet |
| Documentation de code | [TypeDoc](https://typedoc.org/) en mode validation | Génère la doc API et fait échouer la CI si un export n'a pas de TSDoc (voir `conventions.md`) |
| Tests | [Vitest](https://vitest.dev/) | ESM natif, bon support monorepo |
| Versioning/publish | [Changesets](https://github.com/changesets/changesets) | Gère des versions indépendantes par package + changelogs, comprend nativement `workspace:*` |
| Node cible | 24 (Active LTS) en CI, `engines: ">=22"` | Node 20 est end-of-life depuis avril 2026 |
## Dépendances entre packages
Les dépendances internes entre packages du monorepo utilisent le protocole `workspace:*` (résolu localement par pnpm, jamais via le registre distant). Le registre npm privé n'est utilisé que pour la publication et pour la consommation depuis des projets *externes* à ce monorepo.
## Registre npm privé
`https://git.dev.kyuno.fr/api/packages/kyuno/npm/` — voir `ci-cd.md` pour le détail de la publication et de l'authentification.

46
spec/ci-cd.md Normal file
View file

@ -0,0 +1,46 @@
# CI/CD
## Workflows
Les workflows vivent dans `.forgejo/workflows/` (pas `.github/workflows/` — ce dernier n'est probablement pas surveillé par Forgejo Actions sur cette instance).
### `ci.yml`
Déclenché uniquement sur `pull_request` vers `main` (pas de trigger `push` séparé sur la branche source : il ferait tourner la même vérification deux fois sur le même commit dès qu'une PR est ouverte — deux statuts distincts pour un seul résultat logique, et une exposition doublée aux flakys du runner). Étapes : install (`--frozen-lockfile`) → `pnpm lint` → `pnpm build` → `pnpm test` → `pnpm docs:check`. Ne nécessite aucun secret (les dépendances internes entre packages passent par `workspace:*`, résolu localement).
### `release.yml`
Déclenché sur `push` vers `main` (typiquement après le merge d'une PR "Version Packages"). Étapes : install → build → ajout dynamique d'une ligne d'authentification dans `.npmrc` (jamais committée) → `pnpm changeset:publish`.
`changeset publish` est idempotent par package : il republie uniquement les packages dont la version locale n'existe pas encore sur le registre. Un push sur `main` sans bump de version ne publie donc rien.
## Flux Changesets (versioning + publication)
1. Pendant une PR de feature, ajouter un changeset : `pnpm changeset` (choisir le(s) package(s) concerné(s), le type de bump, décrire le changement). Le fichier généré dans `.changeset/` est commité avec la PR.
2. Quand on est prêt à publier, en local : `pnpm changeset version` — bump les `package.json` concernés et génère/met à jour leurs `CHANGELOG.md`.
3. Ouvrir une PR "Version Packages" avec ce résultat, la relire, la merger soi-même (jamais d'auto-merge).
4. Le merge sur `main` déclenche `release.yml`, qui publie effectivement sur le registre les packages dont la version a changé.
Pas de bot automatique type `changesets/action` (spécifique GitHub, incertain sur Forgejo) : le flux est volontairement manuel aux étapes 1-3.
## Registre npm privé Forgejo
- URL : `https://git.dev.kyuno.fr/api/packages/kyuno/npm/` (owner Forgejo = `kyuno`, indépendant du scope npm `@lightweight-sdk` — les deux ne sont pas liés).
- `.npmrc` (racine, committé) : `@lightweight-sdk:registry=https://git.dev.kyuno.fr/api/packages/kyuno/npm/`.
- Authentification : `//git.dev.kyuno.fr/api/packages/kyuno/npm/:_authToken=<token>` — jamais committé, injecté uniquement en CI via le secret `REGISTRY_TOKEN`.
- Limite connue : impossible de republier un nom+version déjà existant sur le registre (Changesets gère ça nativement en forçant un bump à chaque publication).
### Secret `REGISTRY_TOKEN`
1. Forgejo → Settings → Applications → générer un token avec les scopes `write:package` + `read:package`.
2. L'ajouter comme secret de repo nommé `REGISTRY_TOKEN` sur **ce** repo (`lightweight-sdk`) — les secrets de repo ne sont pas partagés entre repos sur cette instance ; celui de `watchparty` ne peut pas être réutilisé tel quel.
### Troubleshooting : 404 sur un package scopé
Si `pnpm changeset:publish` échoue avec une 404 sur un nom de package scopé (`@lightweight-sdk/...`), la cause probable est le reverse proxy devant l'instance : npm encode le `/` d'un nom scopé en `%2F`, ce qu'Apache refuse par défaut. Vérifier `AllowEncodedSlashes NoDecode` dans le VirtualHost et `nocanon` sur la directive `ProxyPass`. Ce point n'a pas pu être vérifié depuis l'agent (config réseau propre à l'instance).
## Pistes futures
- `git push --follow-tags` après `changeset publish` pour taguer les releases (pas fait par défaut aujourd'hui).
- Automatiser l'étape 2 du flux Changesets (PR "Version Packages" ouverte automatiquement) via un workflow `workflow_dispatch` ou planifié.
- Protection de branche réelle sur `main` côté Forgejo (l'interdiction de push direct n'est aujourd'hui que conventionnelle, via CLAUDE.md).

50
spec/conventions.md Normal file
View file

@ -0,0 +1,50 @@
# Conventions
## Traçabilité des règles CLAUDE.md
| Règle CLAUDE.md | Mécanisme dans ce repo |
|---|---|
| Modificateurs d'accès explicites | Biome `useConsistentMemberAccessibility: "explicit"` (partiellement auto-fixable via `pnpm lint:fix`) ✅ |
| TSDoc sur chaque fonction/type/interface/classe/membre/méthode | TypeDoc en mode validation (`pnpm docs:check`, bloquant en CI). Biome n'a pas de règle équivalente ✅ (sauf `@lightweight-sdk/web-component`, exclu — voir `packages.md`) |
| `.catch()` obligatoire sur toute Promise non-awaited | Biome `noFloatingPromises` / `noMisusedPromises` / `useAwaitThenable` ✅ |
| `try/catch` obligatoire autour de chaque `await` individuel | **Aucun linter JS/TS courant ne vérifie ça** (ni Biome ni ESLint). Convention à appliquer soi-même à chaque nouveau package ❌ mécanisme automatique |
| Indentation "2 tabulations" | `biome.json` (`indentStyle: "tab"`, `indentWidth: 2`) + `.editorconfig` ⚠️ deux exceptions : les fichiers YAML n'acceptent pas les tabulations (interdit par la spec YAML) → 2 espaces ; le Markdown n'est pas formaté par Biome → 2 espaces par convention manuelle |
| Logging exhaustif | Convention à appliquer dans chaque nouveau package (pas de mécanisme automatique — à illustrer dans le code) |
| Séparation `.ts`/`.html`/`.scss` (Frontend) | S'applique aux futurs packages `client/` qui rendent des composants concrets avec leur propre template. Ne s'applique pas à `web-component`, qui est une bibliothèque d'abstraction sans template propre (et de toute façon non modifiée, voir `packages.md`) |
| Services modulaires autonomes (Backend) | Chaque package `server/` doit exposer sa propre API, indépendante des autres |
`pnpm lint` (= `biome check .`) tourne **sans** `--error-on-warnings` : les diagnostics de sévérité "warn" restent visibles mais ne font pas échouer la CI. Seules les erreurs bloquent.
## Imports
Sous `module: CommonJS` + `moduleResolution: Node10` (voir `architecture.md`), les imports relatifs **n'ont pas besoin** d'extension (`from "./foo"`, pas `from "./foo.js"`).
## Nommage
- Dossiers de package et noms npm : kebab-case (`@lightweight-sdk/my-package`).
- Classes : PascalCase. Fonctions/variables : camelCase.
## Tests
- Colocalisés : `src/foo.ts` + `src/foo.test.ts`.
- Environnement Vitest par défaut : `"node"`. Pour un test qui a besoin du DOM, utiliser le pragma en première ligne du fichier : `// @vitest-environment happy-dom` (ajouter `happy-dom` en devDependency le jour où c'est nécessaire).
## Exemple : convention async/erreurs/logging attendue dans un nouveau package
```ts
import { logger } from './logger';
/** Fetches and parses the remote config, throwing on failure. */
export async function loadConfig(url: string): Promise<Config> {
try {
const response = await fetch(url);
return await response.json();
} catch (error) {
logger.error('loadConfig failed', { url, error });
throw error;
}
}
// Appel fire-and-forget : .catch() obligatoire (pas d'await)
void loadConfig(configUrl).catch((error) => logger.error('background config load failed', { error }));
```

32
spec/packages.md Normal file
View file

@ -0,0 +1,32 @@
# Registre des packages
| Package | Catégorie | Version | Statut |
|---|---|---|---|
| `@lightweight-sdk/web-component` | client | 0.0.2 | migré depuis `lightweight-sdk-web-component`, non modifié |
## Ajouter un package
```bash
pnpm create-package <client|common|server> <nom-du-package>
```
Le script scaffolde `package.json`, `tsconfig.json`, `src/index.ts` + un test, un `README.md`, ajoute le package aux project references racine et met à jour ce tableau automatiquement. Reste à faire à la main : décrire le package, l'implémenter, et créer un changeset (`pnpm changeset`) quand il est prêt à être publié.
## Champs `package.json` obligatoires pour tout package du monorepo
- `name` (`@lightweight-sdk/<nom>`), `version`, `description`
- `main` (`dist/index.js`), `types` (`dist/index.d.ts`), `files: ["dist"]`
- `engines.node >= 22`
- `publishConfig.registry` pointant vers le registre npm privé (`https://git.dev.kyuno.fr/api/packages/kyuno/npm/`)
- `typedocOptions.entryPoints` (sauf exception documentée, voir ci-dessous)
## Observations sur `@lightweight-sdk/web-component` (non traitées, laissées telles quelles)
Le code source de ce package a été migré strictement à l'identique depuis l'ancien repo `lightweight-sdk-web-component`, sans aucune édition (décision explicite de l'auteur). Quelques points relevés à la lecture, non corrigés, à traiter soi-même dans une PR dédiée si souhaité :
- `connectedCallback()`/`disconnectedCallback()` (`abstractComponent.ts`), la constante `CUSTOM_COMPONENT_CONFIG` (`component.type.ts`) et `REQUEST_DOM_LINK_KEY` (`component.tools.ts`) n'ont pas de TSDoc complet — c'est pourquoi ce package est exclu de la validation `docs:check` dans `typedoc.json` (`exclude: ["packages/client/web-component/**"]`).
- Deux usages de `any` (`ConstructorFunction.new(...args: any[])`, `parseModuleToHtml(mod: any)`) — visibles comme avertissements Biome non bloquants (`noExplicitAny`).
- Le paramètre `constructor` dans `component.tools.ts` masque un nom global restreint (`noShadowRestrictedNames`) — désactivé pour ce package via un `overrides` dans `biome.json`.
- Le style de formatage d'origine (4 espaces, guillemets doubles) diffère de la config Biome du monorepo (tabulations, guillemets simples) — le formatter Biome est désactivé pour ce package via le même `overrides`.
- `REQUEST_DOM_LINK_KEY` semble ne plus être utilisé nulle part dans le code (le mécanisme actif est le tableau `__linkedProperties`) — potentiellement mort, mais pas supprimé.
- Quelques typos mineures ("atatched", "attriute") et un commentaire en français dans `LinkToDom` qui ne correspond plus exactement au comportement (`throw` au lieu d'un simple avertissement).

18
tsconfig.base.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node10",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"incremental": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"experimentalDecorators": true
}
}

4
tsconfig.json Normal file
View file

@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./packages/client/web-component" }]
}

27
typedoc.json Normal file
View file

@ -0,0 +1,27 @@
{
"$schema": "https://typedoc.org/schema.json",
"entryPointStrategy": "packages",
"entryPoints": ["packages/*/*"],
"out": "docs/api",
"validation": {
"notExported": true,
"invalidLink": true,
"notDocumented": true
},
"requiredToBeDocumented": [
"Enum",
"EnumMember",
"Variable",
"Function",
"Class",
"Interface",
"Property",
"Method",
"Accessor",
"TypeAlias",
"Parameter",
"Constructor"
],
"treatWarningsAsErrors": true,
"excludeExternals": true
}

10
vitest.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['packages/*/*/src/**/*.test.ts'],
exclude: ['**/node_modules/**', '**/dist/**'],
environment: 'node',
passWithNoTests: true,
},
});