From 783b4ad50ce9c751d0b14108ee1009f42a0606db Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Tue, 20 Jan 2026 17:51:14 -0500 Subject: [PATCH 1/4] feat(API): add subsection support --- astro.config.mjs | 4 +- package-lock.json | 464 ++++++++++++++++-- package.json | 4 +- pf-docs.config.mjs | 2 + .../[version]/[section]/[page]/[tab].test.ts | 1 + src/pages/api/[version]/[section].ts | 3 + src/pages/api/[version]/[section]/[page].ts | 6 +- .../api/[version]/[section]/[page]/[tab].ts | 9 +- .../[section]/[page]/[tab]/examples.ts | 6 +- .../[page]/[tab]/examples/[example].ts | 172 ++----- .../[version]/[section]/[page]/[tab]/text.ts | 96 +--- src/pages/api/index.ts | 50 +- src/pages/api/openapi.json.ts | 46 +- src/utils/__tests__/apiIndex.test.ts | 86 +++- src/utils/apiIndex/generate.ts | 58 ++- src/utils/apiRoutes/collections.ts | 39 ++ src/utils/apiRoutes/contentMatching.ts | 97 ++++ src/utils/apiRoutes/exampleParsing.ts | 38 ++ 18 files changed, 861 insertions(+), 320 deletions(-) create mode 100644 src/utils/apiRoutes/collections.ts create mode 100644 src/utils/apiRoutes/contentMatching.ts create mode 100644 src/utils/apiRoutes/exampleParsing.ts diff --git a/astro.config.mjs b/astro.config.mjs index 4a9622f..7d5c233 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -11,13 +11,13 @@ export default defineConfig({ vite: { ssr: { noExternal: ["@patternfly/*", "react-dropzone"], - external: ["node:fs", "node:path"] + external: ["node:fs", "node:path", "fs/promises", "path"] }, server: { fs: { allow: ['./'] } - } + }, }, adapter: cloudflare() }); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9bc293c..eb6c533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,8 @@ "@babel/preset-react": "^7.26.3", "@babel/preset-typescript": "^7.26.0", "@eslint/js": "^9.16.0", + "@patternfly/react-data-view": "^6.0.0", + "@patternfly/react-user-feedback": "^6.0.0", "@semantic-release/git": "^10.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", @@ -1517,6 +1519,23 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/is-prop-valid": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.7.3.tgz", + "integrity": "sha512-uxJqm/sqwXw3YPA5GXX365OBcJGFtxUVkB6WyezqFHlNe9jqUWH5ur2O2M8dGBz61kn1g3ZBlzUunFQXQIClhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emotion/memoize": "0.7.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.1.tgz", + "integrity": "sha512-Qv4LTqO11jepd5Qmlp3M1YEjBumoTHcHFdgPTQ+sFlIL5myi/7xu/POwP7IRu6odBdmLXdtIs1D6TuW6kbwbbg==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", @@ -4065,22 +4084,60 @@ "react-dom": "^17 || ^18" } }, + "node_modules/@patternfly/react-component-groups": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-component-groups/-/react-component-groups-6.4.0.tgz", + "integrity": "sha512-vg0761nQ/7hfggbp6+XowRcQQSd9oIToh77+4lmsyrs41MkA5ppQIPBCZ4lUZW87kmEPhkHqglpJcVfsrrIM/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@patternfly/react-core": "^6.0.0", + "@patternfly/react-icons": "^6.0.0", + "@patternfly/react-styles": "^6.0.0", + "@patternfly/react-table": "^6.0.0", + "react-jss": "^10.10.0" + }, + "peerDependencies": { + "@patternfly/react-drag-drop": "^6.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@patternfly/react-core": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.2.2.tgz", - "integrity": "sha512-JUrZ57JQ4bkmed1kxaciXb0ZpIVYyCHc2HjtzoKQ5UNRlx204zR2isATSHjdw2GFcWvwpkC5/fU2BR+oT3opbg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-6.4.0.tgz", + "integrity": "sha512-zMgJmcFohp2FqgAoZHg7EXZS7gnaFESquk0qIavemYI0FsqspVlzV2/PUru7w+86+jXfqebRhgubPRsv1eJwEg==", "license": "MIT", "dependencies": { - "@patternfly/react-icons": "^6.2.2", - "@patternfly/react-styles": "^6.2.2", - "@patternfly/react-tokens": "^6.2.2", + "@patternfly/react-icons": "^6.4.0", + "@patternfly/react-styles": "^6.4.0", + "@patternfly/react-tokens": "^6.4.0", "focus-trap": "7.6.4", "react-dropzone": "^14.3.5", "tslib": "^2.8.1" }, "peerDependencies": { - "react": "^17 || ^18", - "react-dom": "^17 || ^18" + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, + "node_modules/@patternfly/react-data-view": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-data-view/-/react-data-view-6.4.0.tgz", + "integrity": "sha512-AYIJvWLSoZaf3askvBjyyFQEvSCiquw5PFzEOiTsNoM2pDYkRagzppjclpI+MRJr44ZrfpljC6ZKE4f5Ni2p+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@patternfly/react-component-groups": "^6.1.0", + "@patternfly/react-core": "^6.4.0", + "@patternfly/react-icons": "^6.4.0", + "@patternfly/react-table": "^6.4.0", + "clsx": "^2.1.1", + "react-jss": "^10.10.0" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" } }, "node_modules/@patternfly/react-drag-drop": { @@ -4103,45 +4160,60 @@ } }, "node_modules/@patternfly/react-icons": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.2.2.tgz", - "integrity": "sha512-XkBwzuV/uiolX+T6QgB3RIqphM1m+vAZjAe3McYtyY22j1rsOdlWDE4RtRrJ1q7EoIZwyZHj0h8T9vMfUsLn4Q==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-6.4.0.tgz", + "integrity": "sha512-SPjzatm73NUYv/BL6A/cjRA5sFQ15NkiyPAcT8gmklI7HY+ptd6/eg49uBDFmxTQcSwbb5ISW/R6wwCQBY2M+Q==", "license": "MIT", "peerDependencies": { - "react": "^17 || ^18", - "react-dom": "^17 || ^18" + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" } }, "node_modules/@patternfly/react-styles": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.2.2.tgz", - "integrity": "sha512-rncRDq66H8VnLyb9DrHHlZtPddlpNL9+W0XuQC0L7F6p78hOwSZmoGTW2Vq8/wJplDj8h/61qRpfRF9VEYPW0g==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-6.4.0.tgz", + "integrity": "sha512-EXmHA67s5sy+Wy/0uxWoUQ52jr9lsH2wV3QcgtvVc5zxpyBX89gShpqv4jfVqaowznHGDoL6fVBBrSe9BYOliQ==", "license": "MIT" }, "node_modules/@patternfly/react-table": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-6.2.2.tgz", - "integrity": "sha512-7CxVKhnpA+f8dLJ0hVvzZOe4Djx/nE+w70ipeAHf4Yi5JwfDWmbK97YvjYPfamp/bsXTLtPcK2n4AoY5DQX6Pg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-6.4.0.tgz", + "integrity": "sha512-yv0sFOLGts8a2q9C1xUegjp50ayYyVRe0wKjMf+aMSNIK8sVYu8qu0yfBsCDybsUCldue7+qsYKRLFZosTllWQ==", "license": "MIT", "dependencies": { - "@patternfly/react-core": "^6.2.2", - "@patternfly/react-icons": "^6.2.2", - "@patternfly/react-styles": "^6.2.2", - "@patternfly/react-tokens": "^6.2.2", + "@patternfly/react-core": "^6.4.0", + "@patternfly/react-icons": "^6.4.0", + "@patternfly/react-styles": "^6.4.0", + "@patternfly/react-tokens": "^6.4.0", "lodash": "^4.17.21", "tslib": "^2.8.1" }, "peerDependencies": { - "react": "^17 || ^18", - "react-dom": "^17 || ^18" + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" } }, "node_modules/@patternfly/react-tokens": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.2.2.tgz", - "integrity": "sha512-2GRWDPBTrcTlGNFc5NPJjrjEVU90RpgcGX/CIe2MplLgM32tpVIkeUtqIoJPLRk5GrbhyFuHJYRU+O93gU4o3Q==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-6.4.0.tgz", + "integrity": "sha512-iZthBoXSGQ/+PfGTdPFJVulaJZI3rwE+7A/whOXPGp3Jyq3k6X52pr1+5nlO6WHasbZ9FyeZGqXf4fazUZNjbw==", "license": "MIT" }, + "node_modules/@patternfly/react-user-feedback": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-user-feedback/-/react-user-feedback-6.2.0.tgz", + "integrity": "sha512-grhaZQwcESNZD2ifpbPoodCJ/NRTt4B24jVNYgDi23EVvQ0oGpyIXAvbUhX3siSkLjdqdYLsY9o5wNRJM78VkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@patternfly/react-core": "^6.0.0", + "@patternfly/react-icons": "^6.0.0" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@pkgr/core": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", @@ -8993,6 +9065,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/css-jss": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/css-jss/-/css-jss-10.10.0.tgz", + "integrity": "sha512-YyMIS/LsSKEGXEaVJdjonWe18p4vXLo8CMA4FrW/kcaEyqdIGKCFXao31gbJddXEdIxSXFFURWrenBJPlKTgAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "^10.10.0", + "jss-preset-default": "^10.10.0" + } + }, "node_modules/css-tree": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", @@ -9006,6 +9090,17 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css-vendor": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", + "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.3", + "is-in-browser": "^1.0.2" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -9529,9 +9624,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -12244,6 +12339,16 @@ "@babel/runtime": "^7.7.6" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/homedir-polyfill": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", @@ -12383,6 +12488,13 @@ "node": ">=10.17.0" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -13009,6 +13121,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-in-browser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha512-FeXIBgG/CPGd/WUxuEyvgGTEfwiG9Z4EKGxjNMRqviiIIfsmgrpnHLffEDdwUHqNva1VEW91o3xBT/m8Elgl9g==", + "dev": true, + "license": "MIT" + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -15461,6 +15580,186 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jss": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss/-/jss-10.10.0.tgz", + "integrity": "sha512-cqsOTS7jqPsPMjtKYDUpdFC0AbhYFLTcuGRqymgmdJIeQ8cH7+AgX7YSgQy79wXloZq2VvATYxUOUQEvS1V/Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "csstype": "^3.0.2", + "is-in-browser": "^1.1.3", + "tiny-warning": "^1.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/jss" + } + }, + "node_modules/jss-plugin-camel-case": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz", + "integrity": "sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "hyphenate-style-name": "^1.0.3", + "jss": "10.10.0" + } + }, + "node_modules/jss-plugin-compose": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-compose/-/jss-plugin-compose-10.10.0.tgz", + "integrity": "sha512-F5kgtWpI2XfZ3Z8eP78tZEYFdgTIbpA/TMuX3a8vwrNolYtN1N4qJR/Ob0LAsqIwCMLojtxN7c7Oo/+Vz6THow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0", + "tiny-warning": "^1.0.2" + } + }, + "node_modules/jss-plugin-default-unit": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.10.0.tgz", + "integrity": "sha512-SvpajxIECi4JDUbGLefvNckmI+c2VWmP43qnEy/0eiwzRUsafg5DVSIWSzZe4d2vFX1u9nRDP46WCFV/PXVBGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0" + } + }, + "node_modules/jss-plugin-expand": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-expand/-/jss-plugin-expand-10.10.0.tgz", + "integrity": "sha512-ymT62W2OyDxBxr7A6JR87vVX9vTq2ep5jZLIdUSusfBIEENLdkkc0lL/Xaq8W9s3opUq7R0sZQpzRWELrfVYzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0" + } + }, + "node_modules/jss-plugin-extend": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-extend/-/jss-plugin-extend-10.10.0.tgz", + "integrity": "sha512-sKYrcMfr4xxigmIwqTjxNcHwXJIfvhvjTNxF+Tbc1NmNdyspGW47Ey6sGH8BcQ4FFQhLXctpWCQSpDwdNmXSwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0", + "tiny-warning": "^1.0.2" + } + }, + "node_modules/jss-plugin-global": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.10.0.tgz", + "integrity": "sha512-icXEYbMufiNuWfuazLeN+BNJO16Ge88OcXU5ZDC2vLqElmMybA31Wi7lZ3lf+vgufRocvPj8443irhYRgWxP+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0" + } + }, + "node_modules/jss-plugin-nested": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.10.0.tgz", + "integrity": "sha512-9R4JHxxGgiZhurDo3q7LdIiDEgtA1bTGzAbhSPyIOWb7ZubrjQe8acwhEQ6OEKydzpl8XHMtTnEwHXCARLYqYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0", + "tiny-warning": "^1.0.2" + } + }, + "node_modules/jss-plugin-props-sort": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.10.0.tgz", + "integrity": "sha512-5VNJvQJbnq/vRfje6uZLe/FyaOpzP/IH1LP+0fr88QamVrGJa0hpRRyAa0ea4U/3LcorJfBFVyC4yN2QC73lJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0" + } + }, + "node_modules/jss-plugin-rule-value-function": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.10.0.tgz", + "integrity": "sha512-uEFJFgaCtkXeIPgki8ICw3Y7VMkL9GEan6SqmT9tqpwM+/t+hxfMUdU4wQ0MtOiMNWhwnckBV0IebrKcZM9C0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0", + "tiny-warning": "^1.0.2" + } + }, + "node_modules/jss-plugin-rule-value-observable": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-observable/-/jss-plugin-rule-value-observable-10.10.0.tgz", + "integrity": "sha512-ZLMaYrR3QE+vD7nl3oNXuj79VZl9Kp8/u6A1IbTPDcuOu8b56cFdWRZNZ0vNr8jHewooEeq2doy8Oxtymr2ZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0", + "symbol-observable": "^1.2.0" + } + }, + "node_modules/jss-plugin-template": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-template/-/jss-plugin-template-10.10.0.tgz", + "integrity": "sha512-ocXZBIOJOA+jISPdsgkTs8wwpK6UbsvtZK5JI7VUggTD6LWKbtoxUzadd2TpfF+lEtlhUmMsCkTRNkITdPKa6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0", + "tiny-warning": "^1.0.2" + } + }, + "node_modules/jss-plugin-vendor-prefixer": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.10.0.tgz", + "integrity": "sha512-UY/41WumgjW8r1qMCO8l1ARg7NHnfRVWRhZ2E2m0DMYsr2DD91qIXLyNhiX83hHswR7Wm4D+oDYNC1zWCJWtqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "css-vendor": "^2.0.8", + "jss": "10.10.0" + } + }, + "node_modules/jss-preset-default": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/jss-preset-default/-/jss-preset-default-10.10.0.tgz", + "integrity": "sha512-GL175Wt2FGhjE+f+Y3aWh+JioL06/QWFgZp53CbNNq6ZkVU0TDplD8Bxm9KnkotAYn3FlplNqoW5CjyLXcoJ7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.0", + "jss-plugin-camel-case": "10.10.0", + "jss-plugin-compose": "10.10.0", + "jss-plugin-default-unit": "10.10.0", + "jss-plugin-expand": "10.10.0", + "jss-plugin-extend": "10.10.0", + "jss-plugin-global": "10.10.0", + "jss-plugin-nested": "10.10.0", + "jss-plugin-props-sort": "10.10.0", + "jss-plugin-rule-value-function": "10.10.0", + "jss-plugin-rule-value-observable": "10.10.0", + "jss-plugin-template": "10.10.0", + "jss-plugin-vendor-prefixer": "10.10.0" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -17204,12 +17503,36 @@ } }, "node_modules/monaco-editor": { - "version": "0.52.2", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", - "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz", + "integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==", "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.1.7", + "marked": "14.0.0" + } + }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", + "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", + "license": "(MPL-2.0 OR Apache-2.0)", "peer": true }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -21482,6 +21805,13 @@ "node": ">=0.10.0" } }, + "node_modules/react-display-name": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/react-display-name/-/react-display-name-0.2.5.tgz", + "integrity": "sha512-I+vcaK9t4+kypiSgaiVWAipqHRXYmZIuAiS8vzFvXHHXVigg/sMKwlRgLy6LH2i3rmP+0Vzfl5lFsFRwF1r3pg==", + "dev": true, + "license": "MIT" + }, "node_modules/react-docgen": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-7.1.1.tgz", @@ -21578,6 +21908,29 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-jss": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/react-jss/-/react-jss-10.10.0.tgz", + "integrity": "sha512-WLiq84UYWqNBF6579/uprcIUnM1TSywYq6AIjKTTTG5ziJl9Uy+pwuvpN3apuyVwflMbD60PraeTKT7uWH9XEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "@emotion/is-prop-valid": "^0.7.3", + "css-jss": "10.10.0", + "hoist-non-react-statics": "^3.2.0", + "is-in-browser": "^1.1.3", + "jss": "10.10.0", + "jss-preset-default": "10.10.0", + "prop-types": "^15.6.0", + "shallow-equal": "^1.2.0", + "theming": "^3.3.0", + "tiny-warning": "^1.0.2" + }, + "peerDependencies": { + "react": ">=16.8.6" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -22742,6 +23095,13 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shallow-equal": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz", + "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==", + "dev": true, + "license": "MIT" + }, "node_modules/sharp": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", @@ -23514,6 +23874,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -23632,6 +24002,25 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/theming": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/theming/-/theming-3.3.0.tgz", + "integrity": "sha512-u6l4qTJRDaWZsqa8JugaNt7Xd8PPl9+gonZaIe28vAhqgHMIG/DOyFPqiKN/gQLQYj05tHv+YQdNILL4zoiAVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.8", + "react-display-name": "^0.2.4", + "tiny-warning": "^1.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.3" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -23693,6 +24082,13 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", diff --git a/package.json b/package.json index bfe7cf6..cf3fc79 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,9 @@ "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "typescript-eslint": "^8.15.0", - "wrangler": "^4.20.0" + "wrangler": "^4.20.0", + "@patternfly/react-user-feedback": "^6.0.0", + "@patternfly/react-data-view": "^6.0.0" }, "config": { "commitizen": { diff --git a/pf-docs.config.mjs b/pf-docs.config.mjs index 02fde7a..22760e7 100644 --- a/pf-docs.config.mjs +++ b/pf-docs.config.mjs @@ -25,6 +25,8 @@ export const config = { pattern: '*/patternfly-docs/**/*.md', name: 'quickstarts-docs', }, + { packageName: "@patternfly/react-user-feedback", pattern: "patternfly-docs/**/*.md", name: "user-feedback-docs" }, + { packageName: "@patternfly/react-data-view", pattern: "patternfly-docs/**/*.md", name: "data-view-docs" }, ], outputDir: './dist', propsGlobs: [ diff --git a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts index de85fde..ab544bb 100644 --- a/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts +++ b/src/__tests__/pages/api/__tests__/[version]/[section]/[page]/[tab].test.ts @@ -146,6 +146,7 @@ jest.mock('../../../../../../../utils/apiIndex/fetch', () => ({ tabs: { 'v6::components::alert': ['react', 'html', 'react-demos'], }, + examples: {}, }), })) diff --git a/src/pages/api/[version]/[section].ts b/src/pages/api/[version]/[section].ts index 1db5e95..561e272 100644 --- a/src/pages/api/[version]/[section].ts +++ b/src/pages/api/[version]/[section].ts @@ -16,6 +16,8 @@ export const GET: APIRoute = async ({ params, url }) => { try { const index = await fetchApiIndex(url) + + // Get pages for this section (includes underscore-separated subsection pages like "forms-checkbox") const key = createIndexKey(version, section) const pages = index.pages[key] @@ -26,6 +28,7 @@ export const GET: APIRoute = async ({ params, url }) => { ) } + // Return pages array (already sorted, contains both regular and underscore-separated subsection pages) return createJsonResponse(pages) } catch (error) { const details = error instanceof Error ? error.message : String(error) diff --git a/src/pages/api/[version]/[section]/[page].ts b/src/pages/api/[version]/[section]/[page].ts index 9ccbf6e..1364181 100644 --- a/src/pages/api/[version]/[section]/[page].ts +++ b/src/pages/api/[version]/[section]/[page].ts @@ -16,8 +16,10 @@ export const GET: APIRoute = async ({ params, url }) => { try { const index = await fetchApiIndex(url) - const key = createIndexKey(version, section, page) - const tabs = index.tabs[key] + + // Return tabs for this page (page may be underscore-separated like "forms_checkbox") + const pageKey = createIndexKey(version, section, page) + const tabs = index.tabs[pageKey] if (!tabs) { return createJsonResponse( diff --git a/src/pages/api/[version]/[section]/[page]/[tab].ts b/src/pages/api/[version]/[section]/[page]/[tab].ts index 0a92e5f..f92e3a2 100644 --- a/src/pages/api/[version]/[section]/[page]/[tab].ts +++ b/src/pages/api/[version]/[section]/[page]/[tab].ts @@ -23,16 +23,19 @@ export const GET: APIRoute = async ({ params, redirect, url }) => { } // Check if section exists for this version - const sectionKey = createIndexKey(version, section) - if (!index.sections[version]?.includes(section)) { + const sections = index.sections[version] || [] + + if (!sections.includes(section)) { return createJsonResponse( { error: `Section '${section}' not found for version '${version}'` }, 404, ) } - // Check if page exists for this section + const sectionKey = createIndexKey(version, section) const pageKey = createIndexKey(version, section, page) + + // Check if page exists for this section (page may be underscore-separated like "forms_checkbox") if (!index.pages[sectionKey]?.includes(page)) { return createJsonResponse( { diff --git a/src/pages/api/[version]/[section]/[page]/[tab]/examples.ts b/src/pages/api/[version]/[section]/[page]/[tab]/examples.ts index 5d8db83..cbb9cf2 100644 --- a/src/pages/api/[version]/[section]/[page]/[tab]/examples.ts +++ b/src/pages/api/[version]/[section]/[page]/[tab]/examples.ts @@ -17,9 +17,11 @@ export const GET: APIRoute = async ({ params, url }) => { // Get examples with titles directly from the index try { const index = await fetchApiIndex(url) - const tabKey = createIndexKey(version, section, page, tab) - const examples = index.examples[tabKey] || [] + // Example key is version::section::page::tab (page may be underscore-separated like "forms_checkbox") + const exampleKey = createIndexKey(version, section, page, tab) + + const examples = index.examples[exampleKey] || [] return createJsonResponse(examples) } catch (error) { const details = error instanceof Error ? error.message : String(error) diff --git a/src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts b/src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts index 4c13c09..67cd4ae 100644 --- a/src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts +++ b/src/pages/api/[version]/[section]/[page]/[tab]/examples/[example].ts @@ -1,14 +1,12 @@ /* eslint-disable no-console */ import type { APIRoute, GetStaticPaths } from 'astro' -import type { CollectionEntry, CollectionKey } from 'astro:content' -import { getCollection } from 'astro:content' import { readFile } from 'fs/promises' import { resolve } from 'path' -import { content } from '../../../../../../../content' -import { kebabCase, addDemosOrDeprecated } from '../../../../../../../utils' -import { getDefaultTabForApi } from '../../../../../../../utils/packageUtils' -import { createJsonResponse, createTextResponse, createIndexKey } from '../../../../../../../utils/apiHelpers' +import { createJsonResponse, createTextResponse } from '../../../../../../../utils/apiHelpers' import { generateAndWriteApiIndex } from '../../../../../../../utils/apiIndex/generate' +import { getEnrichedCollections } from '../../../../../../../utils/apiRoutes/collections' +import { findContentEntryFilePath } from '../../../../../../../utils/apiRoutes/contentMatching' +import { extractImports, extractExampleFilePath } from '../../../../../../../utils/apiRoutes/exampleParsing' export const prerender = true @@ -26,29 +24,23 @@ export const getStaticPaths: GetStaticPaths = async () => { } }[] = [] - // Build paths from index structure - for (const version of index.versions) { - for (const section of index.sections[version] || []) { - const sectionKey = createIndexKey(version, section) - for (const page of index.pages[sectionKey] || []) { - const pageKey = createIndexKey(version, section, page) - for (const tab of index.tabs[pageKey] || []) { - const tabKey = createIndexKey(version, section, page, tab) - - // Get all examples for this tab - const examples = index.examples[tabKey] || [] - for (const example of examples) { - paths.push({ - params: { - version, - section, - page, - tab, - example: example.exampleName, - }, - }) - } - } + // Build paths from index structure by iterating over examples + // All examples are keyed by version::section::page::tab (page may be underscore-separated like "forms_checkbox") + for (const [exampleKey, examples] of Object.entries(index.examples)) { + const parts = exampleKey.split('::') + + if (parts.length === 4) { + const [version, section, page, tab] = parts + for (const example of examples) { + paths.push({ + params: { + version, + section, + page, + tab, + example: example.exampleName, + }, + }) } } } @@ -56,114 +48,6 @@ export const getStaticPaths: GetStaticPaths = async () => { return paths } -/** - * Extracts import statements from file content - * Matches import statements with relative paths (starting with ./ or ../) - * - * @param fileContent - The file content to parse - * @returns Array of import statements or null if none found - */ -function getImports(fileContent: string): string[] | null { - // Match import statements with relative paths - // Supports: import X from './path', import X from "../path/file.tsx" - const importRegex = /import\s+.*\s+from\s+['"]\.{1,2}\/[^'"]+['"]/gm - const matches = fileContent.match(importRegex) - return matches -} - -/** - * Extracts the file path for a specific example from import statements - * Looks for imports that reference the example name - * - * @param imports - Array of import statements - * @param exampleName - Name of the example to find - * @returns Relative file path without quotes (including query params like ?raw), or null if not found - */ -function getExampleFilePath(imports: string[], exampleName: string): string | null { - const exampleImport = imports.find((imp) => imp.includes(exampleName)) - if (!exampleImport) { - console.error('No import path found for example', exampleName) - return null - } - // Extract path from import statement, handling query parameters like ?raw - // Matches: "./path" or "../path" with optional file extensions and query params - const match = exampleImport.match(/['"](\.[^'"]+)['"]/i) - if (!match || !match[1]) { - return null - } - return match[1] -} - -/** - * Fetches all content collections for a specific version - * Enriches entries with default tab information if not specified - * - * @param version - The documentation version (e.g., 'v6') - * @returns Promise resolving to array of collection entries with metadata - */ -async function getCollections(version: string) { - const collectionsToFetch = content - .filter((entry) => entry.version === version) - .map((entry) => entry.name as CollectionKey) - const collections = await Promise.all( - collectionsToFetch.map(async (name) => await getCollection(name)), - ) - return collections.flat().map(({ data, filePath, ...rest }) => ({ - filePath, - ...rest, - data: { - ...data, - tab: data.tab || data.source || getDefaultTabForApi(filePath), - }, - })) -} - -/** - * Finds the file path for a content entry matching the given parameters - * Prefers .mdx files over .md files when both exist, since .mdx files - * contain the LiveExample components and example imports - * - * @param collections - Array of collection entries to search - * @param section - The section name (e.g., 'components') - * @param page - The page slug (e.g., 'alert') - * @param tab - The tab name (e.g., 'react') - * @returns Promise resolving to the file path, or null if not found - */ -async function getContentEntryFilePath( - collections: CollectionEntry<'core-docs' | 'quickstarts-docs' | 'react-component-docs'>[], - section: string, - page: string, - tab: string -): Promise { - // Find all matching entries - const matchingEntries = collections.filter((entry) => { - const entryTab = addDemosOrDeprecated(entry.data.tab, entry.filePath) - return ( - entry.data.section === section && - kebabCase(entry.data.id) === page && - entryTab === tab - ) - }) - - if (matchingEntries.length === 0) { - console.error('No content entry found for section', section, 'page', page, 'tab', tab) - return null - } - - // Prefer .mdx files over .md files (mdx files have LiveExample components) - const mdxEntry = matchingEntries.find((entry) => - typeof entry.filePath === 'string' && entry.filePath.endsWith('.mdx') - ) - const contentEntry = mdxEntry || matchingEntries[0] - - if (typeof contentEntry.filePath !== 'string') { - console.error('No file path found for content entry', contentEntry.id) - return null - } - - return contentEntry.filePath -} - /** * GET handler for retrieving example source code * Returns the raw source code for a specific example @@ -175,14 +59,18 @@ export const GET: APIRoute = async ({ params }) => { const { version, section, page, tab, example } = params if (!version || !section || !page || !tab || !example) { return createJsonResponse( - { error: 'Version, section, page, tab, and example parameters are required' }, + { error: 'All parameters are required' }, 400 ) } try { - const collections = await getCollections(version) - const contentEntryFilePath = await getContentEntryFilePath(collections, section, page, tab) + const collections = await getEnrichedCollections(version) + const contentEntryFilePath = findContentEntryFilePath(collections, { + section, + page, + tab + }) if (!contentEntryFilePath) { return createJsonResponse( @@ -203,7 +91,7 @@ export const GET: APIRoute = async ({ params }) => { ) } - const contentEntryImports = getImports(contentEntryFileContent) + const contentEntryImports = extractImports(contentEntryFileContent) if (!contentEntryImports) { return createJsonResponse( { error: 'No imports found in content entry' }, @@ -211,7 +99,7 @@ export const GET: APIRoute = async ({ params }) => { ) } - const relativeExampleFilePath = getExampleFilePath(contentEntryImports, example) + const relativeExampleFilePath = extractExampleFilePath(contentEntryImports, example) if (!relativeExampleFilePath) { return createJsonResponse( { error: `Example "${example}" not found in imports` }, diff --git a/src/pages/api/[version]/[section]/[page]/[tab]/text.ts b/src/pages/api/[version]/[section]/[page]/[tab]/text.ts index 24736f9..219dd2e 100644 --- a/src/pages/api/[version]/[section]/[page]/[tab]/text.ts +++ b/src/pages/api/[version]/[section]/[page]/[tab]/text.ts @@ -1,27 +1,16 @@ /* eslint-disable no-console */ import type { APIRoute, GetStaticPaths } from 'astro' -import type { CollectionEntry, CollectionKey } from 'astro:content' -import { getCollection } from 'astro:content' -import { content } from '../../../../../../content' -import { - kebabCase, - addDemosOrDeprecated, - getDefaultTabForApi, -} from '../../../../../../utils' import { generateAndWriteApiIndex } from '../../../../../../utils/apiIndex/generate' import { getApiIndex } from '../../../../../../utils/apiIndex/get' import { createJsonResponse, createTextResponse, - createIndexKey, } from '../../../../../../utils/apiHelpers' +import { getEnrichedCollections } from '../../../../../../utils/apiRoutes/collections' +import { findContentEntry } from '../../../../../../utils/apiRoutes/contentMatching' export const prerender = true -type ContentEntry = CollectionEntry< - 'core-docs' | 'quickstarts-docs' | 'react-component-docs' -> - export const getStaticPaths: GetStaticPaths = async () => { // Generate index file for server-side routes to use // This runs once during build when getCollection() is available @@ -32,14 +21,21 @@ export const getStaticPaths: GetStaticPaths = async () => { }[] = [] // Build paths from index structure - for (const version of index.versions) { - for (const section of index.sections[version] || []) { - const sectionKey = createIndexKey(version, section) - for (const page of index.pages[sectionKey] || []) { - const pageKey = createIndexKey(version, section, page) - for (const tab of index.tabs[pageKey] || []) { - paths.push({ params: { version, section, page, tab } }) - } + // All tab keys are now 3-part: version::section::page (page may be underscore-separated for subsections) + for (const [tabKey, tabs] of Object.entries(index.tabs)) { + const parts = tabKey.split('::') + + if (parts.length === 3) { + const [version, section, page] = parts + for (const tab of tabs) { + paths.push({ + params: { + version, + section, + page, + tab, + }, + }) } } } @@ -74,61 +70,23 @@ export const GET: APIRoute = async ({ params }) => { } // Check if section exists for this version - const sectionKey = createIndexKey(version, section) - if (!index.sections[version]?.includes(section)) { - return createJsonResponse( - { error: `Section '${section}' not found for version '${version}'` }, - 404, - ) - } + const sections = index.sections[version] || [] - // Check if page exists for this section - const pageKey = createIndexKey(version, section, page) - if (!index.pages[sectionKey]?.includes(page)) { + if (!sections.includes(section)) { return createJsonResponse( - { - error: `Page '${page}' not found in section '${section}' for version '${version}'`, - }, - 404, - ) - } - - // Check if tab exists for this page - if (!index.tabs[pageKey]?.includes(tab)) { - return createJsonResponse( - { - error: `Tab '${tab}' not found for page '${page}' in section '${section}' for version '${version}'`, - }, + { error: `Section '${section}' not found for version '${version}'` }, 404, ) } // Path is valid, now fetch the actual content - const collectionsToFetch = content - .filter((entry) => entry.version === version) - .map((entry) => entry.name as CollectionKey) - - const collections = await Promise.all( - collectionsToFetch.map((name) => getCollection(name)), - ) - - const flatEntries = collections.flat().map(({ data, filePath, ...rest }) => ({ - filePath, - ...rest, - data: { - ...data, - tab: data.tab || data.source || getDefaultTabForApi(filePath), - }, - })) - - // Find the matching entry - const matchingEntry = flatEntries.find((entry: ContentEntry) => { - const entryTab = addDemosOrDeprecated(entry.data.tab, entry.filePath) - return ( - entry.data.section === section && - kebabCase(entry.data.id) === page && - entryTab === tab - ) + const flatEntries = await getEnrichedCollections(version) + + // Find the matching entry (page may be underscore-separated for subsections like "forms_checkbox") + const matchingEntry = findContentEntry(flatEntries, { + section, + page, + tab, }) // This shouldn't happen since we validated with index, but handle it anyway diff --git a/src/pages/api/index.ts b/src/pages/api/index.ts index cead58f..e04811c 100644 --- a/src/pages/api/index.ts +++ b/src/pages/api/index.ts @@ -6,6 +6,7 @@ * - Runtime: Routes fetch /apiIndex.json (prerendered static file) via HTTP * - Workers-compatible: No Node.js filesystem APIs in SSR handlers * - Optimized bundle: Runtime handlers don't bundle the 500KB+ index data + * - Flattened structure: Subsections are encoded into page names with underscores (e.g., "forms_checkbox") * * Data Flow: * 1. Build: generateApiIndex() → data.ts + apiIndex.json @@ -68,13 +69,13 @@ export const GET: APIRoute = async () => returns: { type: 'array', items: 'string', - example: ['components', 'layouts', 'utilities'], + example: ['components', 'layouts', 'patterns', 'utility-classes'], }, }, { path: '/api/{version}/{section}', method: 'GET', - description: 'List available pages within a section', + description: 'List available pages within a section (includes underscore-separated subsection pages)', parameters: [ { name: 'version', @@ -94,14 +95,14 @@ export const GET: APIRoute = async () => returns: { type: 'array', items: 'string', - description: 'Array of kebab-cased page IDs', - example: ['alert', 'button', 'card'], + description: 'Array of page IDs. Subsection pages use underscore-separated format: subsection_page', + example: ['alert', 'button', 'forms_checkbox', 'forms_radio', 'menus_dropdown'], }, }, { path: '/api/{version}/{section}/{page}', method: 'GET', - description: 'List available tabs for a page', + description: 'List available tabs for a page (page may be underscore-separated for subsection pages)', parameters: [ { name: 'version', @@ -122,20 +123,21 @@ export const GET: APIRoute = async () => in: 'path', required: true, type: 'string', - example: 'alert', + description: 'Page ID (may be underscore-separated for subsection pages like "forms_checkbox")', + example: 'alert or forms_checkbox', }, ], returns: { type: 'array', items: 'string', - description: 'Array of tab slugs', + description: 'Array of tab slugs available for this page', example: ['react', 'react-demos', 'html'], }, }, { path: '/api/{version}/{section}/{page}/{tab}', method: 'GET', - description: 'Redirects to /text endpoint after validation', + description: 'Redirects to /text endpoint for the specified tab', parameters: [ { name: 'version', @@ -156,7 +158,8 @@ export const GET: APIRoute = async () => in: 'path', required: true, type: 'string', - example: 'alert', + description: 'Page ID (may be underscore-separated for subsection pages)', + example: 'alert or forms_checkbox', }, { name: 'tab', @@ -168,7 +171,7 @@ export const GET: APIRoute = async () => ], returns: { type: 'redirect', - description: 'Redirects to /{version}/{section}/{page}/{tab}/text', + description: '302 redirect to /{version}/{section}/{page}/{tab}/text', }, }, { @@ -195,7 +198,8 @@ export const GET: APIRoute = async () => in: 'path', required: true, type: 'string', - example: 'alert', + description: 'Page ID (may be underscore-separated for subsection pages)', + example: 'alert or forms_checkbox', }, { name: 'tab', @@ -235,7 +239,8 @@ export const GET: APIRoute = async () => in: 'path', required: true, type: 'string', - example: 'alert', + description: 'Page ID (may be underscore-separated for subsection pages)', + example: 'alert or forms_checkbox', }, { name: 'tab', @@ -279,7 +284,8 @@ export const GET: APIRoute = async () => in: 'path', required: true, type: 'string', - example: 'alert', + description: 'Page ID (may be underscore-separated for subsection pages)', + example: 'alert or forms_checkbox', }, { name: 'tab', @@ -304,21 +310,31 @@ export const GET: APIRoute = async () => }, ], usage: { - description: 'Navigate the API hierarchically to discover and retrieve documentation', - exampleFlow: [ + description: 'Navigate the API hierarchically to discover and retrieve documentation. Subsections are flattened into page names using underscore separators (e.g., "forms_checkbox" represents the checkbox page in the forms subsection).', + exampleFlowRegularPage: [ 'GET /api/versions → ["v6"]', 'GET /api/v6 → ["components", "layouts", ...]', - 'GET /api/v6/components → ["alert", "button", ...]', + 'GET /api/v6/components → ["alert", "button", "forms_checkbox", ...]', 'GET /api/v6/components/alert → ["react", "html", ...]', 'GET /api/v6/components/alert/react → 302 redirect to /text', 'GET /api/v6/components/alert/react/text → (markdown content)', 'GET /api/v6/components/alert/react/examples → [{exampleName, title}, ...]', 'GET /api/v6/components/alert/react/examples/AlertBasic → (example code)', ], + exampleFlowSubsectionPage: [ + 'GET /api/v6 → ["components", "layouts", ...]', + 'GET /api/v6/components → ["alert", "forms_checkbox", "forms_radio", ...]', + 'GET /api/v6/components/forms_checkbox → ["react", "html", ...]', + 'GET /api/v6/components/forms_checkbox/react → 302 redirect to /text', + 'GET /api/v6/components/forms_checkbox/react/text → (markdown content)', + 'GET /api/v6/components/forms_checkbox/react/examples → [{exampleName, title}, ...]', + 'GET /api/v6/components/forms_checkbox/react/examples/CheckboxBasic → (example code)', + ], architecture: { buildTime: 'Static index generated to data.ts and apiIndex.json', runtime: 'SSR routes fetch /apiIndex.json to avoid bundling data into Workers', optimization: 'Workers bundle is ~110K instead of 500KB+ with embedded data', + flattenedStructure: 'Subsections encoded into page names with underscores (e.g., "forms_checkbox")', }, }, - }) \ No newline at end of file + }) diff --git a/src/pages/api/openapi.json.ts b/src/pages/api/openapi.json.ts index 0262f0d..af903e0 100644 --- a/src/pages/api/openapi.json.ts +++ b/src/pages/api/openapi.json.ts @@ -7,6 +7,10 @@ * - Fetch /apiIndex.json (prerendered static file) at runtime * - Keep Workers bundle small (~110K vs 500KB+) * - Add only ~5-10ms latency from CDN fetch + * + * Structure Note: + * All routes use a flattened structure where subsections are encoded + * into page names with underscores (e.g., "forms_checkbox") */ import type { APIRoute } from 'astro' import { fetchApiIndex } from '../../utils/apiIndex/fetch' @@ -32,7 +36,7 @@ export const GET: APIRoute = async ({ url }) => { info: { title: 'PatternFly Documentation API', description: - 'Machine-readable documentation API for LLM agents and MCP servers. Provides hierarchical access to PatternFly documentation content.', + 'Machine-readable documentation API for LLM agents and MCP servers. Provides hierarchical access to PatternFly documentation content. Uses a flattened structure where subsections are encoded into page names with underscores (e.g., "forms_checkbox").', version: '1.0.0', contact: { name: 'PatternFly', @@ -150,7 +154,7 @@ export const GET: APIRoute = async ({ url }) => { type: 'string', }, }, - example: ['components', 'layouts', 'utilities'], + example: ['components', 'layouts', 'patterns', 'utility-classes'], }, }, }, @@ -176,7 +180,7 @@ export const GET: APIRoute = async ({ url }) => { get: { summary: 'List pages in a section', description: - 'Returns an array of page IDs within the specified section', + 'Returns an array of page IDs within the specified section. Subsection pages use underscore-separated format (subsection_page, e.g., "forms_checkbox").', operationId: 'getPages', parameters: [ { @@ -203,7 +207,7 @@ export const GET: APIRoute = async ({ url }) => { ], responses: { '200': { - description: 'List of page IDs (kebab-cased)', + description: 'List of page IDs (kebab-cased, with underscore-separated subsection pages)', content: { 'application/json': { schema: { @@ -212,7 +216,7 @@ export const GET: APIRoute = async ({ url }) => { type: 'string', }, }, - example: ['alert', 'button', 'card'], + example: ['alert', 'button', 'forms_checkbox', 'forms_radio', 'menus_dropdown'], }, }, }, @@ -238,7 +242,7 @@ export const GET: APIRoute = async ({ url }) => { get: { summary: 'List tabs for a page', description: - 'Returns an array of available tab slugs for the specified page', + 'Returns an array of tab slugs available for the specified page. Page parameter may be underscore-separated for subsection pages (e.g., "forms_checkbox").', operationId: 'getTabs', parameters: [ { @@ -266,16 +270,16 @@ export const GET: APIRoute = async ({ url }) => { name: 'page', in: 'path', required: true, - description: 'Page ID (kebab-cased)', + description: 'Page ID (kebab-cased, may be underscore-separated for subsection pages like "forms_checkbox")', schema: { type: 'string', }, - example: 'alert', + example: 'alert or forms_checkbox', }, ], responses: { '200': { - description: 'List of available tab slugs', + description: 'List of tab slugs', content: { 'application/json': { schema: { @@ -310,7 +314,7 @@ export const GET: APIRoute = async ({ url }) => { get: { summary: 'Validate and redirect to text endpoint', description: - 'Validates the path parameters and redirects to the /text endpoint', + 'Validates the path parameters and redirects to the /text endpoint. Page parameter may be underscore-separated for subsection pages.', operationId: 'validateTab', parameters: [ { @@ -338,11 +342,11 @@ export const GET: APIRoute = async ({ url }) => { name: 'page', in: 'path', required: true, - description: 'Page ID (kebab-cased)', + description: 'Page ID (kebab-cased, may be underscore-separated for subsection pages)', schema: { type: 'string', }, - example: 'alert', + example: 'alert or forms_checkbox', }, { name: 'tab', @@ -381,7 +385,7 @@ export const GET: APIRoute = async ({ url }) => { get: { summary: 'Get tab content', description: - 'Returns the raw markdown/MDX documentation content for the specified tab', + 'Returns the raw markdown/MDX documentation content for the specified tab. Page parameter may be underscore-separated for subsection pages.', operationId: 'getContent', parameters: [ { @@ -409,11 +413,11 @@ export const GET: APIRoute = async ({ url }) => { name: 'page', in: 'path', required: true, - description: 'Page ID (kebab-cased)', + description: 'Page ID (kebab-cased, may be underscore-separated for subsection pages)', schema: { type: 'string', }, - example: 'alert', + example: 'alert or forms_checkbox', }, { name: 'tab', @@ -461,7 +465,7 @@ export const GET: APIRoute = async ({ url }) => { get: { summary: 'List available examples', description: - 'Returns an array of available examples with their names and titles', + 'Returns an array of available examples with their names and titles. Page parameter may be underscore-separated for subsection pages.', operationId: 'getExamples', parameters: [ { @@ -489,11 +493,11 @@ export const GET: APIRoute = async ({ url }) => { name: 'page', in: 'path', required: true, - description: 'Page ID (kebab-cased)', + description: 'Page ID (kebab-cased, may be underscore-separated for subsection pages)', schema: { type: 'string', }, - example: 'alert', + example: 'alert or forms_checkbox', }, { name: 'tab', @@ -555,7 +559,7 @@ export const GET: APIRoute = async ({ url }) => { get: { summary: 'Get example code', description: - 'Returns the raw source code for a specific example', + 'Returns the raw source code for a specific example. Page parameter may be underscore-separated for subsection pages.', operationId: 'getExampleCode', parameters: [ { @@ -583,11 +587,11 @@ export const GET: APIRoute = async ({ url }) => { name: 'page', in: 'path', required: true, - description: 'Page ID (kebab-cased)', + description: 'Page ID (kebab-cased, may be underscore-separated for subsection pages)', schema: { type: 'string', }, - example: 'alert', + example: 'alert or forms_checkbox', }, { name: 'tab', diff --git a/src/utils/__tests__/apiIndex.test.ts b/src/utils/__tests__/apiIndex.test.ts index df044fe..e55f37e 100644 --- a/src/utils/__tests__/apiIndex.test.ts +++ b/src/utils/__tests__/apiIndex.test.ts @@ -24,10 +24,20 @@ describe('getApiIndex (build-time)', () => { expect(index.versions.length).toBeGreaterThan(0) }) - it('sections is an object with version keys', async () => { + it('sections is an object with version keys containing string arrays', async () => { const index = await getApiIndex() expect(typeof index.sections).toBe('object') expect(Object.keys(index.sections).length).toBeGreaterThan(0) + + // Validate structure of sections array + const sectionsArray = index.sections['v6'] + expect(Array.isArray(sectionsArray)).toBe(true) + expect(sectionsArray.length).toBeGreaterThan(0) + + // All items should be strings (subsections are flattened into page names) + sectionsArray.forEach(item => { + expect(typeof item).toBe('string') + }) }) it('pages is an object with composite keys', async () => { @@ -43,9 +53,9 @@ describe('getApiIndex (build-time)', () => { const index = await getApiIndex() expect(typeof index.tabs).toBe('object') expect(Object.keys(index.tabs).length).toBeGreaterThan(0) - // Keys should be in format "version::section::page" + // Keys should be in format "version::section::page" (always 3 parts, page may be hyphenated) const firstKey = Object.keys(index.tabs)[0] - expect(firstKey.split('::').length).toBeGreaterThanOrEqual(3) + expect(firstKey.split('::').length).toBe(3) }) it('examples is an object with composite keys and example arrays', async () => { @@ -53,9 +63,10 @@ describe('getApiIndex (build-time)', () => { expect(typeof index.examples).toBe('object') const exampleKeys = Object.keys(index.examples) expect(exampleKeys.length).toBeGreaterThan(0) - // Keys should be in format "version::section::page::tab" + // Keys are always "version::section::page::tab" (4 parts, page may be hyphenated) const firstKey = exampleKeys[0] - expect(firstKey.split('::').length).toBe(4) + const parts = firstKey.split('::').length + expect(parts).toBe(4) // Values should be arrays of example objects const examples = index.examples[firstKey] expect(Array.isArray(examples)).toBe(true) @@ -98,6 +109,8 @@ describe('getSections', () => { }) }) + + describe('getPages', () => { it('returns array of pages for valid version and section', async () => { const pages = await getPages('v6', 'components') @@ -105,10 +118,13 @@ describe('getPages', () => { expect(pages.length).toBeGreaterThan(0) }) - it('includes expected pages', async () => { + it('includes expected pages (including underscore-separated subsection pages)', async () => { const pages = await getPages('v6', 'components') expect(pages).toContain('alert') expect(pages).toContain('button') + // Check for at least one underscore-separated page (subsection page like "forms_checkbox") + const hasUnderscorePage = pages.some(page => page.includes('_')) + expect(hasUnderscorePage).toBe(true) }) it('returns empty array for invalid section', async () => { @@ -198,6 +214,60 @@ describe('getExamples', () => { }) }) +describe('Flattened subsection support', () => { + it('subsections are flattened into page names with underscores', async () => { + const index = await getApiIndex() + const pages = index.pages['v6::components'] + expect(pages).toBeDefined() + // Should contain underscore-separated page names like "forms_checkbox" + const formsCheckbox = pages?.find(p => p === 'forms_checkbox') + expect(formsCheckbox).toBeDefined() + }) + + it('flattened pages have tabs with 3-part keys', async () => { + const index = await getApiIndex() + // Underscore-separated page name (former subsection page) + const formsCheckboxKey = createIndexKey('v6', 'components', 'forms_checkbox') + expect(index.tabs[formsCheckboxKey]).toBeDefined() + expect(index.tabs[formsCheckboxKey].length).toBeGreaterThan(0) + expect(formsCheckboxKey.split('::').length).toBe(3) + }) + + it('flattened pages have examples with 4-part keys', async () => { + const index = await getApiIndex() + // Check if forms_checkbox has react examples + const formsCheckboxReactKey = createIndexKey('v6', 'components', 'forms_checkbox', 'react') + // May or may not have examples, just check structure if it exists + if (index.examples[formsCheckboxReactKey]) { + expect(Array.isArray(index.examples[formsCheckboxReactKey])).toBe(true) + expect(formsCheckboxReactKey.split('::').length).toBe(4) + } + }) + + it('regular pages continue to work with 3-part tab keys', async () => { + const index = await getApiIndex() + const alertKey = createIndexKey('v6', 'components', 'alert') + expect(index.tabs[alertKey]).toBeDefined() + expect(alertKey.split('::').length).toBe(3) + }) + + it('all tab keys are 3-part (no 4-part keys)', async () => { + const index = await getApiIndex() + const tabKeys = Object.keys(index.tabs) + // All tab keys should be 3-part (version::section::page) + const allThreePart = tabKeys.every(key => key.split('::').length === 3) + expect(allThreePart).toBe(true) + }) + + it('all example keys are 4-part (no 5-part keys)', async () => { + const index = await getApiIndex() + const exampleKeys = Object.keys(index.examples) + // All example keys should be 4-part (version::section::page::tab) + const allFourPart = exampleKeys.every(key => key.split('::').length === 4) + expect(allFourPart).toBe(true) + }) +}) + describe('API Index architecture', () => { it('data structure supports hierarchical navigation', async () => { const index = await getApiIndex() @@ -206,13 +276,13 @@ describe('API Index architecture', () => { const version = index.versions[0] expect(version).toBeTruthy() - // Get sections for version + // Get sections for version (all sections are flat strings) const sections = index.sections[version] expect(sections).toBeDefined() expect(sections!.length).toBeGreaterThan(0) + const section = sections![0] // Get pages for first section - const section = sections![0] const sectionKey = createIndexKey(version, section) const pages = index.pages[sectionKey] expect(pages).toBeDefined() diff --git a/src/utils/apiIndex/generate.ts b/src/utils/apiIndex/generate.ts index 4a92211..e0e553d 100644 --- a/src/utils/apiIndex/generate.ts +++ b/src/utils/apiIndex/generate.ts @@ -30,20 +30,34 @@ const sortSources = (s1: string, s2: string) => { return s1Index > s2Index ? 1 : -1 } +/** + * Section item is always a string (subsections are flattened into page names) + */ +export type SectionItem = string + /** * Structure of the API index used for routing and navigation * Keys in sections/pages/tabs use '::' separator (e.g., 'v6::components::alert') + * Subsections are flattened into page names with underscores (e.g., 'forms_checkbox') */ export interface ApiIndex { /** Available documentation versions (e.g., ['v5', 'v6']) */ versions: string[] - /** Sections by version (e.g., { 'v6': ['components', 'layouts'] }) */ - sections: Record - /** Pages by version::section (e.g., { 'v6::components': ['alert', 'button'] }) */ + /** Sections by version - flat array of section names + * (e.g., { 'v6': ['components', 'layouts', 'extensions'] }) + */ + sections: Record + /** Pages by version::section with subsections flattened using underscores + * (e.g., { 'v6::components': ['alert', 'button', 'forms_checkbox', 'menus_select'] }) + */ pages: Record - /** Tabs by version::section::page (e.g., { 'v6::components::alert': ['react', 'html'] }) */ + /** Tabs by version::section::page where page may contain underscore-separated subsection + * (e.g., { 'v6::components::alert': ['react'], 'v6::components::forms_checkbox': ['react'] }) + */ tabs: Record - /** Examples by version::section::page::tab with titles (e.g., { 'v6::components::alert::react': [{exampleName: 'AlertDefault', title: 'Default alert'}] }) */ + /** Examples by version::section::page::tab with titles + * (e.g., { 'v6::components::alert::react': [{exampleName: 'AlertDefault', title: 'Default alert'}] }) + */ examples: Record } @@ -132,42 +146,48 @@ export async function generateApiIndex(): Promise { const tabExamples: Record = {} flatEntries.forEach((entry: any) => { - if (!entry.data.section) { + const { section, subsection, id } = entry.data + if (!section) { return } - const section = entry.data.section - const page = kebabCase(entry.data.id) - const sectionKey = `${version}::${section}` - const pageKey = `${version}::${section}::${page}` + // Flatten subsections into page name using underscores + // e.g., subsection="forms", id="checkbox" becomes page="forms_checkbox" + const basePage = kebabCase(id) + const page = subsection ? `${subsection}_${basePage}` : basePage + + // All pages are keyed by version::section (no subsection in key) + const pageKey = `${version}::${section}` + const tabKey = `${version}::${section}::${page}` // Collect section sections.add(section) - // Collect page - if (!sectionPages[sectionKey]) { - sectionPages[sectionKey] = new Set() + // Collect page under section key + if (!sectionPages[pageKey]) { + sectionPages[pageKey] = new Set() } - sectionPages[sectionKey].add(page) + sectionPages[pageKey].add(page) // Collect tab const entryTab = entry.data.tab || entry.data.source || getDefaultTabForApi(entry.filePath) const tab = addDemosOrDeprecated(entryTab, entry.filePath) - if (!pageTabs[pageKey]) { - pageTabs[pageKey] = new Set() + if (!pageTabs[tabKey]) { + pageTabs[tabKey] = new Set() } - pageTabs[pageKey].add(tab) + pageTabs[tabKey].add(tab) // Collect examples for this tab - const tabKey = `${version}::${section}::${page}::${tab}` + const exampleKey = `${tabKey}::${tab}` const examplesWithTitles = extractExamplesWithTitles(entry.body || '') if (examplesWithTitles.length > 0) { - tabExamples[tabKey] = examplesWithTitles + tabExamples[exampleKey] = examplesWithTitles } }) // Convert sets to sorted arrays + // Sections are now always flat strings (subsections are in page names) index.sections[version] = Array.from(sections).sort() Object.entries(sectionPages).forEach(([key, pages]) => { diff --git a/src/utils/apiRoutes/collections.ts b/src/utils/apiRoutes/collections.ts new file mode 100644 index 0000000..d0f6e94 --- /dev/null +++ b/src/utils/apiRoutes/collections.ts @@ -0,0 +1,39 @@ +import type { CollectionKey } from 'astro:content' +import { getCollection } from 'astro:content' +import { content } from '../../content' +import { getDefaultTabForApi } from '../packageUtils' + +export type EnrichedContentEntry = { + filePath: string + data: { + tab: string + [key: string]: any + } + [key: string]: any +} + +/** + * Fetches and enriches all content collections for a specific version + * Enriches entries with default tab information if not specified + * + * @param version - The documentation version (e.g., 'v6') + * @returns Promise resolving to array of collection entries with enriched metadata + */ +export async function getEnrichedCollections(version: string): Promise { + const collectionsToFetch = content + .filter((entry) => entry.version === version) + .map((entry) => entry.name as CollectionKey) + + const collections = await Promise.all( + collectionsToFetch.map((name) => getCollection(name)) + ) + + return collections.flat().map(({ data, filePath, ...rest }) => ({ + filePath, + ...rest, + data: { + ...data, + tab: data.tab || data.source || getDefaultTabForApi(filePath), + }, + })) +} diff --git a/src/utils/apiRoutes/contentMatching.ts b/src/utils/apiRoutes/contentMatching.ts new file mode 100644 index 0000000..ba0b338 --- /dev/null +++ b/src/utils/apiRoutes/contentMatching.ts @@ -0,0 +1,97 @@ +import type { CollectionEntry } from 'astro:content' +import { kebabCase, addDemosOrDeprecated } from '../index' +import type { EnrichedContentEntry } from './collections' + +export type ContentEntry = CollectionEntry< + 'core-docs' | 'quickstarts-docs' | 'react-component-docs' +> + +export interface ContentMatchParams { + section: string + page: string + tab: string +} + +/** + * Finds a content entry matching the specified parameters + * Handles both regular pages and subsection pages (with flattened underscore-separated names) + * + * @param entries - Array of enriched content entries to search + * @param params - Parameters to match against (section, page, tab) + * - page may be underscore-separated for subsection pages (e.g., "forms_checkbox") + * @returns Matching entry or null if not found + */ +export function findContentEntry( + entries: EnrichedContentEntry[], + params: ContentMatchParams +): EnrichedContentEntry | null { + const { section, page, tab } = params + + const matchingEntry = entries.find((entry) => { + // Match section and tab + if (entry.data.section !== section) { + return false + } + + const entryTab = addDemosOrDeprecated(entry.data.tab, entry.filePath) + if (entryTab !== tab) { + return false + } + + // Match page (handling flattened subsection names) + const entryId = kebabCase(entry.data.id) + const entryPage = entry.data.subsection + ? `${entry.data.subsection}_${entryId}` + : entryId + + return entryPage === page + }) + + return matchingEntry || null +} + +/** + * Finds the file path for a content entry matching the given parameters + * Prefers .mdx files over .md files when both exist, since .mdx files + * contain the LiveExample components and example imports + * + * @param entries - Array of enriched content entries to search + * @param params - Parameters to match against (section, page, tab) + * - page may be underscore-separated for subsection pages (e.g., "forms_checkbox") + * @returns The file path, or null if not found + */ +export function findContentEntryFilePath( + entries: EnrichedContentEntry[], + params: ContentMatchParams +): string | null { + const { section, page, tab } = params + + // Find all matching entries + const matchingEntries = entries.filter((entry) => { + if (entry.data.section !== section) { + return false + } + + const entryTab = addDemosOrDeprecated(entry.data.tab, entry.filePath) + if (entryTab !== tab) { + return false + } + + const entryId = kebabCase(entry.data.id) + const entryPage = entry.data.subsection + ? `${entry.data.subsection}_${entryId}` + : entryId + + return entryPage === page + }) + + if (matchingEntries.length === 0) { + return null + } + + // Prefer .mdx over .md (mdx files contain LiveExample components) + const mdxEntry = matchingEntries.find((entry) => entry.filePath.endsWith('.mdx')) + const selectedEntry = mdxEntry || matchingEntries[0] + + return selectedEntry.filePath +} diff --git a/src/utils/apiRoutes/exampleParsing.ts b/src/utils/apiRoutes/exampleParsing.ts new file mode 100644 index 0000000..353d671 --- /dev/null +++ b/src/utils/apiRoutes/exampleParsing.ts @@ -0,0 +1,38 @@ +/** + * Extracts import statements from file content + * Matches import statements with relative paths (starting with ./ or ../) + * + * @param fileContent - The file content to parse + * @returns Array of import statements or null if none found + */ +export function extractImports(fileContent: string): string[] | null { + // Match import statements with relative paths + // Supports: import X from './path', import X from "../path/file.tsx" + const importRegex = /import\s+.*\s+from\s+['"]\.{1,2}\/[^'"]+['"]/gm + const matches = fileContent.match(importRegex) + return matches +} + +/** + * Extracts the file path for a specific example from import statements + * Looks for imports that reference the example name + * + * @param imports - Array of import statements + * @param exampleName - Name of the example to find + * @returns Relative file path without quotes (including query params like ?raw), or null if not found + */ +export function extractExampleFilePath(imports: string[], exampleName: string): string | null { + const exampleImport = imports.find((imp) => imp.includes(exampleName)) + if (!exampleImport) { + console.error('No import path found for example', exampleName) + return null + } + + // Extract path from import statement, handling query parameters like ?raw + // Matches: "./path" or "../path" with optional file extensions and query params + const match = exampleImport.match(/['"](\.[^'"]+)['"]/i) + if (!match || !match[1]) { + return null + } + return match[1] +} From 3e0ee3f26c24fa6604729859ae4f82939f8e5dcc Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Wed, 21 Jan 2026 15:44:21 -0500 Subject: [PATCH 2/4] Update snapshot --- src/components/__tests__/__snapshots__/Navigation.test.tsx.snap | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/__tests__/__snapshots__/Navigation.test.tsx.snap b/src/components/__tests__/__snapshots__/Navigation.test.tsx.snap index d7076eb..2550ce9 100644 --- a/src/components/__tests__/__snapshots__/Navigation.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Navigation.test.tsx.snap @@ -197,6 +197,7 @@ exports[`matches snapshot 1`] = ` aria-labelledby="nav-section-section-two" class="pf-v6-c-nav__subnav" hidden="" + inert="" >
    Date: Wed, 21 Jan 2026 16:03:37 -0500 Subject: [PATCH 3/4] DRY up content matching helper --- src/utils/apiRoutes/contentMatching.ts | 77 ++++++++++++-------------- 1 file changed, 34 insertions(+), 43 deletions(-) diff --git a/src/utils/apiRoutes/contentMatching.ts b/src/utils/apiRoutes/contentMatching.ts index ba0b338..38c1bf0 100644 --- a/src/utils/apiRoutes/contentMatching.ts +++ b/src/utils/apiRoutes/contentMatching.ts @@ -12,6 +12,37 @@ export interface ContentMatchParams { tab: string } +/** + * Checks if a content entry matches the specified parameters + * Handles both regular pages and subsection pages (with flattened underscore-separated names) + * + * @param entry - Content entry to check + * @param params - Parameters to match against (section, page, tab) + * @returns True if entry matches all parameters + */ +function matchesParams(entry: EnrichedContentEntry, params: ContentMatchParams): boolean { + const { section, page, tab } = params + + // Match section + if (entry.data.section !== section) { + return false + } + + // Match tab (with demos/deprecated suffix handling) + const entryTab = addDemosOrDeprecated(entry.data.tab, entry.filePath) + if (entryTab !== tab) { + return false + } + + // Match page (handling flattened subsection names with underscores) + const entryId = kebabCase(entry.data.id) + const entryPage = entry.data.subsection + ? `${entry.data.subsection}_${entryId}` + : entryId + + return entryPage === page +} + /** * Finds a content entry matching the specified parameters * Handles both regular pages and subsection pages (with flattened underscore-separated names) @@ -25,29 +56,7 @@ export function findContentEntry( entries: EnrichedContentEntry[], params: ContentMatchParams ): EnrichedContentEntry | null { - const { section, page, tab } = params - - const matchingEntry = entries.find((entry) => { - // Match section and tab - if (entry.data.section !== section) { - return false - } - - const entryTab = addDemosOrDeprecated(entry.data.tab, entry.filePath) - if (entryTab !== tab) { - return false - } - - // Match page (handling flattened subsection names) - const entryId = kebabCase(entry.data.id) - const entryPage = entry.data.subsection - ? `${entry.data.subsection}_${entryId}` - : entryId - - return entryPage === page - }) - - return matchingEntry || null + return entries.find((entry) => matchesParams(entry, params)) || null } /** @@ -64,26 +73,8 @@ export function findContentEntryFilePath( entries: EnrichedContentEntry[], params: ContentMatchParams ): string | null { - const { section, page, tab } = params - - // Find all matching entries - const matchingEntries = entries.filter((entry) => { - if (entry.data.section !== section) { - return false - } - - const entryTab = addDemosOrDeprecated(entry.data.tab, entry.filePath) - if (entryTab !== tab) { - return false - } - - const entryId = kebabCase(entry.data.id) - const entryPage = entry.data.subsection - ? `${entry.data.subsection}_${entryId}` - : entryId - - return entryPage === page - }) + // Find all matching entries using shared matching logic + const matchingEntries = entries.filter((entry) => matchesParams(entry, params)) if (matchingEntries.length === 0) { return null From efb9917edc7a1c6ed483deedd6d06dd8366dee8c Mon Sep 17 00:00:00 2001 From: Austin Sullivan Date: Wed, 21 Jan 2026 16:50:09 -0500 Subject: [PATCH 4/4] Add word boundary to regex finding example imports --- src/utils/apiRoutes/exampleParsing.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/utils/apiRoutes/exampleParsing.ts b/src/utils/apiRoutes/exampleParsing.ts index 353d671..613def1 100644 --- a/src/utils/apiRoutes/exampleParsing.ts +++ b/src/utils/apiRoutes/exampleParsing.ts @@ -15,15 +15,20 @@ export function extractImports(fileContent: string): string[] | null { /** * Extracts the file path for a specific example from import statements - * Looks for imports that reference the example name + * Looks for imports that reference the example name using word boundaries to avoid substring matches * * @param imports - Array of import statements - * @param exampleName - Name of the example to find + * @param exampleName - Name of the example to find (must be a valid React component name) * @returns Relative file path without quotes (including query params like ?raw), or null if not found */ export function extractExampleFilePath(imports: string[], exampleName: string): string | null { - const exampleImport = imports.find((imp) => imp.includes(exampleName)) + // Use word boundaries to match exact example names (e.g., "AlertBasic" won't match "AlertBasicExpanded") + // No escaping needed - React component names can only contain [A-Za-z0-9_] + const wordRegex = new RegExp(`\\b${exampleName}\\b`) + const exampleImport = imports.find((imp) => wordRegex.test(imp)) + if (!exampleImport) { + // eslint-disable-next-line no-console console.error('No import path found for example', exampleName) return null }