Build, release, and the docs site
Use the vp toolchain, never pnpm directly
vp/vpr/vpxare global bins. Run tasks viavp run <task>, execute bins viavp exec [-F <glob>] <bin>.- Package scripts must never shell out to
pnpm. CI (.github/workflows/docs.yml) providesvpviavoidzero-dev/setup-vpbut notpnpmon PATH, so apnpm run …inside any script breaks the docs deploy withpnpm: command not found. Local dev has pnpm, so a top-levelpnpm run Xis fine — but scripts it calls must stay pnpm-free internally. .cssformatting:vp fmt(oxc) is JS/TS/JSON only and no-ops on.css. Stylelint owns.css—lint:cssrunsstylelint --fix, wired intoreadyand thevite.config.tsstagedhook.
The gate
pnpm run ready— the pass/fail gate. It's avptask DAG (ready:all), not a serial chain:build:allruns once, thencheck:all(vp check),test:all(vp run -r test),lint:css,lint:js,validate:generated:only, andlint:markdownfan out concurrently. Everything that reads generated output depends onbuild:all, so generation happens exactly once (no concurrent codegen race). Must pass before you're done.pnpm run check:publish— the publish gate (gate:publish):gate:repository(asserts every publishable manifest has therepository.urlnpm OIDC provenance needs),gate:publint, andgate:attw. Publint/attw depend onbuild:all; the repository check is a pure manifest read.- CI runs the same checks on every PR (
.github/workflows/ci.yml):build→ paralleltypecheck/test/lint/publint/attw, plus arepositoryjob and acommitlintjob that lints the PR's commit range. Jobs share a persisted vp task cache (node_modules/.vite/task-cache), so thebuildjob warms it and downstream jobs restore it and re-materialize generated dirs from cache. publint/attware scoped to what the PR touches.scripts/release/changed-packages.tsmaps the diff against the base branch to the affected publishable packages (each changed package plus its workspace dependents, since a dependency change can break a dependent's pack/types), and the jobs runvp exec -F <those>instead of packing all ~70. A change to a global file (rootpackage.json, lockfile,vite.config.ts, …) widens back to the full gate; a change touching no publishable package skips them.- Commit messages are conventional-commit-linted locally by the
.vite-hooks/commit-msghook (vp exec commitlint --edit) and in CI by thecommitlintjob. - Release automation uses Changesets and package-tag workflows in
.github/workflows/release.yml. That workflow verifies a clean build/typecheck/test +gate:repository, then scopes the pack-heavypublint/attwgates to exactly the publish set (the fullreadyalready ran on the merge-to-main the tag points at). - Use
pnpm run release:versionto apply package changelog/version updates, thenvp run release:changelog:rootto rebuild the strict chronological rootCHANGELOG.mdbefore creating package tags. - Generated artifacts are gitignored and reproduced on build:
platforms/tokens/src/generated/,platforms/css/style.css, each preprocessor's static file, and web-componentssrc/generated/.build:allproduces them before any gate that reads them runs.
See docs/internal/release-strategy.md for the runbook, dist-tag mapping, prerelease flow, and npm organization governance model.
Linting CSS
Root stylelint.config.js runs error-only core rules plus @cssdoc/stylelint-plugin's cssdoc/valid-doc-comments; anchor-positioning props are ignored and @scope is allowed. A parallel eslint.config.js runs @cssdoc/eslint-plugin (via @eslint/css) over the same .css (lint:js). lint:css targets the web-components src/**/*.css sources and the generated components CSS.
The docs site
docs/ is a VitePress site (@pantoken/docs) with two locales — root (English, /…) and hu (Magyar, /hu/…) — a symmetric prefix swap that VitePress's default routing already handles (don't set a custom i18nRouting).
- Translation layer is
docs/.vitepress/i18n.ts.LOCALES[locale]holds every localizable UI string (nav/sidebar labels,editText, the theme selector, VitePress chrome labels, and local search).config.tsexpands these into per-localethemeConfig(search is the exception — it lives in the globalthemeConfig.search.options.locales). Add new UI strings here, never inline. - Block-level API translation.
build-api-locales.tsdoesn't translate whole.mdfiles — it runssegment-markdown.tsto split each generated page into blocks:prose(descriptions, remarks,@examplecaptions, cssdoc table Description cells),glossary(section headings, stability-badge pills, table column labels), andpreserve(code fences, signatures, breadcrumbs, token tables). Only prose carries a content key, so a page's prose survives the scaffolding churn (badge flips, token-value changes, signature edits) that used to bust a whole-file key.glossaryblocks always go through the deterministicGlossaryTranslationAdapter(keyless, never cached);preserveblocks are emitted verbatim. - The committed cache carries the prose; CI serves it. The translation memory (
docs/i18n-cache/hu.api.json) is content-addressed and adapter-agnostic, so a claude-authored prose entry is served to aglossarybuild as a plain cache hit. The workflow: runpnpm run docs:api:locales:claudelocally to author prose (a cold run is bounded to ~30–40 batchedclaude -pcalls, resumable via the memory's autosave), then commithu.api.json. CI'sdocs:buildruns theglossaryadapter, which serves that prose from cache and only ever fills structural headings/labels. Brand-new prose that isn't cached yet passes through as English — the glossary never caches its own prose passthrough (that would permanently mask the block from a later claude run), so it stays a miss until claude authors it. Never wire:claudeinto CI. - Running the cold pass. Each
claude -pcall cold-starts a full agent, and the dominant cost is the per-call bootstrap — loading MCP servers, plugins, and project settings — not the translation itself (it dwarfs even a small model's inference). So the:claudetasks pass--model claude-haiku-4-5-20251001 --strict-mcp-config --setting-sources user: a fast model, no MCP, and user settings only (keeps auth, drops project/local hooks). That cuts each call from minutes to a few seconds. Override by editing the task or exporting your ownDOCS_TRANSLATION_COMMAND_ARGSbefore a directnode scripts/…run (DOCS_TRANSLATION_COMMANDoverrides theclaudebinary itself). Either task logs progress (… N/M labels + prose blocks translated) and saves the memory after each chunk, so it's resumable — a kill or crash keeps completed chunks and a re-run serves them from cache. - The cold pass is generation-bound, so it runs chunks concurrently. Once MCP is stripped, the wall-clock cost is the model streaming translations, not startup — so
ClaudeCodeTranslationAdapterruns up toDOCS_TRANSLATION_CONCURRENCY(default 5)claude -pcalls at once. Prose is batched atDOCS_TRANSLATION_BATCH_BUDGETchars/request (default 4k) — small enough that each JSON response stays reliable and progress is fine-grained, with the pool hiding the per-call startup. A chunk that errors is logged and skipped (its blocks stay uncached and retry next run), never sinking the whole run. Raise concurrency for more speed if you're not rate-limited; lower the budget if a run trips the per-item fallback (the model dropping a key from a large response).
The cssdoc integration
pantoken consumes @cssdoc/* from npm (catalog entries; consumers use catalog:), not a local workspace link. docs/scripts/build-css-api.ts is a thin wrapper: it builds pantoken's resolveToken (syntax + value + local vars) and resolveDemo hooks, then calls @cssdoc/typedoc's emitCssApi, and keeps the unknownReferences drift guard. All page/index/sidebar rendering lives in @cssdoc/markdown. The live <div class="css-example"> preview is pantoken's own @pantoken/typedoc-plugin-live-example, kept out of @cssdoc/markdown so the upstream stays generic.
Catalog gotcha: cssdoc packages reference catalog: deps; if cssdoc adds a new one, pantoken's catalog must carry it too, or install fails with ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC.
The nested-vp-spawn limitation
Under vpr docs:dev, VitePress runs under vp. Any vp run … / vp pack spawned from inside that process dies with Failed to spawn process: Invalid argument (os error 22) on a cache miss. A direct node scripts/x.ts is unaffected. So:
- The docs orchestrator's
upstream[].builduses["node","scripts/generate.ts"](cwd = the package dir), not a nestedvp run. - The CSS-API node runs
["node","scripts/build-css-api.ts"](cwddocs). @pantoken/web-components'register()bundle genuinely needsvp pack(which also can't nest), so it's not in the live orchestrator — rebuild it in a separate top-level shell (vpr @pantoken/web-components#build);outputWatchPathson itsdistbridges the change into HMR.
A vp run X && vp run Y chain inside a package.json script is fine — that's a top-level script-runner context, not a spawn from within the running VitePress process.