From 7913d977f58778b6d1caee68021a08ba4d2841c5 Mon Sep 17 00:00:00 2001 From: Chris Tauchen Date: Fri, 2 Oct 2026 17:39:59 +0100 Subject: [PATCH 1/5] DOCS-3057: Add a component for feature lifecycle notices Technology preview and deprecation notices were hand-typed prose repeated across 133 pages in at least eight wordings, which is why notices outlive the status they describe. This adds one way to write them. A page that is wholly about a feature declares the status in front matter: lifecycle-status: tech-preview lifecycle-feature: Gateway WAF lifecycle-id: gateway-waf A new remark plugin places the notice directly after the page title, where every hand-written notice already sat. It inserts the component rather than building an admonition, so the front matter form and the hand-placed form render through the same code and cannot drift apart. When only part of an otherwise generally available page is affected, or the notice needs extra guidance such as what to migrate to, the component is used directly in the page instead. The feature name is the admonition title rather than the subject of a sentence, so that one fixed body serves names that are plural, such as Native v3 CRDs, as well as singular ones. The notice reads nothing from data/feature-status.yaml at runtime, since it is a page-level editorial statement. A build-time check reconciles the two instead: lifecycle-id binds a page to a feature, and the test fails when a page and the data file disagree. It is seeded with the contradiction that shipped in 3.33, where the release notes called native v3 CRDs generally available while the install page still called it a technology preview. Converts two pages as pilots, one of each form. The remaining pages follow in later changes. --- .github/copilot-instructions.md | 62 ++++++ .../reference/resources/felixconfig.mdx | 10 +- .../threat/gateway-waf/overview.mdx | 9 +- docusaurus.config.js | 7 +- .../FeatureStatusTable/featureStatus.ts | 27 +++ .../LifecycleNotice/__test__/drift.test.ts | 197 ++++++++++++++++++ .../LifecycleNotice/__test__/index.test.tsx | 89 ++++++++ src/components/LifecycleNotice/index.tsx | 89 ++++++++ .../__test__/lifecycleNoticePlugin.test.js | 94 +++++++++ src/remark/lifecycleNoticePlugin.js | 73 +++++++ src/theme/MDXComponents.js | 2 + 11 files changed, 645 insertions(+), 14 deletions(-) create mode 100644 src/components/LifecycleNotice/__test__/drift.test.ts create mode 100644 src/components/LifecycleNotice/__test__/index.test.tsx create mode 100644 src/components/LifecycleNotice/index.tsx create mode 100644 src/remark/__test__/lifecycleNoticePlugin.test.js create mode 100644 src/remark/lifecycleNoticePlugin.js diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 58ee32b40d..91d979790f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -166,6 +166,68 @@ yarn test:show-report # View test failures - Configuration in `.vale.ini` and `.github/styles/` - No direct yarn/make command for local Vale linting +## Feature Lifecycle Notices + +Technology preview and deprecation notices are rendered by one component, so the wording is the +same on every page. Do not write the admonition by hand. + +### Whole page + +When a page is about the feature, declare it in front matter and write nothing in the body: + +```yaml +--- +description: ... +lifecycle-status: tech-preview +lifecycle-feature: Gateway WAF +lifecycle-id: gateway-waf +--- +``` + +- `lifecycle-status` is `tech-preview` or `deprecated`. There is no value for generally + available: a GA feature says nothing. +- `lifecycle-feature` is the name shown in the notice title. Omit it when the page title already + names the feature unambiguously. +- `lifecycle-id` is the matching `id` in `data/feature-status.yaml`. It is optional, but supply + it when the feature has an entry, because it is what lets CI catch a notice that outlived the + status it describes. Nothing is rendered from it. + +`src/remark/lifecycleNoticePlugin.js` places the notice directly after the page's H1. A page +that declares a status but has no H1 fails the build. + +Do not use `$[variables]` in these front matter fields. Variable substitution is a remark plugin +that runs over the page body, and front matter never passes through it, so the token would be +published literally. Write the product name out instead. + +### Part of a page + +When only one section of an otherwise generally available page is affected, or when the notice +needs extra guidance such as what to migrate to, place the component yourself: + +```mdx +### Selector-scoped configuration + + +``` + +```mdx + + For similar functionality, use [Istio ambient mode](../../compliance/istio/about-istio-ambient.mdx). + +``` + +The component is registered globally in `src/theme/MDXComponents.js`, so no import is needed. + +### When a feature's status changes + +Update `data/feature-status.yaml`, which drives the release notes tables, and update or remove +the notice on the feature's pages in the same change. `yarn test:components` fails when a page +and the data file disagree, so the two cannot drift apart silently. + ## Operator API Documentation Updates ### Auto-generation Commands diff --git a/calico-enterprise/reference/resources/felixconfig.mdx b/calico-enterprise/reference/resources/felixconfig.mdx index e052519daa..c94b30cfae 100644 --- a/calico-enterprise/reference/resources/felixconfig.mdx +++ b/calico-enterprise/reference/resources/felixconfig.mdx @@ -40,11 +40,11 @@ spec: ### Selector-scoped configuration -:::note - -This feature is a **tech preview**. The behavior described here may change in future releases. - -::: + FelixConfiguration resources whose name is not `default` and does not start with `node.` can include a `nodeSelector` field to restrict the configuration to nodes whose labels match the selector. This allows you to apply different Felix settings to groups of nodes without creating individual per-node resources. diff --git a/calico-enterprise/threat/gateway-waf/overview.mdx b/calico-enterprise/threat/gateway-waf/overview.mdx index a54459139c..7d208270ff 100644 --- a/calico-enterprise/threat/gateway-waf/overview.mdx +++ b/calico-enterprise/threat/gateway-waf/overview.mdx @@ -1,15 +1,12 @@ --- description: Protect Calico Ingress Gateway traffic with a per-route and per-namespace web application firewall built on the OWASP Core Rule Set — how the resource families, merge order, and enforcement guardrails fit together. +lifecycle-status: tech-preview +lifecycle-feature: Gateway WAF +lifecycle-id: gateway-waf --- # Gateway WAF -:::note - -This feature is tech preview. Tech preview features may be subject to significant changes before they become GA. - -::: - ## Big picture Protect the traffic entering your cluster through $[prodname] Ingress Gateway with a web application firewall (WAF) that you attach per Gateway or per route, and tune per namespace. diff --git a/docusaurus.config.js b/docusaurus.config.js index 2368378795..65a62b4b6e 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -11,6 +11,7 @@ const nextVersion = process.env.BUILD_NEXT === 'true' ? ['current'] : []; export default async function createAsyncConfig() { const variablesPlugin = await import('./src/remark/variablesPlugin'); + const lifecycleNoticePlugin = await import('./src/remark/lifecycleNoticePlugin'); /** @type {import('@docusaurus/types').Config} */ const config = { @@ -452,7 +453,7 @@ export default async function createAsyncConfig() { }, }, sidebarPath: './sidebars-calico.js', - beforeDefaultRemarkPlugins: [variablesPlugin], + beforeDefaultRemarkPlugins: [lifecycleNoticePlugin, variablesPlugin], editUrl: generateEditUrl, }, ], @@ -499,7 +500,7 @@ export default async function createAsyncConfig() { }, }, sidebarPath: './sidebars-calico-enterprise.js', - beforeDefaultRemarkPlugins: [variablesPlugin], + beforeDefaultRemarkPlugins: [lifecycleNoticePlugin, variablesPlugin], editUrl: generateEditUrl, }, ], @@ -525,7 +526,7 @@ export default async function createAsyncConfig() { }, }, sidebarPath: './sidebars-calico-cloud.js', - beforeDefaultRemarkPlugins: [variablesPlugin], + beforeDefaultRemarkPlugins: [lifecycleNoticePlugin, variablesPlugin], editUrl: generateEditUrl, }, ], diff --git a/src/components/FeatureStatusTable/featureStatus.ts b/src/components/FeatureStatusTable/featureStatus.ts index d5ddff7ffe..43d066e557 100644 --- a/src/components/FeatureStatusTable/featureStatus.ts +++ b/src/components/FeatureStatusTable/featureStatus.ts @@ -117,6 +117,33 @@ function statusAt(changes: ReturnType, version: string): C return current; } +/** + * A feature's status in one product at one release, or `null` if it did not exist yet. + * + * `version` may be null, meaning the unversioned Next tree, which is ahead of every released + * line and so takes the last recorded status. Next has no release number of its own — the + * unversioned trees set `version: 'master'` — so there is nothing to compare against, and + * carrying the final change forward is the only reading consistent with Next being newer than + * everything in the file. + * + * Exported for the lifecycle drift check, which asks the same question one feature at a time + * rather than one table at a time. It shares `statusChanges` and `statusAt` with the tables so + * the two can never answer it differently. + */ +export function resolveStatus( + feature: Feature, + product: string, + version: string | null +): CellStatus { + const history = feature.products?.[product]; + if (!history || history.confirmed === false) return null; + + const changes = statusChanges(history); + if (!changes.length) return null; + + return version === null ? changes[changes.length - 1].status : statusAt(changes, version); +} + /** * The rows of one table: every feature that held one of the `include` statuses at some * point in the window. diff --git a/src/components/LifecycleNotice/__test__/drift.test.ts b/src/components/LifecycleNotice/__test__/drift.test.ts new file mode 100644 index 0000000000..392236dce0 --- /dev/null +++ b/src/components/LifecycleNotice/__test__/drift.test.ts @@ -0,0 +1,197 @@ +import fs from 'fs'; +import path from 'path'; + +import { parse } from 'yaml'; + +import { ceVersionFromCloudversion, resolveStatus } from '../../FeatureStatusTable/featureStatus'; +import type { CellStatus, Feature } from '../../FeatureStatusTable/featureStatus'; + +/** + * Guards the lifecycle notices against data/feature-status.yaml. + * + * The notice itself is a page-level editorial statement and reads nothing from the data file at + * runtime, deliberately. But the release-notes tables render from that file, so when a page and + * the file disagree the site contradicts itself in public — which is what this catches. Calico + * Open Source 3.33 shipped with the release notes calling native v3 CRDs generally available + * while the install page still called it a technology preview. + * + * Only pages that opt in are checked. A page opts in by declaring `lifecycle-id` in front matter, + * or by passing `id` to an inline . A page with no id is not checked, so a + * feature with no entry in the data file costs nothing. + */ + +const ROOT = path.resolve(__dirname, '../../../..'); + +/** + * Each documentation tree, and the release line its pages describe. + * + * `null` means a Next tree, which is ahead of every released line. The Calico Cloud trees are + * keyed to the Calico Enterprise line they are built on, because that is what the data file + * records. The unversioned Calico Cloud tree is treated as Next rather than trusting its own + * `cloudversion`, which trails the published tree and is not what that tree documents. + */ +const TREES: { dir: string; product: string; version: string | null }[] = [ + { dir: 'calico', product: 'calico', version: null }, + { dir: 'calico_versioned_docs/version-3.33', product: 'calico', version: '3.33' }, + { dir: 'calico-enterprise', product: 'calico-enterprise', version: null }, + { dir: 'calico-enterprise_versioned_docs/version-3.24-2', product: 'calico-enterprise', version: '3.24' }, + { dir: 'calico-enterprise_versioned_docs/version-3.23-2', product: 'calico-enterprise', version: '3.23' }, + { dir: 'calico-cloud', product: 'calico-cloud', version: null }, + { + dir: 'calico-cloud_versioned_docs/version-23-2', + product: 'calico-cloud', + version: ceVersionFromCloudversion('v3.23.1-4'), + }, +]; + +/** Statuses a page can declare, mapped to the data file's vocabulary. */ +const DECLARABLE = ['tech-preview', 'deprecated'] as const; +type Declarable = (typeof DECLARABLE)[number]; + +interface Declaration { + file: string; + id: string; + status: Declarable; +} + +const features: Feature[] = parse( + fs.readFileSync(path.join(ROOT, 'data/feature-status.yaml'), 'utf8') +).features; + +const byId = new Map(features.map((feature) => [feature.id, feature])); + +function mdxFiles(dir: string): string[] { + const absolute = path.join(ROOT, dir); + if (!fs.existsSync(absolute)) return []; + + return fs + .readdirSync(absolute, { recursive: true, encoding: 'utf8' }) + .filter((entry) => entry.endsWith('.mdx')) + .map((entry) => path.join(dir, entry)); +} + +const FRONT_MATTER = /^---\n([\s\S]*?)\n---/; +const INLINE_NOTICE = /]*)>/g; + +/** Every lifecycle declaration in one file, from front matter and from inline notices. */ +function declarations(file: string): Declaration[] { + const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); + const found: Declaration[] = []; + + const header = FRONT_MATTER.exec(source); + if (header) { + const frontMatter = parse(header[1]) ?? {}; + const status = frontMatter['lifecycle-status']; + const id = frontMatter['lifecycle-id']; + if (status && id) found.push({ file, id, status }); + } + + for (const [, attributes] of source.matchAll(INLINE_NOTICE)) { + const status = /status=["']([^"']+)["']/.exec(attributes)?.[1]; + const id = /\bid=["']([^"']+)["']/.exec(attributes)?.[1]; + if (status && id) found.push({ file, id, status: status as Declarable }); + } + + return found; +} + +/** + * The statuses in the data file that a declared status is allowed to resolve to. + * + * A page that says deprecated is still right once the feature is recorded as removed: removal + * only reaches a tree whose pages are on their way out, and the notice should keep warning until + * they go. + */ +const ALLOWED: Record = { + 'tech-preview': ['tech-preview'], + deprecated: ['deprecated', 'removed'], +}; + +function allowedFor(declared: Declarable): RegExp { + return new RegExp(`^(${ALLOWED[declared].join('|')})$`); +} + +/** Whether a declared status agrees with what the data file holds. */ +function agrees(declared: Declarable, actual: CellStatus): boolean { + return actual !== null && ALLOWED[declared].includes(actual); +} + +/** + * Proves the check above can fail. + * + * Until pages are migrated there may be few declarations to check, and a suite that only ever + * passes is worth nothing. These cases replay the contradiction that shipped in Calico Open + * Source 3.33, against the real data file, so the detector is exercised whatever the content + * tree currently looks like. + */ +describe('the drift check catches a stale notice', () => { + const nativeV3Crds = () => byId.get('native-v3-crds')!; + + it('reads native v3 CRDs as generally available in Calico Open Source 3.33', () => { + expect(resolveStatus(nativeV3Crds(), 'calico', '3.33')).toBe('ga'); + }); + + it('rejects a technology preview notice on a feature that is generally available', () => { + expect(agrees('tech-preview', resolveStatus(nativeV3Crds(), 'calico', '3.33'))).toBe(false); + }); + + it('still accepts one in the release where the feature was in preview', () => { + expect(agrees('tech-preview', resolveStatus(nativeV3Crds(), 'calico', '3.32'))).toBe(true); + }); + + it('treats Next as carrying the last recorded status forward', () => { + expect(resolveStatus(nativeV3Crds(), 'calico', null)).toBe('ga'); + expect(agrees('tech-preview', resolveStatus(nativeV3Crds(), 'calico', null))).toBe(false); + }); + + it('accepts a deprecation notice once the feature is removed', () => { + const compliance = byId.get('compliance-reporting')!; + expect(resolveStatus(compliance, 'calico-enterprise', '3.24')).toBe('removed'); + expect(agrees('deprecated', resolveStatus(compliance, 'calico-enterprise', '3.24'))).toBe(true); + }); + + it('rejects any notice on a feature the data file does not cover for that product', () => { + expect(resolveStatus(nativeV3Crds(), 'calico-cloud', null)).toBeNull(); + expect(agrees('tech-preview', resolveStatus(nativeV3Crds(), 'calico-cloud', null))).toBe(false); + }); +}); + +describe('lifecycle notices agree with data/feature-status.yaml', () => { + const cases = TREES.flatMap((tree) => + mdxFiles(tree.dir) + .flatMap(declarations) + .map((declaration) => ({ ...tree, ...declaration })) + ); + + it('has something to check', () => { + // A rename or a moved tree would otherwise make this suite pass by checking nothing. + expect(TREES.filter((tree) => mdxFiles(tree.dir).length > 0)).toHaveLength(TREES.length); + }); + + it.each(DECLARABLE)('recognises %s as a declarable status', (status) => { + expect(features.some((feature) => JSON.stringify(feature.products).includes(status))).toBe(true); + }); + + if (cases.length) { + it.each(cases)('$file declares $status for $id', ({ file, id, status, product, version }) => { + const feature = byId.get(id); + + if (!feature) { + throw new Error( + `${file} declares lifecycle-id "${id}", which is not in data/feature-status.yaml` + ); + } + + const actual = resolveStatus(feature, product, version); + + // Asserted as an object so a failure prints the page, the feature and the status the data + // file actually holds, rather than just "expected true, received false". + expect({ file, id, release: version ?? 'next', status: actual }).toEqual({ + file, + id, + release: version ?? 'next', + status: expect.stringMatching(allowedFor(status)), + }); + }); + } +}); diff --git a/src/components/LifecycleNotice/__test__/index.test.tsx b/src/components/LifecycleNotice/__test__/index.test.tsx new file mode 100644 index 0000000000..62fb0be11e --- /dev/null +++ b/src/components/LifecycleNotice/__test__/index.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/react'; + +import LifecycleNotice from '../index'; + +// @theme/Admonition only exists inside a Docusaurus build, so it has to be mocked virtually. +// The stand-in records the type and title as attributes so assertions can check which variant +// rendered without depending on Infima's markup. +jest.mock( + '@theme/Admonition', + () => ({ + __esModule: true, + default: ({ type, title, children }) => ( + + ), + }), + { virtual: true } +); + +const admonition = () => screen.getByTestId('admonition'); +const title = () => screen.getByRole('heading').textContent; + +describe('LifecycleNotice', () => { + it('renders a note for technology preview, naming the feature in the title', () => { + render(); + + expect(admonition()).toHaveAttribute('data-type', 'note'); + expect(title()).toBe('Technology preview: Istio ambient mode'); + expect(screen.getByText(/not supported for production use/)).toBeInTheDocument(); + }); + + it('renders a warning for deprecated, naming the feature in the title', () => { + render(); + + expect(admonition()).toHaveAttribute('data-type', 'warning'); + expect(title()).toBe('Deprecated: Fortinet integration'); + expect(screen.getByText(/scheduled for removal/)).toBeInTheDocument(); + }); + + // The name is a title rather than a sentence subject precisely so that a plural name needs no + // special handling. If this ever renders "Native v3 CRDs is a...", the design has regressed. + it.each([ + ['Native v3 CRDs', 'Technology preview: Native v3 CRDs'], + ['Gateway WAF', 'Technology preview: Gateway WAF'], + ])('uses %s verbatim in the title', (feature, expected) => { + render(); + expect(title()).toBe(expected); + }); + + it.each([ + ['tech-preview', 'Technology preview'], + ['deprecated', 'Deprecation and removal notice'], + ] as const)('falls back to a generic title for %s when no feature is given', (status, expected) => { + render(); + expect(title()).toBe(expected); + }); + + it('renders extra guidance after the standard sentence', () => { + render( + +

Use Istio ambient mode instead.

+
+ ); + + const paragraphs = Array.from(admonition().querySelectorAll('p')).map((p) => p.textContent); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]).toMatch(/scheduled for removal/); + expect(paragraphs[1]).toBe('Use Istio ambient mode instead.'); + }); + + // A mistyped status must fail the build rather than publish a page that says nothing. + it('throws on an unknown status', () => { + const quiet = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => render()).toThrow( + /unknown status "preview"/ + ); + + quiet.mockRestore(); + }); + + // The id binds an inline notice to data/feature-status.yaml for the drift check only. + it('never renders the id', () => { + render(); + expect(admonition().textContent).not.toMatch(/gateway-waf/); + }); +}); diff --git a/src/components/LifecycleNotice/index.tsx b/src/components/LifecycleNotice/index.tsx new file mode 100644 index 0000000000..b0e991d639 --- /dev/null +++ b/src/components/LifecycleNotice/index.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import Admonition from '@theme/Admonition'; + +/** + * The lifecycle statuses a page can declare about itself. + * + * This is deliberately narrower than the set in `data/feature-status.yaml`, which also records + * `ga` and `removed`. A page says nothing when a feature is generally available, and a removed + * feature has no page left to say it on, so neither is expressible here. + */ +export type LifecycleStatus = 'tech-preview' | 'deprecated'; + +interface Variant { + /** The admonition type, chosen to match what each notice already used before this component. */ + type: 'note' | 'warning'; + /** The title when no feature name is given. */ + title: string; + /** The title when one is. The name is a title, not a sentence subject, so that plural names + * ("Native v3 CRDs") and singular ones ("Gateway WAF") both read correctly with one body. */ + titleFor: (feature: string) => string; + body: string; +} + +const VARIANTS: Record = { + 'tech-preview': { + type: 'note', + title: 'Technology preview', + titleFor: (feature) => `Technology preview: ${feature}`, + body: + 'Technology preview features are for evaluation and feedback only and are not supported ' + + 'for production use. Behavior, APIs, and configuration may change before a feature reaches ' + + 'general availability.', + }, + deprecated: { + type: 'warning', + title: 'Deprecation and removal notice', + titleFor: (feature) => `Deprecated: ${feature}`, + body: + 'Deprecated features are still present and supported, but they are scheduled for removal. ' + + 'You should consider migrating away from a deprecated feature before it is removed.', + }, +}; + +export interface LifecycleNoticeProps { + status: LifecycleStatus; + /** The feature name, used as the admonition title. Omit it on a page whose heading already + * names the feature unambiguously. */ + feature?: string; + /** + * The matching `id` in `data/feature-status.yaml`. + * + * Not rendered, and never read at runtime — the notice is a page-level editorial statement and + * does not depend on the data file. It exists so the build-time drift check can bind an inline + * notice to a feature; pages that declare their status in front matter use `lifecycle-id` + * instead. Without it an inline notice is simply not drift-checked. + */ + id?: string; + /** Extra guidance shown after the standard sentence, such as what to migrate to. */ + children?: React.ReactNode; +} + +/** + * A feature lifecycle notice. + * + * Used directly in MDX when only part of an otherwise generally available page is affected. + * For a whole page, declare `lifecycle-status` in front matter instead and let + * `src/remark/lifecycleNoticePlugin.js` place this component after the page title, so the + * wording and position cannot drift from one page to the next. + */ +const LifecycleNotice: React.FC = ({ status, feature, children }) => { + const variant = VARIANTS[status]; + + if (!variant) { + // Thrown rather than rendered: a mistyped status in front matter or a prop should fail the + // build, not publish a page that quietly says nothing about a feature's status. + throw new Error( + `LifecycleNotice: unknown status "${status}". Expected one of ${Object.keys(VARIANTS).join(', ')}.` + ); + } + + return ( + +

{variant.body}

+ {children} +
+ ); +}; + +export default LifecycleNotice; diff --git a/src/remark/__test__/lifecycleNoticePlugin.test.js b/src/remark/__test__/lifecycleNoticePlugin.test.js new file mode 100644 index 0000000000..6c983c60ca --- /dev/null +++ b/src/remark/__test__/lifecycleNoticePlugin.test.js @@ -0,0 +1,94 @@ +import lifecycleNoticePlugin, { COMPONENT_NAME } from '../lifecycleNoticePlugin'; + +/** A tree shaped like a typical page: title, intro paragraph, section heading. */ +const page = () => ({ + type: 'root', + children: [ + { type: 'heading', depth: 1, children: [{ type: 'text', value: 'Gateway WAF' }] }, + { type: 'paragraph', children: [{ type: 'text', value: 'Intro.' }] }, + { type: 'heading', depth: 2, children: [{ type: 'text', value: 'Big picture' }] }, + ], +}); + +/** A stand-in vfile. `fail` throws, as it does in a real build. */ +const vfile = (frontMatter) => ({ + data: { frontMatter }, + fail(message) { + throw new Error(message); + }, +}); + +const run = (tree, frontMatter) => lifecycleNoticePlugin()(tree, vfile(frontMatter)); + +const attributes = (node) => + Object.fromEntries(node.attributes.map((attr) => [attr.name, attr.value])); + +describe('lifecycleNoticePlugin', () => { + it('inserts the component directly after the H1', () => { + const tree = page(); + run(tree, { 'lifecycle-status': 'tech-preview', 'lifecycle-feature': 'Gateway WAF' }); + + expect(tree.children.map((node) => node.type)).toEqual([ + 'heading', + 'mdxJsxFlowElement', + 'paragraph', + 'heading', + ]); + + const inserted = tree.children[1]; + expect(inserted.name).toBe(COMPONENT_NAME); + expect(attributes(inserted)).toEqual({ status: 'tech-preview', feature: 'Gateway WAF' }); + }); + + it('passes lifecycle-id through for the drift check', () => { + const tree = page(); + run(tree, { + 'lifecycle-status': 'deprecated', + 'lifecycle-feature': 'Fortinet integration', + 'lifecycle-id': 'fortinet-integration', + }); + + expect(attributes(tree.children[1])).toEqual({ + status: 'deprecated', + feature: 'Fortinet integration', + id: 'fortinet-integration', + }); + }); + + it('omits the feature attribute when no name is declared', () => { + const tree = page(); + run(tree, { 'lifecycle-status': 'tech-preview' }); + + expect(attributes(tree.children[1])).toEqual({ status: 'tech-preview' }); + }); + + it.each([[undefined], [{}], [{ description: 'Something unrelated.' }]])( + 'leaves the tree untouched when no status is declared (%p)', + (frontMatter) => { + const tree = page(); + const before = JSON.stringify(tree); + run(tree, frontMatter); + expect(JSON.stringify(tree)).toBe(before); + } + ); + + // Only the first H1 counts. A page with two would otherwise get two notices. + it('ignores a second level 1 heading', () => { + const tree = page(); + tree.children.push({ type: 'heading', depth: 1, children: [{ type: 'text', value: 'Again' }] }); + run(tree, { 'lifecycle-status': 'tech-preview' }); + + expect(tree.children.filter((node) => node.type === 'mdxJsxFlowElement')).toHaveLength(1); + expect(tree.children[1].type).toBe('mdxJsxFlowElement'); + }); + + it('fails when the page has no level 1 heading', () => { + const tree = { type: 'root', children: [{ type: 'paragraph', children: [] }] }; + + expect(() => run(tree, { 'lifecycle-status': 'tech-preview' })).toThrow(/no level 1 heading/); + }); + + it('fails on a status that is not one of the known values', () => { + expect(() => run(page(), { 'lifecycle-status': 'preview' })).toThrow(/not a valid status/); + }); +}); diff --git a/src/remark/lifecycleNoticePlugin.js b/src/remark/lifecycleNoticePlugin.js new file mode 100644 index 0000000000..712c1cb4cc --- /dev/null +++ b/src/remark/lifecycleNoticePlugin.js @@ -0,0 +1,73 @@ +import { visit } from 'unist-util-visit'; + +export const COMPONENT_NAME = 'LifecycleNotice'; + +const STATUS_KEY = 'lifecycle-status'; +const FEATURE_KEY = 'lifecycle-feature'; +const ID_KEY = 'lifecycle-id'; + +const VALID_STATUSES = ['tech-preview', 'deprecated']; + +function attribute(name, value) { + return { type: 'mdxJsxAttribute', name, value }; +} + +/** + * This is a remark plugin which runs during the build. When a page declares `lifecycle-status` + * in its front matter, it inserts a element directly after the page's H1, so + * that a whole-page technology preview or deprecation notice is a one-line declaration rather + * than an admonition copied between pages and left behind when the status changes. + * + * It inserts the component rather than building an admonition node directly, so that the front + * matter form and the hand-placed form in MDX render through exactly the same code and cannot + * drift apart. The component resolves through the global registration in + * src/theme/MDXComponents.js; nothing has to be imported by the page. + * + * Placement is after the H1 rather than before it because that is where every hand-written + * notice already sat, and a reader should see the page title first. + */ +export default function lifecycleNoticePlugin(_options) { + return function transformer(tree, file) { + const frontMatter = file.data?.frontMatter; + const status = frontMatter?.[STATUS_KEY]; + + if (!status) return; + + if (!VALID_STATUSES.includes(status)) { + file.fail( + `${STATUS_KEY}: "${status}" is not a valid status. Expected one of ${VALID_STATUSES.join(', ')}.` + ); + } + + const attributes = [attribute('status', status)]; + + const feature = frontMatter[FEATURE_KEY]; + if (feature) attributes.push(attribute('feature', feature)); + + const id = frontMatter[ID_KEY]; + if (id) attributes.push(attribute('id', id)); + + let index = null; + let parent = null; + + visit(tree, 'heading', (node, nodeIndex, nodeParent) => { + if (node.depth !== 1 || index !== null) return; + index = nodeIndex; + parent = nodeParent; + }); + + if (index === null) { + // Failing is the point. A page that declares a status but has no title has nowhere to put + // the notice, and silently dropping it would publish the page as though the feature were + // generally available. + file.fail(`${STATUS_KEY} is set but the page has no level 1 heading to place the notice after.`); + } + + parent.children.splice(index + 1, 0, { + type: 'mdxJsxFlowElement', + name: COMPONENT_NAME, + attributes, + children: [], + }); + }; +} diff --git a/src/theme/MDXComponents.js b/src/theme/MDXComponents.js index d34d4818b4..81bd54990a 100644 --- a/src/theme/MDXComponents.js +++ b/src/theme/MDXComponents.js @@ -3,10 +3,12 @@ import MDXComponents from '@theme-original/MDXComponents'; import GeekDetails from '@site/src/components/partials/GeekDetails'; import Callouts from '@site/src/components/Callouts'; import ImageCaption from '@site/src/___new___/components/ImageCaption'; +import LifecycleNotice from '@site/src/components/LifecycleNotice'; export default { ...MDXComponents, GeekDetails, Callouts, ImageCaption, + LifecycleNotice, }; From ede5b428cbbe6efa15e789b858636303d9e3c2e5 Mon Sep 17 00:00:00 2001 From: Chris Tauchen Date: Wed, 14 Oct 2026 17:02:45 +0100 Subject: [PATCH 2/5] DOCS-3057: Check the Calico Cloud 24 tree for lifecycle drift Calico Cloud 24-1 replaced 23-2 as the published version, so the drift test now checks version-24-1 against the CE 3.24 line. --- src/components/LifecycleNotice/__test__/drift.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/LifecycleNotice/__test__/drift.test.ts b/src/components/LifecycleNotice/__test__/drift.test.ts index 392236dce0..0c62d2a50b 100644 --- a/src/components/LifecycleNotice/__test__/drift.test.ts +++ b/src/components/LifecycleNotice/__test__/drift.test.ts @@ -38,9 +38,9 @@ const TREES: { dir: string; product: string; version: string | null }[] = [ { dir: 'calico-enterprise_versioned_docs/version-3.23-2', product: 'calico-enterprise', version: '3.23' }, { dir: 'calico-cloud', product: 'calico-cloud', version: null }, { - dir: 'calico-cloud_versioned_docs/version-23-2', + dir: 'calico-cloud_versioned_docs/version-24-1', product: 'calico-cloud', - version: ceVersionFromCloudversion('v3.23.1-4'), + version: ceVersionFromCloudversion('v3.24.0-1.0-4'), }, ]; From f500b744d247df547932ee714a8b100f793008c2 Mon Sep 17 00:00:00 2001 From: Chris Tauchen Date: Wed, 14 Oct 2026 17:28:22 +0100 Subject: [PATCH 3/5] DOCS-3057: Use snake_case for lifecycle front matter keys Every other front matter key on the site is snake_case, such as sidebar_label. Rename lifecycle-status, lifecycle-feature and lifecycle-id to match before any page beyond the pilots uses them. --- .github/copilot-instructions.md | 12 ++++++------ .../threat/gateway-waf/overview.mdx | 6 +++--- .../LifecycleNotice/__test__/drift.test.ts | 8 ++++---- src/components/LifecycleNotice/index.tsx | 4 ++-- .../__test__/lifecycleNoticePlugin.test.js | 18 +++++++++--------- src/remark/lifecycleNoticePlugin.js | 8 ++++---- 6 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 91d979790f..bd77fc0728 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -178,17 +178,17 @@ When a page is about the feature, declare it in front matter and write nothing i ```yaml --- description: ... -lifecycle-status: tech-preview -lifecycle-feature: Gateway WAF -lifecycle-id: gateway-waf +lifecycle_status: tech-preview +lifecycle_feature: Gateway WAF +lifecycle_id: gateway-waf --- ``` -- `lifecycle-status` is `tech-preview` or `deprecated`. There is no value for generally +- `lifecycle_status` is `tech-preview` or `deprecated`. There is no value for generally available: a GA feature says nothing. -- `lifecycle-feature` is the name shown in the notice title. Omit it when the page title already +- `lifecycle_feature` is the name shown in the notice title. Omit it when the page title already names the feature unambiguously. -- `lifecycle-id` is the matching `id` in `data/feature-status.yaml`. It is optional, but supply +- `lifecycle_id` is the matching `id` in `data/feature-status.yaml`. It is optional, but supply it when the feature has an entry, because it is what lets CI catch a notice that outlived the status it describes. Nothing is rendered from it. diff --git a/calico-enterprise/threat/gateway-waf/overview.mdx b/calico-enterprise/threat/gateway-waf/overview.mdx index 7d208270ff..4760e50ffd 100644 --- a/calico-enterprise/threat/gateway-waf/overview.mdx +++ b/calico-enterprise/threat/gateway-waf/overview.mdx @@ -1,8 +1,8 @@ --- description: Protect Calico Ingress Gateway traffic with a per-route and per-namespace web application firewall built on the OWASP Core Rule Set — how the resource families, merge order, and enforcement guardrails fit together. -lifecycle-status: tech-preview -lifecycle-feature: Gateway WAF -lifecycle-id: gateway-waf +lifecycle_status: tech-preview +lifecycle_feature: Gateway WAF +lifecycle_id: gateway-waf --- # Gateway WAF diff --git a/src/components/LifecycleNotice/__test__/drift.test.ts b/src/components/LifecycleNotice/__test__/drift.test.ts index 0c62d2a50b..256d29ecee 100644 --- a/src/components/LifecycleNotice/__test__/drift.test.ts +++ b/src/components/LifecycleNotice/__test__/drift.test.ts @@ -15,7 +15,7 @@ import type { CellStatus, Feature } from '../../FeatureStatusTable/featureStatus * Open Source 3.33 shipped with the release notes calling native v3 CRDs generally available * while the install page still called it a technology preview. * - * Only pages that opt in are checked. A page opts in by declaring `lifecycle-id` in front matter, + * Only pages that opt in are checked. A page opts in by declaring `lifecycle_id` in front matter, * or by passing `id` to an inline . A page with no id is not checked, so a * feature with no entry in the data file costs nothing. */ @@ -81,8 +81,8 @@ function declarations(file: string): Declaration[] { const header = FRONT_MATTER.exec(source); if (header) { const frontMatter = parse(header[1]) ?? {}; - const status = frontMatter['lifecycle-status']; - const id = frontMatter['lifecycle-id']; + const status = frontMatter['lifecycle_status']; + const id = frontMatter['lifecycle_id']; if (status && id) found.push({ file, id, status }); } @@ -178,7 +178,7 @@ describe('lifecycle notices agree with data/feature-status.yaml', () => { if (!feature) { throw new Error( - `${file} declares lifecycle-id "${id}", which is not in data/feature-status.yaml` + `${file} declares lifecycle_id "${id}", which is not in data/feature-status.yaml` ); } diff --git a/src/components/LifecycleNotice/index.tsx b/src/components/LifecycleNotice/index.tsx index b0e991d639..328de255eb 100644 --- a/src/components/LifecycleNotice/index.tsx +++ b/src/components/LifecycleNotice/index.tsx @@ -51,7 +51,7 @@ export interface LifecycleNoticeProps { * * Not rendered, and never read at runtime — the notice is a page-level editorial statement and * does not depend on the data file. It exists so the build-time drift check can bind an inline - * notice to a feature; pages that declare their status in front matter use `lifecycle-id` + * notice to a feature; pages that declare their status in front matter use `lifecycle_id` * instead. Without it an inline notice is simply not drift-checked. */ id?: string; @@ -63,7 +63,7 @@ export interface LifecycleNoticeProps { * A feature lifecycle notice. * * Used directly in MDX when only part of an otherwise generally available page is affected. - * For a whole page, declare `lifecycle-status` in front matter instead and let + * For a whole page, declare `lifecycle_status` in front matter instead and let * `src/remark/lifecycleNoticePlugin.js` place this component after the page title, so the * wording and position cannot drift from one page to the next. */ diff --git a/src/remark/__test__/lifecycleNoticePlugin.test.js b/src/remark/__test__/lifecycleNoticePlugin.test.js index 6c983c60ca..abf3df52ba 100644 --- a/src/remark/__test__/lifecycleNoticePlugin.test.js +++ b/src/remark/__test__/lifecycleNoticePlugin.test.js @@ -26,7 +26,7 @@ const attributes = (node) => describe('lifecycleNoticePlugin', () => { it('inserts the component directly after the H1', () => { const tree = page(); - run(tree, { 'lifecycle-status': 'tech-preview', 'lifecycle-feature': 'Gateway WAF' }); + run(tree, { 'lifecycle_status': 'tech-preview', 'lifecycle_feature': 'Gateway WAF' }); expect(tree.children.map((node) => node.type)).toEqual([ 'heading', @@ -40,12 +40,12 @@ describe('lifecycleNoticePlugin', () => { expect(attributes(inserted)).toEqual({ status: 'tech-preview', feature: 'Gateway WAF' }); }); - it('passes lifecycle-id through for the drift check', () => { + it('passes lifecycle_id through for the drift check', () => { const tree = page(); run(tree, { - 'lifecycle-status': 'deprecated', - 'lifecycle-feature': 'Fortinet integration', - 'lifecycle-id': 'fortinet-integration', + 'lifecycle_status': 'deprecated', + 'lifecycle_feature': 'Fortinet integration', + 'lifecycle_id': 'fortinet-integration', }); expect(attributes(tree.children[1])).toEqual({ @@ -57,7 +57,7 @@ describe('lifecycleNoticePlugin', () => { it('omits the feature attribute when no name is declared', () => { const tree = page(); - run(tree, { 'lifecycle-status': 'tech-preview' }); + run(tree, { 'lifecycle_status': 'tech-preview' }); expect(attributes(tree.children[1])).toEqual({ status: 'tech-preview' }); }); @@ -76,7 +76,7 @@ describe('lifecycleNoticePlugin', () => { it('ignores a second level 1 heading', () => { const tree = page(); tree.children.push({ type: 'heading', depth: 1, children: [{ type: 'text', value: 'Again' }] }); - run(tree, { 'lifecycle-status': 'tech-preview' }); + run(tree, { 'lifecycle_status': 'tech-preview' }); expect(tree.children.filter((node) => node.type === 'mdxJsxFlowElement')).toHaveLength(1); expect(tree.children[1].type).toBe('mdxJsxFlowElement'); @@ -85,10 +85,10 @@ describe('lifecycleNoticePlugin', () => { it('fails when the page has no level 1 heading', () => { const tree = { type: 'root', children: [{ type: 'paragraph', children: [] }] }; - expect(() => run(tree, { 'lifecycle-status': 'tech-preview' })).toThrow(/no level 1 heading/); + expect(() => run(tree, { 'lifecycle_status': 'tech-preview' })).toThrow(/no level 1 heading/); }); it('fails on a status that is not one of the known values', () => { - expect(() => run(page(), { 'lifecycle-status': 'preview' })).toThrow(/not a valid status/); + expect(() => run(page(), { 'lifecycle_status': 'preview' })).toThrow(/not a valid status/); }); }); diff --git a/src/remark/lifecycleNoticePlugin.js b/src/remark/lifecycleNoticePlugin.js index 712c1cb4cc..16b1997de5 100644 --- a/src/remark/lifecycleNoticePlugin.js +++ b/src/remark/lifecycleNoticePlugin.js @@ -2,9 +2,9 @@ import { visit } from 'unist-util-visit'; export const COMPONENT_NAME = 'LifecycleNotice'; -const STATUS_KEY = 'lifecycle-status'; -const FEATURE_KEY = 'lifecycle-feature'; -const ID_KEY = 'lifecycle-id'; +const STATUS_KEY = 'lifecycle_status'; +const FEATURE_KEY = 'lifecycle_feature'; +const ID_KEY = 'lifecycle_id'; const VALID_STATUSES = ['tech-preview', 'deprecated']; @@ -13,7 +13,7 @@ function attribute(name, value) { } /** - * This is a remark plugin which runs during the build. When a page declares `lifecycle-status` + * This is a remark plugin which runs during the build. When a page declares `lifecycle_status` * in its front matter, it inserts a element directly after the page's H1, so * that a whole-page technology preview or deprecation notice is a one-line declaration rather * than an admonition copied between pages and left behind when the status changes. From b85fb2db466e1adce69934abc58eb16b7035dab9 Mon Sep 17 00:00:00 2001 From: Chris Tauchen Date: Wed, 14 Oct 2026 17:28:22 +0100 Subject: [PATCH 4/5] DOCS-3057: Keep admonition titles and types in Markdown twins The converter dropped admonition titles, so a lifecycle notice lost its feature name in llms.txt and the .md twins, and every deprecation notice lost "deprecation and removal notice". Keep a custom title in the label. The label also came from the alert color class rather than the admonition type, so notes were labeled SECONDARY and tips SUCCESS. Use the type instead. --- .../__test__/conversion.test.js | 46 +++++++++++++++++++ .../docusaurus-plugin-llms-txt/convert.js | 33 +++++++++---- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/plugins/docusaurus-plugin-llms-txt/__test__/conversion.test.js b/src/plugins/docusaurus-plugin-llms-txt/__test__/conversion.test.js index f2e7c91376..cd42386e1b 100644 --- a/src/plugins/docusaurus-plugin-llms-txt/__test__/conversion.test.js +++ b/src/plugins/docusaurus-plugin-llms-txt/__test__/conversion.test.js @@ -93,6 +93,16 @@ describe('extractFromHtmlString', () => { }); }); +/** + * An admonition in the shape Docusaurus emits: an icon and the title in a heading div, then + * the body in a content div. With no custom title, the heading holds the bare type name. + */ +function admonition(type, alert, title, body) { + return `
` + + `
${title}
` + + `

${body}

`; +} + describe('convertToMarkdown', () => { it('strips the zero-width hash-link anchor from headings', async () => { const extracted = extractFromHtmlString(page(heading(2, 'before-you-begin', 'Before you begin'))); @@ -126,6 +136,42 @@ describe('convertToMarkdown', () => { expect(markdown).toBe('```yaml\nfirst\n\nthird\n```'); }); + it('keeps a custom admonition title in the label', async () => { + const extracted = extractFromHtmlString( + page(admonition('warning', 'warning', 'Deprecation and removal notice', 'The Fortinet integration is deprecated.')) + ); + const markdown = await convertToMarkdown(extracted.html, SITE_URL); + + expect(markdown).toBe( + '> **WARNING: Deprecation and removal notice** The Fortinet integration is deprecated.' + ); + }); + + it('does not repeat the type when the admonition has no custom title', async () => { + const extracted = extractFromHtmlString( + page(admonition('warning', 'warning', 'warning', 'Back up the datastore first.')) + ); + const markdown = await convertToMarkdown(extracted.html, SITE_URL); + + expect(markdown).toBe('> **WARNING:** Back up the datastore first.'); + }); + + it('labels a note as NOTE, not by its alert color class', async () => { + const extracted = extractFromHtmlString( + page(admonition('note', 'secondary', 'note', 'Requires a license.')) + ); + const markdown = await convertToMarkdown(extracted.html, SITE_URL); + + expect(markdown).toBe('> **NOTE:** Requires a license.'); + }); + + it('labels a tip as TIP, not by its alert color class', async () => { + const extracted = extractFromHtmlString(page(admonition('tip', 'success', 'tip', 'Use a label.'))); + const markdown = await convertToMarkdown(extracted.html, SITE_URL); + + expect(markdown).toBe('> **TIP:** Use a label.'); + }); + it('resolves root-relative links against the site URL', async () => { const extracted = extractFromHtmlString(page('

About

')); const markdown = await convertToMarkdown(extracted.html, SITE_URL); diff --git a/src/plugins/docusaurus-plugin-llms-txt/convert.js b/src/plugins/docusaurus-plugin-llms-txt/convert.js index e9840689aa..ee699100b7 100644 --- a/src/plugins/docusaurus-plugin-llms-txt/convert.js +++ b/src/plugins/docusaurus-plugin-llms-txt/convert.js @@ -45,34 +45,47 @@ function createHandlers(siteUrl) { // --- Admonitions --- if (className.includes('theme-admonition')) { - const typeMatch = className.match(/alert--([\w]+)/); - const admonitionType = typeMatch ? typeMatch[1].toUpperCase() : 'NOTE'; - - // Find the content div (class starts with 'admonitionContent') + // Label with the admonition type the author wrote, not its alert--* color class, + // which maps note to "secondary" and tip to "success" + const sourceType = className.match(/theme-admonition-([\w]+)/)?.[1]; + const admonitionType = (sourceType || 'note').toUpperCase(); + + // Find the heading div (class starts with 'admonitionHeading') and the content div + // (class starts with 'admonitionContent') + let headingNode = null; let contentNode = null; for (const child of node.children || []) { const childClass = (child.properties?.className || []).join(' '); - if (childClass.match(/admonitionContent/i)) { + if (childClass.match(/admonitionHeading/i)) { + headingNode = child; + } else if (childClass.match(/admonitionContent/i)) { contentNode = child; - break; } } + // Keep a custom title, such as "Deprecation and removal notice". Docusaurus renders + // the bare type name ("warning") when there is none, which would only repeat the label. + const title = headingNode ? toText(headingNode).trim() : ''; + const label = + title && title.toLowerCase() !== sourceType?.toLowerCase() + ? `${admonitionType}: ${title}` + : `${admonitionType}:`; + // Convert the content children to mdast const contentChildren = contentNode ? state.all(contentNode) : state.all(node); - // Build: > **TYPE:** content - // Prepend the type label to the first paragraph + // Build: > **TYPE:** content, or > **TYPE: Title** content + // Prepend the label to the first paragraph if (contentChildren.length > 0 && contentChildren[0].type === 'paragraph') { contentChildren[0].children.unshift( - strong(`${admonitionType}:`), + strong(label), { type: 'text', value: ' ' } ); } else { contentChildren.unshift( - paragraph([strong(`${admonitionType}:`), { type: 'text', value: ' ' }]) + paragraph([strong(label), { type: 'text', value: ' ' }]) ); } From d5db7e1d1c47b1d5d52a51695186a34f361e0dd5 Mon Sep 17 00:00:00 2001 From: Chris Tauchen Date: Wed, 14 Oct 2026 17:33:45 +0100 Subject: [PATCH 5/5] DOCS-3057: Close gaps in the lifecycle drift check Build the list of checked trees from the product versions files, so every versioned tree is checked, including OSS 3.32 and 3.31 and CE 3.22-2 and 3.21-2, and a new cut needs no edit here. Read each Calico Cloud tree's release line from its own variables.js. Read inline notices whose attributes are expressions or contain >, and fail on an inline notice with an id but no readable status instead of skipping it. Explain a failure on a feature marked confirmed: false. Fail the build on an unknown lifecycle front matter key, or on lifecycle_feature or lifecycle_id without lifecycle_status, since either would otherwise render no notice and pass every check. --- .github/copilot-instructions.md | 3 +- .../LifecycleNotice/__test__/drift.test.ts | 124 ++++++++++++++---- src/components/LifecycleNotice/index.tsx | 2 +- .../__test__/lifecycleNoticePlugin.test.js | 35 +++-- src/remark/lifecycleNoticePlugin.js | 26 +++- 5 files changed, 155 insertions(+), 35 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bd77fc0728..b21ef29999 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -193,7 +193,8 @@ lifecycle_id: gateway-waf status it describes. Nothing is rendered from it. `src/remark/lifecycleNoticePlugin.js` places the notice directly after the page's H1. A page -that declares a status but has no H1 fails the build. +that declares a status but has no H1 fails the build. So does a misspelled key such as +`lifecycle-status`, or a `lifecycle_feature` or `lifecycle_id` without `lifecycle_status`. Do not use `$[variables]` in these front matter fields. Variable substitution is a remark plugin that runs over the page body, and front matter never passes through it, so the token would be diff --git a/src/components/LifecycleNotice/__test__/drift.test.ts b/src/components/LifecycleNotice/__test__/drift.test.ts index 256d29ecee..fa1f3528f1 100644 --- a/src/components/LifecycleNotice/__test__/drift.test.ts +++ b/src/components/LifecycleNotice/__test__/drift.test.ts @@ -22,27 +22,46 @@ import type { CellStatus, Feature } from '../../FeatureStatusTable/featureStatus const ROOT = path.resolve(__dirname, '../../../..'); +const PRODUCTS = ['calico', 'calico-enterprise', 'calico-cloud']; + +/** + * The release line a versioned tree describes. + * + * Enterprise versions carry a Docusaurus suffix, so 3.24-1 and 3.24-2 are both line 3.24. The + * Calico Cloud trees are keyed to the Calico Enterprise line they are built on, because that is + * what the data file records, read from the `cloudversion` in that tree's own variables.js. + */ +function releaseLine(product: string, dir: string, version: string): string | null { + if (product !== 'calico-cloud') return version.replace(/-\d+$/, ''); + + const variables = fs.readFileSync(path.join(ROOT, dir, 'variables.js'), 'utf8'); + const cloudversion = /cloudversion:\s*['"]([^'"]+)['"]/.exec(variables)?.[1]; + if (!cloudversion) throw new Error(`${dir}/variables.js has no cloudversion`); + return ceVersionFromCloudversion(cloudversion); +} + /** * Each documentation tree, and the release line its pages describe. * - * `null` means a Next tree, which is ahead of every released line. The Calico Cloud trees are - * keyed to the Calico Enterprise line they are built on, because that is what the data file - * records. The unversioned Calico Cloud tree is treated as Next rather than trusting its own - * `cloudversion`, which trails the published tree and is not what that tree documents. + * The versioned trees come from each product's versions file, the list Docusaurus itself reads, + * so a newly cut version is checked without anyone remembering to add it here. `null` means a + * Next tree, which is ahead of every released line. The unversioned Calico Cloud tree is treated + * as Next rather than trusting its own `cloudversion`, which can trail what that tree documents. */ -const TREES: { dir: string; product: string; version: string | null }[] = [ - { dir: 'calico', product: 'calico', version: null }, - { dir: 'calico_versioned_docs/version-3.33', product: 'calico', version: '3.33' }, - { dir: 'calico-enterprise', product: 'calico-enterprise', version: null }, - { dir: 'calico-enterprise_versioned_docs/version-3.24-2', product: 'calico-enterprise', version: '3.24' }, - { dir: 'calico-enterprise_versioned_docs/version-3.23-2', product: 'calico-enterprise', version: '3.23' }, - { dir: 'calico-cloud', product: 'calico-cloud', version: null }, - { - dir: 'calico-cloud_versioned_docs/version-24-1', - product: 'calico-cloud', - version: ceVersionFromCloudversion('v3.24.0-1.0-4'), - }, -]; +const TREES: { dir: string; product: string; version: string | null }[] = PRODUCTS.flatMap( + (product) => { + const versions: string[] = JSON.parse( + fs.readFileSync(path.join(ROOT, `${product}_versions.json`), 'utf8') + ); + return [ + { dir: product, product, version: null }, + ...versions.map((version) => { + const dir = `${product}_versioned_docs/version-${version}`; + return { dir, product, version: releaseLine(product, dir, version) }; + }), + ]; + } +); /** Statuses a page can declare, mapped to the data file's vocabulary. */ const DECLARABLE = ['tech-preview', 'deprecated'] as const; @@ -71,11 +90,27 @@ function mdxFiles(dir: string): string[] { } const FRONT_MATTER = /^---\n([\s\S]*?)\n---/; -const INLINE_NOTICE = /]*)>/g; + +/** + * An inline opening tag. Quoted strings and {expressions} are matched whole, + * so a `>` inside a feature name does not end the tag early. + */ +const INLINE_NOTICE = /"'{])*)>/g; + +/** A string attribute, written as status="x", status='x', status={'x'} or status={"x"}. */ +function stringAttribute(attributes: string, name: string): string | undefined { + const match = new RegExp( + `(?:^|\\s)${name}=(?:"([^"]*)"|'([^']*)'|\\{\\s*["']([^"']*)["']\\s*\\})` + ).exec(attributes); + return match ? match[1] ?? match[2] ?? match[3] : undefined; +} /** Every lifecycle declaration in one file, from front matter and from inline notices. */ function declarations(file: string): Declaration[] { - const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); + return declarationsIn(file, fs.readFileSync(path.join(ROOT, file), 'utf8')); +} + +function declarationsIn(file: string, source: string): Declaration[] { const found: Declaration[] = []; const header = FRONT_MATTER.exec(source); @@ -87,9 +122,18 @@ function declarations(file: string): Declaration[] { } for (const [, attributes] of source.matchAll(INLINE_NOTICE)) { - const status = /status=["']([^"']+)["']/.exec(attributes)?.[1]; - const id = /\bid=["']([^"']+)["']/.exec(attributes)?.[1]; - if (status && id) found.push({ file, id, status: status as Declarable }); + const status = stringAttribute(attributes, 'status'); + const id = stringAttribute(attributes, 'id'); + if (!id) continue; + + // An id the check cannot pair with a status would otherwise go unchecked without a word. + if (!status) { + throw new Error( + `${file} has a whose status is not a string literal, ` + + 'so the drift check cannot read it. Write status="tech-preview" or status="deprecated".' + ); + } + found.push({ file, id, status: status as Declarable }); } return found; @@ -156,6 +200,34 @@ describe('the drift check catches a stale notice', () => { }); }); +describe('the drift check reads every inline notice', () => { + const read = (mdx: string) => declarationsIn('page.mdx', mdx); + + it('reads quoted and expression attributes alike', () => { + expect(read(``)).toEqual([ + { file: 'page.mdx', id: 'fortinet-integration', status: 'deprecated' }, + ]); + }); + + it('is not cut short by a > inside the feature name', () => { + expect( + read(``) + ).toEqual([{ file: 'page.mdx', id: 'l7-logs', status: 'tech-preview' }]); + }); + + it('refuses a status it cannot read rather than skipping the notice', () => { + expect(() => read(``)).toThrow( + /status is not a string literal/ + ); + }); + + it('reads the Calico Cloud release line from the tree itself', () => { + expect(TREES.find((tree) => tree.dir === 'calico-cloud_versioned_docs/version-24-1')?.version).toBe( + '3.24' + ); + }); +}); + describe('lifecycle notices agree with data/feature-status.yaml', () => { const cases = TREES.flatMap((tree) => mdxFiles(tree.dir) @@ -182,6 +254,14 @@ describe('lifecycle notices agree with data/feature-status.yaml', () => { ); } + if (feature.products?.[product]?.confirmed === false) { + throw new Error( + `${file} declares lifecycle_id "${id}", but its ${product} entry in ` + + 'data/feature-status.yaml is marked confirmed: false, so it has no status to check ' + + 'against. Confirm the entry or remove the id.' + ); + } + const actual = resolveStatus(feature, product, version); // Asserted as an object so a failure prints the page, the feature and the status the data diff --git a/src/components/LifecycleNotice/index.tsx b/src/components/LifecycleNotice/index.tsx index 328de255eb..45b9aaa582 100644 --- a/src/components/LifecycleNotice/index.tsx +++ b/src/components/LifecycleNotice/index.tsx @@ -50,7 +50,7 @@ export interface LifecycleNoticeProps { * The matching `id` in `data/feature-status.yaml`. * * Not rendered, and never read at runtime — the notice is a page-level editorial statement and - * does not depend on the data file. It exists so the build-time drift check can bind an inline + * does not depend on the data file. It exists so the drift check in CI can bind an inline * notice to a feature; pages that declare their status in front matter use `lifecycle_id` * instead. Without it an inline notice is simply not drift-checked. */ diff --git a/src/remark/__test__/lifecycleNoticePlugin.test.js b/src/remark/__test__/lifecycleNoticePlugin.test.js index abf3df52ba..15d0df1e82 100644 --- a/src/remark/__test__/lifecycleNoticePlugin.test.js +++ b/src/remark/__test__/lifecycleNoticePlugin.test.js @@ -26,7 +26,7 @@ const attributes = (node) => describe('lifecycleNoticePlugin', () => { it('inserts the component directly after the H1', () => { const tree = page(); - run(tree, { 'lifecycle_status': 'tech-preview', 'lifecycle_feature': 'Gateway WAF' }); + run(tree, { lifecycle_status: 'tech-preview', lifecycle_feature: 'Gateway WAF' }); expect(tree.children.map((node) => node.type)).toEqual([ 'heading', @@ -43,9 +43,9 @@ describe('lifecycleNoticePlugin', () => { it('passes lifecycle_id through for the drift check', () => { const tree = page(); run(tree, { - 'lifecycle_status': 'deprecated', - 'lifecycle_feature': 'Fortinet integration', - 'lifecycle_id': 'fortinet-integration', + lifecycle_status: 'deprecated', + lifecycle_feature: 'Fortinet integration', + lifecycle_id: 'fortinet-integration', }); expect(attributes(tree.children[1])).toEqual({ @@ -57,7 +57,7 @@ describe('lifecycleNoticePlugin', () => { it('omits the feature attribute when no name is declared', () => { const tree = page(); - run(tree, { 'lifecycle_status': 'tech-preview' }); + run(tree, { lifecycle_status: 'tech-preview' }); expect(attributes(tree.children[1])).toEqual({ status: 'tech-preview' }); }); @@ -76,7 +76,7 @@ describe('lifecycleNoticePlugin', () => { it('ignores a second level 1 heading', () => { const tree = page(); tree.children.push({ type: 'heading', depth: 1, children: [{ type: 'text', value: 'Again' }] }); - run(tree, { 'lifecycle_status': 'tech-preview' }); + run(tree, { lifecycle_status: 'tech-preview' }); expect(tree.children.filter((node) => node.type === 'mdxJsxFlowElement')).toHaveLength(1); expect(tree.children[1].type).toBe('mdxJsxFlowElement'); @@ -85,10 +85,29 @@ describe('lifecycleNoticePlugin', () => { it('fails when the page has no level 1 heading', () => { const tree = { type: 'root', children: [{ type: 'paragraph', children: [] }] }; - expect(() => run(tree, { 'lifecycle_status': 'tech-preview' })).toThrow(/no level 1 heading/); + expect(() => run(tree, { lifecycle_status: 'tech-preview' })).toThrow(/no level 1 heading/); }); it('fails on a status that is not one of the known values', () => { - expect(() => run(page(), { 'lifecycle_status': 'preview' })).toThrow(/not a valid status/); + expect(() => run(page(), { lifecycle_status: 'preview' })).toThrow(/not a valid status/); + }); + + it('fails on a misspelled lifecycle key rather than rendering nothing', () => { + expect(() => run(page(), { 'lifecycle-status': 'tech-preview' })).toThrow( + /Unknown front matter key lifecycle-status/ + ); + }); + + it('fails when a feature or id is declared without a status', () => { + expect(() => run(page(), { lifecycle_id: 'gateway-waf' })).toThrow( + /lifecycle_id is set but lifecycle_status is not/ + ); + }); + + it('leaves a page with no lifecycle keys alone', () => { + const tree = page(); + run(tree, { sidebar_label: 'Gateway WAF' }); + + expect(tree).toEqual(page()); }); }); diff --git a/src/remark/lifecycleNoticePlugin.js b/src/remark/lifecycleNoticePlugin.js index 16b1997de5..27c35b26ff 100644 --- a/src/remark/lifecycleNoticePlugin.js +++ b/src/remark/lifecycleNoticePlugin.js @@ -6,6 +6,8 @@ const STATUS_KEY = 'lifecycle_status'; const FEATURE_KEY = 'lifecycle_feature'; const ID_KEY = 'lifecycle_id'; +const KNOWN_KEYS = [STATUS_KEY, FEATURE_KEY, ID_KEY]; + const VALID_STATUSES = ['tech-preview', 'deprecated']; function attribute(name, value) { @@ -28,10 +30,28 @@ function attribute(name, value) { */ export default function lifecycleNoticePlugin(_options) { return function transformer(tree, file) { - const frontMatter = file.data?.frontMatter; - const status = frontMatter?.[STATUS_KEY]; + const frontMatter = file.data?.frontMatter ?? {}; - if (!status) return; + // A misspelled key, such as lifecycle-status, would otherwise render no notice and pass + // every check, publishing the page as though the feature were generally available. + const unknown = Object.keys(frontMatter).filter( + (key) => /^lifecycle/i.test(key) && !KNOWN_KEYS.includes(key) + ); + if (unknown.length) { + file.fail( + `Unknown front matter key ${unknown.join(', ')}. Lifecycle keys are ${KNOWN_KEYS.join(', ')}.` + ); + } + + const status = frontMatter[STATUS_KEY]; + + if (!status) { + const orphans = [FEATURE_KEY, ID_KEY].filter((key) => frontMatter[key]); + if (orphans.length) { + file.fail(`${orphans.join(', ')} is set but ${STATUS_KEY} is not, so no notice would render.`); + } + return; + } if (!VALID_STATUSES.includes(status)) { file.fail(