diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index a763a03cd..f06cba747 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -136,6 +136,18 @@ All story files must follow `.storybook/STORY_STANDARD.md`. Key rules: - After editing code, verify with `yarn typecheck` to catch TypeScript errors immediately. - After completing a task or during planning, verify no circular dependencies or barrel import cycles are introduced. Use `npx madge --circular --extensions ts,vue framework/` or manual inspection of import chains. +## Documentation + +`*.docs.md` files in `framework/` are the source of truth for vc-docs. The `cli/docs-sync` package transforms and publishes them. + +```bash +yarn docs:lint # validate *.docs.md template compliance +yarn docs:sync # sync to ../vc-docs (must exist as a sibling checkout) +yarn docs:screenshot --story --out .png # capture a Storybook screenshot per style guide +``` + +CI auto-syncs on framework releases. See `cli/docs-sync/README.md` for details. + ## Debugging Tips - Check git history first for regression bugs: `git log --oneline -15` and `git diff HEAD~3` diff --git a/.github/workflows/docs-lint.yml b/.github/workflows/docs-lint.yml new file mode 100644 index 000000000..aae7c0c14 --- /dev/null +++ b/.github/workflows/docs-lint.yml @@ -0,0 +1,22 @@ +name: docs-lint + +on: + pull_request: + paths: + - "framework/**/*.docs.md" + - "framework/**/*.stories.ts" + - "cli/docs-sync/**" + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + - run: corepack enable + - run: yarn install --immutable + - run: yarn workspace @vc-shell/docs-sync build + - run: yarn workspace @vc-shell/docs-sync exec docs-sync lint diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml new file mode 100644 index 000000000..7886a9a14 --- /dev/null +++ b/.github/workflows/sync-docs.yml @@ -0,0 +1,56 @@ +# Requires repository secret VC_DOCS_BOT_TOKEN — a personal access token +# (or GitHub App installation token) with write access to VirtoCommerce/vc-docs +# (specifically: contents:write and pull-requests:write). +# Set up via Settings → Secrets and variables → Actions. +name: sync-docs + +on: + release: + types: [published] + +jobs: + sync: + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: VirtoCommerce/vc-docs + path: vc-docs + token: ${{ secrets.VC_DOCS_BOT_TOKEN }} + ref: main + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + - run: corepack enable + - run: yarn install --immutable + - run: yarn workspace @vc-shell/docs-sync build + - name: Run sync + run: | + yarn workspace @vc-shell/docs-sync exec docs-sync sync \ + --target ./vc-docs \ + --report ./sync-report.md + - name: Open PR + env: + GH_TOKEN: ${{ secrets.VC_DOCS_BOT_TOKEN }} + run: | + BRANCH="auto/sync-vc-shell-${{ github.event.release.tag_name }}" + cd vc-docs + if [[ -z "$(git status --porcelain)" ]]; then + echo "No changes to sync." + exit 0 + fi + git checkout -b "$BRANCH" + git config user.email "bot@virto.dev" + git config user.name "vc-shell-docs-bot" + git add . + git commit -m "docs: sync from vc-shell@${{ github.event.release.tag_name }}" + git push origin "$BRANCH" + gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "docs: sync from vc-shell@${{ github.event.release.tag_name }}" \ + --body-file ../sync-report.md \ + --label auto-generated diff --git a/cli/docs-sync/README.md b/cli/docs-sync/README.md new file mode 100644 index 000000000..5b51c8475 --- /dev/null +++ b/cli/docs-sync/README.md @@ -0,0 +1,497 @@ +# @vc-shell/docs-sync + +Internal CLI tool that publishes co-located `*.docs.md` files from the vc-shell framework to the public documentation site (vc-docs). Generates Markdown pages, copies image assets, validates the docs template, captures Storybook screenshots, and emits `awesome-pages` navigation files — all driven by frontmatter declared next to the source code. + +This package is **internal**. It is not published to npm. It is invoked locally by maintainers and by GitHub Actions on framework releases. + +> Design spec: `docs/superpowers/specs/2026-04-28-vc-shell-docs-sync-design.md` +> Implementation plan: `docs/superpowers/plans/2026-04-28-vc-shell-docs-sync.md` + +--- + +## Why this exists + +Documentation for `@vc-shell/framework` lives next to the code in `*.docs.md` files. The public docs site is a separate repository (`vc-docs`) built with mkdocs-material and versioned via [`mike`](https://github.com/jimporter/mike). Without automation those two sources drift — fixes to a component's docs file never reach users. + +`docs-sync` is a one-way pipeline: framework `*.docs.md` → transformed Markdown in vc-docs. Manual pages in vc-docs (introduction, getting-started guides, how-tos) are untouched. + +--- + +## Quick start + +From the vc-shell repo root: + +```bash +# 1. Validate every *.docs.md against the template +yarn docs:lint + +# 2. Sync all docs into a sibling vc-docs checkout +yarn docs:sync + +# 3. Capture a Storybook screenshot via Playwright +yarn docs:screenshot --story organisms-vc-data-table--default --out /tmp/preview.png +``` + +The root-level shortcuts assume `../vc-docs` exists as a sibling checkout. To target a different path: + +```bash +yarn workspace @vc-shell/docs-sync exec docs-sync sync --target /custom/path/to/vc-docs +``` + +--- + +## Pipeline at a glance + +``` +┌──────────────────────┐ +│ framework/**/*.docs.md │ ← source of truth (frontmatter + Markdown) +└──────────┬───────────┘ + │ + ▼ + ┌──────────────┐ + │ parser │ zod-validate frontmatter, gray-matter body, extract image refs + └───────┬──────┘ + ▼ + ┌──────────────┐ + │ transformer │ strip-internal → expand-storybook → rewrite-links → AUTO-GENERATED header + └───────┬──────┘ + ▼ + ┌──────────────┐ + │ writer │ atomic write to target tree, copy image assets, emit .pages files + └───────┬──────┘ + ▼ + ┌──────────────┐ + │ report │ detect orphans, render markdown report, exit code + └───────┬──────┘ + ▼ +┌────────────────────────────────────┐ +│ vc-docs/.../vc-shell// │ +│ ├── components/ │ +│ ├── composables/ │ +│ ├── concepts/ │ +│ └── reference/ │ +└────────────────────────────────────┘ +``` + +The pipeline is deterministic and idempotent — running `yarn docs:sync` twice in a row produces zero changes the second time. + +--- + +## The `*.docs.md` format + +Every file destined for the public site declares its routing and metadata in frontmatter: + +````markdown +--- +title: VcDataTable +category: components +group: data-display +slug: vc-data-table # optional; defaults to source filename without `.docs` +--- + +# VcDataTable + +Public-facing description (1–2 sentences for the developer using the framework). + +## Quick Start + +::storybook id="organisms-vc-data-table--default" + +## Examples + +### With Sorting + +::storybook id="organisms-vc-data-table--with-sorting" height="500" + +```vue + + + +``` +```` + +## Props + +| Name | Type | Default | Description | +| ----- | ----- | ------- | ----------- | +| items | `T[]` | `[]` | Row data. | + + + +## Architecture notes + +This block is for vc-shell contributors. Stripped from the public site. + + + +```` + +### Frontmatter contract + +| Field | Required | Allowed values | +| ---------- | -------- | -------------------------------------------------------------- | +| `title` | yes | Free text — used in mkdocs nav. | +| `category` | yes | `components` / `composables` / `concepts` / `reference` | +| `group` | yes | A folder under `category/`. See below. | +| `slug` | no | Filename in target without `.md`. Defaults to source filename. | +| `internal` | no | `true` to skip this file entirely. | + +### Allowed groups per category + +| Category | Groups | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| components | `layout` · `form` · `data-display` · `feedback` · `navigation` · `media` · `misc` | +| composables | `blade-navigation` · `data` · `ui-state` · `services` · `user` · `notifications` · `forms` · `utilities` | +| concepts | `root` (single-level — files land directly in `concepts/.md`) | +| reference | `api` · `api/directives` · `modules` · `cli` | + +A wrong `group` for the chosen `category` is rejected by `frontmatter-required` lint with a clear "allowed groups" message. + +### Routing rules + +Target path is computed as `//.md`, with one special case: `group: root` flattens (no group folder). + +| Source | Target | +| --------------------------------------------------------------------- | -------------------------------------------- | +| `framework/ui/components/organisms/vc-data-table/vc-data-table.docs.md` | `components/data-display/vc-data-table.md` | +| `framework/ui/composables/useDataTableSort.docs.md` | `composables/data/useDataTableSort.md` | +| `framework/core/services/services.docs.md` (group: root) | `concepts/services.md` | +| `framework/core/directives/loading/loading.docs.md` | `reference/api/directives/v-loading.md` | + +### Internal-only blocks + +Wrap any contributor-only sections in `` / `` markers. The transformer strips them on every sync. Use this for architecture notes, gotchas, and references to internal file paths that don't belong in the public site. + +### Storybook embeds + +Use the `::storybook` directive to embed a live story: + +```markdown +::storybook id="organisms-vc-data-table--with-filters" height="500" theme="dark" +```` + +| Param | Purpose | Default | +| -------- | ---------------------------------------------------- | -------- | +| `id` | Story ID matching the URL slug. | required | +| `height` | iframe height in px. | `400` | +| `theme` | `light` / `dark` / `auto`. Sets `?globals=theme:..`. | `auto` | + +The directive expands at sync time into a styled iframe block plus an "Open in Storybook" link, both styled by `vc-shell-docs.css` in the docs site. Story IDs are validated against the live Storybook `index.json` — unknown IDs fail the lint. + +### Image assets + +Images referenced relative to the source file are copied alongside the Markdown: + +``` +framework/ui/components/organisms/vc-data-table/ + vc-data-table.docs.md + images/ + column-resize.png + mobile-card-view.png +``` + +```markdown +![Column resize](./images/column-resize.png) +``` + +The writer copies `./images/X.png` into the target tree at the parallel relative path. Allowed extensions: `png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`. Anything else is rejected by `image-size-limit` lint (also enforces 500 KB warn / 2 MB error budgets). + +### Cross-doc links + +Internal cross-references between two `*.docs.md` files (e.g. linking from `vc-button` to `vc-link`) are rewritten at sync time to point at the correct target path: + +```markdown + + +See also [VcLink](../vc-link). +``` + +becomes + +```markdown + + +See also [VcLink](../navigation/vc-link.md). +``` + +External URLs and anchor-only fragments are left alone. Image refs (`![...](...)`) are handled by the asset copier, not the link rewriter. + +### AUTO-GENERATED marker + +Every synced page begins with: + +```markdown + + + +``` + +The vc-docs `.github/workflows/protect-generated.yml` workflow blocks PRs (other than from the bot) that try to edit files containing this marker. + +--- + +## Commands + +### `yarn docs:sync` + +Walks `framework/**/*.docs.md`, applies the transform pipeline, and writes into a vc-docs checkout. + +```bash +yarn docs:sync # default --target ../vc-docs +yarn workspace @vc-shell/docs-sync exec docs-sync sync \ + --target /Users/me/DEV/vc-docs # explicit target + --dry-run # report only, no writes + --report ./my-report.md # custom report path + --framework-dir /alt/framework # override source root (testing) +``` + +Output: + +``` +vc-shell@v2.0.3 → /Users/me/DEV/vc-docs + changes: 47 + skipped: 5 + orphans: 1 + errors: 0 + report: /Users/me/.../sync-report.md +``` + +Exit code: `0` on success, `1` if any errors were recorded. + +### `yarn docs:lint` + +Runs all lint rules against every `*.docs.md` in `framework/`. Returns exit `1` on any error so CI can block bad PRs. + +```bash +yarn docs:lint +``` + +Sample output: + +```` +warn vue-block-present framework/ui/.../vc-button.docs.md: component page contains no ```vue example block +error frontmatter-required framework/ui/.../bad.docs.md: missing frontmatter + +1 errors, 1 warnings +```` + +### `yarn docs:screenshot` + +Capture a Playwright screenshot using the style-guide defaults below. + +```bash +# Capture from a running dev server +yarn workspace @vc-shell/docs-sync exec docs-sync screenshot \ + --url http://localhost:3000/products \ + --selector ".vc-blade--list" \ + --out apps/vendor-portal/.../guides/blades/images/list-blade.png + +# Capture a Storybook story +yarn workspace @vc-shell/docs-sync exec docs-sync screenshot \ + --story organisms-vc-data-table--with-filters \ + --theme dark \ + --viewport 1280x800 \ + --out framework/.../images/data-table-filters-dark.png +``` + +| Option | Purpose | +| ------------ | ----------------------------------------------------------- | +| `--url` | Direct URL (dev server, deployed site). | +| `--story` | Storybook story ID. Loads `/iframe.html?id=…` | +| `--selector` | CSS selector to crop to. Default: full viewport. | +| `--theme` | `light` / `dark` for theme-aware components. | +| `--viewport` | `x`. Default: `1280x800`. | +| `--out` | Output file path. Required. | + +#### Screenshot style guide + +| Rule | Value | +| -------------- | --------------------------------------------------------------------------------- | +| Viewport | `1280×800` desktop, `390×844` mobile (iPhone 14 frame) | +| Pixel density | `2×` (retina) | +| Browser chrome | None (headless) | +| Theme | Both light + dark when visual differs meaningfully; light only otherwise | +| Format | `png` (Playwright limitation — webp post-process via `sharp` is a follow-up) | +| Filename | kebab-case, descriptive: `list-blade-with-filters.png` | +| Crop | Crop to relevant CSS selector, not viewport. Avoid empty space. | +| Demo data | Realistic — product names, prices, emails. **No "Test 1 / Test 2 / Lorem ipsum"** | +| Sensitive data | Never real customer data. Use seed fixtures or Storybook story args. | + +--- + +## Lint rules + +`yarn docs:lint` runs six rules against every `*.docs.md`. Errors block CI; warnings are advisory. + +| Rule | Severity | What it checks | +| ----------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `frontmatter-required` | error | Frontmatter is present and parses against the schema (title/category/group). | +| `storybook-id-valid` | error | Every `::storybook id="X"` directive references a real story in `index.json`. Skipped gracefully when Storybook is unreachable. | +| `image-size-limit` | warn @ 500 KB / error @ 2 MB | Referenced images stay under size budget. Allowed types: png/jpg/jpeg/gif/webp/svg. | +| `vue-block-present` | warn | Component pages should include at least one ` ```vue ` block. | +| `material-feature-warn` | warn | Long pages (>200 lines) without any admonition / tabs / details / mermaid block — likely missing structure. | +| `mermaid-syntax` | error | Each ` ```mermaid ` block is non-empty and starts with a known diagram keyword. | + +--- + +## CI integration + +Three GitHub Actions wire docs-sync into the release flow. + +| Workflow | Repo | Trigger | Effect | +| ----------------------------------------- | -------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `.github/workflows/docs-lint.yml` | vc-shell | PR touching `*.docs.md` / `*.stories.ts` / `cli/docs-sync/**` | Runs `yarn docs:lint`. Failure blocks merge. | +| `.github/workflows/sync-docs.yml` | vc-shell | `release: published` (non-prerelease) | Syncs into vc-docs `main` and opens an auto-PR labeled `auto-generated` from a bot account. | +| `.github/workflows/protect-generated.yml` | vc-docs | PR touching `vc-shell/**` | Blocks edits to AUTO-GENERATED files unless from the bot or label `auto-generated`. | + +The release sync workflow requires repo secret `VC_DOCS_BOT_TOKEN` — a PAT or GitHub App token with `contents:write` and `pull-requests:write` on `VirtoCommerce/vc-docs`. + +--- + +## Versioning model + +vc-docs is versioned via [`mike`](https://github.com/jimporter/mike). Each major (`2.0`, `3.0`, …) lives on its own branch (`main` for current, `release/X.0` for frozen previous majors). vc-shell and vc-docs version **independently**. + +Implications for docs-sync: + +- Generator only ever writes to vc-docs `main`. Frozen `release/X.0` branches stay immutable. +- Each vc-shell release re-syncs into the active major. There's no per-release mapping table — `git log` of vc-docs gives the audit trail of which framework version produced which content. +- Live Storybook embeds in frozen snapshots will show the **latest** framework version (single Storybook deployment in Phase 1). A banner injected via `overrides/main.html` in vc-docs alerts users on numbered-release URLs ("Live demos may differ from documented version"). +- Phase 2 follow-up: per-version Storybook deployments at `vc-shell-storybook.govirto.com//`. The generator will read the active docs version from `vc-docs/VERSION` and substitute it into iframe URLs at sync time. + +--- + +## Development + +### Build and test + +From the vc-shell repo root: + +```bash +yarn workspace @vc-shell/docs-sync build # tsc → dist/ +yarn workspace @vc-shell/docs-sync test # vitest run, 47 tests +yarn workspace @vc-shell/docs-sync test:watch # vitest watch +``` + +### Project layout + +``` +cli/docs-sync/ + src/ + index.ts # CLI entry, commander setup + config.ts # paths, allowed categories/groups, env override + types.ts # Frontmatter, ParsedDoc, SyncReport, … + commands/ + sync.ts # `sync` command + lint.ts # `lint` command + screenshot.ts # `screenshot` command + parser/ + frontmatter.ts # zod schema + cross-field refinement + images.ts # extract relative ![alt](./path) refs + parser.ts # gray-matter + frontmatter validation + transformer/ + strip-internal.ts # remove blocks + storybook-directive.ts # ::storybook → iframe HTML + header.ts # AUTO-GENERATED header + links.ts # rewrite cross-doc relative links + pipeline.ts # orchestrator + storybook/ + index-fetcher.ts # download index.json, validate IDs + writer/ + atomic.ts # write-file-atomic wrapper + writer.ts # computeTargetPath + writeMarkdown + assets.ts # copy referenced images + pages.ts # emit .pages files for awesome-pages + report/ + tracker.ts # accumulate change/skip/error/orphan entries + orphans.ts # detect orphaned auto-generated files + formatter.ts # render SyncReport → markdown + screenshot/ + defaults.ts # style-guide constants + capture.ts # Playwright wrapper + lint/ + runner.ts # rules registry, runLint orchestrator + rules/ + frontmatter-required.ts + storybook-id-valid.ts + image-size-limit.ts + vue-block-present.ts + material-feature-warn.ts + mermaid-syntax.ts + tests/ + *.test.ts # 14 unit + 1 integration test files + fixtures/ # *.docs.md fixtures (simple-component, integration source tree) +``` + +Tests use real `fs.mkdtemp` for isolation and `vi.spyOn(globalThis, "fetch")` to mock network calls. + +### Adding a new lint rule + +1. Implement `src/lint/rules/.ts` exporting a `LintRule` (object with `name` and `check(file, ctx) -> LintIssue[]` — sync or async). +2. Register it in `src/lint/runner.ts` `ALL_RULES`. +3. Add a test case to `tests/lint.test.ts`. +4. Update the **Lint rules** table in this README. + +### Adding a new transformer step + +1. Implement `src/transformer/.ts` exporting a pure function `(body: string, ctx: TransformContext) => string`. +2. Wire it into `src/transformer/pipeline.ts` in the right order. Order matters — `strip-internal` runs before `expand-storybook` so internal blocks containing storybook directives don't get expanded. +3. Add unit tests for the step. + +### Stack + +- TypeScript ESM with `moduleResolution: "NodeNext"` (matches `cli/migrate`) +- Node 20+ +- `commander` (CLI argv) +- `gray-matter` (frontmatter) +- `zod` (frontmatter schema) +- `globby` (file walking) +- `write-file-atomic` (safe atomic writes) +- `chalk` (terminal output) +- `@playwright/test` (screenshot) +- `vitest` (tests) + +--- + +## Phase 0 — editorial pass + +Before automation can produce useful docs the source files need a one-time editorial pass: 150 `*.docs.md` files normalised to the template (frontmatter added, sections aligned, contributor-only chunks wrapped in ``, examples added, Storybook embeds added). This is content work, not engineering work — done as a separate workstream over several PRs grouped by category. + +Until Phase 0 is complete, `yarn docs:lint` reports many errors and warnings against the current framework. That's expected. CI for `docs-lint.yml` may need `continue-on-error: true` while Phase 0 is in flight. + +--- + +## Troubleshooting + +**`error frontmatter-required: missing frontmatter`** — the file has no `---` block at the top, or the block parses to an empty object. Add the four required fields. + +**`error storybook-id-valid: unknown Storybook story id "X"`** — the story ID doesn't exist in the live Storybook `index.json`. Either the story was renamed in the `.stories.ts` file, or there's a typo in the directive. Check `https://vc-shell-storybook.govirto.com/index.json`. + +**Sync exits 1 with no error in stdout** — open `sync-report.md` in the working directory; the full error list is there. + +**Newly created `*.docs.md` is skipped without comment** — `yarn docs:lint` to surface the reason. Most often a frontmatter issue. + +**`docs:sync` shows N orphans** — files in vc-docs that carry the AUTO-GENERATED marker but no longer have a matching source. Decide per-file: either restore the source (rename happened?), or delete the orphan in a follow-up PR. The generator never deletes orphans automatically. + +**Screenshot command hangs / times out** — Playwright needs the chromium binary on first run. From inside the workspace: `yarn workspace @vc-shell/docs-sync exec playwright install chromium`. + +**Storybook iframe inside synced docs shows the wrong version** — expected during Phase 1. See "Versioning model" above. The frozen-snapshot banner explains this to readers. + +--- + +## Status (2026-05) + +| Component | Status | +| ---------------------------------------- | ------------------------------- | +| `cli/docs-sync` package | ✅ shipped | +| 6 lint rules | ✅ shipped | +| Sync, lint, screenshot commands | ✅ shipped | +| `docs-lint.yml` workflow (vc-shell) | ✅ shipped | +| `sync-docs.yml` workflow (vc-shell) | ✅ shipped | +| `protect-generated.yml` (vc-docs) | ✅ on branch, awaiting merge | +| `vc-shell-docs.css` (vc-docs) | ✅ on branch, awaiting merge | +| Frozen-version banner (vc-docs) | ✅ on branch, awaiting merge | +| Phase 0 editorial pass (150 files) | ⏳ separate workstream | +| Folder restructure in vc-docs | ⏳ deferred until Phase 0 lands | +| Versioned Storybook deployment (Phase 2) | ⏳ follow-up | +| Auto-screenshot pipeline | ⏳ follow-up | diff --git a/cli/docs-sync/package.json b/cli/docs-sync/package.json new file mode 100644 index 000000000..5ec898c09 --- /dev/null +++ b/cli/docs-sync/package.json @@ -0,0 +1,37 @@ +{ + "name": "@vc-shell/docs-sync", + "version": "2.0.3", + "private": true, + "type": "module", + "bin": "./dist/index.js", + "main": "./dist/index.js", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest", + "sync": "tsx src/index.ts sync", + "lint:docs": "tsx src/index.ts lint", + "screenshot": "tsx src/index.ts screenshot" + }, + "dependencies": { + "@playwright/test": "^1.52.0", + "chalk": "^5.3.0", + "commander": "^12.0.0", + "globby": "^14.0.0", + "gray-matter": "^4.0.3", + "write-file-atomic": "^7.0.1", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^20.10.5", + "@types/write-file-atomic": "^4.0.3", + "@vc-shell/ts-config": "workspace:*", + "tsx": "^4.7.0", + "typescript": "^5.8.3", + "vitest": "^3.1.2" + } +} diff --git a/cli/docs-sync/src/commands/lint.ts b/cli/docs-sync/src/commands/lint.ts new file mode 100644 index 000000000..72c3d577c --- /dev/null +++ b/cli/docs-sync/src/commands/lint.ts @@ -0,0 +1,39 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import { globby } from "globby"; +import chalk from "chalk"; +import { FRAMEWORK_DIR, STORYBOOK_URL } from "../config.js"; +import { fetchStorybookIds } from "../storybook/index-fetcher.js"; +import { runLint } from "../lint/runner.js"; + +export async function runLintCommand(): Promise<{ exitCode: number }> { + const sources = await globby(["**/*.docs.md"], { cwd: FRAMEWORK_DIR, absolute: true }); + const files = await Promise.all( + sources.map(async (abs) => ({ + absPath: abs, + relPath: path.relative(FRAMEWORK_DIR, abs), + raw: await fs.readFile(abs, "utf8"), + })), + ); + + let knownStoryIds = new Set(); + try { + knownStoryIds = await fetchStorybookIds(STORYBOOK_URL); + } catch (err) { + console.warn( + chalk.yellow(`warning: could not fetch Storybook index (${(err as Error).message}); skipping story-id checks`), + ); + } + + const result = await runLint({ sources: files, knownStoryIds }); + + for (const w of result.warnings) { + console.log(chalk.yellow(`warn ${w.rule} ${w.file}: ${w.message}`)); + } + for (const e of result.errors) { + console.log(chalk.red(`error ${e.rule} ${e.file}: ${e.message}`)); + } + console.log(chalk.bold(`\n${result.errors.length} errors, ${result.warnings.length} warnings`)); + + return { exitCode: result.errors.length > 0 ? 1 : 0 }; +} diff --git a/cli/docs-sync/src/commands/screenshot.ts b/cli/docs-sync/src/commands/screenshot.ts new file mode 100644 index 000000000..c7ecff0dc --- /dev/null +++ b/cli/docs-sync/src/commands/screenshot.ts @@ -0,0 +1,33 @@ +import path from "node:path"; +import { captureScreenshot } from "../screenshot/capture.js"; + +export interface ScreenshotArgs { + url?: string; + story?: string; + selector?: string; + theme?: "light" | "dark"; + viewport?: string; // "1280x800" + out: string; +} + +export async function runScreenshotCommand(args: ScreenshotArgs): Promise<{ exitCode: number }> { + let viewportWidth: number | undefined; + let viewportHeight: number | undefined; + if (args.viewport) { + const [w, h] = args.viewport.split("x").map((n) => parseInt(n, 10)); + if (Number.isFinite(w) && Number.isFinite(h)) { + viewportWidth = w; + viewportHeight = h; + } + } + await captureScreenshot({ + url: args.url, + story: args.story, + selector: args.selector, + theme: args.theme, + viewportWidth, + viewportHeight, + out: path.resolve(args.out), + }); + return { exitCode: 0 }; +} diff --git a/cli/docs-sync/src/commands/sync.ts b/cli/docs-sync/src/commands/sync.ts new file mode 100644 index 000000000..3177f7c86 --- /dev/null +++ b/cli/docs-sync/src/commands/sync.ts @@ -0,0 +1,155 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import { globby } from "globby"; +import chalk from "chalk"; +import { FRAMEWORK_DIR, STORYBOOK_URL, VC_DOCS_RELATIVE_BASE } from "../config.js"; +import { parseDocFile, FrontmatterError } from "../parser/parser.js"; +import { runTransformPipeline } from "../transformer/pipeline.js"; +import { fetchStorybookIds } from "../storybook/index-fetcher.js"; +import { computeTargetPath, writeMarkdown } from "../writer/writer.js"; +import { copyImageAssets } from "../writer/assets.js"; +import { writePagesFiles } from "../writer/pages.js"; +import { findOrphans } from "../report/orphans.js"; +import { ReportTracker } from "../report/tracker.js"; +import { formatReport } from "../report/formatter.js"; +import type { Frontmatter } from "../types.js"; + +export interface SyncArgs { + target: string; // path to vc-docs root + frameworkDir?: string; // override: defaults to FRAMEWORK_DIR (vc-shell/framework) + dryRun?: boolean; + reportPath?: string; // optional override for report output +} + +export async function runSync(args: SyncArgs): Promise<{ exitCode: number }> { + const frameworkDir = args.frameworkDir ?? FRAMEWORK_DIR; + const targetAbs = path.resolve(args.target); + const targetSection = path.join(targetAbs, VC_DOCS_RELATIVE_BASE); + + // Read framework version. + const fwPkg = JSON.parse(await fs.readFile(path.join(frameworkDir, "package.json"), "utf8")); + const vcShellVersion = `v${fwPkg.version}`; + + // Fetch Storybook story IDs. + const knownStoryIds = await fetchStorybookIds(STORYBOOK_URL); + + // Discover all *.docs.md. + const sources = await globby(["**/*.docs.md"], { cwd: frameworkDir, absolute: true }); + + const tracker = new ReportTracker(); + + // First pass: parse all docs to build a path map for cross-doc link resolution. + const parsed = new Map(); + for (const abs of sources) { + const rel = path.relative(frameworkDir, abs); + try { + const doc = await parseDocFile(abs); + if (doc.frontmatter.internal) { + tracker.recordSkip({ source: rel, reason: "internal" }); + continue; + } + const target = computeTargetPath(doc.frontmatter, path.basename(abs)); + parsed.set(abs, { fm: doc.frontmatter, rel, target }); + } catch (err) { + if (err instanceof FrontmatterError) { + // Distinguish "no frontmatter at all" from "invalid frontmatter" by checking the source. + const raw = await fs.readFile(abs, "utf8"); + if (!raw.startsWith("---")) { + tracker.recordSkip({ source: rel, reason: "no-frontmatter" }); + } else { + tracker.recordSkip({ source: rel, reason: "invalid-frontmatter", detail: err.message }); + } + } else { + tracker.recordError({ source: rel, message: (err as Error).message }); + } + } + } + + // Build resolveTarget: map (sourcePath, href) → target path. + // Strategy: for any link href, resolve absolutely from sourceDir, normalize to .docs.md + // suffix, and look up in `parsed`. + const sourceToTarget = new Map(); + for (const [absSrc, info] of parsed) { + sourceToTarget.set(absSrc, info.target); + } + const resolveTarget = (sourceRel: string, href: string): string | null => { + const sourceAbs = path.join(frameworkDir, sourceRel); + const sourceDir = path.dirname(sourceAbs); + // Try a few common patterns: `../foo`, `../foo/foo`, `../foo/foo.docs.md`. + const candidates = [ + path.resolve(sourceDir, href), + path.resolve(sourceDir, href + ".docs.md"), + path.resolve(sourceDir, href, path.basename(href) + ".docs.md"), + ]; + for (const cand of candidates) { + const found = sourceToTarget.get(cand); + if (found) return found; + } + return null; + }; + + // Second pass: transform and write. + const synced: { target: string; title: string }[] = []; + for (const [abs, info] of parsed) { + try { + const doc = await parseDocFile(abs); + const transformed = runTransformPipeline(doc.body, { + sourceRelPath: info.rel, + sourcePath: info.rel, + targetPath: info.target, + storybookUrl: STORYBOOK_URL, + knownStoryIds, + resolveTarget, + }); + + if (args.dryRun) { + tracker.recordChange({ source: info.rel, target: info.target, kind: "created" }); + synced.push({ target: info.target, title: info.fm.title }); + continue; + } + + const writeRes = await writeMarkdown(targetSection, info.target, transformed); + tracker.recordChange({ source: info.rel, target: info.target, kind: writeRes.kind }); + synced.push({ target: info.target, title: info.fm.title }); + + if (doc.imageRefs.length > 0) { + await copyImageAssets({ + sourceFile: abs, + targetFile: path.join(targetSection, info.target), + imageRefs: doc.imageRefs, + }); + } + } catch (err) { + tracker.recordError({ source: info.rel, message: (err as Error).message }); + } + } + + // Emit .pages files. + if (!args.dryRun) { + await writePagesFiles(targetSection, { synced }); + } + + // Detect orphans. + const syncedSet = new Set(synced.map((s) => s.target)); + const orphans = await findOrphans(targetSection, syncedSet); + tracker.setOrphans(orphans); + + // Build and write report. + const report = tracker.build({ + vcShellVersion, + timestamp: new Date().toISOString(), + }); + const md = formatReport(report); + const reportPath = args.reportPath ?? path.join(process.cwd(), "sync-report.md"); + await fs.writeFile(reportPath, md, "utf8"); + + // Print summary to stdout. + console.log(chalk.bold(`vc-shell@${vcShellVersion} → ${args.target}`)); + console.log(` changes: ${report.changes.length}`); + console.log(` skipped: ${report.skipped.length}`); + console.log(` orphans: ${orphans.length}`); + console.log(` errors: ${report.errors.length}`); + console.log(` report: ${reportPath}`); + + return { exitCode: report.errors.length > 0 ? 1 : 0 }; +} diff --git a/cli/docs-sync/src/config.ts b/cli/docs-sync/src/config.ts new file mode 100644 index 000000000..20be339df --- /dev/null +++ b/cli/docs-sync/src/config.ts @@ -0,0 +1,36 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// vc-shell repo root: cli/docs-sync/src/ → ../../.. +export const FRAMEWORK_ROOT = path.resolve(__dirname, "../../.."); + +export const FRAMEWORK_DIR = path.join(FRAMEWORK_ROOT, "framework"); + +export const VC_DOCS_RELATIVE_BASE = "platform/developer-guide/docs/custom-apps-development/vc-shell"; + +export const STORYBOOK_URL = "https://vc-shell-storybook.govirto.com"; + +export const ALLOWED_CATEGORIES = ["components", "composables", "concepts", "plugins", "reference"] as const; + +export type Category = (typeof ALLOWED_CATEGORIES)[number]; + +// Allowed groups per category. Match the spec's structure section exactly. +export const ALLOWED_GROUPS: Record = { + components: ["layout", "form", "data-display", "feedback", "navigation", "media", "misc"], + composables: ["blade-navigation", "data", "ui-state", "services", "user", "notifications", "forms", "utilities"], + concepts: ["root"], // concepts has no sub-grouping; group="root" → file lands at concepts/.md + plugins: ["root"], + reference: ["api", "api/directives", "modules", "cli"], +}; + +// Folders inside vc-docs that the generator MUST NOT touch. +export const MANUAL_ONLY_FOLDERS = [ + "introduction", + "getting-started", + "guides", + "concepts", + "reference/migration", + "reference/cli", +]; diff --git a/cli/docs-sync/src/index.ts b/cli/docs-sync/src/index.ts new file mode 100644 index 000000000..7e14df484 --- /dev/null +++ b/cli/docs-sync/src/index.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import { Command } from "commander"; + +const program = new Command(); +program.name("docs-sync").description("vc-shell → vc-docs sync tool").version("0.1.0"); + +program + .command("sync") + .description("Sync *.docs.md from vc-shell into vc-docs") + .requiredOption("--target ", "path to vc-docs checkout") + .option("--dry-run", "do not write files; only report what would change") + .option("--report ", "where to write sync-report.md (default: ./sync-report.md)") + .option("--framework-dir ", "override path to vc-shell framework root (for testing)") + .action(async (opts) => { + const { runSync } = await import("./commands/sync.js"); + const res = await runSync({ + target: opts.target, + frameworkDir: opts.frameworkDir, + dryRun: opts.dryRun, + reportPath: opts.report, + }); + process.exit(res.exitCode); + }); + +program + .command("lint") + .description("Validate *.docs.md against the template") + .action(async () => { + const { runLintCommand } = await import("./commands/lint.js"); + const res = await runLintCommand(); + process.exit(res.exitCode); + }); + +program + .command("screenshot") + .description("Capture a Playwright screenshot using style-guide defaults") + .option("--url ", "direct URL (e.g. dev server)") + .option("--story ", "Storybook story id") + .option("--selector ", "CSS selector to crop to") + .option("--theme ", "light | dark") + .option("--viewport ", "viewport size, e.g. 1280x800") + .requiredOption("--out ", "output file path (.webp)") + .action(async (opts) => { + const { runScreenshotCommand } = await import("./commands/screenshot.js"); + const res = await runScreenshotCommand(opts); + process.exit(res.exitCode); + }); + +program.parse(); diff --git a/cli/docs-sync/src/lint/rules/frontmatter-required.ts b/cli/docs-sync/src/lint/rules/frontmatter-required.ts new file mode 100644 index 000000000..1d14e3ad8 --- /dev/null +++ b/cli/docs-sync/src/lint/rules/frontmatter-required.ts @@ -0,0 +1,18 @@ +import matter from "gray-matter"; +import { validateFrontmatter } from "../../parser/frontmatter.js"; +import type { LintRule, LintIssue } from "../runner.js"; + +export const frontmatterRequired: LintRule = { + name: "frontmatter-required", + check(file): LintIssue[] { + const parsed = matter(file.raw); + if (!parsed.data || Object.keys(parsed.data).length === 0) { + return [{ rule: "frontmatter-required", file: file.relPath, severity: "error", message: "missing frontmatter" }]; + } + const v = validateFrontmatter(parsed.data); + if (!v.success) { + return [{ rule: "frontmatter-required", file: file.relPath, severity: "error", message: v.error }]; + } + return []; + }, +}; diff --git a/cli/docs-sync/src/lint/rules/image-size-limit.ts b/cli/docs-sync/src/lint/rules/image-size-limit.ts new file mode 100644 index 000000000..b61915abf --- /dev/null +++ b/cli/docs-sync/src/lint/rules/image-size-limit.ts @@ -0,0 +1,44 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { findRelativeImageRefs } from "../../parser/images.js"; +import type { LintRule, LintIssue } from "../runner.js"; + +const WARN_BYTES = 500 * 1024; +const ERROR_BYTES = 2 * 1024 * 1024; + +export const imageSizeLimit: LintRule = { + name: "image-size-limit", + async check(file): Promise { + const refs = findRelativeImageRefs(file.raw); + const out: LintIssue[] = []; + const dir = path.dirname(file.absPath); + for (const ref of refs) { + const abs = path.resolve(dir, ref); + try { + const stat = await fs.stat(abs); + if (stat.size > ERROR_BYTES) { + out.push({ + rule: "image-size-limit", + file: file.relPath, + severity: "error", + message: `${ref} is ${formatKB(stat.size)} (> 2 MB)`, + }); + } else if (stat.size > WARN_BYTES) { + out.push({ + rule: "image-size-limit", + file: file.relPath, + severity: "warning", + message: `${ref} is ${formatKB(stat.size)} (> 500 KB)`, + }); + } + } catch { + // missing image — handled by a separate rule if needed + } + } + return out; + }, +}; + +function formatKB(bytes: number): string { + return `${(bytes / 1024).toFixed(0)} KB`; +} diff --git a/cli/docs-sync/src/lint/rules/material-feature-warn.ts b/cli/docs-sync/src/lint/rules/material-feature-warn.ts new file mode 100644 index 000000000..ca3f49b06 --- /dev/null +++ b/cli/docs-sync/src/lint/rules/material-feature-warn.ts @@ -0,0 +1,23 @@ +import matter from "gray-matter"; +import type { LintRule, LintIssue } from "../runner.js"; + +const FEATURE_RE = /(^!!! )|(^=== ")|(^\?\?\? )|(^```mermaid)/m; +const LINE_THRESHOLD = 200; + +export const materialFeatureWarn: LintRule = { + name: "material-feature-warn", + check(file): LintIssue[] { + const parsed = matter(file.raw); + const lines = parsed.content.split("\n").length; + if (lines < LINE_THRESHOLD) return []; + if (FEATURE_RE.test(parsed.content)) return []; + return [ + { + rule: "material-feature-warn", + file: file.relPath, + severity: "warning", + message: `${lines}-line page uses no admonition / tabs / details / mermaid — consider breaking up with material features`, + }, + ]; + }, +}; diff --git a/cli/docs-sync/src/lint/rules/mermaid-syntax.ts b/cli/docs-sync/src/lint/rules/mermaid-syntax.ts new file mode 100644 index 000000000..e1e24485a --- /dev/null +++ b/cli/docs-sync/src/lint/rules/mermaid-syntax.ts @@ -0,0 +1,31 @@ +import type { LintRule, LintIssue } from "../runner.js"; + +const BLOCK_RE = /```mermaid\n([\s\S]*?)```/g; + +export const mermaidSyntax: LintRule = { + name: "mermaid-syntax", + check(file): LintIssue[] { + const out: LintIssue[] = []; + for (const m of file.raw.matchAll(BLOCK_RE)) { + const body = m[1].trim(); + if (!body) { + out.push({ rule: "mermaid-syntax", file: file.relPath, severity: "error", message: "empty mermaid block" }); + continue; + } + // Crude: must start with a recognized diagram keyword. + if ( + !/^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitGraph)\b/.test( + body, + ) + ) { + out.push({ + rule: "mermaid-syntax", + file: file.relPath, + severity: "error", + message: "mermaid block does not start with a known diagram keyword", + }); + } + } + return out; + }, +}; diff --git a/cli/docs-sync/src/lint/rules/storybook-id-valid.ts b/cli/docs-sync/src/lint/rules/storybook-id-valid.ts new file mode 100644 index 000000000..31bacc72e --- /dev/null +++ b/cli/docs-sync/src/lint/rules/storybook-id-valid.ts @@ -0,0 +1,23 @@ +import type { LintRule, LintIssue } from "../runner.js"; + +const RE = /::storybook\s+[^\n]*\bid="([^"]+)"/g; + +export const storybookIdValid: LintRule = { + name: "storybook-id-valid", + check(file, ctx): LintIssue[] { + const issues: LintIssue[] = []; + for (const m of file.raw.matchAll(RE)) { + const id = m[1]; + if (ctx.knownStoryIds.size === 0) continue; // skip when index unavailable + if (!ctx.knownStoryIds.has(id)) { + issues.push({ + rule: "storybook-id-valid", + file: file.relPath, + severity: "error", + message: `unknown Storybook story id: "${id}"`, + }); + } + } + return issues; + }, +}; diff --git a/cli/docs-sync/src/lint/rules/vue-block-present.ts b/cli/docs-sync/src/lint/rules/vue-block-present.ts new file mode 100644 index 000000000..19b2aa6df --- /dev/null +++ b/cli/docs-sync/src/lint/rules/vue-block-present.ts @@ -0,0 +1,19 @@ +import matter from "gray-matter"; +import type { LintRule, LintIssue } from "../runner.js"; + +export const vueBlockPresent: LintRule = { + name: "vue-block-present", + check(file): LintIssue[] { + const parsed = matter(file.raw); + if ((parsed.data?.category as string) !== "components") return []; + if (/```vue\b/.test(parsed.content)) return []; + return [ + { + rule: "vue-block-present", + file: file.relPath, + severity: "warning", + message: "component page contains no ```vue example block", + }, + ]; + }, +}; diff --git a/cli/docs-sync/src/lint/runner.ts b/cli/docs-sync/src/lint/runner.ts new file mode 100644 index 000000000..9eba5c527 --- /dev/null +++ b/cli/docs-sync/src/lint/runner.ts @@ -0,0 +1,65 @@ +import { frontmatterRequired } from "./rules/frontmatter-required.js"; +import { storybookIdValid } from "./rules/storybook-id-valid.js"; +import { imageSizeLimit } from "./rules/image-size-limit.js"; +import { vueBlockPresent } from "./rules/vue-block-present.js"; +import { materialFeatureWarn } from "./rules/material-feature-warn.js"; +import { mermaidSyntax } from "./rules/mermaid-syntax.js"; + +export interface LintFile { + absPath: string; + relPath: string; + raw: string; +} + +export type LintSeverity = "error" | "warning"; + +export interface LintIssue { + rule: string; + file: string; + severity: LintSeverity; + message: string; +} + +export interface LintRule { + name: string; + check(file: LintFile, ctx: LintContext): LintIssue[] | Promise; +} + +export interface LintContext { + knownStoryIds: Set; +} + +export interface LintArgs { + sources: LintFile[]; + knownStoryIds: Set; +} + +export interface LintResult { + errors: LintIssue[]; + warnings: LintIssue[]; +} + +const ALL_RULES: LintRule[] = [ + frontmatterRequired, + storybookIdValid, + imageSizeLimit, + vueBlockPresent, + materialFeatureWarn, + mermaidSyntax, +]; + +export async function runLint(args: LintArgs): Promise { + const errors: LintIssue[] = []; + const warnings: LintIssue[] = []; + const ctx: LintContext = { knownStoryIds: args.knownStoryIds }; + for (const file of args.sources) { + for (const rule of ALL_RULES) { + const issues = await rule.check(file, ctx); + for (const issue of issues) { + if (issue.severity === "error") errors.push(issue); + else warnings.push(issue); + } + } + } + return { errors, warnings }; +} diff --git a/cli/docs-sync/src/parser/frontmatter.ts b/cli/docs-sync/src/parser/frontmatter.ts new file mode 100644 index 000000000..865492ee2 --- /dev/null +++ b/cli/docs-sync/src/parser/frontmatter.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { ALLOWED_CATEGORIES, ALLOWED_GROUPS } from "../config.js"; +import type { Frontmatter } from "../types.js"; + +const schema = z + .object({ + title: z.string().min(1), + category: z.enum(ALLOWED_CATEGORIES), + group: z.string().min(1), + slug: z.string().optional(), + placement: z.enum(["index"]).optional(), + internal: z.boolean().optional(), + }) + .refine( + (data) => ALLOWED_GROUPS[data.category]?.includes(data.group) ?? false, + (data) => ({ + message: `group "${data.group}" is not allowed for category "${data.category}". Allowed: ${ALLOWED_GROUPS[data.category]?.join(", ") ?? "none"}`, + path: ["group"], + }), + ) + .refine((data) => !(data.placement === "index" && data.group === "root"), { + message: "placement: index requires a named group, not 'root'", + path: ["placement"], + }); + +export type ValidationResult = { success: true; data: Frontmatter } | { success: false; error: string }; + +export function validateFrontmatter(input: unknown): ValidationResult { + const result = schema.safeParse(input); + if (result.success) { + return { success: true, data: result.data as Frontmatter }; + } + const message = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); + return { success: false, error: message }; +} diff --git a/cli/docs-sync/src/parser/images.ts b/cli/docs-sync/src/parser/images.ts new file mode 100644 index 000000000..b863de6da --- /dev/null +++ b/cli/docs-sync/src/parser/images.ts @@ -0,0 +1,9 @@ +const RE = /!\[[^\]]*\]\((\.\.?\/[^)]+)\)/g; + +export function findRelativeImageRefs(body: string): string[] { + const refs: string[] = []; + for (const match of body.matchAll(RE)) { + refs.push(match[1]); + } + return refs; +} diff --git a/cli/docs-sync/src/parser/parser.ts b/cli/docs-sync/src/parser/parser.ts new file mode 100644 index 000000000..d00e0fd85 --- /dev/null +++ b/cli/docs-sync/src/parser/parser.ts @@ -0,0 +1,32 @@ +import fs from "node:fs/promises"; +import matter from "gray-matter"; +import { validateFrontmatter } from "./frontmatter.js"; +import { findRelativeImageRefs } from "./images.js"; +import type { ParsedDoc } from "../types.js"; + +export class FrontmatterError extends Error { + constructor( + public readonly file: string, + message: string, + ) { + super(`[${file}] ${message}`); + this.name = "FrontmatterError"; + } +} + +export async function parseDocFile(absolutePath: string): Promise { + const raw = await fs.readFile(absolutePath, "utf8"); + const parsed = matter(raw); + + const validation = validateFrontmatter(parsed.data); + if (!validation.success) { + throw new FrontmatterError(absolutePath, validation.error); + } + + return { + sourcePath: absolutePath, + frontmatter: validation.data, + body: parsed.content, + imageRefs: findRelativeImageRefs(parsed.content), + }; +} diff --git a/cli/docs-sync/src/report/formatter.ts b/cli/docs-sync/src/report/formatter.ts new file mode 100644 index 000000000..0ebf67a84 --- /dev/null +++ b/cli/docs-sync/src/report/formatter.ts @@ -0,0 +1,61 @@ +import type { SyncReport, ChangeKind } from "../types.js"; + +const KINDS: ChangeKind[] = ["created", "updated", "unchanged"]; + +export function formatReport(report: SyncReport): string { + const lines: string[] = []; + lines.push(`# vc-docs sync report — vc-shell@${report.vcShellVersion} — ${report.timestamp}`); + lines.push(""); + + const counts: Record = { created: 0, updated: 0, unchanged: 0 }; + for (const c of report.changes) counts[c.kind]++; + const skippedInternal = report.skipped.filter((s) => s.reason === "internal").length; + const skippedNoFm = report.skipped.filter((s) => s.reason === "no-frontmatter").length; + const skippedInvalid = report.skipped.filter((s) => s.reason === "invalid-frontmatter").length; + + lines.push("## Summary"); + lines.push(`- Created: ${counts.created}`); + lines.push(`- Updated: ${counts.updated}`); + lines.push(`- Unchanged: ${counts.unchanged}`); + lines.push(`- Skipped (no frontmatter): ${skippedNoFm}`); + lines.push(`- Skipped (internal): ${skippedInternal}`); + lines.push(`- Skipped (invalid frontmatter): ${skippedInvalid}`); + lines.push(`- Orphaned: ${report.orphans.length}`); + lines.push(`- Errors: ${report.errors.length}`); + lines.push(""); + + for (const kind of KINDS) { + const list = report.changes.filter((c) => c.kind === kind); + if (list.length === 0) continue; + lines.push(`## ${capitalize(kind)}`); + for (const c of list) lines.push(`- ${c.target} (← ${c.source})`); + lines.push(""); + } + + if (report.orphans.length > 0) { + lines.push("## Orphaned (synced previously, source gone)"); + for (const o of report.orphans) lines.push(`- ${o.target}`); + lines.push(""); + } + + if (report.skipped.length > 0) { + lines.push("## Skipped"); + for (const s of report.skipped) + lines.push(`- ${s.source} — reason: ${s.reason}${s.detail ? ` (${s.detail})` : ""}`); + lines.push(""); + } + + lines.push("## Errors"); + if (report.errors.length === 0) { + lines.push("(none)"); + } else { + for (const e of report.errors) lines.push(`- ${e.source}: ${e.message}`); + } + lines.push(""); + + return lines.join("\n"); +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/cli/docs-sync/src/report/orphans.ts b/cli/docs-sync/src/report/orphans.ts new file mode 100644 index 000000000..4b23a9bf9 --- /dev/null +++ b/cli/docs-sync/src/report/orphans.ts @@ -0,0 +1,18 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { globby } from "globby"; +import { hasAutoGeneratedMarker } from "../transformer/header.js"; +import type { OrphanEntry } from "../types.js"; + +export async function findOrphans(targetRoot: string, syncedRelPaths: Set): Promise { + const files = await globby(["**/*.md"], { cwd: targetRoot, absolute: false }); + const out: OrphanEntry[] = []; + for (const rel of files) { + if (syncedRelPaths.has(rel)) continue; + const content = await fs.readFile(path.join(targetRoot, rel), "utf8"); + if (hasAutoGeneratedMarker(content)) { + out.push({ target: rel }); + } + } + return out; +} diff --git a/cli/docs-sync/src/report/tracker.ts b/cli/docs-sync/src/report/tracker.ts new file mode 100644 index 000000000..4f0f20198 --- /dev/null +++ b/cli/docs-sync/src/report/tracker.ts @@ -0,0 +1,30 @@ +import type { SyncReport, SyncEntry, SkippedEntry, OrphanEntry, ErrorEntry } from "../types.js"; + +export class ReportTracker { + private changes: SyncEntry[] = []; + private skipped: SkippedEntry[] = []; + private orphans: OrphanEntry[] = []; + private errors: ErrorEntry[] = []; + + recordChange(entry: SyncEntry): void { + this.changes.push(entry); + } + recordSkip(entry: SkippedEntry): void { + this.skipped.push(entry); + } + recordError(entry: ErrorEntry): void { + this.errors.push(entry); + } + setOrphans(orphans: OrphanEntry[]): void { + this.orphans = orphans; + } + build(meta: { vcShellVersion: string; timestamp: string }): SyncReport { + return { + ...meta, + changes: this.changes, + skipped: this.skipped, + orphans: this.orphans, + errors: this.errors, + }; + } +} diff --git a/cli/docs-sync/src/screenshot/capture.ts b/cli/docs-sync/src/screenshot/capture.ts new file mode 100644 index 000000000..0084da394 --- /dev/null +++ b/cli/docs-sync/src/screenshot/capture.ts @@ -0,0 +1,53 @@ +import { chromium, type Page } from "@playwright/test"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { SCREENSHOT_DEFAULTS } from "./defaults.js"; + +export interface CaptureArgs { + url?: string; // direct URL (e.g. dev server) + story?: string; // Storybook story id + selector?: string; // crop to this selector; defaults to + theme?: "light" | "dark"; + viewportWidth?: number; + viewportHeight?: number; + out: string; // absolute output path +} + +export async function captureScreenshot(args: CaptureArgs): Promise { + if (!args.url && !args.story) { + throw new Error("either --url or --story is required"); + } + const url = args.url + ? args.url + : `${SCREENSHOT_DEFAULTS.storybookUrl}/iframe.html?id=${args.story}&viewMode=story&globals=theme:${args.theme ?? SCREENSHOT_DEFAULTS.theme}`; + + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext({ + viewport: { + width: args.viewportWidth ?? SCREENSHOT_DEFAULTS.viewport.width, + height: args.viewportHeight ?? SCREENSHOT_DEFAULTS.viewport.height, + }, + deviceScaleFactor: SCREENSHOT_DEFAULTS.deviceScaleFactor, + }); + const page: Page = await context.newPage(); + await page.goto(url, { waitUntil: "networkidle" }); + + await fs.mkdir(path.dirname(args.out), { recursive: true }); + + if (args.selector) { + await page.locator(args.selector).screenshot({ + path: args.out, + type: SCREENSHOT_DEFAULTS.format, + }); + } else { + await page.screenshot({ + path: args.out, + fullPage: false, + type: SCREENSHOT_DEFAULTS.format, + }); + } + } finally { + await browser.close(); + } +} diff --git a/cli/docs-sync/src/screenshot/defaults.ts b/cli/docs-sync/src/screenshot/defaults.ts new file mode 100644 index 000000000..bc04c0241 --- /dev/null +++ b/cli/docs-sync/src/screenshot/defaults.ts @@ -0,0 +1,8 @@ +export const SCREENSHOT_DEFAULTS = { + viewport: { width: 1280, height: 800 }, + deviceScaleFactor: 2, + // PNG keeps Playwright happy across versions; webp post-processing via sharp is a follow-up if smaller files become important. + format: "png" as const, + theme: "light" as "light" | "dark", + storybookUrl: "https://vc-shell-storybook.govirto.com", +}; diff --git a/cli/docs-sync/src/storybook/index-fetcher.ts b/cli/docs-sync/src/storybook/index-fetcher.ts new file mode 100644 index 000000000..7e348e2b1 --- /dev/null +++ b/cli/docs-sync/src/storybook/index-fetcher.ts @@ -0,0 +1,23 @@ +interface IndexEntry { + id: string; + type: string; +} + +interface IndexJson { + v?: number; + entries: Record; +} + +export async function fetchStorybookIds(storybookUrl: string): Promise> { + const url = `${storybookUrl}/index.json`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to fetch Storybook index from ${url}: HTTP ${res.status}`); + } + const data = (await res.json()) as IndexJson; + const ids = new Set(); + for (const entry of Object.values(data.entries ?? {})) { + if (entry.type === "story") ids.add(entry.id); + } + return ids; +} diff --git a/cli/docs-sync/src/transformer/header.ts b/cli/docs-sync/src/transformer/header.ts new file mode 100644 index 000000000..af27aa7c9 --- /dev/null +++ b/cli/docs-sync/src/transformer/header.ts @@ -0,0 +1,14 @@ +export const AUTO_GEN_MARKER = "AUTO-GENERATED FROM vc-shell — DO NOT EDIT MANUALLY"; + +export function prependAutoGeneratedHeader(body: string, sourceRelPath: string): string { + const header = [ + ``, + ``, + ``, + ].join("\n"); + return `${header}\n\n${body}`; +} + +export function hasAutoGeneratedMarker(content: string): boolean { + return content.includes(AUTO_GEN_MARKER); +} diff --git a/cli/docs-sync/src/transformer/links.ts b/cli/docs-sync/src/transformer/links.ts new file mode 100644 index 000000000..eb0b8aa0f --- /dev/null +++ b/cli/docs-sync/src/transformer/links.ts @@ -0,0 +1,27 @@ +import path from "node:path"; + +export interface LinkRewriteContext { + sourcePath: string; // relative path of source *.docs.md inside vc-shell + targetPath: string; // relative path of output inside vc-docs + resolveTarget(sourcePath: string, href: string): string | null; +} + +// Match [text](href) but NOT image refs (preceded by `!`) and NOT external URLs. +// Group 1: link text. Group 2: href. +const LINK_RE = /(? { + if (/^[a-z]+:/i.test(href) || href.startsWith("//")) return full; // external + if (href.startsWith("#")) return full; // anchor + + const resolved = ctx.resolveTarget(ctx.sourcePath, href); + if (!resolved) return full; + + // Compute relative path from current target to the resolved target. + const fromDir = path.posix.dirname(ctx.targetPath); + const rel = path.posix.relative(fromDir, resolved); + const normalized = rel.startsWith(".") ? rel : `./${rel}`; + return `[${text}](${normalized})`; + }); +} diff --git a/cli/docs-sync/src/transformer/pipeline.ts b/cli/docs-sync/src/transformer/pipeline.ts new file mode 100644 index 000000000..7c69b4004 --- /dev/null +++ b/cli/docs-sync/src/transformer/pipeline.ts @@ -0,0 +1,30 @@ +import { stripInternalBlocks } from "./strip-internal.js"; +import { expandStorybookDirectives } from "./storybook-directive.js"; +import { rewriteCrossDocLinks } from "./links.js"; +import { prependAutoGeneratedHeader } from "./header.js"; + +export interface TransformContext { + sourceRelPath: string; // path shown in AUTO-GENERATED header + sourcePath: string; // for resolveTarget lookups + targetPath: string; // relative inside vc-docs + storybookUrl: string; + knownStoryIds: Set; + resolveTarget(sourcePath: string, href: string): string | null; +} + +export function runTransformPipeline(body: string, ctx: TransformContext): string { + let out = body; + out = stripInternalBlocks(out); + out = expandStorybookDirectives(out, { + storybookUrl: ctx.storybookUrl, + knownIds: ctx.knownStoryIds, + sourcePath: ctx.sourcePath, + }); + out = rewriteCrossDocLinks(out, { + sourcePath: ctx.sourcePath, + targetPath: ctx.targetPath, + resolveTarget: ctx.resolveTarget, + }); + out = prependAutoGeneratedHeader(out, ctx.sourceRelPath); + return out; +} diff --git a/cli/docs-sync/src/transformer/storybook-directive.ts b/cli/docs-sync/src/transformer/storybook-directive.ts new file mode 100644 index 000000000..8f075f416 --- /dev/null +++ b/cli/docs-sync/src/transformer/storybook-directive.ts @@ -0,0 +1,54 @@ +const DIRECTIVE_RE = /^::storybook\s+([^\n]+)$/gm; +const ATTR_RE = /(\w+)="([^"]*)"/g; + +export interface DirectiveContext { + storybookUrl: string; + knownIds: Set; + sourcePath: string; +} + +export class UnknownStoryError extends Error { + constructor( + public readonly id: string, + public readonly source: string, + ) { + super(`[${source}] unknown storybook id: "${id}"`); + this.name = "UnknownStoryError"; + } +} + +export function expandStorybookDirectives(body: string, ctx: DirectiveContext): string { + return body.replace(DIRECTIVE_RE, (_match, attrsStr: string) => { + const attrs = parseAttrs(attrsStr); + const id = attrs.id; + if (!id) throw new Error(`[${ctx.sourcePath}] ::storybook missing id attribute`); + if (!ctx.knownIds.has(id)) throw new UnknownStoryError(id, ctx.sourcePath); + + const height = parseInt(attrs.height ?? "400", 10); + const theme = attrs.theme; + const globals = theme && theme !== "auto" ? `&globals=theme:${theme}` : ""; + + const iframeSrc = `${ctx.storybookUrl}/iframe.html?id=${id}&viewMode=story${globals}`; + const linkHref = `${ctx.storybookUrl}/?path=/story/${id}`; + const titleText = attrs.title ?? id; + + return [ + `
`, + ` `, + ` Open in Storybook ↗`, + `
`, + ].join("\n"); + }); +} + +function parseAttrs(s: string): Record { + const out: Record = {}; + for (const match of s.matchAll(ATTR_RE)) { + out[match[1]] = match[2]; + } + return out; +} diff --git a/cli/docs-sync/src/transformer/strip-internal.ts b/cli/docs-sync/src/transformer/strip-internal.ts new file mode 100644 index 000000000..f1088f697 --- /dev/null +++ b/cli/docs-sync/src/transformer/strip-internal.ts @@ -0,0 +1,5 @@ +const RE = /([\s\S]*?)/g; + +export function stripInternalBlocks(body: string): string { + return body.replace(RE, ""); +} diff --git a/cli/docs-sync/src/types.ts b/cli/docs-sync/src/types.ts new file mode 100644 index 000000000..b079c7ca6 --- /dev/null +++ b/cli/docs-sync/src/types.ts @@ -0,0 +1,58 @@ +export type { Category } from "./config.js"; +import type { Category } from "./config.js"; + +export interface Frontmatter { + title: string; + category: Category; + group: string; + slug?: string; + placement?: "index"; + internal?: boolean; +} + +export interface ParsedDoc { + sourcePath: string; // absolute path to source *.docs.md + frontmatter: Frontmatter; + body: string; // body without frontmatter + imageRefs: string[]; // relative image paths found in body +} + +export interface SyncContext { + frameworkRoot: string; // absolute path to vc-shell + targetRoot: string; // absolute path to vc-docs + storybookUrl: string; // e.g. https://vc-shell-storybook.govirto.com + storybookIds: Set; // populated from index.json + vcShellVersion: string; // read from framework/package.json +} + +export type ChangeKind = "created" | "updated" | "unchanged"; + +export interface SyncEntry { + source: string; // relative path inside vc-shell + target: string; // relative path inside vc-docs + kind: ChangeKind; +} + +export interface SkippedEntry { + source: string; + reason: "no-frontmatter" | "internal" | "invalid-frontmatter"; + detail?: string; +} + +export interface OrphanEntry { + target: string; // path in vc-docs that has no source +} + +export interface ErrorEntry { + source: string; + message: string; +} + +export interface SyncReport { + vcShellVersion: string; + timestamp: string; + changes: SyncEntry[]; + skipped: SkippedEntry[]; + orphans: OrphanEntry[]; + errors: ErrorEntry[]; +} diff --git a/cli/docs-sync/src/writer/assets.ts b/cli/docs-sync/src/writer/assets.ts new file mode 100644 index 000000000..6d667306c --- /dev/null +++ b/cli/docs-sync/src/writer/assets.ts @@ -0,0 +1,34 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]); + +export interface CopyAssetsArgs { + sourceFile: string; // absolute path to the source *.docs.md + targetFile: string; // absolute path to the target *.md (already resolved) + imageRefs: string[]; // relative paths from the source body +} + +export interface CopyAssetsResult { + copied: string[]; +} + +export async function copyImageAssets(args: CopyAssetsArgs): Promise { + const sourceDir = path.dirname(args.sourceFile); + const targetDir = path.dirname(args.targetFile); + const copied: string[] = []; + + for (const ref of args.imageRefs) { + const ext = path.extname(ref).toLowerCase(); + if (!ALLOWED_EXT.has(ext)) { + throw new Error(`disallowed file type for ${ref}: only png/jpg/jpeg/gif/webp/svg are allowed`); + } + const srcAbs = path.resolve(sourceDir, ref); + const dstAbs = path.resolve(targetDir, ref); + await fs.mkdir(path.dirname(dstAbs), { recursive: true }); + await fs.copyFile(srcAbs, dstAbs); + copied.push(ref); + } + + return { copied }; +} diff --git a/cli/docs-sync/src/writer/atomic.ts b/cli/docs-sync/src/writer/atomic.ts new file mode 100644 index 000000000..cf17dfbac --- /dev/null +++ b/cli/docs-sync/src/writer/atomic.ts @@ -0,0 +1,7 @@ +import writeFileAtomic from "write-file-atomic"; + +export function writeAtomic(filePath: string, content: string): Promise { + return new Promise((resolve, reject) => { + writeFileAtomic(filePath, content, "utf8", (err) => (err ? reject(err) : resolve())); + }); +} diff --git a/cli/docs-sync/src/writer/pages.ts b/cli/docs-sync/src/writer/pages.ts new file mode 100644 index 000000000..7aa9c32ce --- /dev/null +++ b/cli/docs-sync/src/writer/pages.ts @@ -0,0 +1,116 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { writeAtomic } from "./atomic.js"; +import { AUTO_GEN_MARKER } from "../transformer/header.js"; + +const PAGES_MARKER = `# ${AUTO_GEN_MARKER}\n# To update: edit a *.docs.md in vc-shell, then run yarn docs:sync\n`; + +// Top-level group order per category (matches spec). +const GROUP_ORDER: Record = { + components: ["layout", "form", "data-display", "feedback", "navigation", "media", "misc"], + composables: ["blade-navigation", "data", "ui-state", "services", "user", "notifications", "forms", "utilities"], + reference: ["cli", "api", "modules", "migration"], +}; + +const TOP_LEVEL_TITLES: Record = { + components: "Components", + composables: "Composables", + plugins: "Plugins", + reference: "Reference", +}; + +const NESTED_TITLES: Record = { + layout: "Layout", + form: "Form", + "data-display": "Data Display", + feedback: "Feedback", + navigation: "Navigation", + media: "Media", + misc: "Misc", + "blade-navigation": "Blade Navigation", + data: "Data", + "ui-state": "UI State", + services: "Services", + user: "User", + notifications: "Notifications", + forms: "Forms", + utilities: "Utilities", + api: "API", + directives: "Directives", + modules: "Modules", + cli: "CLI", +}; + +// Folders for which the generator emits .pages. +const AUTO_PAGES_TOP = new Set(["components", "composables", "plugins"]); +const AUTO_PAGES_REFERENCE_SUB = new Set(["api", "api/directives", "modules"]); +// concepts/ and reference root NOT in auto set — manual .pages. + +export interface SyncedPage { + target: string; // e.g. components/data-display/vc-data-table.md + title: string; +} + +export interface WritePagesArgs { + synced: SyncedPage[]; +} + +export async function writePagesFiles(targetRoot: string, args: WritePagesArgs): Promise { + // Bucket by top folder and group. + const byTopFolder = new Map>(); + for (const p of args.synced) { + const parts = p.target.split("/"); + const top = parts[0]; + const group = parts.length > 2 ? parts.slice(1, -1).join("/") : ""; + if (!byTopFolder.has(top)) byTopFolder.set(top, new Map()); + const groups = byTopFolder.get(top)!; + if (!groups.has(group)) groups.set(group, []); + groups.get(group)!.push(p); + } + + for (const [top, groups] of byTopFolder) { + const isAutoTop = AUTO_PAGES_TOP.has(top); + + // Emit top-level .pages for components / composables / plugins. + if (isAutoTop) { + const hasOnlyRoot = groups.size === 1 && groups.has(""); + let navContent: string; + if (hasOnlyRoot) { + // Flat top folder (e.g., plugins/) — emit explicit titles, alphabetical + const allPages = groups.get("")!; + const sorted = [...allPages].sort((a, b) => a.title.localeCompare(b.title)); + navContent = sorted.map((p) => ` - ${p.title}: ${path.basename(p.target)}`).join("\n"); + } else { + // Existing logic: list sub-groups in declared order + const order = (GROUP_ORDER[top] ?? []).filter((g) => groups.has(g)); + navContent = order.map((g) => ` - ${g}`).join("\n"); + } + const yaml = PAGES_MARKER + `title: ${TOP_LEVEL_TITLES[top]}\n` + `nav:\n` + navContent + "\n"; + const filePath = path.join(targetRoot, top, ".pages"); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await writeAtomic(filePath, yaml); + } + + // Emit per-group .pages. + for (const [group, pages] of groups) { + if (!group) continue; // single-file in top folder (e.g. concepts/services.md) — no group .pages + const isReferenceSub = top === "reference" && AUTO_PAGES_REFERENCE_SUB.has(group); + if (!isAutoTop && !isReferenceSub) continue; // skip mixed/manual folders + + const indexPage = pages.find((p) => path.basename(p.target) === "index.md"); + const otherPages = pages.filter((p) => path.basename(p.target) !== "index.md"); + const sorted = [...otherPages].sort((a, b) => a.title.localeCompare(b.title)); + const groupTitle = NESTED_TITLES[group.split("/").at(-1)!] ?? group; + + const navLines: string[] = []; + if (indexPage) navLines.push(" - Overview: index.md"); + navLines.push(...sorted.map((p) => ` - ${p.title}: ${path.basename(p.target)}`)); + + const yaml = PAGES_MARKER + `title: ${groupTitle}\n` + `nav:\n` + navLines.join("\n") + "\n"; + + const filePath = path.join(targetRoot, top, group, ".pages"); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await writeAtomic(filePath, yaml); + } + } +} diff --git a/cli/docs-sync/src/writer/writer.ts b/cli/docs-sync/src/writer/writer.ts new file mode 100644 index 000000000..16895e7c7 --- /dev/null +++ b/cli/docs-sync/src/writer/writer.ts @@ -0,0 +1,33 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { writeAtomic } from "./atomic.js"; +import type { Frontmatter, ChangeKind } from "../types.js"; + +export function computeTargetPath(fm: Frontmatter, sourceFilename: string): string { + const slug = fm.slug ?? sourceFilename.replace(/\.docs\.md$/, ""); + const groupPart = fm.group === "root" ? "" : fm.group; + const filename = fm.placement === "index" ? "index.md" : `${slug}.md`; + return [fm.category, groupPart, filename].filter(Boolean).join("/"); +} + +export interface WriteResult { + kind: ChangeKind; +} + +export async function writeMarkdown(targetRoot: string, relPath: string, content: string): Promise { + const abs = path.join(targetRoot, relPath); + await fs.mkdir(path.dirname(abs), { recursive: true }); + + let existing: string | null = null; + try { + existing = await fs.readFile(abs, "utf8"); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } + + if (existing === content) { + return { kind: "unchanged" }; + } + await writeAtomic(abs, content); + return { kind: existing == null ? "created" : "updated" }; +} diff --git a/cli/docs-sync/sync-report.md b/cli/docs-sync/sync-report.md new file mode 100644 index 000000000..7fecf8f6c --- /dev/null +++ b/cli/docs-sync/sync-report.md @@ -0,0 +1,176 @@ +# vc-docs sync report — vc-shell@v2.0.3 — 2026-05-08T12:56:54.763Z + +## Summary + +- Created: 0 +- Updated: 1 +- Unchanged: 126 +- Skipped (no frontmatter): 0 +- Skipped (internal): 24 +- Skipped (invalid frontmatter): 0 +- Orphaned: 0 +- Errors: 0 + +## Updated + +- plugins/permissions.md (← core/plugins/permissions/permissions.docs.md) + +## Unchanged + +- reference/api/injection-keys.md (← injection-keys.docs.md) +- reference/api/platform-client.md (← core/api/platform.docs.md) +- composables/blade-navigation/blade-nav-composables.md (← core/blade-navigation/blade-nav-composables.docs.md) +- plugins/notifications.md (← core/notifications/notifications.docs.md) +- composables/services/index.md (← core/services/services.docs.md) +- reference/api/types-core.md (← core/types/types.docs.md) +- reference/api/shared-utilities.md (← core/utilities/shared-utilities.docs.md) +- reference/api/utilities-overview.md (← core/utilities/utilities.docs.md) +- reference/modules/assets.md (← modules/assets/assets-details.docs.md) +- reference/modules/assets-manager.md (← modules/assets-manager/assets-manager.docs.md) +- reference/api/types-ui.md (← ui/types/ui-types.docs.md) +- composables/ui-state/ui-composables-overview.md (← ui/composables/ui-composables.docs.md) +- composables/data/useDataTablePagination.md (← ui/composables/useDataTablePagination.docs.md) +- composables/data/useDataTableSort.md (← ui/composables/useDataTableSort.docs.md) +- composables/data/useTableSelection.md (← ui/composables/useTableSelection.docs.md) +- composables/data/useTableSort.md (← ui/composables/useTableSort.docs.md) +- composables/blade-navigation/useBladeContext.md (← core/composables/bladeContext/index.docs.md) +- composables/data/useApiClient.md (← core/composables/useApiClient/useApiClient.docs.md) +- composables/services/useAppBarMobileButtons.md (← core/composables/useAppBarMobileButtons/useAppBarMobileButtons.docs.md) +- composables/services/useAppBarWidget.md (← core/composables/useAppBarWidget/useAppBarWidget.docs.md) +- composables/utilities/useAppInsights.md (← core/composables/useAppInsights/useAppInsights.docs.md) +- composables/data/useAssets.md (← core/composables/useAssets/useAssets.docs.md) +- composables/utilities/useAsync.md (← core/composables/useAsync/useAsync.docs.md) +- composables/data/useAssetsManager.md (← core/composables/useAssetsManager/useAssetsManager.docs.md) +- composables/utilities/useBeforeUnload.md (← core/composables/useBeforeUnload/useBeforeUnload.docs.md) +- composables/blade-navigation/useBlade.md (← core/composables/useBlade/useBlade.docs.md) +- composables/forms/useBladeForm.md (← core/composables/useBladeForm/useBladeForm.docs.md) +- composables/blade-navigation/useBladeRegistry.md (← core/composables/useBladeRegistry/useBladeRegistry.docs.md) +- composables/blade-navigation/useBladeWidgets.md (← core/composables/useBladeWidgets/index.docs.md) +- composables/ui-state/useBreadcrumbs.md (← core/composables/useBreadcrumbs/useBreadcrumbs.docs.md) +- composables/ui-state/useConnectionStatus.md (← core/composables/useConnectionStatus/useConnectionStatus.docs.md) +- composables/services/useDashboard.md (← core/composables/useDashboard/useDashboard.docs.md) +- composables/forms/useDynamicProperties.md (← core/composables/useDynamicProperties/useDynamicProperties.docs.md) +- composables/utilities/useErrorHandler.md (← core/composables/useErrorHandler/useErrorHandler.docs.md) +- composables/utilities/useFunctions.md (← core/composables/useFunctions/useFunctions.docs.md) +- composables/ui-state/useKeyboardNavigation.md (← core/composables/useKeyboardNavigation/useKeyboardNavigation.docs.md) +- composables/user/useLanguages.md (← core/composables/useLanguages/useLanguages.docs.md) +- composables/services/useMenuService.md (← core/composables/useMenuService/useMenuService.docs.md) +- composables/ui-state/useLoading.md (← core/composables/useLoading/useLoading.docs.md) +- composables/ui-state/useMenuExpanded.md (← core/composables/useMenuExpanded/index.docs.md) +- composables/forms/useModificationTracker.md (← core/composables/useModificationTracker/useModificationTracker.docs.md) +- composables/notifications/useNotifications.md (← core/composables/useNotifications/useNotifications.docs.md) +- composables/user/usePlatformLocaleSync.md (← core/composables/usePlatformLocaleSync/usePlatformLocaleSync.docs.md) +- composables/user/usePermissions.md (← core/composables/usePermissions/usePermissions.docs.md) +- composables/notifications/usePopup.md (← core/composables/usePopup/usePopup.docs.md) +- composables/services/useSettings.md (← core/composables/useSettings/useSettings.docs.md) +- composables/services/useSettingsMenu.md (← core/composables/useSettingsMenu/useSettingsMenu.docs.md) +- composables/ui-state/useSidebarState.md (← core/composables/useSidebarState/useSidebarState.docs.md) +- composables/ui-state/useResponsive.md (← core/composables/useResponsive/useResponsive.docs.md) +- composables/ui-state/useSlowNetworkDetection.md (← core/composables/useSlowNetworkDetection/useSlowNetworkDetection.docs.md) +- composables/services/useToolbar.md (← core/composables/useToolbar/useToolbar.docs.md) +- composables/ui-state/useTheme.md (← core/composables/useTheme/useTheme.docs.md) +- composables/user/useUser.md (← core/composables/useUser/useUser.docs.md) +- composables/utilities/useWebVitals.md (← core/composables/useWebVitals/useWebVitals.docs.md) +- composables/services/useWidgets.md (← core/composables/useWidgets/useWidgets.docs.md) +- reference/api/directives/v-autofocus.md (← core/directives/autofocus/autofocus.docs.md) +- reference/api/directives/v-loading.md (← core/directives/loading/loading.docs.md) +- plugins/ai-agent.md (← core/plugins/ai-agent/ai-agent.docs.md) +- plugins/extension-points.md (← core/plugins/extension-points/extension-points.docs.md) +- plugins/global-error-handler.md (← core/plugins/global-error-handler/global-error-handler.docs.md) +- plugins/i18n.md (← core/plugins/i18n/i18n.docs.md) +- plugins/modularity.md (← core/plugins/modularity/modularity.docs.md) +- plugins/signalr.md (← core/plugins/signalR/signalR.docs.md) +- plugins/validation.md (← core/plugins/validation/validation.docs.md) +- reference/api/date-utilities.md (← core/utilities/date/date-utilities.docs.md) +- reference/api/thumbnail.md (← core/utilities/thumbnail/thumbnail.docs.md) +- components/form/multilanguage-selector.md (← ui/components/molecules/multilanguage-selector/multilanguage-selector.docs.md) +- components/navigation/vc-breadcrumbs.md (← ui/components/molecules/vc-breadcrumbs/vc-breadcrumbs.docs.md) +- components/form/vc-checkbox-group.md (← ui/components/molecules/vc-checkbox-group/vc-checkbox-group.docs.md) +- components/form/vc-checkbox.md (← ui/components/molecules/vc-checkbox/vc-checkbox.docs.md) +- components/data-display/vc-accordion.md (← ui/components/molecules/vc-accordion/vc-accordion.docs.md) +- components/form/vc-date-picker.md (← ui/components/molecules/vc-date-picker/vc-date-picker.docs.md) +- components/navigation/vc-dropdown.md (← ui/components/molecules/vc-dropdown/vc-dropdown.docs.md) +- components/form/vc-color-input.md (← ui/components/molecules/vc-color-input/vc-color-input.docs.md) +- components/navigation/vc-dropdown-panel.md (← ui/components/molecules/vc-dropdown-panel/vc-dropdown-panel.docs.md) +- components/form/vc-editor.md (← ui/components/molecules/vc-editor/vc-editor.docs.md) +- components/form/vc-field.md (← ui/components/molecules/vc-field/vc-field.docs.md) +- components/form/vc-form.md (← ui/components/molecules/vc-form/vc-form.docs.md) +- components/data-display/vc-image-tile.md (← ui/components/molecules/vc-image-tile/vc-image-tile.docs.md) +- components/form/vc-file-upload.md (← ui/components/molecules/vc-file-upload/vc-file-upload.docs.md) +- components/form/vc-input.md (← ui/components/molecules/vc-input/vc-input.docs.md) +- components/form/vc-input-currency.md (← ui/components/molecules/vc-input-currency/vc-input-currency.docs.md) +- components/form/vc-input-dropdown.md (← ui/components/molecules/vc-input-dropdown/vc-input-dropdown.docs.md) +- components/form/vc-multivalue.md (← ui/components/molecules/vc-multivalue/vc-multivalue.docs.md) +- components/form/vc-input-group.md (← ui/components/molecules/vc-input-group/vc-input-group.docs.md) +- components/navigation/vc-menu.md (← ui/components/molecules/vc-menu/vc-menu.docs.md) +- components/navigation/vc-pagination.md (← ui/components/molecules/vc-pagination/vc-pagination.docs.md) +- components/form/vc-rating.md (← ui/components/molecules/vc-rating/vc-rating.docs.md) +- components/form/vc-radio-group.md (← ui/components/molecules/vc-radio-group/vc-radio-group.docs.md) +- components/form/vc-radio-button.md (← ui/components/molecules/vc-radio-button/vc-radio-button.docs.md) +- components/form/vc-select.md (← ui/components/molecules/vc-select/vc-select.docs.md) +- components/form/vc-switch.md (← ui/components/molecules/vc-switch/vc-switch.docs.md) +- components/form/vc-textarea.md (← ui/components/molecules/vc-textarea/vc-textarea.docs.md) +- components/form/vc-slider.md (← ui/components/molecules/vc-slider/vc-slider.docs.md) +- components/feedback/vc-toast.md (← ui/components/molecules/vc-toast/vc-toast.docs.md) +- components/layout/vc-auth-layout.md (← ui/components/organisms/vc-auth-layout/vc-auth-layout.docs.md) +- components/layout/vc-app.md (← ui/components/organisms/vc-app/vc-app.docs.md) +- components/layout/vc-blade.md (← ui/components/organisms/vc-blade/vc-blade.docs.md) +- components/data-display/vc-data-table.md (← ui/components/organisms/vc-data-table/vc-data-table.docs.md) +- components/form/vc-dynamic-property.md (← ui/components/organisms/vc-dynamic-property/vc-dynamic-property.docs.md) +- components/media/vc-image-upload.md (← ui/components/organisms/vc-image-upload/vc-image-upload.docs.md) +- components/data-display/vc-gallery.md (← ui/components/organisms/vc-gallery/vc-gallery.docs.md) +- components/feedback/vc-popup.md (← ui/components/organisms/vc-popup/vc-popup.docs.md) +- components/layout/vc-sidebar.md (← ui/components/organisms/vc-sidebar/vc-sidebar.docs.md) +- components/misc/vc-badge.md (← ui/components/atoms/vc-badge/vc-badge.docs.md) +- components/layout/vc-card.md (← ui/components/atoms/vc-card/vc-card.docs.md) +- components/misc/vc-button.md (← ui/components/atoms/vc-button/vc-button.docs.md) +- components/feedback/vc-banner.md (← ui/components/atoms/vc-banner/vc-banner.docs.md) +- components/layout/vc-col.md (← ui/components/atoms/vc-col/vc-col.docs.md) +- components/layout/vc-container.md (← ui/components/atoms/vc-container/vc-container.docs.md) +- components/feedback/vc-hint.md (← ui/components/atoms/vc-hint/vc-hint.docs.md) +- components/media/vc-image.md (← ui/components/atoms/vc-image/vc-image.docs.md) +- components/misc/vc-icon.md (← ui/components/atoms/vc-icon/vc-icon.docs.md) +- components/navigation/vc-link.md (← ui/components/atoms/vc-link/vc-link.docs.md) +- components/feedback/vc-loading.md (← ui/components/atoms/vc-loading/vc-loading.docs.md) +- components/misc/vc-label.md (← ui/components/atoms/vc-label/vc-label.docs.md) +- components/feedback/vc-progress.md (← ui/components/atoms/vc-progress/vc-progress.docs.md) +- components/layout/vc-row.md (← ui/components/atoms/vc-row/vc-row.docs.md) +- components/layout/vc-scrollable-container.md (← ui/components/atoms/vc-scrollable-container/vc-scrollable-container.docs.md) +- components/feedback/vc-skeleton.md (← ui/components/atoms/vc-skeleton/vc-skeleton.docs.md) +- components/feedback/vc-status-icon.md (← ui/components/atoms/vc-status-icon/vc-status-icon.docs.md) +- components/feedback/vc-tooltip.md (← ui/components/atoms/vc-tooltip/vc-tooltip.docs.md) +- components/feedback/vc-status.md (← ui/components/atoms/vc-status/vc-status.docs.md) +- components/media/vc-video.md (← ui/components/atoms/vc-video/vc-video.docs.md) +- components/misc/vc-widget.md (← ui/components/atoms/vc-widget/vc-widget.docs.md) +- composables/data/table-composables.md (← ui/components/organisms/vc-data-table/composables/table-composables.docs.md) + +## Skipped + +- core/composables/useUserManagement/useUserManagement.docs.md — reason: internal +- shell/auth/ChangePasswordPage/change-password-page.docs.md — reason: internal +- shell/auth/ForgotPasswordPage/forgot-password-page.docs.md — reason: internal +- shell/auth/InvitePage/invite-page.docs.md — reason: internal +- shell/auth/LoginPage/login-page.docs.md — reason: internal +- shell/auth/ResetPasswordPage/reset-password-page.docs.md — reason: internal +- shell/auth/sign-in/sign-in.docs.md — reason: internal +- shell/dashboard/dashboard-charts/dashboard-charts.docs.md — reason: internal +- shell/dashboard/dashboard-widget-card/dashboard-widget-card.docs.md — reason: internal +- shell/dashboard/draggable-dashboard/dashboard-widget-skeleton.docs.md — reason: internal +- shell/dashboard/draggable-dashboard/draggable-dashboard.docs.md — reason: internal +- shell/components/error-interceptor/error-interceptor.docs.md — reason: internal +- shell/components/language-selector/language-selector.docs.md — reason: internal +- shell/components/change-password-button/change-password-button.docs.md — reason: internal +- shell/components/change-password/change-password.docs.md — reason: internal +- shell/components/logout-button/logout-button.docs.md — reason: internal +- shell/components/notification-template/notification-template.docs.md — reason: internal +- shell/components/notification-dropdown/notification-dropdown.docs.md — reason: internal +- shell/components/settings-menu-item/settings-menu-item.docs.md — reason: internal +- shell/components/sidebar/sidebar.docs.md — reason: internal +- shell/components/settings-menu/settings-menu.docs.md — reason: internal +- shell/components/theme-selector/theme-selector.docs.md — reason: internal +- shell/components/user-dropdown-button/user-dropdown-button.docs.md — reason: internal +- shell/\_internal/popup/common/popup-common.docs.md — reason: internal + +## Errors + +(none) diff --git a/cli/docs-sync/tests/assets.test.ts b/cli/docs-sync/tests/assets.test.ts new file mode 100644 index 000000000..80c355404 --- /dev/null +++ b/cli/docs-sync/tests/assets.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { copyImageAssets } from "../src/writer/assets.js"; + +describe("copyImageAssets", () => { + let tmpSrc: string; + let tmpDst: string; + beforeEach(async () => { + tmpSrc = await fs.mkdtemp(path.join(os.tmpdir(), "docs-sync-src-")); + tmpDst = await fs.mkdtemp(path.join(os.tmpdir(), "docs-sync-dst-")); + await fs.mkdir(path.join(tmpSrc, "images"), { recursive: true }); + await fs.writeFile(path.join(tmpSrc, "images/a.png"), "PNGDATA"); + await fs.writeFile(path.join(tmpSrc, "images/b.png"), "PNGDATA-B"); + }); + afterEach(async () => { + await fs.rm(tmpSrc, { recursive: true, force: true }); + await fs.rm(tmpDst, { recursive: true, force: true }); + }); + + it("copies referenced images to parallel target path", async () => { + const result = await copyImageAssets({ + sourceFile: path.join(tmpSrc, "doc.docs.md"), + targetFile: path.join(tmpDst, "components/misc/doc.md"), + imageRefs: ["./images/a.png", "./images/b.png"], + }); + expect(result.copied).toEqual(["./images/a.png", "./images/b.png"]); + const a = await fs.readFile(path.join(tmpDst, "components/misc/images/a.png"), "utf8"); + expect(a).toBe("PNGDATA"); + }); + + it("rejects forbidden file types", async () => { + await fs.writeFile(path.join(tmpSrc, "images/c.mp4"), "MP4"); + await expect( + copyImageAssets({ + sourceFile: path.join(tmpSrc, "doc.docs.md"), + targetFile: path.join(tmpDst, "components/misc/doc.md"), + imageRefs: ["./images/c.mp4"], + }), + ).rejects.toThrow(/disallowed file type/); + }); +}); diff --git a/cli/docs-sync/tests/fixtures/composable-with-internal.docs.md b/cli/docs-sync/tests/fixtures/composable-with-internal.docs.md new file mode 100644 index 000000000..358cb66cb --- /dev/null +++ b/cli/docs-sync/tests/fixtures/composable-with-internal.docs.md @@ -0,0 +1,21 @@ +--- +title: useBlade +category: composables +group: blade-navigation +--- + +# useBlade + +Public description here. + + + +## Implementation notes + +This part is for contributors only. + + + +## See Also + +Public again. diff --git a/cli/docs-sync/tests/fixtures/integration/source/_internal/secret.docs.md b/cli/docs-sync/tests/fixtures/integration/source/_internal/secret.docs.md new file mode 100644 index 000000000..1bd05f206 --- /dev/null +++ b/cli/docs-sync/tests/fixtures/integration/source/_internal/secret.docs.md @@ -0,0 +1,8 @@ +--- +title: Internal +category: components +group: misc +internal: true +--- + +# Internal stuff diff --git a/cli/docs-sync/tests/fixtures/integration/source/components/atoms/vc-foo/images/screen.png b/cli/docs-sync/tests/fixtures/integration/source/components/atoms/vc-foo/images/screen.png new file mode 100644 index 000000000..0cadfa2b3 --- /dev/null +++ b/cli/docs-sync/tests/fixtures/integration/source/components/atoms/vc-foo/images/screen.png @@ -0,0 +1 @@ +PNGDATA \ No newline at end of file diff --git a/cli/docs-sync/tests/fixtures/integration/source/components/atoms/vc-foo/vc-foo.docs.md b/cli/docs-sync/tests/fixtures/integration/source/components/atoms/vc-foo/vc-foo.docs.md new file mode 100644 index 000000000..257ba5008 --- /dev/null +++ b/cli/docs-sync/tests/fixtures/integration/source/components/atoms/vc-foo/vc-foo.docs.md @@ -0,0 +1,11 @@ +--- +title: VcFoo +category: components +group: misc +--- + +# VcFoo + +A foo component. + +![Screen](./images/screen.png) diff --git a/cli/docs-sync/tests/fixtures/integration/source/package.json b/cli/docs-sync/tests/fixtures/integration/source/package.json new file mode 100644 index 000000000..7688db3b2 --- /dev/null +++ b/cli/docs-sync/tests/fixtures/integration/source/package.json @@ -0,0 +1,3 @@ +{ + "version": "0.0.0-test" +} diff --git a/cli/docs-sync/tests/fixtures/simple-component.docs.md b/cli/docs-sync/tests/fixtures/simple-component.docs.md new file mode 100644 index 000000000..6cc221839 --- /dev/null +++ b/cli/docs-sync/tests/fixtures/simple-component.docs.md @@ -0,0 +1,14 @@ +--- +title: VcButton +category: components +group: misc +--- + +# VcButton + +A simple button. + +![Default state](./images/default.png) +![Hover state](./images/hover.png) + +::storybook id="atoms-vc-button--default" diff --git a/cli/docs-sync/tests/frontmatter.test.ts b/cli/docs-sync/tests/frontmatter.test.ts new file mode 100644 index 000000000..99db84229 --- /dev/null +++ b/cli/docs-sync/tests/frontmatter.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { validateFrontmatter } from "../src/parser/frontmatter.js"; + +describe("validateFrontmatter", () => { + it("accepts a valid component frontmatter", () => { + const result = validateFrontmatter({ + title: "VcDataTable", + category: "components", + group: "data-display", + }); + expect(result.success).toBe(true); + }); + + it("accepts optional slug and internal", () => { + const result = validateFrontmatter({ + title: "Foo", + category: "components", + group: "misc", + slug: "vc-foo", + internal: false, + }); + expect(result.success).toBe(true); + }); + + it("rejects missing title", () => { + const result = validateFrontmatter({ + category: "components", + group: "misc", + }); + expect(result.success).toBe(false); + }); + + it("rejects unknown category", () => { + const result = validateFrontmatter({ + title: "X", + category: "frobnitz", + group: "misc", + }); + expect(result.success).toBe(false); + }); + + it("rejects group not allowed for the category", () => { + const result = validateFrontmatter({ + title: "X", + category: "components", + group: "blade-navigation", // valid for composables, not components + }); + expect(result.success).toBe(false); + }); + + it("accepts category: plugins with group: root", () => { + const result = validateFrontmatter({ + title: "AI Agent", + category: "plugins", + group: "root", + slug: "ai-agent", + }); + expect(result.success).toBe(true); + }); + + it("accepts placement: index when group is named", () => { + const result = validateFrontmatter({ + title: "Services", + category: "composables", + group: "services", + placement: "index", + }); + expect(result.success).toBe(true); + }); + + it("rejects placement: index when group is root", () => { + const result = validateFrontmatter({ + title: "Foo", + category: "concepts", + group: "root", + placement: "index", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain("placement"); + } + }); +}); diff --git a/cli/docs-sync/tests/integration-sync.test.ts b/cli/docs-sync/tests/integration-sync.test.ts new file mode 100644 index 000000000..9cf74136e --- /dev/null +++ b/cli/docs-sync/tests/integration-sync.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_SRC = path.join(__dirname, "fixtures/integration/source"); + +describe("end-to-end sync (integration)", () => { + let target: string; + + beforeEach(async () => { + target = await fs.mkdtemp(path.join(os.tmpdir(), "docs-sync-int-")); + // Mock fetch for Storybook index.json. + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ entries: {} }), + } as Response); + }); + + afterEach(async () => { + await fs.rm(target, { recursive: true, force: true }); + vi.restoreAllMocks(); + // Restore the fixture file to its committed (Prettier-formatted) state so + // the working tree stays clean after each test run. + await fs.writeFile( + path.join(FIXTURE_SRC, "package.json"), + JSON.stringify({ version: "0.0.0-test" }, null, 2) + "\n", + "utf8", + ); + }); + + it("syncs a fixture component, copies images, skips internal files", async () => { + // Stub framework/package.json under fixture (runSync reads version from it). + await fs.writeFile(path.join(FIXTURE_SRC, "package.json"), JSON.stringify({ version: "0.0.0-test" }), "utf8"); + const { runSync } = await import("../src/commands/sync.js"); + const res = await runSync({ + target, + frameworkDir: FIXTURE_SRC, + reportPath: path.join(target, "report.md"), + }); + expect(res.exitCode).toBe(0); + + const targetSection = path.join(target, "platform/developer-guide/docs/custom-apps-development/vc-shell"); + const out = await fs.readFile(path.join(targetSection, "components/misc/vc-foo.md"), "utf8"); + expect(out).toContain("AUTO-GENERATED FROM vc-shell"); + expect(out).toContain("# VcFoo"); + + // Image was copied. + await fs.access(path.join(targetSection, "components/misc/images/screen.png")); + + // Internal file skipped. + let exists = true; + try { + await fs.access(path.join(targetSection, "components/misc/secret.md")); + } catch { + exists = false; + } + expect(exists).toBe(false); + + // Report exists and mentions the synced file. + const report = await fs.readFile(path.join(target, "report.md"), "utf8"); + expect(report).toContain("components/misc/vc-foo.md"); + }); +}); diff --git a/cli/docs-sync/tests/lint.test.ts b/cli/docs-sync/tests/lint.test.ts new file mode 100644 index 000000000..719211b55 --- /dev/null +++ b/cli/docs-sync/tests/lint.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { runLint } from "../src/lint/runner.js"; + +describe("runLint", () => { + it("returns errors for files without frontmatter", async () => { + const result = await runLint({ + sources: [{ absPath: "/abs/x.docs.md", relPath: "framework/x.docs.md", raw: "# no frontmatter" }], + knownStoryIds: new Set(), + }); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0].rule).toBe("frontmatter-required"); + }); + + it("passes for valid files", async () => { + const valid = `---\ntitle: Foo\ncategory: components\ngroup: misc\n---\n\n# Foo\n`; + const result = await runLint({ + sources: [{ absPath: "/abs/x.docs.md", relPath: "framework/x.docs.md", raw: valid }], + knownStoryIds: new Set(), + }); + expect(result.errors).toEqual([]); + }); + + it("flags unknown ::storybook id", async () => { + const raw = `---\ntitle: X\ncategory: components\ngroup: misc\n---\n\n::storybook id="bogus"\n`; + const result = await runLint({ + sources: [{ absPath: "/x.docs.md", relPath: "x.docs.md", raw }], + knownStoryIds: new Set(["real-id"]), + }); + expect(result.errors.find((e) => e.rule === "storybook-id-valid")).toBeTruthy(); + }); +}); diff --git a/cli/docs-sync/tests/orphans.test.ts b/cli/docs-sync/tests/orphans.test.ts new file mode 100644 index 000000000..b02e641e8 --- /dev/null +++ b/cli/docs-sync/tests/orphans.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { findOrphans } from "../src/report/orphans.js"; + +describe("findOrphans", () => { + let tmp: string; + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "docs-sync-orphans-")); + }); + afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + }); + + it("flags files with AUTO-GENERATED marker that are not in synced set", async () => { + await fs.mkdir(path.join(tmp, "components/misc"), { recursive: true }); + await fs.writeFile( + path.join(tmp, "components/misc/old-button.md"), + "\n# Old", + ); + await fs.writeFile( + path.join(tmp, "components/misc/vc-button.md"), + "\n# New", + ); + const synced = new Set(["components/misc/vc-button.md"]); + const orphans = await findOrphans(tmp, synced); + expect(orphans.map((o) => o.target)).toEqual(["components/misc/old-button.md"]); + }); + + it("ignores manual files (no marker)", async () => { + await fs.mkdir(path.join(tmp, "guides"), { recursive: true }); + await fs.writeFile(path.join(tmp, "guides/intro.md"), "# manual page"); + const orphans = await findOrphans(tmp, new Set()); + expect(orphans).toEqual([]); + }); +}); diff --git a/cli/docs-sync/tests/pages.test.ts b/cli/docs-sync/tests/pages.test.ts new file mode 100644 index 000000000..ca1127532 --- /dev/null +++ b/cli/docs-sync/tests/pages.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { writePagesFiles } from "../src/writer/pages.js"; + +describe("writePagesFiles", () => { + let tmp: string; + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "docs-sync-pages-")); + }); + afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + }); + + it("emits a top-level components/.pages with subgroups in declared order", async () => { + await writePagesFiles(tmp, { + synced: [ + { target: "components/data-display/vc-data-table.md", title: "VcDataTable" }, + { target: "components/misc/vc-button.md", title: "VcButton" }, + ], + }); + const top = await fs.readFile(path.join(tmp, "components/.pages"), "utf8"); + expect(top).toContain("AUTO-GENERATED FROM vc-shell"); + expect(top).toContain("title: Components"); + expect(top.indexOf("data-display")).toBeLessThan(top.indexOf("misc")); + }); + + it("emits a per-group .pages alphabetically by title", async () => { + await writePagesFiles(tmp, { + synced: [ + { target: "components/form/vc-input.md", title: "VcInput" }, + { target: "components/form/vc-checkbox.md", title: "VcCheckbox" }, + { target: "components/form/vc-select.md", title: "VcSelect" }, + ], + }); + const group = await fs.readFile(path.join(tmp, "components/form/.pages"), "utf8"); + const lines = group.split("\n").filter((l) => l.startsWith(" - ")); + expect(lines).toEqual([" - VcCheckbox: vc-checkbox.md", " - VcInput: vc-input.md", " - VcSelect: vc-select.md"]); + }); + + it("does NOT emit .pages for concepts (mixed-folder)", async () => { + await writePagesFiles(tmp, { + synced: [{ target: "concepts/services.md", title: "Services" }], + }); + let exists = true; + try { + await fs.access(path.join(tmp, "concepts/.pages")); + } catch { + exists = false; + } + expect(exists).toBe(false); + }); + + it("emits a flat plugins/.pages with explicit titles, alphabetical", async () => { + await writePagesFiles(tmp, { + synced: [ + { target: "plugins/signalr.md", title: "SignalR" }, + { target: "plugins/ai-agent.md", title: "AI Agent" }, + { target: "plugins/i18n.md", title: "Internationalization" }, + ], + }); + const pagesFile = await fs.readFile(path.join(tmp, "plugins/.pages"), "utf8"); + expect(pagesFile).toContain("AUTO-GENERATED FROM vc-shell"); + expect(pagesFile).toContain("title: Plugins"); + const lines = pagesFile.split("\n").filter((l) => l.startsWith(" - ")); + expect(lines).toEqual([ + " - AI Agent: ai-agent.md", + " - Internationalization: i18n.md", + " - SignalR: signalr.md", + ]); + }); + + it("emits Overview: index.md first when a group has an index page", async () => { + await writePagesFiles(tmp, { + synced: [ + { target: "composables/services/useDashboard.md", title: "useDashboard" }, + { target: "composables/services/index.md", title: "Services" }, + { target: "composables/services/useToolbar.md", title: "useToolbar" }, + ], + }); + const pagesFile = await fs.readFile(path.join(tmp, "composables/services/.pages"), "utf8"); + const lines = pagesFile.split("\n").filter((l) => l.startsWith(" - ")); + expect(lines).toEqual([ + " - Overview: index.md", + " - useDashboard: useDashboard.md", + " - useToolbar: useToolbar.md", + ]); + }); +}); diff --git a/cli/docs-sync/tests/parser.test.ts b/cli/docs-sync/tests/parser.test.ts new file mode 100644 index 000000000..70ffbb236 --- /dev/null +++ b/cli/docs-sync/tests/parser.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseDocFile } from "../src/parser/parser.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixtures = path.join(__dirname, "fixtures"); + +describe("parseDocFile", () => { + it("parses a simple component doc", async () => { + const result = await parseDocFile(path.join(fixtures, "simple-component.docs.md")); + expect(result.frontmatter.title).toBe("VcButton"); + expect(result.frontmatter.category).toBe("components"); + expect(result.frontmatter.group).toBe("misc"); + expect(result.body).toContain("# VcButton"); + expect(result.body).not.toContain("---\ntitle:"); + }); + + it("extracts relative image references", async () => { + const result = await parseDocFile(path.join(fixtures, "simple-component.docs.md")); + expect(result.imageRefs).toEqual(["./images/default.png", "./images/hover.png"]); + }); + + it("preserves internal blocks in body (transformer strips them later)", async () => { + const result = await parseDocFile(path.join(fixtures, "composable-with-internal.docs.md")); + expect(result.body).toContain(""); + expect(result.body).toContain(""); + }); +}); diff --git a/cli/docs-sync/tests/report-formatter.test.ts b/cli/docs-sync/tests/report-formatter.test.ts new file mode 100644 index 000000000..c808b068b --- /dev/null +++ b/cli/docs-sync/tests/report-formatter.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { formatReport } from "../src/report/formatter.js"; + +describe("formatReport", () => { + it("renders all sections in markdown", () => { + const md = formatReport({ + vcShellVersion: "v2.1.0", + timestamp: "2026-04-28T10:00:00Z", + changes: [ + { source: "framework/x.docs.md", target: "components/misc/x.md", kind: "created" }, + { source: "framework/y.docs.md", target: "components/misc/y.md", kind: "updated" }, + { source: "framework/z.docs.md", target: "components/misc/z.md", kind: "unchanged" }, + ], + skipped: [{ source: "framework/_internal.docs.md", reason: "internal" }], + orphans: [{ target: "components/misc/old.md" }], + errors: [], + }); + expect(md).toContain("vc-shell@v2.1.0"); + expect(md).toContain("Created: 1"); + expect(md).toContain("Updated: 1"); + expect(md).toContain("Unchanged: 1"); + expect(md).toContain("Orphaned: 1"); + expect(md).toContain("components/misc/old.md"); + }); + + it("notes when there are zero errors", () => { + const md = formatReport({ + vcShellVersion: "v0", + timestamp: "now", + changes: [], + skipped: [], + orphans: [], + errors: [], + }); + expect(md).toContain("(none)"); + }); +}); diff --git a/cli/docs-sync/tests/storybook-fetcher.test.ts b/cli/docs-sync/tests/storybook-fetcher.test.ts new file mode 100644 index 000000000..a4b77c7b5 --- /dev/null +++ b/cli/docs-sync/tests/storybook-fetcher.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { fetchStorybookIds } from "../src/storybook/index-fetcher.js"; + +describe("fetchStorybookIds", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ + v: 5, + entries: { + "atoms-vc-button--default": { id: "atoms-vc-button--default", type: "story" }, + "atoms-vc-button--primary": { id: "atoms-vc-button--primary", type: "story" }, + "docs-overview--page": { id: "docs-overview--page", type: "docs" }, // non-story + }, + }), + } as Response); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns a Set of story IDs (excludes type=docs)", async () => { + const ids = await fetchStorybookIds("https://example.com"); + expect(ids.has("atoms-vc-button--default")).toBe(true); + expect(ids.has("atoms-vc-button--primary")).toBe(true); + expect(ids.has("docs-overview--page")).toBe(false); + }); + + it("throws on fetch failure", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: false, status: 404 } as Response); + await expect(fetchStorybookIds("https://example.com")).rejects.toThrow(/Storybook index/); + }); +}); diff --git a/cli/docs-sync/tests/transformer-header.test.ts b/cli/docs-sync/tests/transformer-header.test.ts new file mode 100644 index 000000000..2ebb91735 --- /dev/null +++ b/cli/docs-sync/tests/transformer-header.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { prependAutoGeneratedHeader, hasAutoGeneratedMarker, AUTO_GEN_MARKER } from "../src/transformer/header.js"; + +describe("prependAutoGeneratedHeader", () => { + it("adds three-line HTML comment header pointing back at source", () => { + const out = prependAutoGeneratedHeader("# Title", "framework/ui/foo.docs.md"); + expect(out.startsWith("\n# x`)).toBe(true); + expect(hasAutoGeneratedMarker("# x")).toBe(false); + }); +}); diff --git a/cli/docs-sync/tests/transformer-links.test.ts b/cli/docs-sync/tests/transformer-links.test.ts new file mode 100644 index 000000000..75f2b4f7c --- /dev/null +++ b/cli/docs-sync/tests/transformer-links.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { rewriteCrossDocLinks } from "../src/transformer/links.js"; + +describe("rewriteCrossDocLinks", () => { + // resolveTarget: given a (sourcePath, linkHref), returns the target relative + // path inside vc-docs if the link points to another synced doc, or null. + const resolveTarget = (source: string, href: string): string | null => { + const map: Record> = { + "framework/ui/components/atoms/vc-button/vc-button.docs.md": { + "../vc-link": "components/navigation/vc-link.md", + }, + }; + return map[source]?.[href] ?? null; + }; + + it("rewrites a recognised cross-doc link to its target relative path", () => { + const body = "See also [VcLink](../vc-link)."; + const out = rewriteCrossDocLinks(body, { + sourcePath: "framework/ui/components/atoms/vc-button/vc-button.docs.md", + targetPath: "components/misc/vc-button.md", + resolveTarget, + }); + expect(out).toContain("[VcLink](../navigation/vc-link.md)"); + }); + + it("leaves external links untouched", () => { + const body = "See [Vue](https://vuejs.org)."; + const out = rewriteCrossDocLinks(body, { + sourcePath: "framework/ui/components/atoms/vc-button/vc-button.docs.md", + targetPath: "components/misc/vc-button.md", + resolveTarget, + }); + expect(out).toContain("https://vuejs.org"); + }); + + it("leaves links with no resolved target untouched (warning is caller's job)", () => { + const body = "See [Mystery](../non-synced-doc)."; + const out = rewriteCrossDocLinks(body, { + sourcePath: "framework/ui/components/atoms/vc-button/vc-button.docs.md", + targetPath: "components/misc/vc-button.md", + resolveTarget, + }); + expect(out).toContain("../non-synced-doc"); + }); + + it("leaves image references alone", () => { + const body = "![hi](./images/x.png)"; + const out = rewriteCrossDocLinks(body, { + sourcePath: "framework/ui/components/atoms/vc-button/vc-button.docs.md", + targetPath: "components/misc/vc-button.md", + resolveTarget, + }); + expect(out).toBe(body); + }); +}); diff --git a/cli/docs-sync/tests/transformer-pipeline.test.ts b/cli/docs-sync/tests/transformer-pipeline.test.ts new file mode 100644 index 000000000..71d1a94c1 --- /dev/null +++ b/cli/docs-sync/tests/transformer-pipeline.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { runTransformPipeline } from "../src/transformer/pipeline.js"; + +describe("runTransformPipeline", () => { + it("runs all transformers in order", () => { + const body = [ + `# Foo`, + ``, + ``, + `secret`, + ``, + ``, + `::storybook id="atoms-vc-button--default"`, + ].join("\n"); + + const out = runTransformPipeline(body, { + sourceRelPath: "framework/x.docs.md", + sourcePath: "framework/x.docs.md", + targetPath: "components/misc/x.md", + storybookUrl: "https://example.com", + knownStoryIds: new Set(["atoms-vc-button--default"]), + resolveTarget: () => null, + }); + + // header prepended + expect(out.startsWith("\nhidden\n\nafter"; + expect(stripInternalBlocks(input)).toBe("before\n\nafter"); + }); + + it("removes multiple internal blocks", () => { + const input = + "a\nX\nb\nY\nc"; + expect(stripInternalBlocks(input)).toBe("a\n\nb\n\nc"); + }); + + it("handles internal block at the very end", () => { + const input = "kept\n\ngone\n"; + expect(stripInternalBlocks(input)).toBe("kept\n"); + }); + + it("returns input unchanged when no markers", () => { + expect(stripInternalBlocks("plain text")).toBe("plain text"); + }); + + it("leaves unbalanced markers alone (and warns is caller's job)", () => { + const input = "\nno end here"; + // We strip only when start AND end present and balanced; unbalanced → no-op. + expect(stripInternalBlocks(input)).toBe(input); + }); +}); diff --git a/cli/docs-sync/tests/writer.test.ts b/cli/docs-sync/tests/writer.test.ts new file mode 100644 index 000000000..25357197d --- /dev/null +++ b/cli/docs-sync/tests/writer.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { computeTargetPath, writeMarkdown } from "../src/writer/writer.js"; +import type { Frontmatter } from "../src/types.js"; + +describe("computeTargetPath", () => { + it("uses category/group/slug.md when slug is provided", () => { + const fm: Frontmatter = { + title: "VcDataTable", + category: "components", + group: "data-display", + slug: "vc-data-table", + }; + expect(computeTargetPath(fm, "vc-data-table.docs.md")).toBe("components/data-display/vc-data-table.md"); + }); + + it("falls back to source filename without .docs.md", () => { + const fm: Frontmatter = { title: "VcButton", category: "components", group: "misc" }; + expect(computeTargetPath(fm, "vc-button.docs.md")).toBe("components/misc/vc-button.md"); + }); + + it("treats group=root as no group folder for concepts", () => { + const fm: Frontmatter = { title: "Services", category: "concepts", group: "root", slug: "services" }; + expect(computeTargetPath(fm, "services.docs.md")).toBe("concepts/services.md"); + }); + + it("supports nested groups (reference/api/directives)", () => { + const fm: Frontmatter = { title: "v-loading", category: "reference", group: "api/directives", slug: "v-loading" }; + expect(computeTargetPath(fm, "loading.docs.md")).toBe("reference/api/directives/v-loading.md"); + }); + + it("emits plugins/.md for category=plugins, group=root", () => { + const fm: Frontmatter = { + title: "AI Agent", + category: "plugins", + group: "root", + slug: "ai-agent", + }; + expect(computeTargetPath(fm, "ai-agent.docs.md")).toBe("plugins/ai-agent.md"); + }); + + it("emits //index.md when placement=index", () => { + const fm: Frontmatter = { + title: "Services", + category: "composables", + group: "services", + placement: "index", + }; + expect(computeTargetPath(fm, "services.docs.md")).toBe("composables/services/index.md"); + }); + + it("ignores slug when placement=index", () => { + const fm: Frontmatter = { + title: "Services", + category: "composables", + group: "services", + slug: "services-overview", + placement: "index", + }; + expect(computeTargetPath(fm, "services.docs.md")).toBe("composables/services/index.md"); + }); +}); + +describe("writeMarkdown", () => { + let tmp: string; + beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), "docs-sync-")); + }); + afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }); + }); + + it("creates the file and parent directories", async () => { + const result = await writeMarkdown(tmp, "components/misc/x.md", "# x"); + expect(result.kind).toBe("created"); + const onDisk = await fs.readFile(path.join(tmp, "components/misc/x.md"), "utf8"); + expect(onDisk).toBe("# x"); + }); + + it("returns 'unchanged' when content is identical", async () => { + await writeMarkdown(tmp, "x.md", "same"); + const result = await writeMarkdown(tmp, "x.md", "same"); + expect(result.kind).toBe("unchanged"); + }); + + it("returns 'updated' when content differs", async () => { + await writeMarkdown(tmp, "x.md", "v1"); + const result = await writeMarkdown(tmp, "x.md", "v2"); + expect(result.kind).toBe("updated"); + }); +}); diff --git a/cli/docs-sync/tsconfig.json b/cli/docs-sync/tsconfig.json new file mode 100644 index 000000000..d187513db --- /dev/null +++ b/cli/docs-sync/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@vc-shell/ts-config/tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "es2022", + "declaration": false, + "declarationMap": false, + "composite": false, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "tests"] +} diff --git a/cli/docs-sync/vitest.config.ts b/cli/docs-sync/vitest.config.ts new file mode 100644 index 000000000..ab679252d --- /dev/null +++ b/cli/docs-sync/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + passWithNoTests: true, + }, +}); diff --git a/framework/core/api/platform.docs.md b/framework/core/api/platform.docs.md index e949cee10..dfa423bfe 100644 --- a/framework/core/api/platform.docs.md +++ b/framework/core/api/platform.docs.md @@ -1,3 +1,10 @@ +--- +title: Platform Client +category: reference +group: api +slug: platform-client +--- + # Platform API Client Auto-generated TypeScript API client for the VirtoCommerce Platform REST API. Generated by NSwag from the platform's Swagger/OpenAPI specification. diff --git a/framework/core/blade-navigation/blade-nav-composables.docs.md b/framework/core/blade-navigation/blade-nav-composables.docs.md index 73b46623b..49872575a 100644 --- a/framework/core/blade-navigation/blade-nav-composables.docs.md +++ b/framework/core/blade-navigation/blade-nav-composables.docs.md @@ -1,3 +1,9 @@ +--- +title: Blade Navigation Composables +category: composables +group: blade-navigation +--- + # Blade Navigation Composables The core composables that implement the blade navigation system -- the stacked-panel UI paradigm used throughout VirtoCommerce admin applications. @@ -14,7 +20,9 @@ Blade navigation manages an ordered stack of blade descriptors (plain data objec - Build or extend the blade navigation infrastructure itself (plugin authors, framework internals) - Need direct access to the blade stack state machine (`useBladeStack`) or inter-blade messaging (`useBladeMessaging`) -- When NOT to use: for everyday blade operations in modules -- prefer `useBlade()` from `core/composables/useBlade/`, which wraps these low-level composables with a cleaner, context-aware API +- When NOT to use: for everyday blade operations in modules -- prefer `useBlade()`, which wraps these low-level composables with a cleaner, context-aware API + + ## Exports @@ -24,6 +32,8 @@ export { createBladeStack, useBladeStack } from "./useBladeStack"; export { createBladeMessaging, useBladeMessaging } from "./useBladeMessaging"; ``` + + ## useBladeStack The blade stack state machine. Manages an ordered array of `BladeDescriptor` objects. @@ -139,7 +149,7 @@ The plain data object stored in the stack for each blade: ## Tips -- Prefer `useBlade()` / `useBladeContext()` (from `core/composables/useBlade/`) for new code -- they provide a cleaner API than the legacy adapter. +- Prefer `useBlade()` / `useBladeContext()` for new code -- they provide a cleaner API than the legacy adapter. - Close guards return `true` to PREVENT closing (opposite of the legacy convention where `false` prevented closing). The adapter handles the inversion. - `replaceCurrentBlade` destroys the current blade and creates a new one at the same index with the same `parentId`. Use `coverCurrentBlade` to hide instead of destroy — the covering blade's `callParent` reaches the hidden blade's exposed methods. - URL sync only updates the address bar for blades that have a `url` segment. Third-level detail panels without URLs leave the previous URL intact. @@ -147,6 +157,12 @@ The plain data object stored in the stack for each blade: ## Related +- [`useBlade`](../composables/useBlade/) -- recommended API for everyday blade operations +- [`useBladeContext`](../composables/bladeContext/) -- share reactive blade data with descendant components + + + - `framework/core/composables/useBlade/` -- `useBlade()`, `useBladeContext()` (new API) -- `framework/shared/components/blade-navigation/plugin-v2.ts` -- plugin that creates and provides the stack/messaging -- `framework/shared/components/blade-navigation/types.ts` -- `BladeDescriptor`, `IBladeStack`, `IBladeMessaging` +- `framework/shell/_internal/blade-nav/plugin-v2.ts` -- plugin that creates and provides the stack/messaging +- `framework/core/blade-navigation/types/index.ts` -- `BladeDescriptor`, `IBladeStack`, `IBladeMessaging` + diff --git a/framework/core/composables/bladeContext/index.docs.md b/framework/core/composables/bladeContext/index.docs.md index a1cca3ebc..297a68f4c 100644 --- a/framework/core/composables/bladeContext/index.docs.md +++ b/framework/core/composables/bladeContext/index.docs.md @@ -1,3 +1,10 @@ +--- +title: useBladeContext +category: composables +group: blade-navigation +slug: useBladeContext +--- + # useBladeContext (defineBladeContext / injectBladeContext) Exposes blade-level data to descendant widgets, extensions, and nested components via Vue's provide/inject mechanism. This pair of functions eliminates the need for prop drilling when child widgets or extension points need access to the parent blade's entity data, loading flags, or other shared state. @@ -96,7 +103,7 @@ defineBladeContext( - **Automatic ref unwrapping**: `defineBladeContext` shallow-unwraps all ref/computed values in the provided object. Consumers get plain values directly (`ctx.value.item` instead of `ctx.value.item.value`). This works reactively — when the source ref changes, the context updates automatically. - **Reactivity**: The provided context is always wrapped in a `computed`, so consumers receive a `ComputedRef` regardless of whether the provider passed a plain object, a ref, or a getter. Changes propagate automatically. -- **Injection key**: Uses `BladeContextKey` from `framework/injection-keys.ts`. This is a framework-level Symbol, so there is no risk of key collision with application code. +- **Injection key**: Uses `BladeContextKey` (a framework-level Symbol), so there is no risk of key collision with application code. - **Error handling**: `injectBladeContext` throws an `InjectionError` with a descriptive message if called outside a blade component tree. This fails fast during development rather than silently returning `undefined`. - **Scope**: The context is scoped to the Vue component subtree. Each blade in the stack has its own context, so nested blades do not leak data upward or sideways. @@ -108,6 +115,11 @@ defineBladeContext( ## Related +- [`useBladeWidgets`](../useBladeWidgets/) -- widgets that consume blade context +- [`useBlade`](../useBlade/) -- cross-blade communication via `callParent` / `exposeToChildren` + + + - `BladeContextKey` in `framework/injection-keys.ts` -- `useBladeWidgets` -- widgets that consume blade context -- `useBladeStack` -- manages the blade navigation stack +- `useBladeStack` in `framework/core/blade-navigation/` -- manages the blade navigation stack + diff --git a/framework/core/composables/useApiClient/useApiClient.docs.md b/framework/core/composables/useApiClient/useApiClient.docs.md index 9a3c35aa1..f36064a4e 100644 --- a/framework/core/composables/useApiClient/useApiClient.docs.md +++ b/framework/core/composables/useApiClient/useApiClient.docs.md @@ -1,7 +1,16 @@ +--- +title: useApiClient +category: composables +group: data +--- + # useApiClient Creates a typed API client instance for communicating with VirtoCommerce platform APIs. The composable accepts a generated client class constructor and returns an async factory function that produces a configured, authenticated client. Base URL resolution and authentication token injection are handled automatically. +!!! tip "Always call getApiClient inside async functions" +`getApiClient` is async. Never call it at the top level of ` ``` +::storybook id="action-vcaccordion--bordered-variant" + ### Multiple Open Items Set `multiple` to `true` to allow expanding several items simultaneously. The `v-model` value becomes an array. @@ -86,6 +99,8 @@ Set `multiple` to `true` to allow expanding several items simultaneously. The `v ``` +::storybook id="action-vcaccordion--multiple-open" + ### Partial Content Preview (collapsedHeight) When `collapsedHeight` > 0, collapsed items show that many pixels of content with a fade-out gradient. The chevron only appears when content overflows. @@ -262,6 +277,8 @@ interface AccordionItem { - [VcAccordionItem](./_internal/vc-accordion-item/) -- individual accordion panel (used internally and available via the default slot) +::storybook id="action-vcaccordion--skeleton" + ## Skeleton / Loading State When placed inside a `VcBlade` with `loading=true`, the component automatically renders a skeleton placeholder matching its visual footprint. No additional props or configuration needed. diff --git a/framework/ui/components/molecules/vc-breadcrumbs/vc-breadcrumbs.docs.md b/framework/ui/components/molecules/vc-breadcrumbs/vc-breadcrumbs.docs.md index 113c6e2dd..8148948af 100644 --- a/framework/ui/components/molecules/vc-breadcrumbs/vc-breadcrumbs.docs.md +++ b/framework/ui/components/molecules/vc-breadcrumbs/vc-breadcrumbs.docs.md @@ -1,7 +1,18 @@ +--- +title: VcBreadcrumbs +category: components +group: navigation +--- + +!!! tip "Quick reference" +Jump to [Props](#props) · [Slots](#slots) · [CSS Variables](#css-variables) · [Accessibility](#accessibility) + # VcBreadcrumbs Navigation breadcrumb trail that displays the user's location within a hierarchy and adaptively collapses middle items into a dropdown when horizontal space is limited. +::storybook id="navigation-vcbreadcrumbs--default" + ## When to Use - Showing the user's current position in a blade navigation hierarchy @@ -51,6 +62,8 @@ interface Breadcrumbs { ## Adaptive Overflow +::storybook id="navigation-vcbreadcrumbs--many-items" + VcBreadcrumbs monitors the container width with `useAdaptiveItems` and automatically collapses middle items into a dropdown when they do not fit. The first and last items stay visible; overflow items appear behind a "more" button (vertical ellipsis). The collapse algorithm uses a reverse strategy -- it hides items starting from the left (after the first) so the current page (last item) always remains visible. @@ -64,6 +77,8 @@ The collapse algorithm uses a reverse strategy -- it hides items starting from t ## Separated Style +::storybook id="navigation-vcbreadcrumbs--separated" + Enable slash separators between items with the `separated` prop: ```vue @@ -275,3 +290,15 @@ clickHandler: () => { navigate(); return true; } - [VcDropdown](../vc-dropdown/) -- Used internally to render the overflow menu - [VcBreadcrumbsItem](./_internal/vc-breadcrumbs-item/) -- Internal sub-component for individual breadcrumb rendering - [VcButton](../../atoms/vc-button/) -- Can be used inside the `trigger` slot for a styled overflow button + + + +## Architecture Notes + +- Overflow detection uses `useAdaptiveItems` composable with `calculationStrategy: "reverse"` — items are hidden starting from the second item (left side), keeping the last item (current page) always visible. +- `MORE_BUTTON_WIDTH = 36` and `INITIAL_ITEM_WIDTH = 60` are conservative estimates used in the initial layout pass before DOM measurement. +- The `VcDropdown` instance for the overflow menu uses `floating` and `placement="bottom-start"`. +- `VcBreadcrumbsItem` is an internal sub-component in `_internal/vc-breadcrumbs-item/` — not exported from the index. +- The `useBreadcrumbs` composable lives in `framework/core/composables/useBreadcrumbs/`. + + diff --git a/framework/ui/components/molecules/vc-checkbox-group/vc-checkbox-group.docs.md b/framework/ui/components/molecules/vc-checkbox-group/vc-checkbox-group.docs.md index 7bb915b1d..435895ca6 100644 --- a/framework/ui/components/molecules/vc-checkbox-group/vc-checkbox-group.docs.md +++ b/framework/ui/components/molecules/vc-checkbox-group/vc-checkbox-group.docs.md @@ -1,7 +1,15 @@ +--- +title: VcCheckboxGroup +category: components +group: form +--- + # VcCheckboxGroup Accessible checkbox group that wraps multiple checkboxes in a semantic fieldset with shared label, hint, error state, and layout control. +::storybook id="form-vccheckboxgroup--default" + ## When to Use - Selecting multiple options from a set (e.g., notification channels, feature flags) @@ -75,6 +83,8 @@ interface CheckboxGroupOption { ### Horizontal Layout +::storybook id="form-vccheckboxgroup--horizontal" + ```vue { ### With Disabled Option +::storybook id="form-vccheckboxgroup--with-disabled-option" + ```vue { ## Skeleton / Loading State When placed inside a `VcBlade` with `loading=true`, the component renders a skeleton placeholder matching its shape — a control indicator and label block. No configuration needed. + + + +## Architecture Notes + +- VcCheckboxGroup delegates rendering to `VcInputGroup` (semantic fieldset wrapper) and renders `VcCheckbox` items from the `options` array, or passes through the default slot for custom layouts. +- The group generates a unique `name` attribute via `useId()` when none is provided, ensuring native radio group behavior. +- `normalizedModelValue` guards against non-array `modelValue` to avoid runtime errors when the parent passes `undefined`. +- Source file: `framework/ui/components/molecules/vc-checkbox-group/vc-checkbox-group.vue` + + diff --git a/framework/ui/components/molecules/vc-checkbox/vc-checkbox.docs.md b/framework/ui/components/molecules/vc-checkbox/vc-checkbox.docs.md index 62c9d0b80..8cd266625 100644 --- a/framework/ui/components/molecules/vc-checkbox/vc-checkbox.docs.md +++ b/framework/ui/components/molecules/vc-checkbox/vc-checkbox.docs.md @@ -1,7 +1,18 @@ +--- +title: VcCheckbox +category: components +group: form +--- + # VcCheckbox A checkbox component supporting boolean toggling, array-based multi-selection, indeterminate state, three size variants, and animated check/uncheck transitions. Works as both a standalone boolean toggle and a member of a multi-select group. +!!! note "Large reference" +This page is over 200 lines. Use the section headings to jump directly to what you need: [Quick Start](#quick-start), [Features](#features), [Props](#props), [CSS Variables](#css-variables). + +::storybook id="form-vccheckbox--basic" + ## When to Use - Single boolean toggle with an inline label (e.g., "I agree to terms") @@ -69,6 +80,8 @@ const selected = ref([]); ### Size Variants +::storybook id="form-vccheckbox--sizes" + Three sizes are available via the `size` prop: | Size | Value | Pixel dimension | @@ -85,6 +98,8 @@ Three sizes are available via the `size` prop: ### Indeterminate State (Select All) +::storybook id="form-vccheckbox--indeterminate" + The `indeterminate` prop renders a horizontal dash instead of a checkmark. This is commonly used for "select all" checkboxes where only some children are selected. ```vue @@ -355,3 +370,15 @@ const selected = ref([]); ## Skeleton / Loading State When placed inside a `VcBlade` with `loading=true`, the component renders a skeleton placeholder matching its shape — a control indicator and label block. No configuration needed. + + + +## Architecture Notes + +- VcCheckbox uses a visually hidden native `` for full keyboard and screen reader support. The custom visual (`vc-checkbox__custom-input`) mirrors its state via CSS sibling selectors (`:checked + .vc-checkbox__custom-input`). +- Indeterminate state is set imperatively (`checkboxRef.value.indeterminate = val`) since HTML does not support `indeterminate` as an attribute — a watcher syncs the prop to the DOM property. +- Check/uncheck icons use Vue `` with `@keyframes` animations for the scale/opacity effect. +- `useFormField` injects validation context (error, disabled, name) from a parent form provider. +- Source file: `framework/ui/components/molecules/vc-checkbox/vc-checkbox.vue` + + diff --git a/framework/ui/components/molecules/vc-color-input/vc-color-input.docs.md b/framework/ui/components/molecules/vc-color-input/vc-color-input.docs.md index 32c283ced..903aae895 100644 --- a/framework/ui/components/molecules/vc-color-input/vc-color-input.docs.md +++ b/framework/ui/components/molecules/vc-color-input/vc-color-input.docs.md @@ -1,7 +1,18 @@ +--- +title: VcColorInput +category: components +group: form +--- + +!!! tip "Quick reference" +See [Key Props](#key-props) for the full prop table, or jump to [Common Patterns](#common-patterns) for copy-paste examples. + # VcColorInput A color input that combines a text field for hex values with a clickable color swatch that opens the native browser color picker. Accepts hex codes and CSS color names. +::storybook id="form-vccolorinput--default" + ## Overview Color selection is needed in various admin scenarios: configuring brand colors, setting category badges, defining product attribute colors, or customizing theme variables. The native HTML `` provides a color picker but lacks text entry, validation, and consistent styling. @@ -53,6 +64,8 @@ const color = ref(null); ### With Validation +::storybook id="form-vccolorinput--with-error" + ```vue