diff --git a/.sync/PORTING.md b/.sync/PORTING.md index bbce1ba7f..9c75b8a8d 100644 --- a/.sync/PORTING.md +++ b/.sync/PORTING.md @@ -599,6 +599,58 @@ Comparison is per section on purpose: 18 packages are declared on both sides in different ones — the `@tiptap/*` family is a peer `^3` upstream and a dependency `^3.29.2` here — and a peer range against a dependency range compares nothing. +**Adopting a new component is not finished when the component compiles.** +`src/` is only the first of eleven places a component has to appear, and the +other ten used to fail quietly: nothing in `lint`, `typecheck`, `test`, `build` +or `docs:generate` knew that a component exists but is missing from a registry. +Splitter (#441) and ProgressGroup (#443) each landed with `src/`, tests, docs and +one playground, and each was still missing four of the entries below — found by +reading, not by a gate. `test/utils/docs-component-registries.spec.ts` now +covers points 5 and the `links:` notes; the rest is still on you. Work the list +top to bottom: + +1. `src/runtime/components/.vue` and `src/theme/.ts`. +2. `src/theme/index.ts` (`export { default as } from './'`), + `src/runtime/types/index.ts` (`export * from '../components/.vue'`) + and `src/runtime/types/theme.ts` (the `?: Partial<…Props>` line). +3. `test/components/.spec.ts` plus its snapshots. +4. `docs/content/docs/2.components/.md`, and `docs/app/components/content/examples//*.vue` + for anything the page renders with `::component-example`. +5. **`docs/nuxt.config.ts` → `pages`**, in the region matching the page's + `category:` front matter. The crawler happens to reach a new page anyway, so + omitting it breaks nothing visible — but that array is the declared registry + the `/raw/.md` prerender routes are built from (`@memo need add pages + for raw/***.md`), and the skills reference links to exactly those URLs. Both + new components were missing from it, and so were `empty` and + `page-card-group`, for longer. Guarded in both directions now. +6. `playgrounds/nuxt/app/pages/components/.vue` **and** + `playgrounds/demo/app/pages/components/.vue`. The demo page is not + optional: its docs page links to `https://bitrix24.github.io/b24ui/demo/components/`, + and it is a different exercise — the nuxt playground is a scratch page, the + demo one wraps `` with `#controls` and drives the theme's + variants through ``. The guard checks that a Demo link resolves to a + file; it cannot require the page to exist in the first place, since 33 docs + pages legitimately have no Demo link at all. +7. The component name in `useNavigation.ts` for **both** playgrounds, in the + list's alphabetical position. +8. A row in `skills/b24-ui-nuxt/references/components.md`. + +Front matter for the docs page, which upstream's file does not give you: + +- `description:` — **rewrite it.** Upstream's sentence is upstream's voice; ours + should say the same thing in different words. Splitter arrived carrying + "A set of resizable panels separated by draggable handles." verbatim. +- `category:` — decides the sidebar group, and must agree with the region you + picked in `docs/nuxt.config.ts`. +- `keywords:` — the docs search reads them; upstream's list is a good start. +- `links:` — GitHub, then Demo (`iconName: DemonstrationOnIcon`, the URL from + point 6), then Nuxt UI, then the primitive. **The Reka link is an avatar, not + an icon**: `label: ` with `avatar: {src: /b24ui/avatar/rekaui.svg}`, as + 34 other pages have it. `iconName: RekaIcon` looks right and renders nothing — + `resolveIcon()` returns `undefined` for a name outside + `src/runtime/dictionary/icons.ts`, silently, and the link loses its icon. Every + `iconName:` on these pages is now checked against that dictionary. + **If the cursor SHA disappears** (upstream force-pushed `v4`, so `git cat-file -e ^{commit}` fails): pick the nearest surviving ancestor on `v4`, set `cursor` to it, and open a tracking issue — do not silently jump @@ -645,3 +697,4 @@ forward, since every commit between the two would then never be judged. - 2026-08-18 — hardening of #92 (PR #424): added the §2 rule **`get()`/`set()` reject prototype keys and `set()` descends only through own properties**, and moved the shared logic into `utils/prototype-guard.ts`. The issue asked for a denylist of `__proto__`/`constructor`/`prototype`; review turned up three things a denylist alone does not cover. `acc[key] === undefined` reads through the prototype chain, so `set({}, 'toString.x', 1)` wrote onto a shared intrinsic through a path holding no reserved word. A key is coerced by `object[key]` after the guard has inspected it, so `new String('prototype')` and any object with a fitting `toString` walked straight past a `typeof key === 'string'` check — `get({}, [{ toString: () => '__proto__' }])` returned `Object.prototype` itself. And `utils/form.ts` held a second, independent copy of the same walk, reachable from `Form.vue` rather than only from the public entry. Reachability was audited rather than assumed: nothing in `src/` imports `set()` at all — its only consumers here are two docs-site components passing component-metadata prop names — whereas `setAtPath` is on a live in-library path. `get`/`set` and `setAtPath`/`getAtPath` had almost no tests before this; 38 were added across the two spec files, 20 of which fail against the previous implementation. Last reviewed: 2026-08-18. - 2026-08-18 — added the §6 **dependency parity** step, `.sync/dep-parity.json` and its guard, after the maintainer asked for a rule that checks pins against the parent repo. The gap was real and had already bitten: porting `6bcc97a6` turned up `@ai-sdk/vue` at 3.x here against upstream's 4.x and `ai` at 6.x against 7.x, found only because a comparison table happened to get printed. Writing the snapshot then turned up a third, `prettier` at ^3.8.4 against ^3.9.6, adrift through four ported batches. The mechanism is worth stating because each individual port was correct: a `chore(deps)` port bumps only where this fork matched upstream's pre-image, so a version that leaves the line is skipped by every later batch — the rule that keeps a deliberate hold from being dragged along is the same rule that makes an accidental divergence permanent. The snapshot is section-aware after the first draft compared upstream's peer ranges against our dependency ranges and reported `tailwindcss ^4.3.3` and `@internationalized/date ^3.12.3` as drift; 18 packages sit in different sections on the two sides, the whole `@tiptap/*` family among them, and are outside the file by construction. `nuxt-schema-org` is the one recorded exception, held at ^6.2.1 because anything newer drags `nuxt-site-config` to 4.2.3, which calls two `@nuxt/kit` functions no published 4.x exports. Guard verified by five mutations — drifting a version, dropping the exception, making the exception stale by aligning the version under it, staling the snapshot's cursor, and deleting a package from a manifest — each failing, and the guard was red on the real `reka-ui` drift before #428 landed and green after. Last reviewed: 2026-08-18. - 2026-08-18 — added the §2 **AI provider packages are fork-only** invariant, after #425 broke the docs assistant and nothing noticed. That PR aligned dependencies to upstream on instruction, taking `ai` from ^6.0.214 to ^7.0.66. Its description called the gap accidental drift; it was not. Six ledger entries record the v6 line as a deliberate deferral — `c8e810ca` says "ai (v6 line, deferred v7), @ai-sdk/vue (v3)" and `229b64f6` says "ai+@ai-sdk/* (v6 line + DeepSeek)" — and the reason was precisely the coupling that then broke: `@ai-sdk/deepseek` had not caught up. The bump ended a considered deferral rather than correcting an oversight, and the record now says so. What actually broke: `ai@7` resolves `@ai-sdk/provider@4` while `@ai-sdk/deepseek@2.0.38` and `@ai-sdk/mcp@1.0.52` implement `@ai-sdk/provider@3`, so two majors of the provider spec sat in one tree. Every gate stayed green — the providers peer-depend on `zod` rather than `ai`, so pnpm was quiet, and the request path is disabled during `docs:generate` — which is the whole lesson: this class of break is invisible to a build and first appears to a user. Found while checking why upstream's `CLAUDE.md` commit did not apply, which turned up the `.gitignore` entry from `b55bd3e7` and, in that commit's body, the deferral note. Fixed by taking the providers to ^3.0.28 and 2.0.32 across `docs/` and both Nuxt playgrounds — the playgrounds carry them too, and bumping only `docs/` would have left the same mismatch behind. Verified by resolution rather than by a passing suite: one `@ai-sdk/provider@4.0.7` across the lockfile, down from two majors. Second finding along the way, now also a §2 note: reaching for `@ai-sdk/mcp@2.0.33`, published the previous day, made pnpm silently append it to `minimumReleaseAgeExclude` — a supply-chain policy waived by a line that reads as ordinary config. `2.0.32` carries the same `@ai-sdk/provider@4.0.7`, so the exclusion was unnecessary and the list is back to what it was. Last reviewed: 2026-08-18. +- 2026-08-20 — added the §6 **new-component checklist** and `test/utils/docs-component-registries.spec.ts`, after the maintainer read the Splitter and ProgressGroup ports and found four gaps in each. Both were missing from `docs/nuxt.config.ts`'s `pages` array, from `playgrounds/demo` entirely, and from the demo `useNavigation.ts`; both carried a `description:` copied word for word from Nuxt UI; and Splitter's Reka link used `iconName: RekaIcon`, a name absent from `src/runtime/dictionary/icons.ts`, so `resolveIcon()` returned `undefined` and the link rendered without an icon. Every one of those is invisible to a build — the docs crawler follows the sidebar and prerenders an unregistered page anyway, a missing playground page is a page that does not exist, and an unresolved icon is a falsy value rendered as nothing — so the checklist names eleven places rather than stating a rule. The `pages` array is worth singling out: it is not what makes the page build, it is the declared list the `/raw/.md` routes are generated from, and `skills/b24-ui-nuxt/references/components.md` links to exactly those URLs. Writing the guard turned up two older instances of the same omission, `empty` and `page-card-group`, both now registered. What is enforceable is enforced: page ↔ `pages` entry in both directions, every `iconName:` in a component page's front matter resolvable in the dictionary, every Demo link resolving to a `playgrounds/demo` file, every demo nav entry resolving to one. What is not: the guard cannot demand a demo page per component (33 of 120 pages carry no Demo link on purpose) or a nav entry per demo page (five are unlisted today), and it cannot tell a rewritten `description:` from a copied one — those stay checklist items. Verified by four mutations, each red: dropping the `splitter` route, adding a route with no page, restoring `iconName: RekaIcon`, and renaming the demo page away. Last reviewed: 2026-08-20. diff --git a/docs/content/docs/2.components/progress-group.md b/docs/content/docs/2.components/progress-group.md index 1f11e8875..3cf480d10 100644 --- a/docs/content/docs/2.components/progress-group.md +++ b/docs/content/docs/2.components/progress-group.md @@ -1,11 +1,19 @@ --- title: ProgressGroup -description: A progress bar split into segments, each with its own value and colour. +description: One progress bar carrying several values at once, each drawn as its own coloured part. category: element +keywords: + - meter group + - segmented progress + - stacked bar + - breakdown links: - label: GitHub iconName: GitHubIcon to: https://github.com/bitrix24/b24ui/blob/main/src/runtime/components/ProgressGroup.vue + - label: Demo + iconName: DemonstrationOnIcon + to: https://bitrix24.github.io/b24ui/demo/components/progress-group - label: Nuxt UI iconName: NuxtIcon to: https://ui.nuxt.com/docs/components/progress-group diff --git a/docs/content/docs/2.components/splitter.md b/docs/content/docs/2.components/splitter.md index 818b44d86..9a2dc13fc 100644 --- a/docs/content/docs/2.components/splitter.md +++ b/docs/content/docs/2.components/splitter.md @@ -1,17 +1,26 @@ --- title: Splitter -description: A set of resizable panels separated by draggable handles. +description: Panels that share one area and are resized by dragging the divider between them. category: layout +keywords: + - resizable + - panel group + - split view + - pane links: - label: GitHub iconName: GitHubIcon to: https://github.com/bitrix24/b24ui/blob/main/src/runtime/components/Splitter.vue - - label: Reka UI - iconName: RekaIcon - to: https://reka-ui.com/docs/components/splitter + - label: Demo + iconName: DemonstrationOnIcon + to: https://bitrix24.github.io/b24ui/demo/components/splitter - label: Nuxt UI iconName: NuxtIcon to: https://ui.nuxt.com/docs/components/splitter + - label: Splitter + avatar: + src: /b24ui/avatar/rekaui.svg + to: https://reka-ui.com/docs/components/splitter --- ## Usage diff --git a/docs/nuxt.config.ts b/docs/nuxt.config.ts index 93b729858..30732e83f 100644 --- a/docs/nuxt.config.ts +++ b/docs/nuxt.config.ts @@ -38,6 +38,7 @@ const pages = [ '/docs/components/footer/', '/docs/components/header/', '/docs/components/main/', + '/docs/components/splitter/', // endregion //// // region Element //// '/docs/components/advice/', @@ -55,6 +56,7 @@ const pages = [ '/docs/components/field-group/', '/docs/components/kbd/', '/docs/components/progress/', + '/docs/components/progress-group/', '/docs/components/separator/', '/docs/components/sidebar/', '/docs/components/skeleton/', @@ -85,6 +87,7 @@ const pages = [ // region Data ////, '/docs/components/accordion/', '/docs/components/description-list/', + '/docs/components/empty/', '/docs/components/scroll-area/', '/docs/components/table/', '/docs/components/table-wrapper/', @@ -116,6 +119,7 @@ const pages = [ '/docs/components/page-aside/', '/docs/components/page-body/', '/docs/components/page-card/', + '/docs/components/page-card-group/', '/docs/components/page-feature/', '/docs/components/page-columns/', '/docs/components/page-grid/', diff --git a/playgrounds/demo/app/composables/useNavigation.ts b/playgrounds/demo/app/composables/useNavigation.ts index 40acb5f2c..6ea9a64cc 100644 --- a/playgrounds/demo/app/composables/useNavigation.ts +++ b/playgrounds/demo/app/composables/useNavigation.ts @@ -79,6 +79,7 @@ const components = [ // 'pricing-plan', // 'pricing-table', 'progress', + 'progress-group', 'radio-group', 'scroll-area', 'select-menu', @@ -89,6 +90,7 @@ const components = [ 'skeleton', 'slideover', 'range', + 'splitter', 'stepper', 'switch', 'table', diff --git a/playgrounds/demo/app/pages/components/progress-group.vue b/playgrounds/demo/app/pages/components/progress-group.vue new file mode 100644 index 000000000..d0745ad14 --- /dev/null +++ b/playgrounds/demo/app/pages/components/progress-group.vue @@ -0,0 +1,68 @@ + + + diff --git a/playgrounds/demo/app/pages/components/splitter.vue b/playgrounds/demo/app/pages/components/splitter.vue new file mode 100644 index 000000000..5835a5dd9 --- /dev/null +++ b/playgrounds/demo/app/pages/components/splitter.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/theme/progress-group.ts b/src/theme/progress-group.ts index 069bf933b..95918d28d 100644 --- a/src/theme/progress-group.ts +++ b/src/theme/progress-group.ts @@ -1,6 +1,6 @@ /** * ProgressGroup - * A progress bar split into segments, each with its own value and colour. + * One progress bar carrying several values at once, each drawn as its own coloured part. * --- * @see src/theme/progress.ts */ diff --git a/test/utils/docs-component-registries.spec.ts b/test/utils/docs-component-registries.spec.ts new file mode 100644 index 000000000..ecd643ffb --- /dev/null +++ b/test/utils/docs-component-registries.spec.ts @@ -0,0 +1,124 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, it, expect } from 'vitest' + +/** + * A component lives in eleven places (`.sync/PORTING.md` §6) and only the first + * one — `src/` — is covered by a gate. The rest fail quietly, which is how + * Splitter and ProgressGroup each shipped missing four of them: + * + * - **`docs/nuxt.config.ts` → `pages`.** The page still builds without an entry, + * because the prerender crawler follows the sidebar link and finds it anyway. + * The array is not what makes the page exist; it is the declared list the + * `/raw/.md` routes are generated from, and + * `skills/b24-ui-nuxt/references/components.md` links to exactly those URLs. + * `empty` and `page-card-group` had been missing from it far longer. + * - **`links[].iconName`.** `resolveIcon()` returns `undefined` for a name that + * is not in `src/runtime/dictionary/icons.ts` and the template renders the + * link without an icon — no warning, no failure. `splitter.md` shipped with + * `RekaIcon`, which has never existed; the convention for that link is an + * avatar (`/b24ui/avatar/rekaui.svg`), not an icon. + * - **The Demo link.** It is typed by hand and points into a separate app, so a + * missing or misnamed `playgrounds/demo` page is a 404 nobody sees from here. + * + * All three are "does this resolve" checks against the tree, in the same spirit + * as `skill-manifest.spec.ts`, rather than a second list that would drift the + * same way. + */ + +const read = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf-8') + +const COMPONENTS_DIR = 'docs/content/docs/2.components' +const DEMO_PAGES_DIR = 'playgrounds/demo/app/pages/components' +const DEMO_BASE = 'https://bitrix24.github.io/b24ui/demo/components/' + +/** Every component page, by slug — `0.index.md` is the category listing, not a component. */ +const pageSlugs = readdirSync(resolve(process.cwd(), COMPONENTS_DIR)) + .filter(name => name.endsWith('.md') && name !== '0.index.md') + .map(name => name.slice(0, -3)) + .sort() + +/** + * The `pages` array from `docs/nuxt.config.ts`, restricted to component routes. + * + * Parsed rather than imported: the file is a `defineNuxtConfig` call that pulls + * in `@nuxt/kit`, and evaluating it under vitest to read one array is a lot of + * machinery for a list of string literals. Commented-out entries are skipped by + * the anchor — `content-navigation` is parked that way on purpose. + */ +const registeredSlugs = [...read('docs/nuxt.config.ts').matchAll(/^ {2}'\/docs\/components\/([a-z0-9-]+)\/',$/gm)] + .map(match => match[1]!) + .sort() + +/** The front-matter block of a page, as raw YAML-ish text. */ +function frontMatter(slug: string): string { + const source = read(`${COMPONENTS_DIR}/${slug}.md`) + expect(source.startsWith('---\n'), `${slug}.md: no front matter`).toBe(true) + const end = source.indexOf('\n---\n', 4) + expect(end, `${slug}.md: unterminated front matter`).toBeGreaterThan(0) + return source.slice(4, end) +} + +const frontMatters = new Map(pageSlugs.map(slug => [slug, frontMatter(slug)])) + +const iconDictionary = read('src/runtime/dictionary/icons.ts') + +/** The names `resolveIcon()` can resolve: the shorthand and `key: Value` entries of the dictionary's default export. */ +const dictionaryNames = new Set( + [...iconDictionary.slice(iconDictionary.indexOf('export default {')).matchAll(/^ {2}(\w+)(?::[ \t]*[\w.]+)?,?[ \t]*(?:\/\/.*)?$/gm)] + .map(match => match[1]!) +) + +describe('docs component pages', () => { + it('reads a non-trivial set of pages', () => { + // Every comparison below is vacuous against an empty list. + expect(pageSlugs.length).toBeGreaterThan(100) + expect(registeredSlugs.length).toBeGreaterThan(100) + expect(dictionaryNames.size).toBeGreaterThan(40) + }) + + it('is registered in `pages` in docs/nuxt.config.ts', () => { + expect(pageSlugs.filter(slug => !registeredSlugs.includes(slug))).toEqual([]) + }) + + it('has no `pages` entry without a page behind it', () => { + expect(registeredSlugs.filter(slug => !pageSlugs.includes(slug))).toEqual([]) + }) + + it('names only icons the dictionary can resolve', () => { + const unresolvable = pageSlugs.flatMap((slug) => { + return [...frontMatters.get(slug)!.matchAll(/^\s*iconName:\s*(\S+)$/gm)] + .map(match => match[1]!) + .filter(name => !dictionaryNames.has(name)) + .map(name => `${slug}.md: ${name}`) + }) + + expect(unresolvable).toEqual([]) + }) + + it('points its Demo link at a demo playground page that exists', () => { + const broken = pageSlugs.flatMap((slug) => { + return [...frontMatters.get(slug)!.matchAll(new RegExp(`^\\s*to:\\s*${DEMO_BASE}(\\S*)$`, 'gm'))] + .map(match => match[1]!.replace(/\/$/, '')) + .filter(name => !existsSync(resolve(process.cwd(), DEMO_PAGES_DIR, `${name}.vue`))) + .map(name => `${slug}.md → ${DEMO_BASE}${name}`) + }) + + expect(broken).toEqual([]) + }) +}) + +describe('demo playground navigation', () => { + const navigation = read('playgrounds/demo/app/composables/useNavigation.ts') + const listed = [...navigation.slice(navigation.indexOf('const components = ['), navigation.indexOf('].map(component')) + .matchAll(/^ {2}'([a-z0-9/-]+)',$/gm)].map(match => match[1]!) + + it('lists a non-trivial number of components', () => { + expect(listed.length).toBeGreaterThan(50) + }) + + it('lists only pages that exist', () => { + const missing = listed.filter(name => !existsSync(resolve(process.cwd(), DEMO_PAGES_DIR, `${name}.vue`))) + expect(missing).toEqual([]) + }) +})