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.mdImplementation plan:docs/superpowers/plans/2026-04-28-vc-shell-docs-sync.md
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. 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.
From the vc-shell repo root:
# 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.pngThe root-level shortcuts assume ../vc-docs exists as a sibling checkout. To target a different path:
yarn workspace @vc-shell/docs-sync exec docs-sync sync --target /custom/path/to/vc-docs┌──────────────────────┐
│ 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/<category>/ │
│ ├── components/ │
│ ├── composables/ │
│ ├── concepts/ │
│ └── reference/ │
└────────────────────────────────────┘
The pipeline is deterministic and idempotent — running yarn docs:sync twice in a row produces zero changes the second time.
Every file destined for the public site declares its routing and metadata in frontmatter:
---
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
<VcDataTable :items="products">
<VcColumn id="name" field="name" sortable />
</VcDataTable>
```| Name | Type | Default | Description |
|---|---|---|---|
| items | T[] |
[] |
Row data. |
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/<slug>.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 `<category>/<group>/<slug>.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 `<!-- internal:start -->` / `<!-- internal:end -->` 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.
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
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).
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:
<!-- in vc-button.docs.md -->
See also [VcLink](../vc-link).becomes
<!-- in components/misc/vc-button.md -->
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.
Every synced page begins with:
<!-- AUTO-GENERATED FROM vc-shell — DO NOT EDIT MANUALLY -->
<!-- Source: framework/ui/components/.../X.docs.md -->
<!-- To update: edit the source file in vc-shell, then run yarn docs:sync -->The vc-docs .github/workflows/protect-generated.yml workflow blocks PRs (other than from the bot) that try to edit files containing this marker.
Walks framework/**/*.docs.md, applies the transform pipeline, and writes into a vc-docs checkout.
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.
Runs all lint rules against every *.docs.md in framework/. Returns exit 1 on any error so CI can block bad PRs.
yarn docs:lintSample 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
Capture a Playwright screenshot using the style-guide defaults below.
# 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 <storybookUrl>/iframe.html?id=… |
--selector |
CSS selector to crop to. Default: full viewport. |
--theme |
light / dark for theme-aware components. |
--viewport |
<width>x<height>. Default: 1280x800. |
--out |
Output file path. Required. |
| 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. |
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. |
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.
vc-docs is versioned via 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. Frozenrelease/X.0branches stay immutable. - Each vc-shell release re-syncs into the active major. There's no per-release mapping table —
git logof 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.htmlin 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/<docs-version>/. The generator will read the active docs version fromvc-docs/VERSIONand substitute it into iframe URLs at sync time.
From the vc-shell repo root:
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 watchcli/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  refs
parser.ts # gray-matter + frontmatter validation
transformer/
strip-internal.ts # remove <!-- internal:* --> 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.
- Implement
src/lint/rules/<rule-name>.tsexporting aLintRule(object withnameandcheck(file, ctx) -> LintIssue[]— sync or async). - Register it in
src/lint/runner.tsALL_RULES. - Add a test case to
tests/lint.test.ts. - Update the Lint rules table in this README.
- Implement
src/transformer/<step>.tsexporting a pure function(body: string, ctx: TransformContext) => string. - Wire it into
src/transformer/pipeline.tsin the right order. Order matters —strip-internalruns beforeexpand-storybookso internal blocks containing storybook directives don't get expanded. - Add unit tests for the step.
- TypeScript ESM with
moduleResolution: "NodeNext"(matchescli/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)
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 <!-- internal:* -->, 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.
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.
| 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 |