From a13fd58149d0082fe2531b46daa35808b2d39bc6 Mon Sep 17 00:00:00 2001 From: ysyou Date: Thu, 23 Jul 2026 06:02:19 +0000 Subject: [PATCH 1/2] fix(api): render API reference from CRD/OpenAPI facts instead of guessing The API components ignored facts already present in the CRD/OpenAPI sources and pushed the responsibility onto authors, who miss it. This fixes a family of rendering defects and adds offline integrity tooling. Correctness: - derive `namespaced` from `spec.scope` (Cluster resources no longer render an unreachable `/namespaces/{namespace}/` path); prop still overrides - use `spec.names.plural` for endpoint paths instead of `pluralize(kind)` (fixes hyphenated / irregular plurals); add a `plural` escape-hatch prop - default multi-version CRDs to the version kubectl resolves (highest-priority `served` version, never `served: false`), for both schema and endpoint version sites; configurable via `api.crdVersion` - render `/status` (and new `/scale`) from `subresources`, not from a `status` property; fixes both fabricated and missing status endpoints - render `x-kubernetes-int-or-string` (`anyOf`) types instead of an empty cell - de-duplicate schema array-item anchors (`.spec.foo[]` vs `.spec.foo`) with a page-level slugger; give summary-less operations a real id (no `#undefined`) - read `requestBody.required` for the Request Body badge, not the body schema's required-property list Tooling / config: - new `doom api check` command: offline validation of local CRD/OpenAPI sources - new `no-unresolved-api-ref` lint rule for the five API components - `api.references` object form `{ href, routePath?: string | false }` (strings unchanged); `translate.copyOnlyDirectories` - deterministic source ordering; `filepath`/`openapiPath` keys independent of `process.cwd()` - `` shows a visible "not found" row instead of dropping it - translate component chrome via `useTranslation` (en/zh/ru) - surface top-level schema properties in the API-page outline Includes fixture-docs regression fixtures for the CRD-scope / plural / version / subresources / anchor / int-or-string cases (doom's own tekton fixtures cannot trip them). See the changeset for downstream impact. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/fix-api-reference-rendering.md | 35 + .../en/apis/kubernetes_apis/anchortest.mdx | 3 + .../en/apis/kubernetes_apis/clusterwidget.mdx | 3 + .../apis/kubernetes_apis/iptablestestrule.mdx | 3 + .../kubernetes_apis/multiversionthing.mdx | 3 + .../kubernetes_apis/nostatussubresource.mdx | 3 + .../apis/kubernetes_apis/openapipathtest.mdx | 7 + .../kubernetes_apis/testloadbalancer2.mdx | 3 + .../crds/doomtest.alauda.io_anchortests.yaml | 70 ++ .../doomtest.alauda.io_clusterwidgets.yaml | 40 + ...oomtest.alauda.io_iptables-test-rules.yaml | 31 + ...doomtest.alauda.io_multiversionthings.yaml | 52 ++ ...omtest.alauda.io_nostatussubresources.yaml | 31 + .../doomtest.alauda.io_testloadbalancer2.yaml | 31 + fixture-docs/shared/openapis/testapi.json | 64 ++ packages/doom/src/cli/api.ts | 198 ++++ packages/doom/src/cli/index.ts | 2 + packages/doom/src/cli/translate.ts | 16 +- packages/doom/src/plugins/api/index.ts | 7 +- packages/doom/src/plugins/api/types.ts | 37 +- packages/doom/src/remark-lint/index.ts | 1 + .../src/remark-lint/no-unresolved-api-ref.ts | 219 +++++ packages/doom/src/remarkrc.ts | 2 + .../doom/src/runtime/components/K8sAPI.tsx | 49 +- .../runtime/components/K8sPermissionTable.tsx | 24 +- .../src/runtime/components/OpenAPIPath.tsx | 33 +- .../src/runtime/components/_HeadingTitle.tsx | 31 +- .../runtime/components/_K8sAPIEndpoints.tsx | 859 ++++++++++-------- .../src/runtime/components/_K8sAPISchema.tsx | 80 +- packages/doom/src/runtime/translation.ts | 51 ++ packages/doom/src/runtime/utils.ts | 124 ++- packages/doom/src/types.ts | 10 + packages/doom/src/utils/helpers.ts | 10 +- 33 files changed, 1699 insertions(+), 433 deletions(-) create mode 100644 .changeset/fix-api-reference-rendering.md create mode 100644 fixture-docs/en/apis/kubernetes_apis/anchortest.mdx create mode 100644 fixture-docs/en/apis/kubernetes_apis/clusterwidget.mdx create mode 100644 fixture-docs/en/apis/kubernetes_apis/iptablestestrule.mdx create mode 100644 fixture-docs/en/apis/kubernetes_apis/multiversionthing.mdx create mode 100644 fixture-docs/en/apis/kubernetes_apis/nostatussubresource.mdx create mode 100644 fixture-docs/en/apis/kubernetes_apis/openapipathtest.mdx create mode 100644 fixture-docs/en/apis/kubernetes_apis/testloadbalancer2.mdx create mode 100644 fixture-docs/shared/crds/doomtest.alauda.io_anchortests.yaml create mode 100644 fixture-docs/shared/crds/doomtest.alauda.io_clusterwidgets.yaml create mode 100644 fixture-docs/shared/crds/doomtest.alauda.io_iptables-test-rules.yaml create mode 100644 fixture-docs/shared/crds/doomtest.alauda.io_multiversionthings.yaml create mode 100644 fixture-docs/shared/crds/doomtest.alauda.io_nostatussubresources.yaml create mode 100644 fixture-docs/shared/crds/doomtest.alauda.io_testloadbalancer2.yaml create mode 100644 fixture-docs/shared/openapis/testapi.json create mode 100644 packages/doom/src/cli/api.ts create mode 100644 packages/doom/src/remark-lint/no-unresolved-api-ref.ts diff --git a/.changeset/fix-api-reference-rendering.md b/.changeset/fix-api-reference-rendering.md new file mode 100644 index 000000000..088460686 --- /dev/null +++ b/.changeset/fix-api-reference-rendering.md @@ -0,0 +1,35 @@ +--- +"@alauda/doom": minor +--- + +Fix a family of API-reference rendering defects where the components ignored facts already present in the CRD / OpenAPI sources, plus add offline integrity tooling. + +**Correctness — endpoints and schema now read the source instead of guessing:** + +- `` derives `namespaced` from the CRD's `spec.scope` instead of always defaulting to `true`, so `Cluster`-scoped resources no longer render an unreachable `/namespaces/{namespace}/` path. The `namespaced` prop still overrides. +- Endpoint paths use the resource's real plural from `spec.names.plural` instead of guessing with `pluralize(kind)`, fixing hyphenated (`vpc-egress-gateways`) and irregular (`alaudaloadbalancer2`) plurals. A new `plural` prop is an escape hatch for OpenAPI-sourced resources. (`toLocaleLowerCase` → `toLowerCase`.) +- When a page does not pass `apiVersion`, a multi-version CRD now renders the version `kubectl` resolves to — the highest-priority `served` version (GA > beta > alpha, apimachinery ordering) — instead of `spec.versions[0]`. This never publishes a `served: false` version. Both the schema and the endpoint-path version now come from this single resolved version. Configurable via `api.crdVersion: 'preferred' | 'storage' | 'first'`. +- `/status` (and the new `/scale`) endpoints are rendered based on the version's declared `spec.versions[].subresources`, not on whether the schema happens to contain a `status` property — fixing both fabricated `/status` endpoints and missing ones. +- `x-kubernetes-int-or-string` fields (carried under `anyOf`) now render their type (`integer|string`) instead of an empty cell. + +**Anchors and badges:** + +- Array-item schema sections (`.spec.foo[]`) no longer collide with their parent (`.spec.foo`) on the same HTML id; schema headings use a page-level stateful slugger, so every property section is uniquely addressable (HTML id uniqueness / WCAG 4.1.1). +- OpenAPI operations without a `summary` no longer produce `id="undefined"` / `href="#undefined"` or bare numeric anchors; the heading id is derived from the method (and summary when present). +- The `` Request Body **required** badge reads `requestBody.required` (the boolean on the request body) instead of the body schema's list of required _properties_. + +**New configuration and tooling:** + +- `api.references` accepts an object form `{ href, routePath?: string | false }`, separating the link href from the page-identity key used to decide inline expansion. Plain string values are unchanged. `routePath: false` explicitly means "always link, never expand". +- `translate.copyOnlyDirectories` overrides which directories are copied instead of translated (default unchanged). +- New `doom api check` command: an offline validation of the local CRD / OpenAPI sources — every file parses, CRDs have the right kind and a unique name, filenames follow the `_.yaml` convention, and OpenAPI definitions do not conflict across files. +- New `no-unresolved-api-ref` lint rule flags `` / `` / `` / `` / `` references that cannot be resolved, before they ship as blank pages. +- Deterministic source ordering: schema files are sorted, and the `filepath` / `openapiPath` map key no longer depends on `process.cwd()`, so pinning a source is stable across working directories and `` uses a consistent first-match. + +**Other:** + +- `` renders a visible "not found" row instead of silently dropping an unresolved function. +- API component chrome (`Property`, `Type`, `Description`, `Required`, `Specification`, `API Endpoints`, `HTTP method`, `Common Parameters`, `Request Body`, `Response`, …) is now translated via `useTranslation` (en/zh/ru) instead of hardcoded English. +- API reference pages surface their top-level properties in the page outline (previously a two-line TOC), while deeper nested properties stay excluded. + +> **Downstream impact:** the scope / plural / version / status fixes change the rendered endpoints on already-published pages (measured: immutable-infra-docs 8, asm-docs 4, aml-docs 4, acp-docs 3, plus fabricated `/status` across ~19 pages). `namespaced` (`docs/*/usage/api.md`) and the CRD default-version behavior were documented public defaults; downstream docs should re-review their API pages after upgrading. diff --git a/fixture-docs/en/apis/kubernetes_apis/anchortest.mdx b/fixture-docs/en/apis/kubernetes_apis/anchortest.mdx new file mode 100644 index 000000000..642db2f2d --- /dev/null +++ b/fixture-docs/en/apis/kubernetes_apis/anchortest.mdx @@ -0,0 +1,3 @@ +# AnchorTest [doomtest.alauda.io/v1] + + diff --git a/fixture-docs/en/apis/kubernetes_apis/clusterwidget.mdx b/fixture-docs/en/apis/kubernetes_apis/clusterwidget.mdx new file mode 100644 index 000000000..1c00ca260 --- /dev/null +++ b/fixture-docs/en/apis/kubernetes_apis/clusterwidget.mdx @@ -0,0 +1,3 @@ +# ClusterWidget [doomtest.alauda.io/v1] + + diff --git a/fixture-docs/en/apis/kubernetes_apis/iptablestestrule.mdx b/fixture-docs/en/apis/kubernetes_apis/iptablestestrule.mdx new file mode 100644 index 000000000..01ffa8e9d --- /dev/null +++ b/fixture-docs/en/apis/kubernetes_apis/iptablestestrule.mdx @@ -0,0 +1,3 @@ +# IptablesTestRule [doomtest.alauda.io/v1] + + diff --git a/fixture-docs/en/apis/kubernetes_apis/multiversionthing.mdx b/fixture-docs/en/apis/kubernetes_apis/multiversionthing.mdx new file mode 100644 index 000000000..0cdddc6c0 --- /dev/null +++ b/fixture-docs/en/apis/kubernetes_apis/multiversionthing.mdx @@ -0,0 +1,3 @@ +# MultiVersionThing [doomtest.alauda.io/v1beta1] + + diff --git a/fixture-docs/en/apis/kubernetes_apis/nostatussubresource.mdx b/fixture-docs/en/apis/kubernetes_apis/nostatussubresource.mdx new file mode 100644 index 000000000..19d49f44e --- /dev/null +++ b/fixture-docs/en/apis/kubernetes_apis/nostatussubresource.mdx @@ -0,0 +1,3 @@ +# NoStatusSubresource [doomtest.alauda.io/v1] + + diff --git a/fixture-docs/en/apis/kubernetes_apis/openapipathtest.mdx b/fixture-docs/en/apis/kubernetes_apis/openapipathtest.mdx new file mode 100644 index 000000000..c1fb50676 --- /dev/null +++ b/fixture-docs/en/apis/kubernetes_apis/openapipathtest.mdx @@ -0,0 +1,7 @@ +# OpenAPIPath Fixture + +Exercises DOOM-18 (operations without `summary` must not produce `#undefined` +or bare numeric anchors) and DOOM-12 (the `required` badge must read +`requestBody.required`, not the body schema's list of required properties). + + diff --git a/fixture-docs/en/apis/kubernetes_apis/testloadbalancer2.mdx b/fixture-docs/en/apis/kubernetes_apis/testloadbalancer2.mdx new file mode 100644 index 000000000..db1b07e59 --- /dev/null +++ b/fixture-docs/en/apis/kubernetes_apis/testloadbalancer2.mdx @@ -0,0 +1,3 @@ +# TEST2 [doomtest.alauda.io/v1] + + diff --git a/fixture-docs/shared/crds/doomtest.alauda.io_anchortests.yaml b/fixture-docs/shared/crds/doomtest.alauda.io_anchortests.yaml new file mode 100644 index 000000000..3d6f144a5 --- /dev/null +++ b/fixture-docs/shared/crds/doomtest.alauda.io_anchortests.yaml @@ -0,0 +1,70 @@ +# FIXTURE for DOOM-11 (array `[]` anchor collides with parent property), +# DOOM-21 (x-kubernetes-int-or-string Type column is empty), +# DOOM-23 (`/scale` subresource endpoint never rendered). +# Namespaced + pluralize-compatible + single served/storage version, so it does +# NOT also trip DOOM-2/3/4 — it isolates the anchor / schema / scale defects. +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: anchortests.doomtest.alauda.io +spec: + group: doomtest.alauda.io + scope: Namespaced + names: + kind: AnchorTest + listKind: AnchorTestList + plural: anchortests + singular: anchortest + versions: + - name: v1 + served: true + storage: true + subresources: + status: {} + scale: + specReplicasPath: .spec.replicas + statusReplicasPath: .status.replicas + schema: + openAPIV3Schema: + type: object + description: A fixture resource exercising anchors, IntOrString and scale. + properties: + spec: + type: object + description: Desired state. + properties: + replicas: + type: integer + description: Number of replicas. + port: + x-kubernetes-int-or-string: true + anyOf: + - type: integer + - type: string + description: An IntOrString port (DOOM-21 — Type column must not be empty). + selector: + type: object + description: Label selector. + properties: + matchExpressions: + type: array + description: A list of selector requirements. + items: + type: object + description: A single selector requirement. + properties: + key: + type: string + description: The label key. + values: + type: array + description: An array of string values. + items: + type: string + status: + type: object + description: Observed state. + properties: + replicas: + type: integer + description: Observed replica count. diff --git a/fixture-docs/shared/crds/doomtest.alauda.io_clusterwidgets.yaml b/fixture-docs/shared/crds/doomtest.alauda.io_clusterwidgets.yaml new file mode 100644 index 000000000..d753d7ded --- /dev/null +++ b/fixture-docs/shared/crds/doomtest.alauda.io_clusterwidgets.yaml @@ -0,0 +1,40 @@ +# FIXTURE for DOOM-2 —— Cluster-scoped CRD +# doom 今天渲染 /apis/doomtest.alauda.io/v1/namespaces/{namespace}/clusterwidgets ← 错,会 404 +# 修复后应渲染 /apis/doomtest.alauda.io/v1/clusterwidgets +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusterwidgets.doomtest.alauda.io +spec: + group: doomtest.alauda.io + scope: Cluster + names: + kind: ClusterWidget + listKind: ClusterWidgetList + plural: clusterwidgets + singular: clusterwidget + versions: + - name: v1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + description: A cluster-scoped fixture resource for doom API rendering tests. + properties: + spec: + type: object + description: Desired state. + properties: + size: + type: integer + description: Widget size. + status: + type: object + description: Observed state. + properties: + ready: + type: boolean + description: Whether the widget is ready. diff --git a/fixture-docs/shared/crds/doomtest.alauda.io_iptables-test-rules.yaml b/fixture-docs/shared/crds/doomtest.alauda.io_iptables-test-rules.yaml new file mode 100644 index 000000000..37e53fdb6 --- /dev/null +++ b/fixture-docs/shared/crds/doomtest.alauda.io_iptables-test-rules.yaml @@ -0,0 +1,31 @@ +# FIXTURE for DOOM-3 —— plural 无法由 pluralize(kind) 推导(连字符型,kube-ovn 同款) +# pluralize('IptablesTestRule') => 'iptablestestrules' ← doom 今天渲染这个,错 +# spec.names.plural => 'iptables-test-rules' ← 修复后应渲染这个 +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: iptables-test-rules.doomtest.alauda.io +spec: + group: doomtest.alauda.io + scope: Namespaced + names: + kind: IptablesTestRule + listKind: IptablesTestRuleList + plural: iptables-test-rules + singular: iptables-test-rule + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + description: A hyphenated-plural fixture resource for doom API rendering tests. + properties: + spec: + type: object + description: Desired state. + properties: + rule: + type: string + description: The rule expression. diff --git a/fixture-docs/shared/crds/doomtest.alauda.io_multiversionthings.yaml b/fixture-docs/shared/crds/doomtest.alauda.io_multiversionthings.yaml new file mode 100644 index 000000000..e3ce7492b --- /dev/null +++ b/fixture-docs/shared/crds/doomtest.alauda.io_multiversionthings.yaml @@ -0,0 +1,52 @@ +# FIXTURE for DOOM-4 —— 多版本,且 versions[0] 是 served:false +# doom 今天渲染 v1alpha1 ← API server 明确拒绝服务这个版本 +# 修复后(preferred-served)应渲染 v1beta1 +# 同时覆盖 DOOM-10 的正向:只有 v1beta1 声明了 subresources.status +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: multiversionthings.doomtest.alauda.io +spec: + group: doomtest.alauda.io + scope: Namespaced + names: + kind: MultiVersionThing + listKind: MultiVersionThingList + plural: multiversionthings + singular: multiversionthing + versions: + - name: v1alpha1 + served: false + storage: false + schema: + openAPIV3Schema: + type: object + description: Retired alpha version, no longer served by the API server. + properties: + spec: + type: object + description: Desired state. + - name: v1beta1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + description: A multi-version fixture resource for doom API rendering tests. + properties: + spec: + type: object + description: Desired state. + properties: + mode: + type: string + description: Operating mode. + status: + type: object + description: Observed state. + properties: + phase: + type: string + description: Current phase. diff --git a/fixture-docs/shared/crds/doomtest.alauda.io_nostatussubresources.yaml b/fixture-docs/shared/crds/doomtest.alauda.io_nostatussubresources.yaml new file mode 100644 index 000000000..67a76db33 --- /dev/null +++ b/fixture-docs/shared/crds/doomtest.alauda.io_nostatussubresources.yaml @@ -0,0 +1,31 @@ +# FIXTURE for DOOM-10 —— schema 里有 status 属性,但 CRD 没有声明 subresources.status +# doom 今天渲染 /nostatussubresources/{name}/status ← 该端点在真实集群上 404 +# 修复后不应渲染任何 /status 端点 +# (注:doom 现有的 8 个 tekton fixture 已经是这个形状,本文件只是把它显式化) +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: nostatussubresources.doomtest.alauda.io +spec: + group: doomtest.alauda.io + scope: Namespaced + names: + kind: NoStatusSubresource + listKind: NoStatusSubresourceList + plural: nostatussubresources + singular: nostatussubresource + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + description: Has a status property but no status subresource. + properties: + spec: + type: object + description: Desired state. + status: + type: object + description: Observed state, reconciled via the main resource (no subresource). diff --git a/fixture-docs/shared/crds/doomtest.alauda.io_testloadbalancer2.yaml b/fixture-docs/shared/crds/doomtest.alauda.io_testloadbalancer2.yaml new file mode 100644 index 000000000..9f02f5aa6 --- /dev/null +++ b/fixture-docs/shared/crds/doomtest.alauda.io_testloadbalancer2.yaml @@ -0,0 +1,31 @@ +# FIXTURE for DOOM-3(第二形态)—— 不规则复数(ALB2 同款,作者连 apiKind 变通都用不了) +# pluralize('TEST2') => 'test2s' ← doom 今天渲染这个,错 +# spec.names.plural => 'testloadbalancer2' ← 修复后应渲染这个 +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: testloadbalancer2.doomtest.alauda.io +spec: + group: doomtest.alauda.io + scope: Namespaced + names: + kind: TEST2 + listKind: TEST2List + plural: testloadbalancer2 + singular: testloadbalancer2 + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + description: An irregular-plural fixture resource for doom API rendering tests. + properties: + spec: + type: object + description: Desired state. + properties: + address: + type: string + description: Load balancer address. diff --git a/fixture-docs/shared/openapis/testapi.json b/fixture-docs/shared/openapis/testapi.json new file mode 100644 index 000000000..edeec89ad --- /dev/null +++ b/fixture-docs/shared/openapis/testapi.json @@ -0,0 +1,64 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Doom API Fixture", "version": "1.0.0" }, + "paths": { + "/test/foo": { + "get": { + "responses": { "200": { "description": "OK" } } + }, + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/WithReq" } + } + } + }, + "responses": { "200": { "description": "OK" } } + } + }, + "/test/bar": { + "put": { + "requestBody": { + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/WithReq" } + } + } + }, + "responses": { "200": { "description": "OK" } } + } + }, + "/test/baz": { + "post": { + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/NoReq" } + } + } + }, + "responses": { "200": { "description": "OK" } } + } + } + }, + "components": { + "schemas": { + "WithReq": { + "type": "object", + "required": ["bar"], + "properties": { + "bar": { "type": "string", "description": "A required property." } + } + }, + "NoReq": { + "type": "object", + "properties": { + "baz": { "type": "string", "description": "An optional property." } + } + } + } + } +} diff --git a/packages/doom/src/cli/api.ts b/packages/doom/src/cli/api.ts new file mode 100644 index 000000000..98fd1f580 --- /dev/null +++ b/packages/doom/src/cli/api.ts @@ -0,0 +1,198 @@ +import path from 'node:path' + +import { logger } from '@rspress/core' +import { Command } from 'commander' +import { glob } from 'tinyglobby' +import { cyan, red, yellow } from 'yoctocolors' + +import type { GlobalCliOptions } from '../types.ts' +import { resolveStaticConfig } from '../utils/helpers.ts' + +import { loadConfig } from './load-config.ts' + +interface Problem { + file: string + message: string +} + +// The parsed files are untrusted YAML/JSON — an empty file parses to `null`, +// and a non-CRD file has none of these fields. Type them loosely so the runtime +// guards below are real, not dead code. +interface LooseCrd { + kind?: string + metadata?: { name?: string } + spec?: { group?: string; names?: { plural?: string } } +} + +const rel = (base: string, file: string) => path.relative(base, file) || file + +/** + * `doom api check` — a zero-network, offline validation of the local API + * schema sources (`api.crds` / `api.openapis`). It closes the gap the ingest + * contract leaves open: there is no naming rule, no drift detection and no + * conflict report for the hand-committed CRD / OpenAPI files. It never talks to + * a cluster; it only reads what is already on disk, so a documentation build + * stays reproducible. + */ +const checkApiSources = async ( + root: string | undefined, + globalOptions: GlobalCliOptions, +) => { + const { config, configFilePath } = await loadConfig(root, globalOptions) + const base = configFilePath ? path.dirname(configFilePath) : config.root! + + const problems: Problem[] = [] + const warnings: Problem[] = [] + + // ---- CRDs ------------------------------------------------------------- + const crdFiles = config.api?.crds?.length + ? await glob(config.api.crds, { cwd: base, absolute: true }) + : [] + + const crdNameOwners = new Map() + + for (const file of crdFiles) { + let crd: LooseCrd | null | undefined + try { + crd = await resolveStaticConfig(file) + } catch (error) { + problems.push({ + file: rel(base, file), + message: `failed to parse: ${(error as Error).message}`, + }) + continue + } + + if (crd?.kind !== 'CustomResourceDefinition') { + problems.push({ + file: rel(base, file), + message: `not a CustomResourceDefinition (kind: ${cyan(String(crd?.kind))})`, + }) + continue + } + + const name = crd.metadata?.name + if (!name) { + problems.push({ file: rel(base, file), message: 'missing metadata.name' }) + continue + } + + const previous = crdNameOwners.get(name) + if (previous) { + problems.push({ + file: rel(base, file), + message: `duplicate CRD \`${cyan(name)}\` (also in ${cyan(previous)})`, + }) + } else { + crdNameOwners.set(name, rel(base, file)) + } + + // Filename convention: `_.yaml`. + const group = crd.spec?.group + const plural = crd.spec?.names?.plural + if (group && plural) { + const expected = `${group}_${plural}.yaml` + const actual = path.basename(file) + if (actual !== expected && actual !== `${group}_${plural}.yml`) { + warnings.push({ + file: rel(base, file), + message: `filename \`${actual}\` does not match the \`_.yaml\` convention (expected \`${cyan(expected)}\`)`, + }) + } + } + } + + // ---- OpenAPI definitions --------------------------------------------- + const openapiFiles = config.api?.openapis?.length + ? await glob(config.api.openapis, { cwd: base, absolute: true }) + : [] + + // schema name -> list of { file, content } + const schemaOwners = new Map() + + for (const file of openapiFiles) { + let doc: Record | null | undefined + try { + doc = await resolveStaticConfig | null>(file) + } catch (error) { + problems.push({ + file: rel(base, file), + message: `failed to parse: ${(error as Error).message}`, + }) + continue + } + + const components = doc?.components as + | { schemas?: Record } + | undefined + const schemas: Record = { + ...(components?.schemas ?? {}), + ...((doc?.definitions as Record | undefined) ?? {}), + } + for (const [name, schema] of Object.entries(schemas)) { + const list = schemaOwners.get(name) ?? [] + list.push({ file: rel(base, file), content: JSON.stringify(schema) }) + schemaOwners.set(name, list) + } + } + + let duplicateNames = 0 + let conflictNames = 0 + for (const [name, owners] of schemaOwners) { + if (owners.length < 2) { + continue + } + duplicateNames++ + const distinctContent = new Set(owners.map((o) => o.content)) + if (distinctContent.size > 1) { + conflictNames++ + problems.push({ + file: owners.map((o) => o.file).join(', '), + message: `OpenAPI definition \`${cyan(name)}\` is defined ${owners.length} times with ${distinctContent.size} different bodies`, + }) + } + } + + // ---- report ----------------------------------------------------------- + logger.info( + `Checked ${cyan(String(crdFiles.length))} CRD file(s) and ${cyan(String(openapiFiles.length))} OpenAPI file(s).`, + ) + if (duplicateNames) { + logger.info( + `${cyan(String(duplicateNames))} OpenAPI definition name(s) appear in more than one file (${conflictNames} with conflicting bodies).`, + ) + } + + for (const w of warnings) { + logger.warn(`${yellow(w.file)}: ${w.message}`) + } + for (const p of problems) { + logger.error(`${red(p.file)}: ${p.message}`) + } + + if (problems.length) { + logger.error( + `\`doom api check\` found ${red(String(problems.length))} problem(s) and ${warnings.length} warning(s).`, + ) + process.exitCode = 1 + } else { + logger.success( + `\`doom api check\` passed with ${warnings.length} warning(s).`, + ) + } +} + +export const apiCommand = new Command('api').description( + 'Work with the local API schema sources (`api.crds` / `api.openapis`)', +) + +apiCommand + .command('check') + .description( + 'Validate the local CRD / OpenAPI sources offline: every file parses, CRDs have the right kind, names are unique, filenames follow the `_.yaml` convention, and OpenAPI definitions do not conflict across files', + ) + .argument('[root]', 'Root directory of the documentation') + .action(async function (root?: string) { + const globalOptions = this.optsWithGlobals() + await checkApiSources(root, globalOptions) + }) diff --git a/packages/doom/src/cli/index.ts b/packages/doom/src/cli/index.ts index aee37b6a5..caa88a263 100644 --- a/packages/doom/src/cli/index.ts +++ b/packages/doom/src/cli/index.ts @@ -16,6 +16,7 @@ import { green } from 'yoctocolors' import type { GlobalCliOptions, ServeOptions } from '../types.js' import { setNodeEnv } from '../utils/index.js' +import { apiCommand } from './api.js' import { CWD, DEFAULT_CONFIGS, SITES_FILE } from './constants.js' import { exportCommand } from './export.js' import { parseBoolean, parseBooleanOrString } from './helpers.js' @@ -267,6 +268,7 @@ program.addCommand(newCommand) program.addCommand(translateCommand) program.addCommand(exportCommand) program.addCommand(lintCommand) +program.addCommand(apiCommand) program.parseAsync().catch((err: unknown) => { if (err instanceof Error && err.name === 'ExitPromptError') { diff --git a/packages/doom/src/cli/translate.ts b/packages/doom/src/cli/translate.ts index 6b3cd4a15..6a01c9376 100644 --- a/packages/doom/src/cli/translate.ts +++ b/packages/doom/src/cli/translate.ts @@ -52,8 +52,9 @@ export interface I18nFrontmatter { export const TERMS_SUPPORTED_LANGUAGES: Language[] = ['en', 'zh', 'ru'] -// Directories that should be copied instead of translated -const COPY_ONLY_DIRECTORIES = [ +// Directories that are copied instead of translated by default. Override with +// `translate.copyOnlyDirectories` in the config. +const DEFAULT_COPY_ONLY_DIRECTORIES = [ 'apis/advanced_apis/**', 'apis/crds/**', 'apis/kubernetes_apis/**', @@ -364,10 +365,13 @@ export const translateCommand = new Command('translate') } // Get copy-only files using glob patterns - const copyOnlyFilePaths = await glob(COPY_ONLY_DIRECTORIES, { - absolute: true, - cwd: sourceDir, - }) + const copyOnlyFilePaths = await glob( + config.translate?.copyOnlyDirectories ?? DEFAULT_COPY_ONLY_DIRECTORIES, + { + absolute: true, + cwd: sourceDir, + }, + ) const copyOnlyFilePathsSet = new Set(copyOnlyFilePaths) diff --git a/packages/doom/src/plugins/api/index.ts b/packages/doom/src/plugins/api/index.ts index 0982b3e77..a46ba1baf 100644 --- a/packages/doom/src/plugins/api/index.ts +++ b/packages/doom/src/plugins/api/index.ts @@ -21,7 +21,10 @@ declare module 'doom-@api-openapisMap' { // @internal declare module 'doom-@api-virtual' { - const virtual: Pick + const virtual: Pick< + ApiPluginOptions, + 'references' | 'pathPrefix' | 'crdVersion' + > } export const apiPlugin = ({ @@ -31,7 +34,7 @@ export const apiPlugin = ({ name: 'doom-api', async addRuntimeModules(config, isProd) { return { - 'doom-@api-virtual': `export default ${JSON.stringify({ references: config.api?.references, pathPrefix: config.api?.pathPrefix }, null, isProd ? 0 : 2)}`, + 'doom-@api-virtual': `export default ${JSON.stringify({ references: config.api?.references, pathPrefix: config.api?.pathPrefix, crdVersion: config.api?.crdVersion }, null, isProd ? 0 : 2)}`, ...(await generateRuntimeModule( config.api?.crds, 'api-crds', diff --git a/packages/doom/src/plugins/api/types.ts b/packages/doom/src/plugins/api/types.ts index 106386756..008c117be 100644 --- a/packages/doom/src/plugins/api/types.ts +++ b/packages/doom/src/plugins/api/types.ts @@ -4,8 +4,34 @@ export interface ApiPluginOptions { localBasePath: string crds?: string[] openapis?: string[] - references?: Record + /** + * Map a schema ref name to how it should be linked / whether it is documented + * elsewhere. A plain string is the link href (backward compatible). The object + * form separates the two roles a value plays: + * + * - `href`: where a `RefLink` to this schema points. + * - `routePath`: the page that documents this schema inline. On that page the + * schema is expanded; elsewhere it is only linked. `false` disables the + * omit entirely (always link, never expand) — the explicit form of the + * `.html`-href trick. + */ + references?: Record< + string, + string | { href: string; routePath?: string | false } + > pathPrefix?: string + /** + * Which version of a multi-version CRD to render by default when a page does + * not pass an explicit `apiVersion` prop. + * + * - `preferred` (default): the version `kubectl` resolves to — highest + * priority among `served` versions (GA > beta > alpha). + * - `storage`: the etcd storage version (`storage: true`). + * - `first`: legacy behavior — `spec.versions[0]`. + * + * @default 'preferred' + */ + crdVersion?: 'preferred' | 'storage' | 'first' } export interface CustomResourceDefinitionVersion { @@ -15,6 +41,15 @@ export interface CustomResourceDefinitionVersion { } served: boolean storage: boolean + /** + * Subresources the API server actually exposes for this version. `/status` + * and `/scale` endpoints exist if and only if they are declared here — not + * because the schema happens to contain a `status` property. + */ + subresources?: { + status?: object + scale?: object + } } export interface CustomResourceDefinition { diff --git a/packages/doom/src/remark-lint/index.ts b/packages/doom/src/remark-lint/index.ts index 8016f1b8d..a416777d4 100644 --- a/packages/doom/src/remark-lint/index.ts +++ b/packages/doom/src/remark-lint/index.ts @@ -19,6 +19,7 @@ export * from './no-legacy-os-names.ts' export * from './no-multi-open-api-paths.ts' export * from './no-paragraph-indent.ts' export * from './no-unmatched-anchor.ts' +export * from './no-unresolved-api-ref.ts' export * from './site.ts' export * from './table-size.ts' export * from './title-required.ts' diff --git a/packages/doom/src/remark-lint/no-unresolved-api-ref.ts b/packages/doom/src/remark-lint/no-unresolved-api-ref.ts new file mode 100644 index 000000000..a6b2c9581 --- /dev/null +++ b/packages/doom/src/remark-lint/no-unresolved-api-ref.ts @@ -0,0 +1,219 @@ +import path from 'node:path' + +import type { Root } from 'mdast' +import type { + MdxJsxAttribute, + MdxJsxExpressionAttribute, +} from 'mdast-util-mdx-jsx' +import { glob } from 'tinyglobby' +import { lintRule } from 'unified-lint-rule' +import { visitParents } from 'unist-util-visit-parents' + +import { resolveStaticConfig } from '../utils/helpers.ts' + +import { getConfig } from './utils.ts' + +/** + * Build-integrity face for the API components (DOOM-1). + * + * Every `` / `` / `` / `` / + * `` whose reference cannot be resolved renders a blank page + * (or a table with vanished rows) with a green `doom build`. These components + * scatter `console.error` calls that are never surfaced. This rule resolves the + * same sources the runtime plugins load and reports an unresolved reference at + * lint time, before it ships silently. + */ + +interface ApiSources { + crdNames: Set + schemaNames: Set + pathNames: Set + functionNames: Set +} + +let sourcesPromise: Promise | undefined + +const loadYamlOrJson = async (base: string, patterns?: string[]) => { + if (!patterns?.length) { + return [] as T[] + } + const files = await glob(patterns, { cwd: base }) + const out: T[] = [] + for (const file of files) { + try { + out.push(await resolveStaticConfig(path.resolve(base, file))) + } catch { + // A file that fails to parse is a source problem of its own; skip it here + // so a single bad file does not blind the whole check. + } + } + return out +} + +const loadSources = async (): Promise => { + const { config, configFilePath } = await getConfig() + const base = configFilePath ? path.dirname(configFilePath) : config.root! + + const crdNames = new Set() + const schemaNames = new Set() + const pathNames = new Set() + const functionNames = new Set() + + const crds = await loadYamlOrJson<{ + metadata?: { name?: string } + } | null>(base, config.api?.crds) + for (const crd of crds) { + if (crd?.metadata?.name) { + crdNames.add(crd.metadata.name) + } + } + + const openapis = await loadYamlOrJson | null>( + base, + config.api?.openapis, + ) + for (const doc of openapis) { + if (!doc) { + continue + } + const components = doc.components as + | { schemas?: Record } + | undefined + for (const key of Object.keys(components?.schemas ?? {})) { + schemaNames.add(key) + } + // swagger 2.0 sources carry schemas under `definitions`. + for (const key of Object.keys( + (doc.definitions as Record | undefined) ?? {}, + )) { + schemaNames.add(key) + } + for (const key of Object.keys( + (doc.paths as Record | undefined) ?? {}, + )) { + pathNames.add(key) + } + } + + const functionResources = await loadYamlOrJson<{ + metadata?: { name?: string } + } | null>(base, config.permission?.functionresources) + for (const fr of functionResources) { + if (fr?.metadata?.name) { + functionNames.add(fr.metadata.name) + } + } + + return { crdNames, schemaNames, pathNames, functionNames } +} + +/** Collect static string values from a JSX attribute (string, `{'x'}` or `{['a','b']}`). */ +const staticStrings = (attr: MdxJsxAttribute | undefined): string[] => { + if (!attr) { + return [] + } + if (typeof attr.value === 'string') { + return [attr.value] + } + const estree = attr.value?.data?.estree + const expr = estree?.body[0] + if (!expr || expr.type !== 'ExpressionStatement') { + return [] + } + const node = expr.expression + if (node.type === 'Literal' && typeof node.value === 'string') { + return [node.value] + } + if (node.type === 'ArrayExpression') { + return node.elements.flatMap((el) => + el && el.type === 'Literal' && typeof el.value === 'string' + ? [el.value] + : [], + ) + } + // Anything dynamic (a variable, a computed value) cannot be validated here. + return [] +} + +const attrOf = ( + element: { attributes: unknown[] }, + name: string, +): MdxJsxAttribute | undefined => + ( + element.attributes as Array + ).find( + (attr): attr is MdxJsxAttribute => + attr.type === 'mdxJsxAttribute' && attr.name === name, + ) + +export const noUnresolvedApiRef = lintRule( + 'doom-lint:no-unresolved-api-ref', + async (root, vfile) => { + const { crdNames, schemaNames, pathNames, functionNames } = + await (sourcesPromise ??= loadSources()) + + visitParents( + root, + ['mdxJsxFlowElement', 'mdxJsxTextElement'] as const, + (element, parents) => { + const report = (message: string, attr?: MdxJsxAttribute) => + vfile.message(message, { + ancestors: [...parents, element], + place: (attr ?? element).position, + }) + + switch (element.name) { + case 'K8sAPI': + case 'K8sCrd': { + const attr = attrOf(element, 'name') + const name = staticStrings(attr)[0] + if (name && !crdNames.has(name) && !schemaNames.has(name)) { + report( + `\`<${element.name}>\` references \`name="${name}"\`, which resolves to neither a CRD (\`api.crds\`) nor an OpenAPI schema (\`api.openapis\`). The page would render blank.`, + attr, + ) + } + break + } + case 'OpenAPIRef': { + const attr = attrOf(element, 'schema') + const name = staticStrings(attr)[0] + if (name && !schemaNames.has(name)) { + report( + `\`\` references \`schema="${name}"\`, which is not defined in any \`api.openapis\` source.`, + attr, + ) + } + break + } + case 'OpenAPIPath': { + const attr = attrOf(element, 'path') + for (const p of staticStrings(attr)) { + if (!pathNames.has(p)) { + report( + `\`\` references \`path\` \`${p}\`, which is not defined in any \`api.openapis\` source.`, + attr, + ) + } + } + break + } + case 'K8sPermissionTable': { + const attr = attrOf(element, 'functions') + for (const fn of staticStrings(attr)) { + if (!functionNames.has(fn)) { + report( + `\`\` references function \`${fn}\`, which is not a known FunctionResource (\`permission.functionresources\`). Its row would silently vanish.`, + attr, + ) + } + } + break + } + default: + break + } + }, + ) + }, +) diff --git a/packages/doom/src/remarkrc.ts b/packages/doom/src/remarkrc.ts index 4fd96e1af..c10b35320 100644 --- a/packages/doom/src/remarkrc.ts +++ b/packages/doom/src/remarkrc.ts @@ -20,6 +20,7 @@ import doomLint, { noMultiOpenAPIPaths, noParagraphIndent, noUnmatchedAnchor, + noUnresolvedApiRef, site, tableSize, titleRequired, @@ -46,6 +47,7 @@ export default { noMultiOpenAPIPaths, noParagraphIndent, noUnmatchedAnchor, + noUnresolvedApiRef, site, tableSize, titleRequired, diff --git a/packages/doom/src/runtime/components/K8sAPI.tsx b/packages/doom/src/runtime/components/K8sAPI.tsx index 01e69d982..be88e4a0b 100644 --- a/packages/doom/src/runtime/components/K8sAPI.tsx +++ b/packages/doom/src/runtime/components/K8sAPI.tsx @@ -1,8 +1,9 @@ import crdsMap from 'doom-@api-crdsMap' import openapisMap from 'doom-@api-openapisMap' +import virtual from 'doom-@api-virtual' import { useMemo } from 'react' -import { resolveRef } from '../utils.js' +import { resolveRef, selectCrdVersionName } from '../utils.js' import { K8sAPIEndpoints, type K8sAPIDefinition } from './_K8sAPIEndpoints.js' import { K8sAPISchema } from './_K8sAPISchema.js' @@ -15,6 +16,12 @@ export interface K8sAPIProps { apiGroup?: string apiVersion?: string apiKind?: string + /** + * Override the resource's plural name in the endpoint paths. Only needed for + * OpenAPI-sourced resources whose plural cannot be read from a CRD; for CRD + * sources the plural comes from `spec.names.plural` automatically. + */ + plural?: string } export const K8sAPI = ({ @@ -25,6 +32,7 @@ export const K8sAPI = ({ apiGroup, apiVersion, apiKind, + plural, }: K8sAPIProps) => { const [, openapi] = useMemo( () => @@ -48,6 +56,24 @@ export const K8sAPI = ({ [filepath, name], ) + // The version this page renders: an explicit `apiVersion` prop wins, + // otherwise the version `kubectl` would resolve to (see `selectCrdVersionName` + // / `api.crdVersion`). Used consistently for the schema AND the endpoint + // paths so the two never disagree. + const resolvedVersion = useMemo( + () => + crd + ? (apiVersion ?? + selectCrdVersionName(crd.spec.versions, virtual.crdVersion)) + : apiVersion, + [apiVersion, crd], + ) + + const versionDef = useMemo( + () => crd?.spec.versions.find((ver) => ver.name === resolvedVersion), + [crd, resolvedVersion], + ) + const schema = useMemo(() => { if (openapi) { return resolveRef(openapi, name) @@ -57,19 +83,16 @@ export const K8sAPI = ({ return } - const { versions } = crd.spec - const v = apiVersion ?? versions[0]?.name - const versionDef = versions.find((ver) => ver.name === v) if (!versionDef) { console.error( - `CRD ${name} does not have version ${v}, available versions: ${versions + `CRD ${name} does not have version ${resolvedVersion}, available versions: ${crd.spec.versions .map((ver) => ver.name) .join(', ')}`, ) return } return versionDef.schema.openAPIV3Schema - }, [apiVersion, crd, name, openapi]) + }, [crd, name, openapi, resolvedVersion, versionDef]) const k8sApiDef = useMemo(() => { if (!schema) { @@ -86,11 +109,11 @@ export const K8sAPI = ({ if (crd) { return { group: crd.spec.group, - version: crd.spec.versions[0].name, + version: resolvedVersion ?? crd.spec.versions[0].name, kind: crd.spec.names.kind, } } - }, [crd, schema]) + }, [crd, resolvedVersion, schema]) if (!openapi && !crd) { console.error( @@ -103,12 +126,18 @@ export const K8sAPI = ({ <> ) : null diff --git a/packages/doom/src/runtime/components/K8sPermissionTable.tsx b/packages/doom/src/runtime/components/K8sPermissionTable.tsx index 358ac815d..d302e87f6 100644 --- a/packages/doom/src/runtime/components/K8sPermissionTable.tsx +++ b/packages/doom/src/runtime/components/K8sPermissionTable.tsx @@ -98,13 +98,15 @@ const allFunctionResources = Object.values(functionResourcesMap).reduce< export const K8sPermissionTable = ({ functions }: K8sPermissionTableProps) => { const functionResources = useMemo( () => - functions.flatMap((name) => { + functions.map((name) => { const matched = allFunctionResources[name] if (!matched) { + // Do NOT drop the row: a missing FunctionResource used to vanish + // silently, leaving a complete-looking table with a build exit 0. + // Keep it visible and log for the build-integrity summary. console.error(`FunctionResource \`${name}\` not found!\n`) - return [] } - return matched + return { name, resource: matched } }), // eslint-disable-next-line @eslint-react/exhaustive-deps functions, @@ -140,9 +142,21 @@ export const K8sPermissionTable = ({ functions }: K8sPermissionTableProps) => { - {functionResources.map((fr) => { + {functionResources.map(({ name, resource: fr }) => { + if (!fr) { + return ( + + + {name} + + + FunctionResource {name} not found + + + ) + } const { - metadata: { annotations, name }, + metadata: { annotations }, } = fr return ( diff --git a/packages/doom/src/runtime/components/OpenAPIPath.tsx b/packages/doom/src/runtime/components/OpenAPIPath.tsx index b9e398d6b..f3ff73b99 100644 --- a/packages/doom/src/runtime/components/OpenAPIPath.tsx +++ b/packages/doom/src/runtime/components/OpenAPIPath.tsx @@ -6,6 +6,7 @@ import virtual from 'doom-@api-virtual' import { OpenAPIV3, type OpenAPIV3_1 } from 'openapi-types' import { Fragment, useMemo, type ReactNode } from 'react' +import { useTranslation } from '../hooks/index.js' import { omitRoutePathRefs, resolveRef } from '../utils.js' import { Markdown } from './Markdown.js' @@ -188,6 +189,8 @@ const OpenAPIPathBase = ({ pathPrefix: pathPrefix_, slugger, }: OpenAPIPathBaseProps) => { + const t = useTranslation() + const pathPrefix = pathPrefix_ ?? (virtual.pathPrefix || '') if (!pathItem || !openapi) { @@ -205,7 +208,7 @@ const OpenAPIPathBase = ({ {pathItem.parameters && ( <> - Common Parameters + {t('common_parameters')} ( + openapi, + requestBody.$ref, + ) + : requestBody) + const requestBodyRequired = !!requestBodyObj?.required + return ( @@ -249,7 +266,7 @@ const OpenAPIPathBase = ({ {parameters && ( <> - Parameters + {t('parameters')} @@ -257,11 +274,11 @@ const OpenAPIPathBase = ({ {requestBodySchema && ( <> - Request Body + {t('request_body')} - {requestBodySchema.required && required} + {requestBodyRequired && required} )} @@ -269,7 +286,7 @@ const OpenAPIPathBase = ({ {responses && ( <> - Response + {t('response')} @@ -305,6 +322,12 @@ export const OpenAPIPath = ({ continue } for (const path of paths) { + // First matched file wins, consistent with `K8sAPI`/`OpenAPIRef` and + // this component's own JSDoc. Files are sorted (see + // `generateRuntimeModule`), so "first" is deterministic. + if (pathMap.has(path)) { + continue + } const pathItem = openapi.paths?.[path] if (pathItem) { pathMap.set(path, { diff --git a/packages/doom/src/runtime/components/_HeadingTitle.tsx b/packages/doom/src/runtime/components/_HeadingTitle.tsx index 6055f32e4..36345bda0 100644 --- a/packages/doom/src/runtime/components/_HeadingTitle.tsx +++ b/packages/doom/src/runtime/components/_HeadingTitle.tsx @@ -1,5 +1,5 @@ import BananaSlug from '@rspress/shared/github-slugger' -import { Children, type ReactNode, useMemo } from 'react' +import { isValidElement, type ReactNode, useMemo } from 'react' import { X } from './_X.js' @@ -10,6 +10,29 @@ export interface HeadingTitleProps { children: ReactNode } +/** + * Flatten a React node tree to its visible text. Unlike a plain + * `Children.toArray().filter(string)`, this descends into element children, so + * a heading like `{method} {summary}` still yields `method` even + * when `summary` is `undefined` — instead of an empty slug that becomes + * `id="undefined"` / `href="#undefined"`. + */ +const extractText = (node: ReactNode): string => { + if (node == null || typeof node === 'boolean') { + return '' + } + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + if (Array.isArray(node)) { + return (node as ReactNode[]).map(extractText).join('') + } + if (isValidElement(node)) { + return extractText((node.props as { children?: ReactNode }).children) + } + return '' +} + // TODO: use context to simplify the usage of `slugger` export const HeadingTitle = ({ slug, @@ -22,11 +45,7 @@ export const HeadingTitle = ({ }, []) const HeadingComponent = HeadingComponents[level] const slugFromChildren = useMemo( - () => - // eslint-disable-next-line @eslint-react/no-children-to-array - Children.toArray(children) - .filter((it): it is string => typeof it === 'string' && !!it.trim()) - .join(''), + () => extractText(children).trim(), [children], ) const id = useMemo( diff --git a/packages/doom/src/runtime/components/_K8sAPIEndpoints.tsx b/packages/doom/src/runtime/components/_K8sAPIEndpoints.tsx index 95bffea52..53ddfebda 100644 --- a/packages/doom/src/runtime/components/_K8sAPIEndpoints.tsx +++ b/packages/doom/src/runtime/components/_K8sAPIEndpoints.tsx @@ -1,6 +1,8 @@ import { FallbackHeading } from '@rspress/core/theme' import virtual from 'doom-@api-virtual' -import { plural } from 'pluralize' +import { plural as pluralize } from 'pluralize' + +import { useTranslation } from '../hooks/index.js' import { APIReferenceLink } from './_APIReferenceLink.js' import { X } from './_X.js' @@ -14,6 +16,13 @@ export interface K8sAPIDefinition { export interface K8sAPIEndpointsProps extends K8sAPIDefinition { namespaced?: boolean hasStatus?: boolean + hasScale?: boolean + /** + * The resource's plural name. When provided (e.g. read from a CRD's + * `spec.names.plural`) it is used verbatim; otherwise it is guessed with the + * `pluralize` package, which is wrong for irregular or hyphenated plurals. + */ + plural?: string pathPrefix?: string } @@ -22,17 +31,18 @@ const QueryParameters = ({ }: { fieldValidation?: boolean }) => { + const t = useTranslation() return ( <>
-
Query parameters
+
{t('query_parameters')}
- Parameter - Type - Description + {t('parameter')} + {t('type')} + {t('description')} @@ -85,17 +95,18 @@ const QueryParameters = ({ } const BodyParameters = ({ kind }: { kind: string }) => { + const t = useTranslation() return ( <>
-
Body parameters
+
{t('body_parameters')}
- Parameter - Type - Description + {t('parameter')} + {t('type')} + {t('description')} @@ -120,389 +131,463 @@ interface K8sAPIEndpointProps { kind: string } -const K8sAPIListEndpoint = ({ kind }: K8sAPIEndpointProps) => ( - <> -
-
HTTP method
-
- DELETE -
-
Description
-
delete collection of {kind}
-
HTTP responses
-
- - - - HTTP code - Response body - - - - - 200 - OK - - - - {' '} - schema - - - - 401 - Unauthorized - Empty - - - -
-
HTTP method
-
- GET -
-
Description
-
list objects of kind {kind}
-
HTTP responses
-
- - - - HTTP code - Response body - - - - - 200 - OK - - {kind}List schema - - - - 401 - Unauthorized - Empty - - - -
-
HTTP method
-
- POST -
-
Description
-
create a new {kind}
-
- - -
-
HTTP responses
-
- - - - HTTP code - Response body - - - - - 200 - OK - - {kind} schema - - - - 201 - Created - - {kind} schema - - - - 202 - Accepted - - {kind} schema - - - - 401 - Unauthorized - Empty - - - - -) +const K8sAPIListEndpoint = ({ kind }: K8sAPIEndpointProps) => { + const t = useTranslation() + return ( + <> +
+
{t('http_method')}
+
+ DELETE +
+
{t('description')}
+
delete collection of {kind}
+
{t('http_responses')}
+
+ + + + {t('http_code')} + {t('response_body')} + + + + + 200 - OK + + + + {' '} + schema + + + + 401 - Unauthorized + Empty + + + +
+
{t('http_method')}
+
+ GET +
+
{t('description')}
+
list objects of kind {kind}
+
{t('http_responses')}
+
+ + + + {t('http_code')} + {t('response_body')} + + + + + 200 - OK + + {kind}List schema + + + + 401 - Unauthorized + Empty + + + +
+
{t('http_method')}
+
+ POST +
+
{t('description')}
+
create a new {kind}
+
+ + +
+
{t('http_responses')}
+
+ + + + {t('http_code')} + {t('response_body')} + + + + + 200 - OK + + {kind} schema + + + + 201 - Created + + {kind} schema + + + + 202 - Accepted + + {kind} schema + + + + 401 - Unauthorized + Empty + + + + + ) +} -const K8sAPIItemEndpoint = ({ kind }: K8sAPIEndpointProps) => ( - <> -
-
HTTP method
-
- DELETE -
-
Description
-
delete the specified {kind}
-
- -
-
HTTP responses
-
- - - - HTTP code - Response body - - - - - 200 - OK - - - - {' '} - schema - - - - 202 - Accepted - - - - {' '} - schema - - - - 401 - Unauthorized - Empty - - - -
-
HTTP method
-
- GET -
-
Description
-
read the specified {kind}
-
HTTP responses
-
- - - - HTTP code - Response body - - - - - 200 - OK - - {kind} schema - - - - 401 - Unauthorized - Empty - - - -
-
HTTP method
-
- PATCH -
-
Description
-
partially update the specified {kind}
-
- -
-
HTTP responses
-
- - - - HTTP code - Response body - - - - - 200 - OK - - {kind} schema - - - - 401 - Unauthorized - Empty - - - -
-
HTTP method
-
- PUT -
-
Description
-
replace the specified {kind}
-
- - -
-
HTTP responses
-
- - - - HTTP code - Response body - - - - - 200 - OK - - {kind} schema - - - - 201 - Created - - {kind} schema - - - - 401 - Unauthorized - Empty - - - - -) +const K8sAPIItemEndpoint = ({ kind }: K8sAPIEndpointProps) => { + const t = useTranslation() + return ( + <> +
+
{t('http_method')}
+
+ DELETE +
+
{t('description')}
+
delete the specified {kind}
+
+ +
+
{t('http_responses')}
+
+ + + + {t('http_code')} + {t('response_body')} + + + + + 200 - OK + + + + {' '} + schema + + + + 202 - Accepted + + + + {' '} + schema + + + + 401 - Unauthorized + Empty + + + +
+
{t('http_method')}
+
+ GET +
+
{t('description')}
+
read the specified {kind}
+
{t('http_responses')}
+
+ + + + {t('http_code')} + {t('response_body')} + + + + + 200 - OK + + {kind} schema + + + + 401 - Unauthorized + Empty + + + +
+
{t('http_method')}
+
+ PATCH +
+
{t('description')}
+
partially update the specified {kind}
+
+ +
+
{t('http_responses')}
+
+ + + + {t('http_code')} + {t('response_body')} + + + + + 200 - OK + + {kind} schema + + + + 401 - Unauthorized + Empty + + + +
+
{t('http_method')}
+
+ PUT +
+
{t('description')}
+
replace the specified {kind}
+
+ + +
+
{t('http_responses')}
+
+ + + + {t('http_code')} + {t('response_body')} + + + + + 200 - OK + + {kind} schema + + + + 201 - Created + + {kind} schema + + + + 401 - Unauthorized + Empty + + + + + ) +} + +const K8sAPIStatusEndpoint = ({ kind }: K8sAPIEndpointProps) => { + const t = useTranslation() + return ( + <> +
+
{t('http_method')}
+
+ GET +
+
{t('description')}
+
read status of the specified {kind}
+
{t('http_responses')}
+
+ + + + {t('http_code')} + {t('response_body')} + + + + + 200 - OK + + {kind} schema + + + + 401 - Unauthorized + Empty + + + +
+
{t('http_method')}
+
+ PATCH +
+
{t('description')}
+
partially update status of the specified {kind}
+
+ +
+
{t('http_responses')}
+
+ + + + {t('http_code')} + {t('response_body')} + + + + + 200 - OK + + {kind} schema + + + + 401 - Unauthorized + Empty + + + +
+
{t('http_method')}
+
+ PUT +
+
{t('description')}
+
replace status of the specified {kind}
+
+ + +
+
{t('http_responses')}
+
+ + + + {t('http_code')} + {t('response_body')} + + + + + 200 - OK + + {kind} schema + + + + 201 - Created + + {kind} schema + + + + 401 - Unauthorized + Empty + + + + + ) +} -const K8sAPIStatusEndpoint = ({ kind }: K8sAPIEndpointProps) => ( - <> -
-
HTTP method
-
- GET -
-
Description
-
read status of the specified {kind}
-
HTTP responses
-
- - - - HTTP code - Response body - - - - - 200 - OK - - {kind} schema - - - - 401 - Unauthorized - Empty - - - -
-
HTTP method
-
- PATCH -
-
Description
-
partially update status of the specified {kind}
-
- -
-
HTTP responses
-
- - - - HTTP code - Response body - - - - - 200 - OK - - {kind} schema - - - - 401 - Unauthorized - Empty - - - -
-
HTTP method
-
- PUT -
-
Description
-
replace status of the specified {kind}
-
- - -
-
HTTP responses
-
- - - - HTTP code - Response body - - - - - 200 - OK - - {kind} schema - - - - 201 - Created - - {kind} schema - - - - 401 - Unauthorized - Empty - - - - -) +const K8sAPIScaleEndpoint = ({ kind }: K8sAPIEndpointProps) => { + const t = useTranslation() + return ( + <> +
+
{t('http_method')}
+
+ GET +
+
{t('description')}
+
read scale of the specified {kind}
+
{t('http_responses')}
+
+ + + + {t('http_code')} + {t('response_body')} + + + + + 200 - OK + + + + {' '} + schema + + + + 401 - Unauthorized + Empty + + + +
+
{t('http_method')}
+
+ PATCH +
+
{t('description')}
+
partially update scale of the specified {kind}
+
+ +
+
{t('http_method')}
+
+ PUT +
+
{t('description')}
+
replace scale of the specified {kind}
+
+ + + + ) +} export const K8sAPIEndpoints = ({ hasStatus, + hasScale, group, version, kind, + plural, pathPrefix: pathPrefix_, namespaced = true, }: K8sAPIEndpointsProps) => { + const t = useTranslation() + const pathPrefix = pathPrefix_ ?? (virtual.pathPrefix || '') + const pluralName = plural ?? pluralize(kind).toLowerCase() + const apiPath = `${ pathPrefix - }/${group ? `apis/${group}` : 'api'}/${version}/${namespaced ? `namespaces/{namespace}/` : ''}${plural(kind).toLocaleLowerCase()}` + }/${group ? `apis/${group}` : 'api'}/${version}/${namespaced ? `namespaces/{namespace}/` : ''}${pluralName}` return ( <> - - The following API endpoints are available: + + {t('available_api_endpoints')} {apiPath} @@ -552,6 +637,23 @@ export const K8sAPIEndpoints = ({ )} + {hasScale && ( + + {`${apiPath}/{name}/scale`} + + + GET: read scale of the specified {kind} + + + PATCH: partially update scale of the specified{' '} + {kind} + + + PUT: replace scale of the specified {kind} + + + + )}
@@ -572,6 +674,15 @@ export const K8sAPIEndpoints = ({ )} + + {hasScale && ( + <> +
+ +
+ + + )} ) } diff --git a/packages/doom/src/runtime/components/_K8sAPISchema.tsx b/packages/doom/src/runtime/components/_K8sAPISchema.tsx index 4fd17c794..c3c0b00d5 100644 --- a/packages/doom/src/runtime/components/_K8sAPISchema.tsx +++ b/packages/doom/src/runtime/components/_K8sAPISchema.tsx @@ -1,11 +1,13 @@ -import { FallbackHeading } from '@rspress/core/theme' +import BananaSlug from '@rspress/shared/github-slugger' import type { OpenAPIV3_1 } from 'openapi-types' import { Fragment, useMemo } from 'react' +import { useTranslation } from '../hooks/index.js' import { resolveRef } from '../utils.js' import { Markdown } from './Markdown.js' import { APIReferenceLink } from './_APIReferenceLink.js' +import { HeadingTitle } from './_HeadingTitle.js' import { X } from './_X.js' export interface K8sAPISchemaProps { @@ -17,6 +19,13 @@ export interface K8sAPISchemaItemProps { schema: OpenAPIV3_1.SchemaObject fullSchema?: OpenAPIV3_1.Document propertyPath?: string + /** + * A single stateful slugger shared across the whole schema tree of a page. + * github-slugger's stateless `slug()` strips `.`/`[`/`]`, so + * `.spec.foo` and `.spec.foo[]` collide into one id; a shared instance + * de-duplicates them (WCAG 4.1.1 / HTML id uniqueness). + */ + slugger?: BananaSlug } const getSchemaValue = < @@ -41,6 +50,17 @@ const getSchemaValue = < if (key === 'type' && schema.oneOf?.length) { return schema.oneOf.map((it) => (it as T)[key]) as T[K] } + + // `x-kubernetes-int-or-string` fields carry their type under `anyOf` + // (`[{ type: 'integer' }, { type: 'string' }]`) with no top-level `type`. + if (key === 'type' && schema.anyOf?.length) { + const types = schema.anyOf + .map((it) => (it as T)[key]) + .filter(Boolean) as unknown[] + if (types.length) { + return types as T[K] + } + } } const NESTED_SCHEMA_TYPES = new Set(['object', 'array']) @@ -70,20 +90,21 @@ export const K8sAPISchemaItemBasic = ({ }: { schema: OpenAPIV3_1.SchemaObject }) => { + const t = useTranslation() const description = getSchemaValue(schema, 'description') return (
{description && ( <> -
Description
+
{t('description')}
{description}
)} -
Type
+
{t('type')}
{typeCode(getSchemaValue(schema))}
{schema.required && ( <> -
Required
+
{t('required')}
{schema.required.map((it) => ( {it} @@ -99,7 +120,10 @@ export const K8sAPISchemaItem = ({ schema, fullSchema, propertyPath = '', + slugger, }: K8sAPISchemaItemProps) => { + const t = useTranslation() + const schemaType = useMemo(() => getSchemaValue(schema), [schema]) const isArraySchema = schemaType === 'array' @@ -128,11 +152,17 @@ export const K8sAPISchemaItem = ({ return ( <> - {!propertyPath && } + {!propertyPath && ( + + {t('specification')} + + )} {isArraySchema && ( <>
- + + {`${propertyPath}[]`} +
@@ -142,9 +172,9 @@ export const K8sAPISchemaItem = ({ - Property - Type - Description + {t('property')} + {t('type')} + {t('description')} @@ -185,15 +215,28 @@ export const K8sAPISchemaItem = ({ const nestedPropertyPath = `${propertyPath}${isArraySchema ? '[]' : ''}.${key}` + const heading = ( + + {nestedPropertyPath} + + ) + return ( -
- -
+ {/* Top-level properties (`.spec`, `.status`, …) feed the page + outline so a reference page has a usable TOC (DOOM-14); + deeper headings stay excluded so it does not explode into + hundreds of entries. */} + {propertyPath ? ( +
{heading}
+ ) : ( + heading + )}
) @@ -204,6 +247,13 @@ export const K8sAPISchemaItem = ({ ) } -export const K8sAPISchema = ({ schema, fullSchema }: K8sAPISchemaProps) => ( - -) +export const K8sAPISchema = ({ schema, fullSchema }: K8sAPISchemaProps) => { + const slugger = useMemo(() => new BananaSlug(), []) + return ( + + ) +} diff --git a/packages/doom/src/runtime/translation.ts b/packages/doom/src/runtime/translation.ts index 2a2e2c73a..901cc67cb 100644 --- a/packages/doom/src/runtime/translation.ts +++ b/packages/doom/src/runtime/translation.ts @@ -12,6 +12,23 @@ const en = { chinese: 'Chinese', english: 'English', description: 'Description', + type: 'Type', + required: 'Required', + property: 'Property', + specification: 'Specification', + api_endpoints: 'API Endpoints', + available_api_endpoints: 'The following API endpoints are available:', + http_method: 'HTTP method', + http_responses: 'HTTP responses', + http_code: 'HTTP code', + response_body: 'Response body', + query_parameters: 'Query parameters', + body_parameters: 'Body parameters', + parameter: 'Parameter', + parameters: 'Parameters', + common_parameters: 'Common Parameters', + request_body: 'Request Body', + response: 'Response', chinese_bad_cases: 'Chinese Bad Cases', english_bad_cases: 'English Bad Cases', download_pdf: 'Download PDF', @@ -81,6 +98,23 @@ const zh: Translation = { chinese: '中文', english: '英文', description: '描述', + type: '类型', + required: '必填', + property: '属性', + specification: '规格', + api_endpoints: 'API 端点', + available_api_endpoints: '可用的 API 端点如下:', + http_method: 'HTTP 方法', + http_responses: 'HTTP 响应', + http_code: 'HTTP 状态码', + response_body: '响应体', + query_parameters: '查询参数', + body_parameters: '请求体参数', + parameter: '参数', + parameters: '参数', + common_parameters: '公共参数', + request_body: '请求体', + response: '响应', chinese_bad_cases: '中文反例', english_bad_cases: '英文反例', download_pdf: '下载 PDF', @@ -143,6 +177,23 @@ const ru: Translation = { chinese: 'Китайский', english: 'Английский', description: 'Описание', + type: 'Тип', + required: 'Обязательно', + property: 'Свойство', + specification: 'Спецификация', + api_endpoints: 'API конечные точки', + available_api_endpoints: 'Доступны следующие API конечные точки:', + http_method: 'HTTP метод', + http_responses: 'HTTP ответы', + http_code: 'HTTP код', + response_body: 'Тело ответа', + query_parameters: 'Параметры запроса', + body_parameters: 'Параметры тела', + parameter: 'Параметр', + parameters: 'Параметры', + common_parameters: 'Общие параметры', + request_body: 'Тело запроса', + response: 'Ответ', chinese_bad_cases: 'Примеры ошибок на китайском', english_bad_cases: 'Примеры ошибок на английском', download_pdf: 'Скачать PDF', diff --git a/packages/doom/src/runtime/utils.ts b/packages/doom/src/runtime/utils.ts index 7f911d765..c6b0953af 100644 --- a/packages/doom/src/runtime/utils.ts +++ b/packages/doom/src/runtime/utils.ts @@ -52,23 +52,133 @@ const DEFAULT_COMMON_REFS = { const K8S_DOC_PREFIX = 'https://kubernetes.io/docs/reference/kubernetes-api/common-definitions/' -export const COMMON_REFS = { +export type ReferenceValue = + | string + | { href: string; routePath?: string | false } + +// A reference config value plays two roles: the literal href a `RefLink` points +// at, AND the page-identity key `omitRoutePathRefs` uses to tell whether a ref +// is already documented on the current page. A `.html` href never equals a +// (locale-prefixed, extension-less) routePath, so a plain string silently +// disables the omit — which is exactly why acp-docs relies on `.html`. Split +// the two roles: `routePath: false` makes "never omit" explicit; a real +// routePath makes the omit actually work. +const resolveRefValue = ( + value: ReferenceValue, +): { href: string; routePath: string | false } => + typeof value === 'string' + ? { href: value, routePath: value } + : { href: value.href, routePath: value.routePath ?? value.href } + +const RESOLVED_REFS: Record< + string, + { href: string; routePath: string | false } +> = { + ...Object.fromEntries( + Object.entries(DEFAULT_COMMON_REFS).map(([key, value]) => { + const href = `${K8S_DOC_PREFIX}${value}` + return [key, { href, routePath: href }] + }), + ), ...Object.fromEntries( - Object.entries(DEFAULT_COMMON_REFS).map(([key, value]) => [ + Object.entries(virtual.references ?? {}).map(([key, value]) => [ key, - `${K8S_DOC_PREFIX}${value}`, + resolveRefValue(value), ]), ), - ...virtual.references, } +export const COMMON_REFS: Record = Object.fromEntries( + Object.entries(RESOLVED_REFS).map(([key, { href }]) => [key, href]), +) + export const omitRoutePathRefs = (routePath: string) => Object.fromEntries( - Object.entries(COMMON_REFS).filter( - ([, value]) => routePath !== value.split('#')[0], - ), + Object.entries(COMMON_REFS).filter(([key]) => { + const { routePath: rp } = RESOLVED_REFS[key] + // `false` → never omit (always link-only). Otherwise omit only on the + // page that IS this reference's own page. + return rp === false ? true : routePath !== rp.split('#')[0] + }), ) +export type CrdVersionStrategy = 'preferred' | 'storage' | 'first' + +interface CrdVersionLike { + name: string + served?: boolean + storage?: boolean +} + +/** + * Kubernetes version priority, equivalent to apimachinery's + * `CompareKubeAwareVersionStrings`: GA (`v1`) > beta (`v1beta1`) > + * alpha (`v1alpha1`); within the same tier the higher number wins; names that + * don't follow the convention sort last (lexicographically). + */ +const parseKubeVersion = (v: string) => { + const m = /^v(\d+)(?:(alpha|beta)(\d+))?$/.exec(v) + if (!m) { + return null + } + return { + major: +m[1], + tier: m[2] ? (m[2] === 'beta' ? 2 : 1) : 3, + minor: m[3] ? +m[3] : 0, + } +} + +const compareKubeVersions = (a: string, b: string) => { + const pa = parseKubeVersion(a) + const pb = parseKubeVersion(b) + if (pa && !pb) { + return -1 + } + if (!pa && pb) { + return 1 + } + if (!pa || !pb) { + return a.localeCompare(b) + } + if (pa.tier !== pb.tier) { + return pb.tier - pa.tier + } + if (pa.major !== pb.major) { + return pb.major - pa.major + } + return pb.minor - pa.minor +} + +/** + * Pick the CRD version a doc should render by default. + * + * - `preferred` (default): the version `kubectl` would resolve to — the + * highest-priority version whose `served` is not `false`. This is what an + * operator actually talks to, and it never renders a version the API server + * rejects. + * - `storage`: the version persisted in etcd (`storage: true`). An + * implementation detail, offered only as an explicit opt-in. + * - `first`: the legacy behavior — literally `spec.versions[0]`, i.e. whatever + * order the manifest happened to list. + */ +export const selectCrdVersionName = ( + versions: CrdVersionLike[] | undefined, + strategy: CrdVersionStrategy = 'preferred', +): string | undefined => { + if (!versions?.length) { + return undefined + } + if (strategy === 'first') { + return versions[0].name + } + if (strategy === 'storage') { + return (versions.find((v) => v.storage) ?? versions[0]).name + } + const served = versions.filter((v) => v.served !== false) + const pool = served.length ? served : versions + return [...pool].sort((a, b) => compareKubeVersions(a.name, b.name))[0].name +} + export const CJK_PATTERN = /\p{sc=Han}/u export const handleCJKWhitespaces = (text?: string) => { diff --git a/packages/doom/src/types.ts b/packages/doom/src/types.ts index c1e593478..1c36e401f 100644 --- a/packages/doom/src/types.ts +++ b/packages/doom/src/types.ts @@ -38,6 +38,16 @@ export interface GlobalCliOptions { export interface TranslateOptions { systemPrompt?: string userPrompt?: string + /** + * Glob patterns (relative to each language directory) whose files are copied + * verbatim to the target language instead of being translated. Defaults to + * the generated API directories (`apis/advanced_apis/**`, `apis/crds/**`, + * `apis/kubernetes_apis/**`, `apis/references/**`) — override to change which + * content is copy-only. To keep a hand-maintained translation inside a + * copy-only directory, set `i18n.disableAutoTranslation: true` in that file's + * frontmatter. + */ + copyOnlyDirectories?: string[] } export interface LintOptions { diff --git a/packages/doom/src/utils/helpers.ts b/packages/doom/src/utils/helpers.ts index 01cbb4d6e..095129618 100644 --- a/packages/doom/src/utils/helpers.ts +++ b/packages/doom/src/utils/helpers.ts @@ -44,7 +44,10 @@ export async function generateRuntimeModule( mapper?: (input: T) => R | Promise, ) { const runtimeModules: StringMapper = {} - const files = patterns.length ? await glob(patterns, { cwd }) : [] + // `tinyglobby`/`fdir` return files in filesystem (readdir) order, which is + // not stable across machines. Sort so the map insertion order — and thus the + // "first matched wins" tie-break in the API components — is deterministic. + const files = (patterns.length ? await glob(patterns, { cwd }) : []).sort() for (const file of files) { const result = await resolveStaticConfig(path.resolve(cwd, file)) runtimeModules[`doom-@${kind}/${file}.mjs`] = @@ -58,7 +61,10 @@ export async function generateRuntimeModule( files .map((file, index) => `import _${index} from 'doom-@${kind}/${file}.mjs'`) .join('\n') + - `\nexport default {${files.map((file, index) => `'${path.relative(root, file)}':_${index}`).join(',')}}` + // `file` is relative to `cwd`; resolve it against `cwd` before making it + // relative to `root`, otherwise `path.relative` measures from + // `process.cwd()` and the key silently changes with the working directory. + `\nexport default {${files.map((file, index) => `'${path.relative(root, path.resolve(cwd, file))}':_${index}`).join(',')}}` return runtimeModules } From fb22c26d3c5f236ba8d7cf3661622305f11af3d3 Mon Sep 17 00:00:00 2001 From: ysyou Date: Thu, 23 Jul 2026 07:50:54 +0000 Subject: [PATCH 2/2] test(api): cover generateRuntimeModule ordering/cwd + apiPlugin virtual module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds unit tests for the lines codecov flagged as uncovered in this PR: - `generateRuntimeModule` (utils/helpers.ts): deterministic sort of the map keys, and the map key staying relative to `root` (independent of `process.cwd()`) — the DOOM-17 / DOOM-19 fixes. - `apiPlugin.addRuntimeModules` (plugins/api/index.ts): the virtual module now carries `crdVersion` (and `pathPrefix` / `references`), and drops it when unset — the DOOM-4 escape hatch. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/doom/test/plugins/api/index.spec.ts | 48 ++++++++++++ packages/doom/test/utils/helpers.spec.ts | 77 +++++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 packages/doom/test/plugins/api/index.spec.ts diff --git a/packages/doom/test/plugins/api/index.spec.ts b/packages/doom/test/plugins/api/index.spec.ts new file mode 100644 index 000000000..549286a92 --- /dev/null +++ b/packages/doom/test/plugins/api/index.spec.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import type { UserConfig } from '@rspress/shared' +import { afterAll, beforeAll, describe, expect, test } from '@rstest/core' + +import { apiPlugin } from '#plugins/api/index.ts' + +describe('apiPlugin addRuntimeModules', () => { + let dir: string + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'doom-api-')) + }) + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }) + }) + + const run = async ( + api: UserConfig['api'], + ): Promise> => { + const plugin = apiPlugin({ localBasePath: dir }) + // eslint-disable-next-line @typescript-eslint/no-deprecated -- doom's plugin implements this hook; exercise it directly + return (await plugin.addRuntimeModules?.({ root: dir, api }, true)) ?? {} + } + + test('exposes crdVersion / pathPrefix / references in the virtual module', async () => { + const mods = await run({ + crdVersion: 'storage', + pathPrefix: '/gw', + references: { Foo: 'Foo.html' }, + }) + const virtual = mods['doom-@api-virtual'] + + expect(virtual).toContain('"crdVersion":"storage"') + expect(virtual).toContain('"pathPrefix":"/gw"') + expect(virtual).toContain('"references"') + }) + + test('omits crdVersion when not configured', async () => { + const mods = await run({}) + + // JSON.stringify drops undefined values. + expect(mods['doom-@api-virtual']).not.toContain('crdVersion') + }) +}) diff --git a/packages/doom/test/utils/helpers.spec.ts b/packages/doom/test/utils/helpers.spec.ts index ffc99154e..64eb9c798 100644 --- a/packages/doom/test/utils/helpers.spec.ts +++ b/packages/doom/test/utils/helpers.spec.ts @@ -1,8 +1,14 @@ +import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' -import { describe, expect, test } from '@rstest/core' +import { afterAll, beforeAll, describe, expect, test } from '@rstest/core' -import { baseResolve, pkgResolve } from '#utils/helpers.ts' +import { + baseResolve, + generateRuntimeModule, + pkgResolve, +} from '#utils/helpers.ts' describe('baseResolve', () => { test('resolves path relative to BASE_DIR', () => { @@ -96,3 +102,70 @@ describe('pkgResolve', () => { expect(pkg).toBe(path.dirname(base)) }) }) + +describe('generateRuntimeModule', () => { + let dir: string + + const mapKeys = (mods: Record) => + [...mods['doom-@testMap'].matchAll(/'([^']+)':_\d+/g)].map((m) => m[1]) + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'doom-grm-')) + // Written out of alphabetical order on purpose. + fs.writeFileSync(path.join(dir, 'c.json'), JSON.stringify({ n: 'c' })) + fs.writeFileSync(path.join(dir, 'a.json'), JSON.stringify({ n: 'a' })) + fs.writeFileSync(path.join(dir, 'b.json'), JSON.stringify({ n: 'b' })) + }) + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }) + }) + + test('map keys are deterministically sorted', async () => { + const mods = await generateRuntimeModule(['*.json'], 'test', dir, dir, true) + + expect(mapKeys(mods)).toEqual(['a.json', 'b.json', 'c.json']) + }) + + test('map key is relative to root, independent of process.cwd()', async () => { + // `root === cwd === dir`, but `process.cwd()` is the repo root. The buggy + // `path.relative(root, file)` would measure the relative file from + // `process.cwd()` and produce a `../…` path; the key must stay `a.json`. + const mods = await generateRuntimeModule(['a.json'], 'test', dir, dir, true) + + expect(mapKeys(mods)).toEqual(['a.json']) + expect(mods['doom-@testMap']).not.toContain('..') + }) + + test('key stays relative to root when root is a parent of cwd', async () => { + const parent = path.dirname(dir) + const mods = await generateRuntimeModule( + ['a.json'], + 'test', + parent, + dir, + true, + ) + + expect(mapKeys(mods)).toEqual([path.join(path.basename(dir), 'a.json')]) + }) + + test('empty patterns produce an empty map', async () => { + const mods = await generateRuntimeModule([], 'test', dir, dir, true) + + expect(mods['doom-@testMap']).toBe('\nexport default {}') + }) + + test('mapper transforms each entry', async () => { + const mods = await generateRuntimeModule<{ n: string }, { upper: string }>( + ['a.json'], + 'test', + dir, + dir, + true, + (input) => ({ upper: input.n.toUpperCase() }), + ) + + expect(mods['doom-@test/a.json.mjs']).toContain('"upper":"A"') + }) +})