From a13fd58149d0082fe2531b46daa35808b2d39bc6 Mon Sep 17 00:00:00 2001 From: ysyou Date: Thu, 23 Jul 2026 06:02:19 +0000 Subject: [PATCH 1/3] 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/3] 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"') + }) +}) From 7eaefd9cd658353c9475dedc9d6d44897bbce686 Mon Sep 17 00:00:00 2001 From: ysyou Date: Mon, 3 Aug 2026 09:59:46 +0000 Subject: [PATCH 3/3] fix(api): fail loud when the group/version/kind cannot be derived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` fell back to empty strings for an unknown group/version/kind and concatenated them into paths like `/api//` and `/api///{name}` — a guess rendered as a fact, on a green build with no warning anywhere. It happens whenever an OpenAPI schema carries no `x-kubernetes-group-version-kind` and no CRD backs the name, which is the normal shape of an aggregation-layer apiserver document (measured: all 27 schemas of one such document lack it). The endpoints section is now omitted instead, with a `console.error` naming the props to declare; the schema still renders. `no-unresolved-api-ref` reports the same condition at lint time. `/status` in the OpenAPI branch was decided by whether the schema carried a `status` property — that is state, not an interface. It now follows the routes the document lists, and falls back to the property only when the document does not route the resource at all, so a schema embedded in an unrelated document does not lose endpoints that do exist. The new `hasStatus` prop overrides both. Measured against the whole consumer corpus (222 `` / `` tags across 11 repositories, 26 of them OpenAPI-sourced): no page changes. Verified before/after on new fixtures — 3 assertions red before the fix, 8 green after. Also documents the props and `api.crdVersion` that earlier commits on this branch changed but left undocumented, and adds the first tests for `no-unresolved-api-ref`. Co-Authored-By: Claude Fable 5 --- .changeset/fix-api-reference-rendering.md | 7 +- docs/en/usage/api.md | 8 +- docs/en/usage/configuration.md | 5 + docs/zh/usage/api.md | 8 +- docs/zh/usage/configuration.md | 5 + fixture-docs/doom.config.yml | 4 + .../en/apis/kubernetes_apis/gvklessview.mdx | 15 ++ .../kubernetes_apis/gvklessviewdeclared.mdx | 19 ++ .../en/apis/kubernetes_apis/statusfulview.mdx | 13 ++ fixture-docs/shared/openapis/aggregated.json | 180 +++++++++++++++ .../src/remark-lint/no-unresolved-api-ref.ts | 210 ++++++++++++------ packages/doom/src/runtime/api-paths.ts | 37 +++ .../doom/src/runtime/components/K8sAPI.tsx | 79 +++++-- .../remark-lint/no-unresolved-api-ref.spec.ts | 147 ++++++++++++ packages/doom/test/runtime/api-paths.spec.ts | 81 +++++++ 15 files changed, 731 insertions(+), 87 deletions(-) create mode 100644 fixture-docs/en/apis/kubernetes_apis/gvklessview.mdx create mode 100644 fixture-docs/en/apis/kubernetes_apis/gvklessviewdeclared.mdx create mode 100644 fixture-docs/en/apis/kubernetes_apis/statusfulview.mdx create mode 100644 fixture-docs/shared/openapis/aggregated.json create mode 100644 packages/doom/src/runtime/api-paths.ts create mode 100644 packages/doom/test/remark-lint/no-unresolved-api-ref.spec.ts create mode 100644 packages/doom/test/runtime/api-paths.spec.ts diff --git a/.changeset/fix-api-reference-rendering.md b/.changeset/fix-api-reference-rendering.md index 088460686..13a5914da 100644 --- a/.changeset/fix-api-reference-rendering.md +++ b/.changeset/fix-api-reference-rendering.md @@ -9,7 +9,8 @@ Fix a family of API-reference rendering defects where the components ignored fac - `` 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. +- `` no longer renders endpoint paths it cannot derive. When an OpenAPI schema carries no `x-kubernetes-group-version-kind` — aggregation-layer documents routinely omit it — and no CRD backs the name, the group, version and kind used to fall back to empty strings and concatenate into `/api//` and `/api///{name}`, shipping a broken path on a green build. The endpoints section is now omitted, with a `console.error` naming the props to declare; the schema still renders. `apiVersion` + `apiKind` (plus `apiGroup` outside the core group) make the page render endpoints again. +- `/status` (and the new `/scale`) endpoints follow what the source declares, not whether the schema happens to contain a `status` property — fixing both fabricated `/status` endpoints and missing ones. A CRD declares the subresource in `spec.versions[].subresources`; an OpenAPI document declares it by routing it, so its `paths` decide, and a document that routes the resource without a `/status` route renders none. A document that does not route the resource at all says nothing either way, so the schema property stays the fallback there. The new `hasStatus` prop overrides both. - `x-kubernetes-int-or-string` fields (carried under `anyOf`) now render their type (`integer|string`) instead of an empty cell. **Anchors and badges:** @@ -23,7 +24,7 @@ Fix a family of API-reference rendering defects where the components ignored fac - `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. +- New `no-unresolved-api-ref` lint rule flags `` / `` / `` / `` / `` references that cannot be resolved, before they ship as blank pages. It also flags a `` whose group, version and kind can be derived from neither the schema nor a CRD nor explicit props, so that failure surfaces at lint time instead of as a missing endpoints section. - 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:** @@ -33,3 +34,5 @@ Fix a family of API-reference rendering defects where the components ignored fac - 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. +> +> The OpenAPI-branch `/status` change was measured against the full consumer corpus (222 `` / `` tags across 11 repositories; 26 resolve to OpenAPI schemas, all of them in acp-docs): **no page changes**, because every document that routes one of those resources also routes its `/status`. The two pages whose documents do not are covered by the fallback rather than losing endpoints. diff --git a/docs/en/usage/api.md b/docs/en/usage/api.md index 15c8b6408..49560a356 100644 --- a/docs/en/usage/api.md +++ b/docs/en/usage/api.md @@ -43,12 +43,16 @@ Refer to [ArtifactCleanupRun](../apis/crds/ArtifactCleanupRun). ### `props` - `name`: Reference name under OpenAPI schema `definitions` (v2) or `components/schemas` (v3), or CRD `metadata.name` -- `namespaced`: Indicates whether the resource is namespace-scoped; defaults to `true`, meaning the API Endpoints include the namespace path parameter `namespaces/{namespace}` +- `namespaced`: Indicates whether the resource is namespace-scoped, i.e. whether the API Endpoints include the namespace path parameter `namespaces/{namespace}`. A CRD's `spec.scope` decides it when this is unset; OpenAPI sources do not carry a scope and default to `true` - `pathPrefix`: Can be used to override the global configuration `api.pathPrefix` - `filepath`: Similar to [specifying openapi path](#specified-openapi-path), used to specify a particular openapi or CRD file - `apiGroup`: Optional, specifies the API group; openapi will try to read the referenced `x-kubernetes-group-version-kind`, same below -- `apiVersion`: Optional, specifies the API version; CRD defaults to using the first version in `spec.versions` +- `apiVersion`: Optional, specifies the API version; a CRD defaults to the version `kubectl` resolves to — the highest-priority `served` version — which [`api.crdVersion`](./configuration#api) can change - `apiKind`: Optional, specifies the API resource kind +- `plural`: Optional, the resource's plural name used in the endpoint paths. A CRD's `spec.names.plural` is read automatically; set this for OpenAPI sources whose plural is irregular, otherwise it is derived from the kind +- `hasStatus`: Optional, whether the API server exposes a `status` subresource. A CRD's `subresources.status` decides it; for an OpenAPI source the routes listed in the document decide it. This prop overrides both + +An OpenAPI document that carries no `x-kubernetes-group-version-kind` — aggregation-layer documents routinely omit it — must have `apiVersion` and `apiKind` declared here, plus `apiGroup` outside the core group. Otherwise the group, version and kind cannot be derived: the API Endpoints section is omitted rather than built from empty path segments, and `doom lint` reports the page. ## Advanced API diff --git a/docs/en/usage/configuration.md b/docs/en/usage/configuration.md index f3c72bf80..ac47a444d 100644 --- a/docs/en/usage/configuration.md +++ b/docs/en/usage/configuration.md @@ -44,6 +44,11 @@ api: v1alpha1.CodeQualityBranch: /apis/references/CodeQualityBranch#v1alpha1.CodeQualityBranch # Optional, API documentation path prefix. If the current business uses gateway or other proxy services, this can be configured pathPrefix: /apis + # Optional, which version of a multi-version CRD a page renders when it passes no `apiVersion` prop + # `preferred` (default): the version `kubectl` resolves to — the highest-priority `served` version + # `storage`: the etcd storage version (`storage: true`) + # `first`: legacy behavior — `spec.versions[0]`, even when it is not served + crdVersion: preferred ``` Refer to [API Documentation](./api) for writing documentation. diff --git a/docs/zh/usage/api.md b/docs/zh/usage/api.md index ff570c032..36ea91414 100644 --- a/docs/zh/usage/api.md +++ b/docs/zh/usage/api.md @@ -42,12 +42,16 @@ weight: 5 ### `props` - `name`: OpenAPI schema `definitions`(v2) or `components/schemas`(v3) 下的引用名称或CRD `metadata.name` -- `namespaced`: 指示资源是否为命名空间级别,默认为 `true`,即 API Endpoints 是否包含命名空间路径参数 `namespaces/{namespace}` +- `namespaced`: 指示资源是否为命名空间级别,即 API Endpoints 是否包含命名空间路径参数 `namespaces/{namespace}`。未指定时由 CRD 的 `spec.scope` 决定;OpenAPI 源不携带 scope,默认为 `true` - `pathPrefix`: 可以用于覆盖全局配置中的 `api.pathPrefix` - `filepath`: 类似[指定 openapi 路径](#specified-openapi-path),用于指定特定的 openapi 或 CRD 文件 - `apiGroup`: 可选,指定 API 组,openapi 会尝试读取引用的 `x-kubernetes-group-version-kind`,下同 -- `apiVersion`: 可选,指定 API 版本,CRD 会默认使用 `spec.versions` 中第一个版本 +- `apiVersion`: 可选,指定 API 版本;CRD 默认使用 `kubectl` 会解析到的版本,即 `served` 版本中优先级最高的那个,可通过 [`api.crdVersion`](./configuration#api) 更改 - `apiKind`: 可选,指定 API 资源类型 +- `plural`: 可选,端点路径中使用的资源复数名。CRD 会自动读取 `spec.names.plural`;OpenAPI 源的复数形式不规则时需要指定,否则由 kind 推导 +- `hasStatus`: 可选,API server 是否暴露 `status` 子资源。CRD 由 `subresources.status` 决定,OpenAPI 源由文档中登记的路由决定,本 prop 可覆盖两者 + +如果 OpenAPI 文档不带 `x-kubernetes-group-version-kind`(聚合层 apiserver 的文档普遍不带),必须在此显式声明 `apiVersion` 与 `apiKind`,非 core 组还需 `apiGroup`。否则无法推导出 group/version/kind:API Endpoints 段会整段省略,而不是用空路径段拼出来,同时 `doom lint` 会报告该页面。 ## 高级 API diff --git a/docs/zh/usage/configuration.md b/docs/zh/usage/configuration.md index f8882f71c..2cf972f5d 100644 --- a/docs/zh/usage/configuration.md +++ b/docs/zh/usage/configuration.md @@ -43,6 +43,11 @@ api: v1alpha1.CodeQualityBranch: /apis/references/CodeQualityBranch#v1alpha1.CodeQualityBranch # 可选,API 文档路径前缀,如果当前业务使用 gateway 等代理服务,可以配置此项 pathPrefix: /apis + # 可选,页面未传 `apiVersion` prop 时,多版本 CRD 默认渲染哪个版本 + # `preferred`(默认):`kubectl` 会解析到的版本,即 `served` 版本中优先级最高的那个 + # `storage`:etcd 存储版本(`storage: true`) + # `first`:旧行为——`spec.versions[0]`,即使它没有 served + crdVersion: preferred ``` 文档编写参考 [API 文档](./api) diff --git a/fixture-docs/doom.config.yml b/fixture-docs/doom.config.yml index 202aafdbc..74ce26d33 100644 --- a/fixture-docs/doom.config.yml +++ b/fixture-docs/doom.config.yml @@ -64,7 +64,11 @@ lint: debugMode: true cspell: words: + - gvkless + - gvklessviews - openshiftpipelinesascodes + - statusful + - statusfulviews - tekton - tektonchains - tektonconfigs diff --git a/fixture-docs/en/apis/kubernetes_apis/gvklessview.mdx b/fixture-docs/en/apis/kubernetes_apis/gvklessview.mdx new file mode 100644 index 000000000..05d324e0b --- /dev/null +++ b/fixture-docs/en/apis/kubernetes_apis/gvklessview.mdx @@ -0,0 +1,15 @@ +# GvklessView [doomtest.alauda.io/v1] + +The OpenAPI document behind this page carries no group-version-kind extension +and no CRD defines the resource, so the group, version and kind cannot be +derived — and this page declares none of them. The endpoints section is +therefore expected to be absent rather than built out of empty path segments. +Nothing below the schema should render. + +The `no-unresolved-api-ref` rule detects this (see its unit tests), but it is +not reported when the component is the last node of the file, as it is here and +on most real API pages: the eslint-mdx layer `doom lint` runs on drops messages +anchored there. Keep the component last so this page keeps documenting the real +shape. + + diff --git a/fixture-docs/en/apis/kubernetes_apis/gvklessviewdeclared.mdx b/fixture-docs/en/apis/kubernetes_apis/gvklessviewdeclared.mdx new file mode 100644 index 000000000..28974af98 --- /dev/null +++ b/fixture-docs/en/apis/kubernetes_apis/gvklessviewdeclared.mdx @@ -0,0 +1,19 @@ +# GvklessView, declared [doomtest.alauda.io/v1] + +The same schema, with the group, version and kind supplied by the page. The +endpoints render from those props, and the props win over the document: the +document routes the resource under a namespace, while this page declares the +resource cluster-scoped. + +The status subresource is expected to be absent, because the document routes +this resource but no status route for it — the schema's status property alone +does not put one there. + + diff --git a/fixture-docs/en/apis/kubernetes_apis/statusfulview.mdx b/fixture-docs/en/apis/kubernetes_apis/statusfulview.mdx new file mode 100644 index 000000000..222d453fd --- /dev/null +++ b/fixture-docs/en/apis/kubernetes_apis/statusfulview.mdx @@ -0,0 +1,13 @@ +# StatusfulView [doomtest.alauda.io/v1] + +The document routes a status subresource for this resource, so the endpoints are +expected to include it. This page's schema and `GvklessView`'s both carry a +status property; only the routes tell the two cases apart. + + diff --git a/fixture-docs/shared/openapis/aggregated.json b/fixture-docs/shared/openapis/aggregated.json new file mode 100644 index 000000000..ece90d8c5 --- /dev/null +++ b/fixture-docs/shared/openapis/aggregated.json @@ -0,0 +1,180 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Aggregated apiserver fixture", + "description": "Models an aggregation-layer OpenAPI document: schema keys are short names and no schema carries the group-version-kind extension, so a page has to declare the group, version and kind itself. GvklessView is routed without a status subresource; StatusfulView is routed with one. Both schemas carry a status property, which is state rather than an interface and therefore does not decide whether the subresource route exists.", + "version": "v1" + }, + "paths": { + "/apis/doomtest.alauda.io/v1/namespaces/{namespace}/gvklessviews": { + "get": { + "operationId": "listGvklessView", + "summary": "List GvklessView objects", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/v1.GvklessView" + } + } + } + } + } + } + }, + "/apis/doomtest.alauda.io/v1/namespaces/{namespace}/gvklessviews/{name}": { + "get": { + "operationId": "readGvklessView", + "summary": "Read a GvklessView object", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/v1.GvklessView" + } + } + } + } + } + } + }, + "/apis/doomtest.alauda.io/v1/namespaces/{namespace}/statusfulviews": { + "get": { + "operationId": "listStatusfulView", + "summary": "List StatusfulView objects", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/v1.StatusfulView" + } + } + } + } + } + } + }, + "/apis/doomtest.alauda.io/v1/namespaces/{namespace}/statusfulviews/{name}": { + "get": { + "operationId": "readStatusfulView", + "summary": "Read a StatusfulView object", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/v1.StatusfulView" + } + } + } + } + } + } + }, + "/apis/doomtest.alauda.io/v1/namespaces/{namespace}/statusfulviews/{name}/status": { + "get": { + "operationId": "readStatusfulViewStatus", + "summary": "Read the status of a StatusfulView object", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/v1.StatusfulView" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "v1.GvklessView": { + "type": "object", + "description": "A resource served by an aggregated apiserver that publishes no group-version-kind extension.", + "properties": { + "apiVersion": { + "type": "string", + "description": "APIVersion defines the versioned schema of this representation of an object." + }, + "kind": { + "type": "string", + "description": "Kind is a string value representing the REST resource this object represents." + }, + "metadata": { + "type": "object", + "description": "Standard object metadata." + }, + "spec": { + "type": "object", + "description": "Spec defines the desired state of the view.", + "properties": { + "displayName": { + "type": "string", + "description": "DisplayName is the name shown in the console." + } + } + }, + "status": { + "type": "object", + "description": "Status describes the observed state of the view. Its presence does not imply that a status subresource is routed.", + "properties": { + "phase": { + "type": "string", + "description": "Phase is the current lifecycle phase of the view." + } + } + } + } + }, + "v1.StatusfulView": { + "type": "object", + "description": "A resource whose document routes a status subresource.", + "properties": { + "apiVersion": { + "type": "string", + "description": "APIVersion defines the versioned schema of this representation of an object." + }, + "kind": { + "type": "string", + "description": "Kind is a string value representing the REST resource this object represents." + }, + "metadata": { + "type": "object", + "description": "Standard object metadata." + }, + "spec": { + "type": "object", + "description": "Spec defines the desired state of the view.", + "properties": { + "displayName": { + "type": "string", + "description": "DisplayName is the name shown in the console." + } + } + }, + "status": { + "type": "object", + "description": "Status describes the observed state of the view.", + "properties": { + "phase": { + "type": "string", + "description": "Phase is the current lifecycle phase of the view." + } + } + } + } + } + } + } +} diff --git a/packages/doom/src/remark-lint/no-unresolved-api-ref.ts b/packages/doom/src/remark-lint/no-unresolved-api-ref.ts index a6b2c9581..621ea3f8e 100644 --- a/packages/doom/src/remark-lint/no-unresolved-api-ref.ts +++ b/packages/doom/src/remark-lint/no-unresolved-api-ref.ts @@ -7,6 +7,7 @@ import type { } from 'mdast-util-mdx-jsx' import { glob } from 'tinyglobby' import { lintRule } from 'unified-lint-rule' +import type { Node, Position } from 'unist' import { visitParents } from 'unist-util-visit-parents' import { resolveStaticConfig } from '../utils/helpers.ts' @@ -24,13 +25,25 @@ import { getConfig } from './utils.ts' * lint time, before it ships silently. */ -interface ApiSources { +export interface ApiSources { crdNames: Set schemaNames: Set + /** + * OpenAPI schema names that carry no `x-kubernetes-group-version-kind` + * extension. `` derives the endpoint paths' group/version/kind from + * that extension (or from a CRD); without either, the paths cannot be built + * from facts and the caller has to declare them via props. + */ + schemaNamesWithoutGvk: Set pathNames: Set functionNames: Set } +const GVK_EXTENSION = 'x-kubernetes-group-version-kind' + +const hasGvk = (schema: unknown) => + !!schema && typeof schema === 'object' && GVK_EXTENSION in schema + let sourcesPromise: Promise | undefined const loadYamlOrJson = async (base: string, patterns?: string[]) => { @@ -56,6 +69,7 @@ const loadSources = async (): Promise => { const crdNames = new Set() const schemaNames = new Set() + const schemaNamesWithGvk = new Set() const pathNames = new Set() const functionNames = new Set() @@ -79,14 +93,16 @@ const loadSources = async (): Promise => { 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) ?? {}, - )) { + const definitions = doc.definitions as Record | undefined + for (const [key, schema] of [ + ...Object.entries(components?.schemas ?? {}), + ...Object.entries(definitions ?? {}), + ]) { schemaNames.add(key) + if (hasGvk(schema)) { + schemaNamesWithGvk.add(key) + } } for (const key of Object.keys( (doc.paths as Record | undefined) ?? {}, @@ -104,7 +120,20 @@ const loadSources = async (): Promise => { } } - return { crdNames, schemaNames, pathNames, functionNames } + // A name defined in several sources counts as resolvable as long as *one* of + // them carries the extension — the runtime stops at the first source that + // resolves, so flagging the others would be a false positive. + const schemaNamesWithoutGvk = new Set( + [...schemaNames].filter((name) => !schemaNamesWithGvk.has(name)), + ) + + return { + crdNames, + schemaNames, + schemaNamesWithoutGvk, + pathNames, + functionNames, + } } /** Collect static string values from a JSX attribute (string, `{'x'}` or `{['a','b']}`). */ @@ -146,74 +175,121 @@ const attrOf = ( 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, - ) - } +/** + * The rule's decision logic, separated from source loading so it can be + * exercised against hand-built {@link ApiSources} in tests. + */ +export const checkApiRefs = ( + root: Root, + sources: ApiSources, + report: ( + message: string, + place: Position | undefined, + ancestors: Node[], + ) => void, +) => { + const { + crdNames, + schemaNames, + schemaNamesWithoutGvk, + pathNames, + functionNames, + } = sources + + visitParents( + root, + ['mdxJsxFlowElement', 'mdxJsxTextElement'] as const, + (element, parents) => { + const reportAt = (message: string, attr?: MdxJsxAttribute) => { + report(message, (attr ?? element).position, [...parents, element]) + } + + switch (element.name) { + case 'K8sAPI': + case 'K8sCrd': { + const attr = attrOf(element, 'name') + const name = staticStrings(attr)[0] + if (!name) { 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, - ) - } + if (!crdNames.has(name) && !schemaNames.has(name)) { + reportAt( + `\`<${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 '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, - ) - } + // The endpoint paths need a group/version/kind. It comes from the + // schema's `x-kubernetes-group-version-kind` extension, else from a + // CRD of the same name, else it has to be declared by the caller — + // aggregation-layer OpenAPI documents routinely ship without the + // extension. Without any of the three the endpoints section is + // dropped at render time, which a green build would otherwise hide. + if ( + schemaNamesWithoutGvk.has(name) && + !crdNames.has(name) && + // A spread (`{...props}`) can carry the props unseen; do not guess. + !element.attributes.some( + (attribute) => attribute.type === 'mdxJsxExpressionAttribute', + ) && + !(attrOf(element, 'apiVersion') && attrOf(element, 'apiKind')) + ) { + reportAt( + `\`<${element.name} name="${name}">\` resolves to an OpenAPI schema without the \`${GVK_EXTENSION}\` extension, and no CRD defines it, so its group/version/kind cannot be derived. Pass explicit \`apiVersion\` and \`apiKind\` props (plus \`apiGroup\` for non-core groups, and \`plural\` if the plural is irregular), or the API endpoints section is omitted.`, + attr, + ) + } + break + } + case 'OpenAPIRef': { + const attr = attrOf(element, 'schema') + const name = staticStrings(attr)[0] + if (name && !schemaNames.has(name)) { + reportAt( + `\`\` 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)) { + reportAt( + `\`\` 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 + } + case 'K8sPermissionTable': { + const attr = attrOf(element, 'functions') + for (const fn of staticStrings(attr)) { + if (!functionNames.has(fn)) { + reportAt( + `\`\` references function \`${fn}\`, which is not a known FunctionResource (\`permission.functionresources\`). Its row would silently vanish.`, + attr, + ) } - break } - default: - break + break } - }, + default: + break + } + }, + ) +} + +export const noUnresolvedApiRef = lintRule( + 'doom-lint:no-unresolved-api-ref', + async (root, vfile) => { + const sources = await (sourcesPromise ??= loadSources()) + + checkApiRefs(root, sources, (message, place, ancestors) => + vfile.message(message, { ancestors, place }), ) }, ) diff --git a/packages/doom/src/runtime/api-paths.ts b/packages/doom/src/runtime/api-paths.ts new file mode 100644 index 000000000..72ddae7c9 --- /dev/null +++ b/packages/doom/src/runtime/api-paths.ts @@ -0,0 +1,37 @@ +/** + * Reading facts off an OpenAPI document's `paths`. + * + * Kept free of the `doom-@api-*` virtual modules so it stays importable — and + * therefore testable — outside a build. + */ + +/** + * Whether an OpenAPI document declares a `/status` subresource route for a + * resource. + * + * A `/status` endpoint exists because the API server routes it, not because the + * resource's schema happens to carry a `status` property — a property is state, + * a route is an interface. CRDs say so in `subresources.status`; an OpenAPI + * document says so by listing the route. + * + * Returns `undefined` when the document does not describe the resource's routes + * at all: it then says nothing about the subresource either, and the caller must + * fall back to a weaker signal rather than read the silence as a denial. + */ +export const declaresStatusSubresource = ( + paths: string[], + pluralName: string | undefined, +): boolean | undefined => { + if (!pluralName) { + return + } + + const covers = (path: string) => + path.includes(`/${pluralName}/`) || path.endsWith(`/${pluralName}`) + + if (!paths.some(covers)) { + return + } + + return paths.some((path) => path.endsWith('/status') && covers(path)) +} diff --git a/packages/doom/src/runtime/components/K8sAPI.tsx b/packages/doom/src/runtime/components/K8sAPI.tsx index be88e4a0b..f5dc7d0ec 100644 --- a/packages/doom/src/runtime/components/K8sAPI.tsx +++ b/packages/doom/src/runtime/components/K8sAPI.tsx @@ -1,8 +1,10 @@ import crdsMap from 'doom-@api-crdsMap' import openapisMap from 'doom-@api-openapisMap' import virtual from 'doom-@api-virtual' +import { plural as pluralize } from 'pluralize' import { useMemo } from 'react' +import { declaresStatusSubresource } from '../api-paths.js' import { resolveRef, selectCrdVersionName } from '../utils.js' import { K8sAPIEndpoints, type K8sAPIDefinition } from './_K8sAPIEndpoints.js' @@ -22,6 +24,13 @@ export interface K8sAPIProps { * sources the plural comes from `spec.names.plural` automatically. */ plural?: string + /** + * Whether the API server exposes a `/status` subresource. Declared by the + * caller when the source cannot be trusted to answer — an aggregation-layer + * OpenAPI document may describe neither the subresource routes nor the + * resource's own routes. + */ + hasStatus?: boolean } export const K8sAPI = ({ @@ -33,6 +42,7 @@ export const K8sAPI = ({ apiVersion, apiKind, plural, + hasStatus, }: K8sAPIProps) => { const [, openapi] = useMemo( () => @@ -115,6 +125,39 @@ export const K8sAPI = ({ } }, [crd, resolvedVersion, schema]) + // The endpoint paths are built by concatenating group / version / plural. An + // unknown segment used to fall back to `''`, which concatenates into a + // silently broken path such as `/api//` — a guess dressed up as a fact. Merge + // the caller's props with whatever the fact sources yielded, then render the + // endpoints only if the result is actually complete. + const group = apiGroup ?? k8sApiDef?.group + const version = apiVersion ?? k8sApiDef?.version + const kind = apiKind ?? k8sApiDef?.kind + + const pluralName = + plural ?? + crd?.spec.names.plural ?? + (kind ? pluralize(kind).toLowerCase() : undefined) + + // `subresources.status` is the CRD's own declaration. An OpenAPI source has + // no such field, so the routes it lists are the next best fact; only when it + // lists none for this resource does the weaker "the schema has a `status` + // property" signal still apply (see `declaresStatusSubresource`). + const derivedHasStatus = useMemo(() => { + if (crd) { + return !!versionDef?.subresources?.status + } + if (!schema) { + return false + } + return ( + declaresStatusSubresource( + Object.keys(openapi?.paths ?? {}), + pluralName, + ) ?? !!schema.properties?.status + ) + }, [crd, openapi, pluralName, schema, versionDef]) + if (!openapi && !crd) { console.error( `No OpenAPI nor CustomResourceDefinition schema found for ${name}`, @@ -122,23 +165,31 @@ export const K8sAPI = ({ return null } + // `group` is legitimately absent for core-group resources (`/api/v1/...`); + // `version` and `kind` are not — without them there is no path to render. + const endpointDef = version && kind ? { group, version, kind } : undefined + + if (schema && !endpointDef) { + console.error( + `Cannot resolve the group/version/kind of ${name}: its OpenAPI schema carries no \`x-kubernetes-group-version-kind\` extension and no CustomResourceDefinition defines it. Pass explicit \`apiVersion\` and \`apiKind\` props (plus \`apiGroup\` for non-core groups, and \`plural\` if the plural is irregular). Omitting the API endpoints section.`, + ) + } + return schema ? ( <> - + {endpointDef && ( + + )} ) : null } diff --git a/packages/doom/test/remark-lint/no-unresolved-api-ref.spec.ts b/packages/doom/test/remark-lint/no-unresolved-api-ref.spec.ts new file mode 100644 index 000000000..c7bd04c80 --- /dev/null +++ b/packages/doom/test/remark-lint/no-unresolved-api-ref.spec.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from '@rstest/core' +import remarkMdx from 'remark-mdx' +import remarkParse from 'remark-parse' +import { unified } from 'unified' + +import { + type ApiSources, + checkApiRefs, +} from '#remark-lint/no-unresolved-api-ref.ts' + +const parse = (value: string) => + unified().use(remarkParse).use(remarkMdx).parse(value) + +const sources = (overrides: Partial = {}): ApiSources => ({ + crdNames: new Set(), + schemaNames: new Set(), + schemaNamesWithoutGvk: new Set(), + pathNames: new Set(), + functionNames: new Set(), + ...overrides, +}) + +const check = (mdx: string, apiSources: ApiSources) => { + const messages: string[] = [] + checkApiRefs(parse(mdx), apiSources, (message) => messages.push(message)) + return messages +} + +describe('no-unresolved-api-ref', () => { + describe('reference resolution', () => { + test('flags a name that resolves to neither a CRD nor a schema', () => { + const messages = check( + '\n', + sources({ crdNames: new Set(['widgets.example.com']) }), + ) + expect(messages).toHaveLength(1) + expect(messages[0]).toContain('resolves to neither') + }) + + test('accepts a name backed by a CRD', () => { + const messages = check( + '\n', + sources({ crdNames: new Set(['widgets.example.com']) }), + ) + expect(messages).toHaveLength(0) + }) + + test('ignores a dynamic name it cannot read statically', () => { + const messages = check('\n', sources()) + expect(messages).toHaveLength(0) + }) + + test('flags an unresolved OpenAPIRef schema', () => { + const messages = check('\n', sources()) + expect(messages).toHaveLength(1) + expect(messages[0]).toContain('OpenAPIRef') + }) + + test('flags an unresolved OpenAPIPath path', () => { + const messages = check( + '\n', + sources({ pathNames: new Set(['/test/foo']) }), + ) + expect(messages).toHaveLength(1) + expect(messages[0]).toContain('/test/gone') + }) + + test('flags an unknown K8sPermissionTable function', () => { + const messages = check( + `\n`, + sources({ functionNames: new Set(['known']) }), + ) + expect(messages).toHaveLength(1) + expect(messages[0]).toContain('unknown') + }) + }) + + // The endpoint paths are concatenated from group/version/plural. An OpenAPI + // source without `x-kubernetes-group-version-kind` leaves them unknown, so + // the caller has to declare them — otherwise the endpoints section silently + // disappears from an otherwise green build. + describe('derivable group/version/kind', () => { + const gvkless = () => + sources({ + schemaNames: new Set(['v1.ClusterView']), + schemaNamesWithoutGvk: new Set(['v1.ClusterView']), + }) + + test('flags a GVK-less schema with no explicit props', () => { + const messages = check( + '\n', + gvkless(), + ) + expect(messages).toHaveLength(1) + expect(messages[0]).toContain('x-kubernetes-group-version-kind') + }) + + test('flags the deprecated K8sCrd alias too', () => { + const messages = check('\n', gvkless()) + expect(messages).toHaveLength(1) + }) + + test('accepts explicit apiVersion + apiKind props', () => { + const messages = check( + '\n', + gvkless(), + ) + expect(messages).toHaveLength(0) + }) + + test('flags a half-declared resource (apiVersion without apiKind)', () => { + const messages = check( + '\n', + gvkless(), + ) + expect(messages).toHaveLength(1) + }) + + test('accepts a schema that carries the GVK extension', () => { + const messages = check( + '\n', + sources({ schemaNames: new Set(['v1.Pod']) }), + ) + expect(messages).toHaveLength(0) + }) + + test('accepts a GVK-less schema that a CRD of the same name backs', () => { + const messages = check( + '\n', + sources({ + crdNames: new Set(['widgets.example.com']), + schemaNames: new Set(['widgets.example.com']), + schemaNamesWithoutGvk: new Set(['widgets.example.com']), + }), + ) + expect(messages).toHaveLength(0) + }) + + test('stays quiet when a spread may carry the props', () => { + const messages = check( + '\n', + gvkless(), + ) + expect(messages).toHaveLength(0) + }) + }) +}) diff --git a/packages/doom/test/runtime/api-paths.spec.ts b/packages/doom/test/runtime/api-paths.spec.ts new file mode 100644 index 000000000..3072b976f --- /dev/null +++ b/packages/doom/test/runtime/api-paths.spec.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from '@rstest/core' + +import { declaresStatusSubresource } from '#runtime/api-paths.ts' + +// Shape of an aggregation-layer document that routes a resource but exposes no +// subresource — the case that used to fabricate a `/status` section out of the +// schema's `status` property. +const CLUSTER_SCOPED_NO_STATUS = [ + '/apis/cluster.alauda.io/v1/clusterviews', + '/apis/cluster.alauda.io/v1/namespaces/{namespace}/clusterviews', + '/apis/cluster.alauda.io/v1/namespaces/{namespace}/clusterviews/{name}', + '/apis/cluster.alauda.io/v1/watch/clusterviews', +] + +const WITH_STATUS = [ + '/apis/apps/v1/namespaces/{namespace}/deployments', + '/apis/apps/v1/namespaces/{namespace}/deployments/{name}', + '/apis/apps/v1/namespaces/{namespace}/deployments/{name}/status', +] + +describe('declaresStatusSubresource', () => { + test('reports the subresource when the document routes it', () => { + expect(declaresStatusSubresource(WITH_STATUS, 'deployments')).toBe(true) + }) + + test('denies it when the document routes the resource but not the subresource', () => { + expect( + declaresStatusSubresource(CLUSTER_SCOPED_NO_STATUS, 'clusterviews'), + ).toBe(false) + }) + + test('abstains when the document does not route the resource at all', () => { + // A schema embedded in an unrelated document (a referenced type) must not + // be read as "this resource has no /status". + expect( + declaresStatusSubresource(WITH_STATUS, 'persistentvolumeclaims'), + ).toBeUndefined() + }) + + test('abstains on a document with no paths', () => { + expect(declaresStatusSubresource([], 'clusterviews')).toBeUndefined() + }) + + test('abstains when the plural name is unknown', () => { + expect(declaresStatusSubresource(WITH_STATUS, undefined)).toBeUndefined() + }) + + test('matches a collection path that ends with the plural', () => { + expect( + declaresStatusSubresource( + ['/apis/example.com/v1/widgets', '/apis/example.com/v1/widgets/{name}'], + 'widgets', + ), + ).toBe(false) + }) + + test('does not confuse a different resource sharing a path prefix', () => { + // `widgetsets` must not satisfy a lookup for `widgets`. + expect( + declaresStatusSubresource( + [ + '/apis/example.com/v1/widgetsets', + '/apis/example.com/v1/widgetsets/{name}/status', + ], + 'widgets', + ), + ).toBeUndefined() + }) + + test('ignores a /status route belonging to another resource', () => { + expect( + declaresStatusSubresource( + [ + '/apis/example.com/v1/widgets', + '/apis/example.com/v1/gadgets/{name}/status', + ], + 'widgets', + ), + ).toBe(false) + }) +})