diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 58ee32b40d..b21ef29999 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -166,6 +166,69 @@ 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. 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 +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..4760e50ffd 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..fa1f3528f1 --- /dev/null +++ b/src/components/LifecycleNotice/__test__/drift.test.ts @@ -0,0 +1,277 @@ +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, '../../../..'); + +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. + * + * 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 }[] = 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; +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---/; + +/** + * 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[] { + 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); + 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 = 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; +} + +/** + * 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('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) + .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` + ); + } + + 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 + // 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..45b9aaa582 --- /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 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. + */ + 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/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: ' ' }]) ); } diff --git a/src/remark/__test__/lifecycleNoticePlugin.test.js b/src/remark/__test__/lifecycleNoticePlugin.test.js new file mode 100644 index 0000000000..15d0df1e82 --- /dev/null +++ b/src/remark/__test__/lifecycleNoticePlugin.test.js @@ -0,0 +1,113 @@ +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/); + }); + + 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 new file mode 100644 index 0000000000..27c35b26ff --- /dev/null +++ b/src/remark/lifecycleNoticePlugin.js @@ -0,0 +1,93 @@ +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 KNOWN_KEYS = [STATUS_KEY, FEATURE_KEY, ID_KEY]; + +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 ?? {}; + + // 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( + `${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, };