diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 01a41a267b..1b817fb5b6 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -45,6 +45,18 @@ jobs: - name: Install dependencies run: yarn install --frozen-lockfile + # Whole-corpus check: the docs gate is a ratchet on changed files, so it + # cannot see a pre-existing violation nobody touched. This is what makes a + # framework value outside the enum - in docs OR in the code maps that read + # it - break the build instead of silently mis-resolving. + - name: Verify framework identifiers + run: yarn verify:frameworks + + # Pins the membership of every derived framework map. The refactor risk + # here is not a crash, it is a map quietly gaining or losing an entry. + - name: Test framework registry + run: yarn test:frameworks + - name: Build documentation run: yarn build diff --git a/.github/workflows/docs-gate.yml b/.github/workflows/docs-gate.yml index 95ea4a0b6a..256a36dba3 100644 --- a/.github/workflows/docs-gate.yml +++ b/.github/workflows/docs-gate.yml @@ -71,10 +71,12 @@ jobs: - name: Install Vale (pinned) run: | - # The version the rules in styles/Scandit/ were measured against - - # both of them encode behaviour that is version-specific, down to - # which regex constructs the token engine honours, and a local pass - # on one version proves nothing about another. + # The version the rules in styles/Scandit/ and the frontmatter cap in + # scripts/docs-gate were both measured against. Both encode behaviour + # that is version-specific, down to which regex constructs the token + # engine honours, and the cap additionally rests on Vale attributing a + # `description` alert to its true file line. A local pass on one + # version proves nothing about another. # # CI only. scripts/setup-vale.cjs does not install Vale at all - it # checks for it, prints winget/brew guidance if it is missing, and @@ -97,6 +99,23 @@ jobs: - name: Sync Vale styles run: yarn docs:gate:setup + # The gate scripts' own readers and, through a fixture tree, the whole of + # verify-frameworks. Each defect these rows pin made a gate print OK while + # a check went unperformed, which no amount of green output would show. + # + # Here rather than in build-docs.yml because the prose rows need the Vale + # BINARY on PATH - without it the gate prints "Vale not installed", skips + # the prose checks, and the rows would pass vacuously, so the suite asserts + # its presence and goes red instead. This job is the one that installs it. + # It needs the binary ONLY: the fixture writes its own .vale.ini with no + # Packages line, so it runs before or after `vale sync` indifferently and + # never touches the network. + # + # Before the real gate deliberately: if the gate's own readers are broken, + # that is worth knowing before reading anything the gate reports. + - name: Test docs-gate readers + run: yarn test:docs-gate + - name: Run docs quality gate # GITHUB_BASE_REF makes the gate ratchet against the PR's target branch. run: yarn docs:gate diff --git a/.gitignore b/.gitignore index 6e0ab13406..19f2867bdb 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ validation/cache/ npm-debug.log* yarn-debug.log* yarn-error.log* + +# scripts/test-docs-gate.cjs scratch dirs (pid-suffixed; removed in a finally, +# so these only appear after a hard kill) +scripts/.test-docs-gate-tmp-* diff --git a/docs-schema.yml b/docs-schema.yml index 8271e643a0..079573ebed 100644 --- a/docs-schema.yml +++ b/docs-schema.yml @@ -35,14 +35,39 @@ properties: matrixscan-ar, matrixscan-pick, barcode-selection, barcode-generator, id-capture, id-bolt, label-capture, parser, express, core, platform] + # Canonical framework slugs. These are the SSOT for every framework identifier + # under docs/ and in code: they are exactly the `/sdks//` URL segments + # (with `hosted` for the /hosted/ tree), and src/constants/frameworks.ts must + # cover this same set - `yarn verify:frameworks` fails the build if the two + # drift. That file is the registry the gate reads; + # src/components/utils/frameworks.ts is derived from it and is never read by the + # gate, so adding a slug there alone satisfies nothing. + # + # NOT versioned_docs/. Those snapshots still carry the pre-rename vocabulary + # (`react`, `netIos`, `xamarinForms`, `express`) and are exempt on purpose; + # see the scope note in scripts/verify-frameworks.cjs. Saying "every identifier + # in the repo" here was wrong: ~470 declarations in the snapshots are outside + # this enum and nothing checks them. + # + # `kmp` (Kotlin Multiplatform) is documented only in the in-development docs + # version; that is tracked by UNRELEASED_FRAMEWORK_SLUGS in + # src/constants/unreleasedFrameworks.ts, not by leaving it out of this enum. + # + # Singular `framework` and plural `frameworks` share the set deliberately: a + # page states one platform or several, never two different vocabularies. + framework: + type: string + enum: [ios, android, web, react-native, flutter, cordova, capacitor, kmp, + titanium, linux, net-ios, net-android, hosted] + frameworks: type: array minItems: 1 uniqueItems: true items: type: string - enum: [ios, android, web, react-native, flutter, cordova, capacitor, titanium, - linux, net-ios, net-android, hosted] + enum: [ios, android, web, react-native, flutter, cordova, capacitor, kmp, + titanium, linux, net-ios, net-android, hosted] version_status: type: string diff --git a/docs/hosted/express/configuration/custom-data-transfer/input-configuration.md b/docs/hosted/express/configuration/custom-data-transfer/input-configuration.md index 68e334366a..f7349dddea 100644 --- a/docs/hosted/express/configuration/custom-data-transfer/input-configuration.md +++ b/docs/hosted/express/configuration/custom-data-transfer/input-configuration.md @@ -1,6 +1,7 @@ --- description: "Define the input fields Custom Data Transfer collects during scanning in Scandit Express, and set each field's type and format." -framework: express +framework: hosted +product: express sidebar_label: Input Configuration keywords: - express diff --git a/docs/hosted/express/configuration/custom-data-transfer/output-configuration.md b/docs/hosted/express/configuration/custom-data-transfer/output-configuration.md index 07376fefe0..58bc24271c 100644 --- a/docs/hosted/express/configuration/custom-data-transfer/output-configuration.md +++ b/docs/hosted/express/configuration/custom-data-transfer/output-configuration.md @@ -1,6 +1,7 @@ --- description: "Configure how Custom Data Transfer structures, transforms, and exports scanned data to external systems in Scandit Express." -framework: express +framework: hosted +product: express sidebar_label: Output Configuration keywords: - express diff --git a/docs/hosted/express/configuration/custom-data-transfer/overview.md b/docs/hosted/express/configuration/custom-data-transfer/overview.md index 1da19f253b..f869dcdfb8 100644 --- a/docs/hosted/express/configuration/custom-data-transfer/overview.md +++ b/docs/hosted/express/configuration/custom-data-transfer/overview.md @@ -1,6 +1,7 @@ --- description: "Import and export data in Scandit Express using a configurable CSV file or Google Sheet, aligning formats with your system without coding." -framework: express +framework: hosted +product: express sidebar_label: Overview keywords: - express diff --git a/docs/hosted/express/configuration/device-pairing/bluetooth-pairing.md b/docs/hosted/express/configuration/device-pairing/bluetooth-pairing.md index 0f00bf19d9..dde57be826 100644 --- a/docs/hosted/express/configuration/device-pairing/bluetooth-pairing.md +++ b/docs/hosted/express/configuration/device-pairing/bluetooth-pairing.md @@ -1,6 +1,7 @@ --- description: "Set up Bluetooth device pairing in Scandit Express to send scanned data from one device to another." -framework: express +framework: hosted +product: express keywords: - express --- diff --git a/docs/hosted/express/configuration/device-pairing/device-pairing.md b/docs/hosted/express/configuration/device-pairing/device-pairing.md index e2c0e6a0cd..77c384ac4f 100644 --- a/docs/hosted/express/configuration/device-pairing/device-pairing.md +++ b/docs/hosted/express/configuration/device-pairing/device-pairing.md @@ -1,6 +1,7 @@ --- description: "Use Device Pairing in Scandit Express to transfer scanned barcode data in real time from a sender device to the receiver running your app." -framework: express +framework: hosted +product: express sidebar_label: Overview keywords: - express diff --git a/docs/hosted/express/configuration/device-pairing/online-connection.md b/docs/hosted/express/configuration/device-pairing/online-connection.md index a7e467d7cd..0200c90794 100644 --- a/docs/hosted/express/configuration/device-pairing/online-connection.md +++ b/docs/hosted/express/configuration/device-pairing/online-connection.md @@ -1,6 +1,7 @@ --- description: "Set up online device pairing in Scandit Express to send scanned data to a web application." -framework: express +framework: hosted +product: express keywords: - express --- diff --git a/docs/hosted/express/configuration/express-find.md b/docs/hosted/express/configuration/express-find.md index 283cd79c1a..29def4ef10 100644 --- a/docs/hosted/express/configuration/express-find.md +++ b/docs/hosted/express/configuration/express-find.md @@ -1,7 +1,8 @@ --- description: "Find Items in Scandit Express, powered by MatrixScan Find, speeds up picking by scanning many items and highlighting the right ones in AR." -framework: express +framework: hosted +product: express keywords: - express --- diff --git a/docs/hosted/express/configuration/index.md b/docs/hosted/express/configuration/index.md index 4ef004f6ee..0ca6f5c390 100644 --- a/docs/hosted/express/configuration/index.md +++ b/docs/hosted/express/configuration/index.md @@ -2,7 +2,8 @@ description: "Overview of the configuration options available in Scandit Express and how to set them up." toc_max_heading_level: 4 -framework: express +framework: hosted +product: express keywords: - express --- diff --git a/docs/hosted/express/configuration/inventory-count.md b/docs/hosted/express/configuration/inventory-count.md index c9d99516b3..48165ce6b1 100644 --- a/docs/hosted/express/configuration/inventory-count.md +++ b/docs/hosted/express/configuration/inventory-count.md @@ -1,6 +1,7 @@ --- description: "Use the Inventory Count mode in Scandit Express for fast, accurate stock counting." -framework: express +framework: hosted +product: express keywords: - express --- diff --git a/docs/hosted/express/configuration/scan-labels.md b/docs/hosted/express/configuration/scan-labels.md index 461ef8a28f..10f4b20b18 100644 --- a/docs/hosted/express/configuration/scan-labels.md +++ b/docs/hosted/express/configuration/scan-labels.md @@ -1,6 +1,7 @@ --- description: "Scan Labels in Scandit Express, powered by Smart Label Capture, reads multiple barcodes and printed text on a label in one scan." -framework: express +framework: hosted +product: express keywords: - express --- diff --git a/docs/hosted/express/getting-started/installation.md b/docs/hosted/express/getting-started/installation.md index 31bef8f80b..5f86572396 100644 --- a/docs/hosted/express/getting-started/installation.md +++ b/docs/hosted/express/getting-started/installation.md @@ -3,7 +3,8 @@ description: "Install Scandit Express on iOS or Android and activate it to add b sidebar_label: 'Installation' displayed_sidebar: expressSidebar sidebar_position: 1 -framework: express +framework: hosted +product: express keywords: - express --- diff --git a/docs/hosted/express/getting-started/rollout.md b/docs/hosted/express/getting-started/rollout.md index 7db70f4e6f..043d4cd462 100644 --- a/docs/hosted/express/getting-started/rollout.md +++ b/docs/hosted/express/getting-started/rollout.md @@ -4,7 +4,8 @@ description: "Roll out Scandit Express to production with a mobile device manage sidebar_label: 'Rollout via MDM/EMM' displayed_sidebar: expressSidebar sidebar_position: 2 -framework: express +framework: hosted +product: express keywords: - express --- diff --git a/docs/hosted/express/overview.md b/docs/hosted/express/overview.md index 2b5df306f6..6f3db77ad1 100644 --- a/docs/hosted/express/overview.md +++ b/docs/hosted/express/overview.md @@ -5,7 +5,8 @@ sidebar_position: 1 sidebar_label: 'Overview' title: 'Scandit Express' displayed_sidebar: expressSidebar -framework: express +framework: hosted +product: express keywords: - express --- diff --git a/docs/hosted/id-bolt/advanced.md b/docs/hosted/id-bolt/advanced.md index 4da47a46f8..4602b1ddf8 100644 --- a/docs/hosted/id-bolt/advanced.md +++ b/docs/hosted/id-bolt/advanced.md @@ -5,7 +5,8 @@ sidebar_label: "Advanced Options" title: "Advanced Options" displayed_sidebar: boltSidebar toc_max_heading_level: 4 -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt - advanced diff --git a/docs/hosted/id-bolt/api-overview.md b/docs/hosted/id-bolt/api-overview.md index 1281e63123..6f426e3a6a 100644 --- a/docs/hosted/id-bolt/api-overview.md +++ b/docs/hosted/id-bolt/api-overview.md @@ -5,7 +5,8 @@ sidebar_label: "API Overview" title: "API Overview" displayed_sidebar: boltSidebar toc_max_heading_level: 4 -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt --- diff --git a/docs/hosted/id-bolt/callbacks.md b/docs/hosted/id-bolt/callbacks.md index 739c9a6282..7d71367ebf 100644 --- a/docs/hosted/id-bolt/callbacks.md +++ b/docs/hosted/id-bolt/callbacks.md @@ -5,7 +5,8 @@ sidebar_label: "Callbacks" title: "Callbacks" displayed_sidebar: boltSidebar toc_max_heading_level: 4 -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt - callbacks diff --git a/docs/hosted/id-bolt/data-handling.md b/docs/hosted/id-bolt/data-handling.md index 24e80e8fb5..aaaa8eae05 100644 --- a/docs/hosted/id-bolt/data-handling.md +++ b/docs/hosted/id-bolt/data-handling.md @@ -5,7 +5,8 @@ sidebar_label: "Data Handling" title: "Data Handling" displayed_sidebar: boltSidebar toc_max_heading_level: 4 -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt - data handling diff --git a/docs/hosted/id-bolt/document-selection.md b/docs/hosted/id-bolt/document-selection.md index 9d08fb11a9..032a8b1573 100644 --- a/docs/hosted/id-bolt/document-selection.md +++ b/docs/hosted/id-bolt/document-selection.md @@ -5,7 +5,8 @@ sidebar_label: "Document Selection" title: "Document Selection" displayed_sidebar: boltSidebar toc_max_heading_level: 4 -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt - document selection diff --git a/docs/hosted/id-bolt/getting-started.md b/docs/hosted/id-bolt/getting-started.md index 3b9c704600..d886d459d9 100644 --- a/docs/hosted/id-bolt/getting-started.md +++ b/docs/hosted/id-bolt/getting-started.md @@ -5,7 +5,8 @@ sidebar_label: "Getting Started" title: "Getting Started" hide_title: false displayed_sidebar: boltSidebar -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt --- diff --git a/docs/hosted/id-bolt/overview.md b/docs/hosted/id-bolt/overview.md index 20462a79e4..ec54221c5e 100644 --- a/docs/hosted/id-bolt/overview.md +++ b/docs/hosted/id-bolt/overview.md @@ -5,7 +5,8 @@ sidebar_position: 1 sidebar_label: "Overview" title: "ID Bolt" displayed_sidebar: boltSidebar -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt --- diff --git a/docs/hosted/id-bolt/release-notes.md b/docs/hosted/id-bolt/release-notes.md index c3f9a15d5d..ce84a952fc 100644 --- a/docs/hosted/id-bolt/release-notes.md +++ b/docs/hosted/id-bolt/release-notes.md @@ -2,7 +2,8 @@ description: "Release notes for ID Bolt: new features, changes, and fixes by date." toc_max_heading_level: 3 displayed_sidebar: boltSidebar -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt hide_title: true diff --git a/docs/hosted/id-bolt/text-overrides.md b/docs/hosted/id-bolt/text-overrides.md index dfdae7735f..a6d15db3ba 100644 --- a/docs/hosted/id-bolt/text-overrides.md +++ b/docs/hosted/id-bolt/text-overrides.md @@ -5,7 +5,8 @@ sidebar_label: "Text Overrides" title: "Text Overrides" displayed_sidebar: boltSidebar toc_max_heading_level: 4 -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt - customization diff --git a/docs/hosted/id-bolt/theming.md b/docs/hosted/id-bolt/theming.md index ebb87de1bf..f0799e2f54 100644 --- a/docs/hosted/id-bolt/theming.md +++ b/docs/hosted/id-bolt/theming.md @@ -5,7 +5,8 @@ sidebar_label: "Theming" title: "Theming" displayed_sidebar: boltSidebar toc_max_heading_level: 4 -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt - theming diff --git a/docs/hosted/id-bolt/validators.md b/docs/hosted/id-bolt/validators.md index 6764ae008a..a78b462532 100644 --- a/docs/hosted/id-bolt/validators.md +++ b/docs/hosted/id-bolt/validators.md @@ -5,7 +5,8 @@ sidebar_label: "Validators" title: "Validators" displayed_sidebar: boltSidebar toc_max_heading_level: 4 -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt - validators diff --git a/docs/hosted/id-bolt/workflow.md b/docs/hosted/id-bolt/workflow.md index bec467005c..f2ebb5c2c4 100644 --- a/docs/hosted/id-bolt/workflow.md +++ b/docs/hosted/id-bolt/workflow.md @@ -5,7 +5,8 @@ sidebar_label: "Workflow Options" title: "Workflow & Scanner Options" displayed_sidebar: boltSidebar toc_max_heading_level: 4 -framework: bolt +framework: hosted +product: id-bolt keywords: - bolt - workflow diff --git a/docs/sdks/net/android/add-sdk.md b/docs/sdks/net/android/add-sdk.md index ea3ae80d15..82285ca2cd 100644 --- a/docs/sdks/net/android/add-sdk.md +++ b/docs/sdks/net/android/add-sdk.md @@ -3,7 +3,7 @@ description: "Add the Scandit Data Capture SDK to your .NET Android project: dep sidebar_position: 1 toc_max_heading_level: 4 pagination_next: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/ai-powered-barcode-scanning.md b/docs/sdks/net/android/ai-powered-barcode-scanning.md index a1f8709967..a31f738ef5 100644 --- a/docs/sdks/net/android/ai-powered-barcode-scanning.md +++ b/docs/sdks/net/android/ai-powered-barcode-scanning.md @@ -1,7 +1,7 @@ --- description: "import AIPoweredBarcodeScanning from '../../../partials/_ai-powered-barcode-scanning.mdx';" toc_max_heading_level: 4 -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/barcode-capture/configure-barcode-symbologies.md b/docs/sdks/net/android/barcode-capture/configure-barcode-symbologies.md index 3f069dbfce..1d8561d158 100644 --- a/docs/sdks/net/android/barcode-capture/configure-barcode-symbologies.md +++ b/docs/sdks/net/android/barcode-capture/configure-barcode-symbologies.md @@ -2,7 +2,7 @@ description: "Enable and configure barcode symbologies for Barcode Capture on .NET Android." sidebar_position: 3 pagination_next: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/barcode-capture/get-started.md b/docs/sdks/net/android/barcode-capture/get-started.md index 57ea839aa3..7634351389 100644 --- a/docs/sdks/net/android/barcode-capture/get-started.md +++ b/docs/sdks/net/android/barcode-capture/get-started.md @@ -2,7 +2,7 @@ description: "Add Barcode Capture to your .NET Android app: configure the mode, set up the camera and view, and handle scanned barcodes." sidebar_position: 2 pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/barcode-selection/get-started.md b/docs/sdks/net/android/barcode-selection/get-started.md index af9d8da1c9..bc11957d51 100644 --- a/docs/sdks/net/android/barcode-selection/get-started.md +++ b/docs/sdks/net/android/barcode-selection/get-started.md @@ -2,7 +2,7 @@ description: "Add Barcode Selection to your .NET Android app so users can tap or aim to select a single barcode among many." sidebar_position: 2 pagination_next: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/barcode-selection/intro.md b/docs/sdks/net/android/barcode-selection/intro.md index c9425a30f7..8c3d5101a6 100644 --- a/docs/sdks/net/android/barcode-selection/intro.md +++ b/docs/sdks/net/android/barcode-selection/intro.md @@ -2,7 +2,7 @@ description: "Barcode Selection lets users tap or aim to pick one barcode among many in your .NET Android app." sidebar_position: 1 pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/batch-scanning.md b/docs/sdks/net/android/batch-scanning.md index 951e7f2102..f006026350 100644 --- a/docs/sdks/net/android/batch-scanning.md +++ b/docs/sdks/net/android/batch-scanning.md @@ -1,7 +1,7 @@ --- description: "Scan and process many barcodes in one session in your .NET Android app with the Scandit Data Capture SDK." pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/id-capture/advanced.md b/docs/sdks/net/android/id-capture/advanced.md index 47dccee6c2..bcef758425 100644 --- a/docs/sdks/net/android/id-capture/advanced.md +++ b/docs/sdks/net/android/id-capture/advanced.md @@ -2,7 +2,7 @@ description: "Advanced ID Capture settings for .NET Android: configure document types, extracted fields, and verification." sidebar_position: 4 pagination_next: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/id-capture/get-started.md b/docs/sdks/net/android/id-capture/get-started.md index c860809355..65936d3528 100644 --- a/docs/sdks/net/android/id-capture/get-started.md +++ b/docs/sdks/net/android/id-capture/get-started.md @@ -1,7 +1,7 @@ --- description: "Add ID Capture to your .NET Android app to scan and extract data from IDs such as driver's licenses, passports, and ID cards." sidebar_position: 2 -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/id-capture/intro.md b/docs/sdks/net/android/id-capture/intro.md index b80b187ad4..93d74fee29 100644 --- a/docs/sdks/net/android/id-capture/intro.md +++ b/docs/sdks/net/android/id-capture/intro.md @@ -5,7 +5,7 @@ title: About ID Capture sidebar_position: 1 toc_max_heading_level: 4 pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/id-capture/supported-documents.md b/docs/sdks/net/android/id-capture/supported-documents.md index bede24184d..c71bbf3252 100644 --- a/docs/sdks/net/android/id-capture/supported-documents.md +++ b/docs/sdks/net/android/id-capture/supported-documents.md @@ -6,7 +6,7 @@ hide_title: true toc_min_heading_level: 2 toc_max_heading_level: 4 sidebar_position: 3 -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan-ar/get-started.md b/docs/sdks/net/android/matrixscan-ar/get-started.md index 968ddcdfb9..c3e34762aa 100644 --- a/docs/sdks/net/android/matrixscan-ar/get-started.md +++ b/docs/sdks/net/android/matrixscan-ar/get-started.md @@ -1,7 +1,7 @@ --- description: "Add MatrixScan AR to your .NET Android app: set up the view and the overlays that guide users to the right items." sidebar_position: 2 -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan-ar/intro.md b/docs/sdks/net/android/matrixscan-ar/intro.md index 9722380f79..78ab765fd6 100644 --- a/docs/sdks/net/android/matrixscan-ar/intro.md +++ b/docs/sdks/net/android/matrixscan-ar/intro.md @@ -2,7 +2,7 @@ description: "MatrixScan AR adds augmented-reality overlays that highlight barcodes and show extra information in your .NET Android app." displayed_sidebar: netIosSidebar pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan-count/advanced.md b/docs/sdks/net/android/matrixscan-count/advanced.md index fc64adbcb9..8d9fdef2e3 100644 --- a/docs/sdks/net/android/matrixscan-count/advanced.md +++ b/docs/sdks/net/android/matrixscan-count/advanced.md @@ -2,7 +2,7 @@ description: "Advanced MatrixScan Count settings for .NET Android to tune counting accuracy, performance, and the user experience." sidebar_position: 3 pagination_next: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan-count/get-started.md b/docs/sdks/net/android/matrixscan-count/get-started.md index 3a898ec0fa..6fe7814022 100644 --- a/docs/sdks/net/android/matrixscan-count/get-started.md +++ b/docs/sdks/net/android/matrixscan-count/get-started.md @@ -1,7 +1,7 @@ --- description: "Add MatrixScan Count to your .NET Android app: set up the mode, scan and count multiple barcodes, and handle the results." sidebar_position: 2 -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan-count/intro.md b/docs/sdks/net/android/matrixscan-count/intro.md index bebf4b152e..6c163ef44d 100644 --- a/docs/sdks/net/android/matrixscan-count/intro.md +++ b/docs/sdks/net/android/matrixscan-count/intro.md @@ -2,7 +2,7 @@ description: "MatrixScan Count scans and counts many barcodes at once in your .NET Android app." sidebar_position: 1 pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan-find/advanced.md b/docs/sdks/net/android/matrixscan-find/advanced.md index 0c79fca498..0cb1effdd3 100644 --- a/docs/sdks/net/android/matrixscan-find/advanced.md +++ b/docs/sdks/net/android/matrixscan-find/advanced.md @@ -2,7 +2,7 @@ description: "Advanced MatrixScan Find settings for .NET Android: customize matching, overlays, and search behavior." sidebar_position: 3 pagination_next: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan-find/get-started.md b/docs/sdks/net/android/matrixscan-find/get-started.md index d587308692..f9309f62e1 100644 --- a/docs/sdks/net/android/matrixscan-find/get-started.md +++ b/docs/sdks/net/android/matrixscan-find/get-started.md @@ -1,7 +1,7 @@ --- description: "Add MatrixScan Find to your .NET Android app to search for and highlight specific items with the camera." sidebar_position: 2 -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan-find/intro.md b/docs/sdks/net/android/matrixscan-find/intro.md index 63079ef671..2288a36663 100644 --- a/docs/sdks/net/android/matrixscan-find/intro.md +++ b/docs/sdks/net/android/matrixscan-find/intro.md @@ -2,7 +2,7 @@ description: "MatrixScan Find helps users locate specific items in your .NET Android app by highlighting matches in the camera feed." sidebar_position: 1 pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan-pick/advanced.md b/docs/sdks/net/android/matrixscan-pick/advanced.md index 708ceed00c..f9334d3807 100644 --- a/docs/sdks/net/android/matrixscan-pick/advanced.md +++ b/docs/sdks/net/android/matrixscan-pick/advanced.md @@ -2,7 +2,7 @@ description: "Advanced MatrixScan Pick settings for .NET Android: customize picking logic, overlays, and workflows." sidebar_position: 3 pagination_next: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan-pick/get-started.md b/docs/sdks/net/android/matrixscan-pick/get-started.md index c4427dcfbd..4b73c23275 100644 --- a/docs/sdks/net/android/matrixscan-pick/get-started.md +++ b/docs/sdks/net/android/matrixscan-pick/get-started.md @@ -1,7 +1,7 @@ --- description: "Add MatrixScan Pick to your .NET Android app to guide users through order picking with AR cues." sidebar_position: 2 -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan-pick/intro.md b/docs/sdks/net/android/matrixscan-pick/intro.md index 646aae7e22..2836577039 100644 --- a/docs/sdks/net/android/matrixscan-pick/intro.md +++ b/docs/sdks/net/android/matrixscan-pick/intro.md @@ -2,7 +2,7 @@ description: "MatrixScan Pick guides order picking in your .NET Android app with on-screen AR cues for the items to pick." sidebar_position: 1 pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan/advanced.md b/docs/sdks/net/android/matrixscan/advanced.md index 9b084e2317..f867a6f92e 100644 --- a/docs/sdks/net/android/matrixscan/advanced.md +++ b/docs/sdks/net/android/matrixscan/advanced.md @@ -2,7 +2,7 @@ description: "Advanced MatrixScan settings for .NET Android: tune tracking, overlays, and scanning performance." sidebar_position: 3 pagination_next: null -framework: netAndroid +framework: net-android keywords: - netAndroid diff --git a/docs/sdks/net/android/matrixscan/get-started.md b/docs/sdks/net/android/matrixscan/get-started.md index 6fd549327f..064e9a2080 100644 --- a/docs/sdks/net/android/matrixscan/get-started.md +++ b/docs/sdks/net/android/matrixscan/get-started.md @@ -1,7 +1,7 @@ --- description: "Add MatrixScan to your .NET Android app to detect, track, and highlight multiple barcodes in the same frame." sidebar_position: 2 -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/matrixscan/intro.md b/docs/sdks/net/android/matrixscan/intro.md index da33c690f1..32fd7cc4d2 100644 --- a/docs/sdks/net/android/matrixscan/intro.md +++ b/docs/sdks/net/android/matrixscan/intro.md @@ -2,7 +2,7 @@ description: "MatrixScan detects, tracks, and highlights multiple barcodes in the same frame in your .NET Android app." sidebar_position: 1 pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/parser/get-started.md b/docs/sdks/net/android/parser/get-started.md index 09cc0a926c..3e1adca8c7 100644 --- a/docs/sdks/net/android/parser/get-started.md +++ b/docs/sdks/net/android/parser/get-started.md @@ -3,7 +3,7 @@ description: "Use the Scandit parser in your .NET Android app to turn barcode da sidebar_position: 2 pagination_prev: null pagination_next: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/release-notes.md b/docs/sdks/net/android/release-notes.md index 72c093f703..bb8c80ea3a 100644 --- a/docs/sdks/net/android/release-notes.md +++ b/docs/sdks/net/android/release-notes.md @@ -5,7 +5,7 @@ displayed_sidebar: netAndroidSidebar hide_title: true title: Release Notes pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/single-scanning.md b/docs/sdks/net/android/single-scanning.md index b76eeb751b..e6bd4c4ebd 100644 --- a/docs/sdks/net/android/single-scanning.md +++ b/docs/sdks/net/android/single-scanning.md @@ -2,7 +2,7 @@ description: "Scan a single barcode at a time in your .NET Android app with the Scandit Data Capture SDK." toc_max_heading_level: 4 pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/sparkscan/advanced.md b/docs/sdks/net/android/sparkscan/advanced.md index 8dbcaa0049..390bf2ea04 100644 --- a/docs/sdks/net/android/sparkscan/advanced.md +++ b/docs/sdks/net/android/sparkscan/advanced.md @@ -2,7 +2,7 @@ description: "Advanced SparkScan settings for .NET Android: customize the UI, scanning behavior, and feedback beyond the defaults." sidebar_position: 3 pagination_next: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/sparkscan/get-started.md b/docs/sdks/net/android/sparkscan/get-started.md index 179a89bfe1..7aab41b2fd 100644 --- a/docs/sdks/net/android/sparkscan/get-started.md +++ b/docs/sdks/net/android/sparkscan/get-started.md @@ -1,7 +1,7 @@ --- description: "Add SparkScan to your .NET Android app: create a data capture context, configure the mode, add the view, and handle scanned barcodes." sidebar_position: 2 -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/android/sparkscan/intro.md b/docs/sdks/net/android/sparkscan/intro.md index e6e48d0b9b..b9d873799c 100644 --- a/docs/sdks/net/android/sparkscan/intro.md +++ b/docs/sdks/net/android/sparkscan/intro.md @@ -2,7 +2,7 @@ description: "SparkScan is a prebuilt smartphone scanning UI for fast, ergonomic barcode scanning that drops on top of any .NET Android app." sidebar_position: 1 pagination_prev: null -framework: netAndroid +framework: net-android keywords: - netAndroid --- diff --git a/docs/sdks/net/ios/add-sdk.md b/docs/sdks/net/ios/add-sdk.md index 199412adc2..e1cbd785fd 100644 --- a/docs/sdks/net/ios/add-sdk.md +++ b/docs/sdks/net/ios/add-sdk.md @@ -3,7 +3,7 @@ description: "Add the Scandit Data Capture SDK to your .NET iOS project: depende sidebar_position: 1 toc_max_heading_level: 4 pagination_next: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/ai-powered-barcode-scanning.md b/docs/sdks/net/ios/ai-powered-barcode-scanning.md index cff13dce65..5d508e8e86 100644 --- a/docs/sdks/net/ios/ai-powered-barcode-scanning.md +++ b/docs/sdks/net/ios/ai-powered-barcode-scanning.md @@ -1,7 +1,7 @@ --- description: "import AIPoweredBarcodeScanning from '../../../partials/_ai-powered-barcode-scanning.mdx';" toc_max_heading_level: 4 -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/barcode-capture/configure-barcode-symbologies.md b/docs/sdks/net/ios/barcode-capture/configure-barcode-symbologies.md index 4c139ba710..e7aac752b6 100644 --- a/docs/sdks/net/ios/barcode-capture/configure-barcode-symbologies.md +++ b/docs/sdks/net/ios/barcode-capture/configure-barcode-symbologies.md @@ -2,7 +2,7 @@ description: "Enable and configure barcode symbologies for Barcode Capture on .NET iOS." sidebar_position: 3 pagination_next: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/barcode-capture/get-started.md b/docs/sdks/net/ios/barcode-capture/get-started.md index 678d075b0f..d8811f30ee 100644 --- a/docs/sdks/net/ios/barcode-capture/get-started.md +++ b/docs/sdks/net/ios/barcode-capture/get-started.md @@ -2,7 +2,7 @@ description: "Add Barcode Capture to your .NET iOS app: configure the mode, set up the camera and view, and handle scanned barcodes." sidebar_position: 2 pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/barcode-selection/get-started.md b/docs/sdks/net/ios/barcode-selection/get-started.md index 2fb26bbb28..db24709bd2 100644 --- a/docs/sdks/net/ios/barcode-selection/get-started.md +++ b/docs/sdks/net/ios/barcode-selection/get-started.md @@ -2,7 +2,7 @@ description: "Add Barcode Selection to your .NET iOS app so users can tap or aim to select a single barcode among many." sidebar_position: 2 pagination_next: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/barcode-selection/intro.md b/docs/sdks/net/ios/barcode-selection/intro.md index 583e41b0ce..46c1f36162 100644 --- a/docs/sdks/net/ios/barcode-selection/intro.md +++ b/docs/sdks/net/ios/barcode-selection/intro.md @@ -2,7 +2,7 @@ description: "Barcode Selection lets users tap or aim to pick one barcode among many in your .NET iOS app." sidebar_position: 1 pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/batch-scanning.md b/docs/sdks/net/ios/batch-scanning.md index e137ef4e1a..22da320d5b 100644 --- a/docs/sdks/net/ios/batch-scanning.md +++ b/docs/sdks/net/ios/batch-scanning.md @@ -1,7 +1,7 @@ --- description: "Scan and process many barcodes in one session in your .NET iOS app with the Scandit Data Capture SDK." pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/id-capture/advanced.md b/docs/sdks/net/ios/id-capture/advanced.md index 3b06c4738f..664ad4849a 100644 --- a/docs/sdks/net/ios/id-capture/advanced.md +++ b/docs/sdks/net/ios/id-capture/advanced.md @@ -2,7 +2,7 @@ description: "Advanced ID Capture settings for .NET iOS: configure document types, extracted fields, and verification." sidebar_position: 4 pagination_next: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/id-capture/get-started.md b/docs/sdks/net/ios/id-capture/get-started.md index a410a02f59..2abc09d109 100644 --- a/docs/sdks/net/ios/id-capture/get-started.md +++ b/docs/sdks/net/ios/id-capture/get-started.md @@ -1,7 +1,7 @@ --- description: "Add ID Capture to your .NET iOS app to scan and extract data from IDs such as driver's licenses, passports, and ID cards." sidebar_position: 2 -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/id-capture/intro.md b/docs/sdks/net/ios/id-capture/intro.md index 89a7490288..939aa36943 100644 --- a/docs/sdks/net/ios/id-capture/intro.md +++ b/docs/sdks/net/ios/id-capture/intro.md @@ -5,7 +5,7 @@ title: About ID Capture sidebar_position: 1 toc_max_heading_level: 4 pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/id-capture/supported-documents.md b/docs/sdks/net/ios/id-capture/supported-documents.md index e9c45de56e..12326edbfb 100644 --- a/docs/sdks/net/ios/id-capture/supported-documents.md +++ b/docs/sdks/net/ios/id-capture/supported-documents.md @@ -6,7 +6,7 @@ hide_title: true toc_min_heading_level: 2 toc_max_heading_level: 4 sidebar_position: 3 -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan-ar/get-started.md b/docs/sdks/net/ios/matrixscan-ar/get-started.md index e03da39baa..90305bc79d 100644 --- a/docs/sdks/net/ios/matrixscan-ar/get-started.md +++ b/docs/sdks/net/ios/matrixscan-ar/get-started.md @@ -1,7 +1,7 @@ --- description: "Add MatrixScan AR to your .NET iOS app: set up the view and the overlays that guide users to the right items while scanning." sidebar_position: 2 -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan-ar/intro.md b/docs/sdks/net/ios/matrixscan-ar/intro.md index e74a49871e..32131b4828 100644 --- a/docs/sdks/net/ios/matrixscan-ar/intro.md +++ b/docs/sdks/net/ios/matrixscan-ar/intro.md @@ -2,7 +2,7 @@ description: "MatrixScan AR adds augmented-reality overlays that highlight barcodes and show extra information in your .NET iOS app." displayed_sidebar: netIosSidebar pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan-count/advanced.md b/docs/sdks/net/ios/matrixscan-count/advanced.md index 67eaa57990..5f0804b04e 100644 --- a/docs/sdks/net/ios/matrixscan-count/advanced.md +++ b/docs/sdks/net/ios/matrixscan-count/advanced.md @@ -2,7 +2,7 @@ description: "Advanced MatrixScan Count settings for .NET iOS to tune counting accuracy, performance, and the user experience." sidebar_position: 3 pagination_next: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan-count/get-started.md b/docs/sdks/net/ios/matrixscan-count/get-started.md index fa3f52969c..55a4fac548 100644 --- a/docs/sdks/net/ios/matrixscan-count/get-started.md +++ b/docs/sdks/net/ios/matrixscan-count/get-started.md @@ -1,7 +1,7 @@ --- description: "Add MatrixScan Count to your .NET iOS app: set up the mode, scan and count multiple barcodes, and handle the results." sidebar_position: 2 -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan-count/intro.md b/docs/sdks/net/ios/matrixscan-count/intro.md index f9b529e2e4..96fcc20c70 100644 --- a/docs/sdks/net/ios/matrixscan-count/intro.md +++ b/docs/sdks/net/ios/matrixscan-count/intro.md @@ -2,7 +2,7 @@ description: "MatrixScan Count scans and counts many barcodes at once in your .NET iOS app." sidebar_position: 1 pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan-find/advanced.md b/docs/sdks/net/ios/matrixscan-find/advanced.md index dee033a174..3ea99974a9 100644 --- a/docs/sdks/net/ios/matrixscan-find/advanced.md +++ b/docs/sdks/net/ios/matrixscan-find/advanced.md @@ -2,7 +2,7 @@ description: "Advanced MatrixScan Find settings for .NET iOS: customize matching, overlays, and search behavior." sidebar_position: 3 pagination_next: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan-find/get-started.md b/docs/sdks/net/ios/matrixscan-find/get-started.md index 8a10e58bcd..c789fbd41a 100644 --- a/docs/sdks/net/ios/matrixscan-find/get-started.md +++ b/docs/sdks/net/ios/matrixscan-find/get-started.md @@ -1,7 +1,7 @@ --- description: "Add MatrixScan Find to your .NET iOS app to search for and highlight specific items with the camera." sidebar_position: 2 -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan-find/intro.md b/docs/sdks/net/ios/matrixscan-find/intro.md index 0791d8af06..944a279e87 100644 --- a/docs/sdks/net/ios/matrixscan-find/intro.md +++ b/docs/sdks/net/ios/matrixscan-find/intro.md @@ -2,7 +2,7 @@ description: "MatrixScan Find helps users locate specific items in your .NET iOS app by highlighting matches in the camera feed." sidebar_position: 1 pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan-pick/advanced.md b/docs/sdks/net/ios/matrixscan-pick/advanced.md index ae1798e6c7..96b1b5f67a 100644 --- a/docs/sdks/net/ios/matrixscan-pick/advanced.md +++ b/docs/sdks/net/ios/matrixscan-pick/advanced.md @@ -2,7 +2,7 @@ description: "Advanced MatrixScan Pick settings for .NET iOS: customize picking logic, overlays, and workflows." sidebar_position: 3 pagination_next: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan-pick/get-started.md b/docs/sdks/net/ios/matrixscan-pick/get-started.md index e0a1d7adbf..24669d833e 100644 --- a/docs/sdks/net/ios/matrixscan-pick/get-started.md +++ b/docs/sdks/net/ios/matrixscan-pick/get-started.md @@ -1,7 +1,7 @@ --- description: "Add MatrixScan Pick to your .NET iOS app to guide users through order picking with AR cues." sidebar_position: 2 -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan-pick/intro.md b/docs/sdks/net/ios/matrixscan-pick/intro.md index b8a7f9712e..5921c6ddb7 100644 --- a/docs/sdks/net/ios/matrixscan-pick/intro.md +++ b/docs/sdks/net/ios/matrixscan-pick/intro.md @@ -2,7 +2,7 @@ description: "MatrixScan Pick guides order picking in your .NET iOS app with on-screen AR cues for the items to pick." sidebar_position: 1 pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan/advanced.md b/docs/sdks/net/ios/matrixscan/advanced.md index 5a246c1ba2..dd3405f5dd 100644 --- a/docs/sdks/net/ios/matrixscan/advanced.md +++ b/docs/sdks/net/ios/matrixscan/advanced.md @@ -2,7 +2,7 @@ description: "Advanced MatrixScan settings for .NET iOS: tune tracking, overlays, and scanning performance." sidebar_position: 3 pagination_next: null -framework: netIos +framework: net-ios keywords: - netIos diff --git a/docs/sdks/net/ios/matrixscan/get-started.md b/docs/sdks/net/ios/matrixscan/get-started.md index 33b9e14e70..e7017cac29 100644 --- a/docs/sdks/net/ios/matrixscan/get-started.md +++ b/docs/sdks/net/ios/matrixscan/get-started.md @@ -1,7 +1,7 @@ --- description: "Add MatrixScan to your .NET iOS app to detect, track, and highlight multiple barcodes in the same frame." sidebar_position: 2 -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/matrixscan/intro.md b/docs/sdks/net/ios/matrixscan/intro.md index 0d72393a44..21bf1df854 100644 --- a/docs/sdks/net/ios/matrixscan/intro.md +++ b/docs/sdks/net/ios/matrixscan/intro.md @@ -2,7 +2,7 @@ description: "MatrixScan detects, tracks, and highlights multiple barcodes in the same frame in your .NET iOS app." sidebar_position: 1 pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/parser/get-started.md b/docs/sdks/net/ios/parser/get-started.md index a736faf522..59671cd228 100644 --- a/docs/sdks/net/ios/parser/get-started.md +++ b/docs/sdks/net/ios/parser/get-started.md @@ -3,7 +3,7 @@ description: "Use the Scandit parser in your .NET iOS app to turn barcode data s sidebar_position: 2 pagination_prev: null pagination_next: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/release-notes.md b/docs/sdks/net/ios/release-notes.md index 111b6408d0..2b0942b6e2 100644 --- a/docs/sdks/net/ios/release-notes.md +++ b/docs/sdks/net/ios/release-notes.md @@ -5,7 +5,7 @@ displayed_sidebar: netIosSidebar hide_title: true title: Release Notes pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/single-scanning.md b/docs/sdks/net/ios/single-scanning.md index 22f3371842..23b5bd96fb 100644 --- a/docs/sdks/net/ios/single-scanning.md +++ b/docs/sdks/net/ios/single-scanning.md @@ -2,7 +2,7 @@ description: "Scan a single barcode at a time in your .NET iOS app with the Scandit Data Capture SDK." toc_max_heading_level: 4 pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/sparkscan/advanced.md b/docs/sdks/net/ios/sparkscan/advanced.md index abf1fefb5b..8468d9df4d 100644 --- a/docs/sdks/net/ios/sparkscan/advanced.md +++ b/docs/sdks/net/ios/sparkscan/advanced.md @@ -2,7 +2,7 @@ description: "Advanced SparkScan settings for .NET iOS: customize the UI, scanning behavior, and feedback beyond the defaults." sidebar_position: 3 pagination_next: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/sparkscan/get-started.md b/docs/sdks/net/ios/sparkscan/get-started.md index d316aeb216..1539abf942 100644 --- a/docs/sdks/net/ios/sparkscan/get-started.md +++ b/docs/sdks/net/ios/sparkscan/get-started.md @@ -1,7 +1,7 @@ --- description: "Add SparkScan to your .NET iOS app: create a data capture context, configure the mode, add the view, and handle scanned barcodes." sidebar_position: 2 -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/net/ios/sparkscan/intro.md b/docs/sdks/net/ios/sparkscan/intro.md index ccfdde90aa..39f9bcf299 100644 --- a/docs/sdks/net/ios/sparkscan/intro.md +++ b/docs/sdks/net/ios/sparkscan/intro.md @@ -2,7 +2,7 @@ description: "SparkScan is a prebuilt scanning UI for fast, ergonomic barcode scanning that drops on top of any .NET iOS app." sidebar_position: 1 pagination_prev: null -framework: netIos +framework: net-ios keywords: - netIos --- diff --git a/docs/sdks/react-native/add-sdk.md b/docs/sdks/react-native/add-sdk.md index 18c205a3b2..950497d1fb 100644 --- a/docs/sdks/react-native/add-sdk.md +++ b/docs/sdks/react-native/add-sdk.md @@ -3,7 +3,7 @@ description: "Add the Scandit Data Capture SDK to your React Native project: dep sidebar_position: 1 toc_max_heading_level: 4 pagination_next: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/barcode-capture/configure-barcode-symbologies.md b/docs/sdks/react-native/barcode-capture/configure-barcode-symbologies.md index 0d9b45a955..5aaaf48795 100644 --- a/docs/sdks/react-native/barcode-capture/configure-barcode-symbologies.md +++ b/docs/sdks/react-native/barcode-capture/configure-barcode-symbologies.md @@ -3,7 +3,7 @@ description: "Learn about the available symbologies and the corresponding config sidebar_position: 3 pagination_next: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/barcode-capture/get-started.md b/docs/sdks/react-native/barcode-capture/get-started.md index 1b55dfd66c..a4661f847d 100644 --- a/docs/sdks/react-native/barcode-capture/get-started.md +++ b/docs/sdks/react-native/barcode-capture/get-started.md @@ -2,7 +2,7 @@ description: "Add Barcode Capture to your React Native app: configure the mode, set up the camera and view, and handle scanned barcodes." sidebar_position: 2 pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/barcode-selection/get-started.md b/docs/sdks/react-native/barcode-selection/get-started.md index a0c13a3432..7b397c8b69 100644 --- a/docs/sdks/react-native/barcode-selection/get-started.md +++ b/docs/sdks/react-native/barcode-selection/get-started.md @@ -3,7 +3,7 @@ description: "Add Barcode Selection to your React Native app so users can tap or sidebar_position: 2 pagination_next: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/barcode-selection/intro.md b/docs/sdks/react-native/barcode-selection/intro.md index cf82c55cc7..4da37cb3ad 100644 --- a/docs/sdks/react-native/barcode-selection/intro.md +++ b/docs/sdks/react-native/barcode-selection/intro.md @@ -3,7 +3,7 @@ description: "Barcode Selection lets users tap or aim to pick one barcode among sidebar_position: 1 pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/batch-scanning.md b/docs/sdks/react-native/batch-scanning.md index 8c87fa6710..c0b6d79719 100644 --- a/docs/sdks/react-native/batch-scanning.md +++ b/docs/sdks/react-native/batch-scanning.md @@ -4,7 +4,7 @@ toc_max_heading_level: 4 pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/id-capture/advanced.md b/docs/sdks/react-native/id-capture/advanced.md index 9d8863d8ff..935a15fe3c 100644 --- a/docs/sdks/react-native/id-capture/advanced.md +++ b/docs/sdks/react-native/id-capture/advanced.md @@ -3,7 +3,7 @@ description: "Advanced ID Capture settings for React Native: configure document sidebar_position: 4 pagination_next: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/id-capture/get-started.md b/docs/sdks/react-native/id-capture/get-started.md index 687c0384d5..60e83ae98c 100644 --- a/docs/sdks/react-native/id-capture/get-started.md +++ b/docs/sdks/react-native/id-capture/get-started.md @@ -2,7 +2,7 @@ description: "Add ID Capture to your React Native app to scan and extract data from IDs such as driver's licenses, passports, and ID cards." sidebar_position: 2 -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/id-capture/intro.md b/docs/sdks/react-native/id-capture/intro.md index 02a2c044ca..effc62f62c 100644 --- a/docs/sdks/react-native/id-capture/intro.md +++ b/docs/sdks/react-native/id-capture/intro.md @@ -6,7 +6,7 @@ title: About ID Capture sidebar_position: 1 toc_max_heading_level: 4 pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/label-capture/label-definitions.md b/docs/sdks/react-native/label-capture/label-definitions.md index 5ad0d2526c..b4be9e2695 100644 --- a/docs/sdks/react-native/label-capture/label-definitions.md +++ b/docs/sdks/react-native/label-capture/label-definitions.md @@ -1,7 +1,7 @@ --- description: "Define label layouts for Smart Label Capture on React Native: the barcodes and text fields to extract." -framework: react +framework: react-native toc_max_heading_level: 4 keywords: - react diff --git a/docs/sdks/react-native/matrixscan-ar/get-started.md b/docs/sdks/react-native/matrixscan-ar/get-started.md index b4973f45a4..de13867428 100644 --- a/docs/sdks/react-native/matrixscan-ar/get-started.md +++ b/docs/sdks/react-native/matrixscan-ar/get-started.md @@ -2,7 +2,7 @@ description: "Add MatrixScan AR to your React Native app: set up the view and the overlays that guide users to the right items." sidebar_position: 2 -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/matrixscan-ar/intro.md b/docs/sdks/react-native/matrixscan-ar/intro.md index cc031bfbfa..7613f8c439 100644 --- a/docs/sdks/react-native/matrixscan-ar/intro.md +++ b/docs/sdks/react-native/matrixscan-ar/intro.md @@ -3,7 +3,7 @@ description: "MatrixScan AR adds augmented-reality overlays that highlight barco sidebar_position: 1 pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/matrixscan-count/advanced.md b/docs/sdks/react-native/matrixscan-count/advanced.md index 0131a0cfa6..13e408fdc0 100644 --- a/docs/sdks/react-native/matrixscan-count/advanced.md +++ b/docs/sdks/react-native/matrixscan-count/advanced.md @@ -3,7 +3,7 @@ description: "Advanced MatrixScan Count settings for React Native to tune counti sidebar_position: 3 pagination_next: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/matrixscan-count/get-started.md b/docs/sdks/react-native/matrixscan-count/get-started.md index 1dbfaf3754..8ee80935c6 100644 --- a/docs/sdks/react-native/matrixscan-count/get-started.md +++ b/docs/sdks/react-native/matrixscan-count/get-started.md @@ -2,7 +2,7 @@ description: "Add MatrixScan Count to your React Native app: set up the mode, scan and count multiple barcodes, and handle the results." sidebar_position: 2 -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/matrixscan-count/intro.md b/docs/sdks/react-native/matrixscan-count/intro.md index 1fb9378b2e..9725735c25 100644 --- a/docs/sdks/react-native/matrixscan-count/intro.md +++ b/docs/sdks/react-native/matrixscan-count/intro.md @@ -3,7 +3,7 @@ description: "MatrixScan Count scans and counts many barcodes at once in your Re sidebar_position: 1 pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/matrixscan-find/advanced.md b/docs/sdks/react-native/matrixscan-find/advanced.md index 0a8ad0a6ff..320d1e1c11 100644 --- a/docs/sdks/react-native/matrixscan-find/advanced.md +++ b/docs/sdks/react-native/matrixscan-find/advanced.md @@ -3,7 +3,7 @@ description: "Advanced MatrixScan Find settings for React Native: customize matc sidebar_position: 3 pagination_next: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/matrixscan-find/get-started.md b/docs/sdks/react-native/matrixscan-find/get-started.md index acdc0c9fcc..60fd671c65 100644 --- a/docs/sdks/react-native/matrixscan-find/get-started.md +++ b/docs/sdks/react-native/matrixscan-find/get-started.md @@ -2,7 +2,7 @@ description: "Add MatrixScan Find to your React Native app to search for and highlight specific items with the camera." sidebar_position: 2 -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/matrixscan-find/intro.md b/docs/sdks/react-native/matrixscan-find/intro.md index a2aa33e5d0..1084a3a3fd 100644 --- a/docs/sdks/react-native/matrixscan-find/intro.md +++ b/docs/sdks/react-native/matrixscan-find/intro.md @@ -3,7 +3,7 @@ description: "MatrixScan Find helps users locate specific items in your React Na sidebar_position: 1 pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/matrixscan-pick/advanced.md b/docs/sdks/react-native/matrixscan-pick/advanced.md index 525dc4e1cf..ef00fa86d8 100644 --- a/docs/sdks/react-native/matrixscan-pick/advanced.md +++ b/docs/sdks/react-native/matrixscan-pick/advanced.md @@ -3,7 +3,7 @@ description: "Advanced MatrixScan Pick settings for React Native: customize pick sidebar_position: 3 pagination_next: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/matrixscan-pick/get-started.md b/docs/sdks/react-native/matrixscan-pick/get-started.md index f5c6a92e32..46ddd473c9 100644 --- a/docs/sdks/react-native/matrixscan-pick/get-started.md +++ b/docs/sdks/react-native/matrixscan-pick/get-started.md @@ -2,7 +2,7 @@ description: "Add MatrixScan Pick to your React Native app to guide users through order picking with AR cues." sidebar_position: 2 -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/matrixscan-pick/intro.md b/docs/sdks/react-native/matrixscan-pick/intro.md index 1d33888278..fc06cac400 100644 --- a/docs/sdks/react-native/matrixscan-pick/intro.md +++ b/docs/sdks/react-native/matrixscan-pick/intro.md @@ -3,7 +3,7 @@ description: "MatrixScan Pick guides order picking in your React Native app with sidebar_position: 1 pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/matrixscan/advanced.md b/docs/sdks/react-native/matrixscan/advanced.md index 41451a77a8..afd691c016 100644 --- a/docs/sdks/react-native/matrixscan/advanced.md +++ b/docs/sdks/react-native/matrixscan/advanced.md @@ -3,7 +3,7 @@ description: "Advanced MatrixScan settings for React Native: tune tracking, over sidebar_position: 3 pagination_next: null -framework: react +framework: react-native keywords: - react diff --git a/docs/sdks/react-native/matrixscan/get-started.md b/docs/sdks/react-native/matrixscan/get-started.md index f55dadf72e..7194069bdc 100644 --- a/docs/sdks/react-native/matrixscan/get-started.md +++ b/docs/sdks/react-native/matrixscan/get-started.md @@ -1,7 +1,7 @@ --- description: "Add MatrixScan to your React Native app to detect, track, and highlight multiple barcodes in the same frame." -framework: react +framework: react-native keywords: - react diff --git a/docs/sdks/react-native/matrixscan/intro.md b/docs/sdks/react-native/matrixscan/intro.md index ab3d0972ca..d69d1783ab 100644 --- a/docs/sdks/react-native/matrixscan/intro.md +++ b/docs/sdks/react-native/matrixscan/intro.md @@ -3,7 +3,7 @@ description: "MatrixScan detects, tracks, and highlights multiple barcodes in th sidebar_position: 1 pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/parser/get-started.md b/docs/sdks/react-native/parser/get-started.md index 1403eb8ebb..c89555f56f 100644 --- a/docs/sdks/react-native/parser/get-started.md +++ b/docs/sdks/react-native/parser/get-started.md @@ -4,7 +4,7 @@ description: "Use the Scandit parser in React Native to turn barcode data string sidebar_position: 2 pagination_prev: null pagination_next: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/release-notes.md b/docs/sdks/react-native/release-notes.md index 9fadf1b1e8..65267b93be 100644 --- a/docs/sdks/react-native/release-notes.md +++ b/docs/sdks/react-native/release-notes.md @@ -5,7 +5,7 @@ displayed_sidebar: reactnativeSidebar hide_title: true title: Release Notes pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/single-scanning.md b/docs/sdks/react-native/single-scanning.md index 7e8c7fa1bc..1f1641bbba 100644 --- a/docs/sdks/react-native/single-scanning.md +++ b/docs/sdks/react-native/single-scanning.md @@ -3,7 +3,7 @@ description: "Scan a single barcode at a time in your React Native app with the toc_max_heading_level: 4 pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/sparkscan/advanced.md b/docs/sdks/react-native/sparkscan/advanced.md index 34f8b041bd..ea52ba8e1f 100644 --- a/docs/sdks/react-native/sparkscan/advanced.md +++ b/docs/sdks/react-native/sparkscan/advanced.md @@ -3,7 +3,7 @@ description: "Advanced SparkScan settings for React Native: customize the UI, sc sidebar_position: 3 pagination_next: null -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/sparkscan/get-started.md b/docs/sdks/react-native/sparkscan/get-started.md index 41424f1752..8219eec22f 100644 --- a/docs/sdks/react-native/sparkscan/get-started.md +++ b/docs/sdks/react-native/sparkscan/get-started.md @@ -2,7 +2,7 @@ description: "Add SparkScan to your React Native app: create a data capture context, configure the mode, add the view, and handle scanned barcodes." sidebar_position: 2 -framework: react +framework: react-native keywords: - react --- diff --git a/docs/sdks/react-native/sparkscan/intro.md b/docs/sdks/react-native/sparkscan/intro.md index 051919ae81..2969fa7b86 100644 --- a/docs/sdks/react-native/sparkscan/intro.md +++ b/docs/sdks/react-native/sparkscan/intro.md @@ -3,7 +3,7 @@ description: "SparkScan is a prebuilt scanning UI for fast, ergonomic barcode sc sidebar_position: 1 pagination_prev: null -framework: react +framework: react-native keywords: - react --- diff --git a/package.json b/package.json index fbd6f63278..73eb2b6763 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "docs:gate:setup": "node scripts/setup-vale.cjs", "verify:search-tags": "node scripts/verify-search-tags.cjs", "test:search-facets": "node scripts/test-search-facets.cjs", + "verify:frameworks": "node scripts/verify-frameworks.cjs", + "test:frameworks": "node scripts/test-frameworks.cjs", + "test:docs-gate": "node scripts/test-docs-gate.cjs", "verify:api-reference-seo": "node scripts/verify-api-reference-seo.cjs", "test:api-reference-seo": "node scripts/test-api-reference-seo.cjs", "test:api-reference-seo-verdicts": "node scripts/test-api-reference-seo-verdicts.cjs", diff --git a/scripts/docs-gate/frontmatter.cjs b/scripts/docs-gate/frontmatter.cjs index 5f2d6c4af0..e126f40d08 100644 --- a/scripts/docs-gate/frontmatter.cjs +++ b/scripts/docs-gate/frontmatter.cjs @@ -41,9 +41,22 @@ function validateValue(val, sch, loc, errs) { } function validateFile(file, schema) { - const text = fs.readFileSync(file, "utf8"); + // BOM stripped, as in docs-gate's bodyOf/frontmatterEndLine and + // verify-frameworks' declaredFrameworks. Without it a BOM'd page with + // perfectly valid frontmatter was reported "missing or invalid" - fail closed, + // but the diagnostic named the wrong problem. + // \uFEFF as an ESCAPE, not the raw character. Written literally, the regex + // is three invisible bytes that any editor, formatter or copy-paste can + // normalise away - after which it silently becomes /^/ and every BOM'd page + // is back to a false "missing or invalid frontmatter". The test row that + // guards this carried a raw BOM in its input too, so the same normalisation + // would have stripped the fixture and kept the test green. + const text = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, ""); if (text.includes(":` cannot succeed for a file the PR adds, and sh() + * inherits git's stderr on purpose, so a PR adding forty pages printed forty + * `fatal: path ... exists on disk, but not in ...` lines before the gate said + * anything - which reads as a crashed gate rather than as forty new files. + */ +function shQuiet(cmd) { + return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); +} + function changedDocs() { // Ratchet against the PR's target branch, not a hardcoded main — so a PR into // release/** diffs against that release branch, not main's fork point. @@ -65,9 +77,14 @@ function changedDocs() { // expensive than on an ordinary page. _barcode-scanning.mdx is 288 lines // that nothing imports today and is kept deliberately, so it is // gate-blocking like any other partial. - return files.filter( - (f) => /\.(md|mdx)$/i.test(f) && fs.existsSync(path.join(ROOT, f)) - ); + // Returned WITH the resolved base, so main() takes the ratchet base from the + // call rather than from module state written as a side effect of it. + return { + files: files.filter( + (f) => /\.(md|mdx)$/i.test(f) && fs.existsSync(path.join(ROOT, f)) + ), + base, + }; } // Partials are kept out of the schema check (they carry no frontmatter) and @@ -92,6 +109,80 @@ function findVale() { try { execFileSync(cand, ["-v"], { stdio: "ignore" }); return cand; } catch { return null; } } +/** Everything after the frontmatter block, or the whole file if there is none. */ +function bodyOf(text) { + // git show hands back the repo blob with LF while the Windows working copy + // has CRLF; without this every file compares as changed and the skip never fires. + text = text.replace(/\r\n/g, "\n").replace(/^\uFEFF/, ""); + // BOM stripped, like frontmatterEndLine and declaredFrameworks: without it + // `startsWith("---")` is false, the whole file counts as body, and a + // frontmatter-only edit to a BOM'd page never qualified for the skip. + if (!text.startsWith("---")) return text; + const end = text.indexOf("\n---", 3); + return end === -1 ? text : text.slice(end + 4); +} + +/** + * Whether two versions of a file have the same body. + * + * The TRAILING edge is trimmed, not both. `.trim()` on both admitted any change + * that is purely leading whitespace, and indentation is not cosmetic in + * Markdown: de-indenting the first body line turns an indented code block, + * which Vale skips, into a paragraph, which it lints. Such a file was then + * classified metadata-only, capped at the frontmatter, and the new alert + * dropped - the gate printing OK on prose the change had made lintable. + * Reproduced end to end by the `body-indentation-only` row. + * + * The trailing trim stays: a missing or added final newline is not a body + * change anyone needs to review. + */ +function sameBody(before, after) { + return bodyOf(before).replace(/\s+$/, "") === bodyOf(after).replace(/\s+$/, ""); +} + +/** + * Files whose diff against the ratchet base touches frontmatter only. + * + * The ratchet checks a whole file as soon as a PR touches one line of it, which + * is right for prose someone is actually editing and wrong for a mechanical + * metadata pass: normalizing a `framework:` value across 117 pages dragged in + * 233 pre-existing Vale findings that the change neither caused nor altered. + * Nobody writes prose in frontmatter, so when the body is byte-identical to the + * base there is no prose to review, and the prose checks are skipped for that + * file. Schema and link checks still run on every changed file. + */ +function frontmatterOnly(files, base) { + const out = new Set(); + if (!base) return out; + for (const f of files) { + let before; + try { + // shQuiet: a file the PR ADDS has no base blob, and that failure is + // both expected and meaningless here - see the note on shQuiet. + // execFileSync with an argv, not a shell string: `f` is a path from git + // output, and interpolating it into a command line is a class of bug + // rather than a present one. No doc path in the repo carries a shell + // metacharacter today, and a failure here falls back to "treat as + // body-changed", which is the safe direction - so this removes the class + // rather than fixing a symptom. + before = execFileSync("git", ["show", `${base}:${f}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + continue; // new file - it is all new, check everything + } + let after; + try { + after = fs.readFileSync(path.join(ROOT, f), "utf8"); + } catch { + continue; + } + if (sameBody(before, after)) out.add(f); + } + return out; +} + function runCspell(files) { let bin = path.join(ROOT, "node_modules", "cspell", "bin.mjs"); if (!fs.existsSync(bin)) bin = path.join(ROOT, "node_modules", "cspell", "bin.cjs"); @@ -113,7 +204,83 @@ function runCspell(files) { } } -function runVale(files, bin) { +/** + * Line of the frontmatter's closing `---`, or 0 if the file has none. + * + * Used to keep a metadata-only change answerable for the prose it DID change. + * Vale lints frontmatter: a banned word in `description` is a Severity: error + * alert on line 3, and .vale.ini sets MinAlertLevel = error. Skipping such a + * file wholesale therefore turned off a blocking check for exactly the workflow + * the skip was built for - a PR that rewrites descriptions and nothing else. + * Measured before the fix: a description reading "scans identity documents + * effortlessly and obviously" gave `0 error(s)`, where the same words in the + * body fail the build. Neither word is in frontmatter.cjs's FLUFF_WORDS, and + * cspell only catches misspellings, so nothing else covered it. + */ +function frontmatterEndLine(file) { + let lines; + try { + lines = fs.readFileSync(path.join(ROOT, file), "utf8").split(/\r?\n/); + } catch { + return 0; + } + // `startsWith`, not equality, on BOTH fences - but for different reasons, + // and the distinction matters because an earlier version of this comment got + // it wrong: + // + // - Closing fence: bodyOf (`indexOf("\\n---")`), frontmatter.cjs + // (`/\\r?\\n---/`) and gray-matter all end the frontmatter at any line + // BEGINNING with `---`, so a `----` fence closes it everywhere else. + // Exact equality here returned -1 and dropped such a page out of Vale + // completely - a banned word in its description exited 0, where before + // this branch it was caught. + // - Opening fence: gray-matter and frontmatter.cjs require `---` plus + // optional trailing whitespace, NOT any `---`-prefixed line, so `----` + // at the top opens nothing for them. `startsWith` is deliberately looser + // here only because bodyOf makes the identical call - the cap and the + // metadata-only decision therefore agree on every input, which is the + // property the cap actually rests on - and because such a page is + // blocked by the schema check regardless. + if (!lines[0].replace(/^\uFEFF/, "").startsWith("---")) return 0; + for (let i = 1; i < lines.length; i += 1) { + if (lines[i].startsWith("---")) return i + 1; + } + // Opening fence, no closing one: the extent is unknown, so there is no honest + // cap. Returning 0 would charge the whole file; -1 tells the caller to leave + // the file out of Vale entirely rather than guess. + // + // Reaching this needs the BASE blob to be unterminated too. Deleting a + // closing fence moves the frontmatter text into bodyOf's output, so the file + // is body-changed and never gets here - measured: the count drops to 116 + // metadata-only files and Vale runs uncapped on it. The branch exists for a + // page that was already unterminated on the base branch. + return -1; +} + +/** + * @param frontmatterOnly Map of file -> last line Vale alerts are kept for. + * A file in it had only its frontmatter changed, so alerts BELOW the + * frontmatter belong to prose this PR did not touch and are dropped; alerts + * inside it are this PR's. + * + * What the cap preserves is bounded by what Vale itself reports. Measured on + * 3.15.1: a banned word is flagged in a single-line `description` (quoted, + * plain or single-quoted) and in `title`, and in a literal block scalar, but + * NOT inside a folded `>-` scalar spanning two lines, nor in a `keywords:` + * list item. The cap never dropped an alert Vale emitted; it just cannot + * restore one Vale never made. Single-line descriptions are what + * docs-schema.yml's maxLength keeps true today. + * + * The cap is the WHOLE frontmatter, not the frontmatter lines the diff + * touched. A mechanical `framework:` pass is therefore answerable for a + * pre-existing Vale error elsewhere in the same frontmatter. That is a + * deliberate trade: line-level attribution needs the diff hunks, and the + * alternative - skipping the file - is what disabled the check in the first + * place. Corpus-wide there is exactly one such page today + * (docs/sdks/android/unit-testing.mdx:4), and it is already blocked by a + * schema error. + */ +function runVale(files, bin, frontmatterOnly = new Map()) { const out = []; let json; try { @@ -132,8 +299,29 @@ function runVale(files, bin) { return [{ file: ".vale.ini", level: "error", check: "vale", msg: `Vale failed to run (not an alert): ${stderr}` }]; } } + out.push(...capAlerts(json, frontmatterOnly, ROOT)); + return out; +} + +/** + * Vale's alerts, with body alerts dropped on the files that changed + * frontmatter only. + * + * Pure and exported because nothing drove it: the cap could be made to drop + * everything, and every test stayed green. That is the regression 0d05551ca and + * c28f18e77 were written to fix - a frontmatter-only skip that disabled a + * blocking check, and a cap that failed open. + */ +function capAlerts(json, frontmatterOnly, root) { + const out = []; + const rootSlashes = String(root).replace(/\\/g, "/"); for (const [file, alerts] of Object.entries(json || {})) { - for (const a of alerts) { + const rel = file.replace(/\\/g, "/").replace(`${rootSlashes}/`, ""); + const ceiling = frontmatterOnly.get(rel); + for (const a of alerts || []) { + // Body alerts on a metadata-only change are pre-existing prose, which the + // ratchet is not asking this PR to fix. Frontmatter alerts are not. + if (ceiling !== undefined && a.Line > ceiling) continue; const level = a.Severity === "error" ? "error" : "warn"; out.push({ file: file.replace(/\\/g, "/"), level, check: `vale:${a.Check}`, msg: `${a.Message} (line ${a.Line})` }); } @@ -141,22 +329,132 @@ function runVale(files, bin) { return out; } +/** + * Which changed files Vale sees, and with what ceiling. + * + * Extracted and exported for the same reason as capAlerts - deleting the line + * that puts a metadata-only file into the Vale list left every test green, and + * that is exactly the "skipping the file disabled the check" bug. Not pure, + * unlike capAlerts: it reads each file through frontmatterEndLine, which is why + * its test writes real files. + */ +function partitionForVale(files, bodyChanged) { + const bodySet = new Set(bodyChanged); + const frontmatterOnly = new Map(); + const valeFiles = []; + const skippedUnreadable = []; + const uncappable = []; + for (const f of files) { + if (bodySet.has(f)) { + valeFiles.push(f); + continue; + } + const end = frontmatterEndLine(f); + // -1: the frontmatter has no readable extent, so neither charging the body + // nor capping is honest. Left out of Vale. + if (end === -1) { + skippedUnreadable.push(f); + continue; + } + valeFiles.push(f); + // 0 means NO opening fence, so the file has no frontmatter to cap at - + // docs/partials/_*.mdx are the realistic case. Such a file reaches Vale + // UNCAPPED, deliberately: the ratchet is file-scoped, so touching a file at + // all means the whole file must pass, and the frontmatter cap is the one + // narrow exception to that. A file with no frontmatter cannot qualify for + // an exception defined by its frontmatter. + // + // Skipping it instead would drop a changed file out of Vale entirely, which + // is the bug that motivated this function - see the partitionForVale test. + // Counted separately only so the log can stop claiming it was capped. + if (end) frontmatterOnly.set(f, end); + else uncappable.push(f); + } + return { valeFiles, frontmatterOnly, skippedUnreadable, uncappable }; +} + function main() { - const files = changedDocs(); + const { files, base: ratchetBase } = changedDocs(); if (files.length === 0) { console.log("docs-gate: no changed docs — nothing to check."); process.exit(0); } console.log(`docs-gate: checking ${files.length} changed doc(s)…\n`); + // VALE only looks at files whose body actually changed. cspell still sees all + // of them, because `description` and `title` ARE prose: this gate's own + // frontmatter.cjs runs anti-fluff checks on `description`, and cspell.json has + // no frontmatter exclusion. A PR that only rewrote + // `description: "Add the SDK to your Reakt Native projekt"` had its spelling + // check skipped entirely and would have shipped the typo. + const metaOnly = frontmatterOnly(files, ratchetBase); + const bodyChanged = files.filter((f) => !metaOnly.has(f)); + if (metaOnly.size) { + console.log( + `docs-gate: ${metaOnly.size} file(s) changed frontmatter only - ` + + `Vale runs on them capped at the frontmatter (their body is identical to ` + + `base, so its prose is not this change's); cspell runs on everything.`, + ); + } + const schema = loadSchema(path.join(ROOT, "docs-schema.yml")); let findings = []; for (const f of pagesOnly(files)) { findings.push(...validateFile(path.join(ROOT, f), schema).map((x) => ({ ...x, file: f }))); findings.push(...checkLinks(path.join(ROOT, f)).map((x) => ({ ...x, file: f }))); } - findings.push(...runCspell(files)); + if (files.length) findings.push(...runCspell(files)); const vale = findVale(); if (vale) { - findings.push(...runVale(files, vale)); + // Every changed file goes to Vale, not just the ones whose body changed. + // For the metadata-only ones the alerts are capped at the frontmatter, so a + // rewritten `description` is still checked while untouched body prose is + // not - skipping those files entirely disabled a blocking check. + if (files.length) { + // `capMap`, not `frontmatterOnly`: that name belongs to the module-scope + // FUNCTION called above, and binding it here shadowed it inside this + // block. Correct only by the accident of the call sitting outside the + // block - move or duplicate it inwards and the gate dies with a TDZ + // ReferenceError instead of reporting a finding. + const { valeFiles, frontmatterOnly: capMap, skippedUnreadable, uncappable } = partitionForVale( + files, + bodyChanged, + ); + if (skippedUnreadable.length) { + // Deliberately split by file class. The schema check only sees PAGES - + // pagesOnly() strips `_`-prefixed files - so for a partial nothing else + // reports the malformed frontmatter and the file leaves the gate + // entirely unchecked for prose. Saying so is the difference between an + // informational line and a misleading one. + const parts = skippedUnreadable.filter((f) => path.basename(f).startsWith("_")); + const pages = skippedUnreadable.filter((f) => !path.basename(f).startsWith("_")); + if (pages.length) { + console.log( + `docs-gate: frontmatter extent unreadable, so Vale was NOT run on ` + + `${pages.join(", ")} - the schema check reports the frontmatter ` + + `itself.\n`, + ); + } + if (parts.length) { + console.log( + `docs-gate: frontmatter extent unreadable on partial(s) ` + + `${parts.join(", ")}, so Vale was NOT run on them - and the ` + + `schema check skips partials, so NOTHING checked these files. ` + + `Fix the frontmatter fences to get them back under the gate.\n`, + ); + } + } + if (uncappable.length) { + // The headline message above says metadata-only files are "capped at + // the frontmatter". For these there is no frontmatter to cap at, so + // they are linted in full and that sentence does not describe them. + console.log( + `docs-gate: ${uncappable.join(", ")} changed but has no frontmatter, ` + + `so there is nothing to cap Vale at - it is linted in FULL, ` + + `including prose this change did not touch. That is the ` + + `file-scoped ratchet, not a bug.\n`, + ); + } + if (valeFiles.length) findings.push(...runVale(valeFiles, vale, capMap)); + } } else if (process.env.CI) { // In CI, a missing Vale must fail — otherwise the headline prose-style check // silently no-ops while the job stays green. Locally it's still advisory. @@ -191,4 +489,6 @@ function main() { process.exit(0); } -main(); +if (require.main === module) main(); + +module.exports = { frontmatterEndLine, bodyOf, sameBody, capAlerts, partitionForVale }; diff --git a/scripts/fixtures/docs-gate-fixture.cjs b/scripts/fixtures/docs-gate-fixture.cjs new file mode 100644 index 0000000000..1901e68458 --- /dev/null +++ b/scripts/fixtures/docs-gate-fixture.cjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +"use strict"; +/** + * A minimal git repository that scripts/docs-gate/index.cjs can be run against. + * + * The counterpart to verify-frameworks-fixture.cjs, and it exists for the same + * reason. capAlerts and partitionForVale are pinned as functions, but nothing + * pinned that main() calls them with the right arguments - so the frontmatter + * cap could still be switched off by a one-token edit at the call site with + * every test green. Measured: swapping partitionForVale's two arguments, or + * passing an empty bodyChanged list, each made the gate print `0 error(s)` and + * exit 0 on prose the change introduced. + * + * The gate ratchets against `origin/main`, so the tree needs real history: a + * base commit, a remote-tracking ref pointing at it, and the mutation applied + * as a working-tree change on top. `changedDocs` picks up unstaged edits, so + * nothing needs committing twice. + * + * Vale is required for the rows that assert prose alerts. The caller checks for + * it; without it the gate prints "Vale not installed" and skips those checks, + * which would make the rows pass for the wrong reason. + * + * One call site is NOT covered, and deliberately said so rather than left to be + * rediscovered: cspell. runCspell resolves its binary under ROOT, which here is + * this fixture and has no node_modules, so it short-circuits and no row can + * tell `runCspell(files)` from `runCspell(bodyChanged)`. That distinction is + * load-bearing - a PR that only rewrote `description: "Add the SDK to your + * Reakt Native projekt"` had its spelling check skipped entirely before + * 0d05551ca. Covering it needs the fixture to reach a real cspell install. + */ +const { execFileSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const VALE_INI = `StylesPath = styles +MinAlertLevel = error + +[formats] +mdx = md + +[*.{md,mdx}] +BasedOnStyles = Scandit +`; + +// Only the one rule the rows need. No Packages line, so no `vale sync` and no +// network: the real .vale.ini pulls Google, and what is under test here is the +// gate's wiring, not the style set. +const BANNED_YML = `extends: existence +message: "Avoid '%s' — state what happens precisely instead." +level: error +ignorecase: true +tokens: + - obviously + - blatantly +`; + +const SCHEMA_YML = `required: + - description +properties: + description: + type: string + maxLength: 150 +`; + +const page = (description, body) => `---\ntitle: T\ndescription: ${description}\n---\n\n${body}`; + +const git = (dir, args) => + execFileSync("git", args, { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + +const write = (dir, rel, text) => { + const file = path.join(dir, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); +}; + +/** + * Builds the repo and applies `mutate`. Returns the directory. + * + * Base state: two pages, no violations. `meta.md` carries a banned word in its + * BODY from the base commit onward - that is the pre-existing prose the cap is + * supposed to protect a metadata-only change from. + */ +function build(dir, mutate) { + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + + write(dir, ".vale.ini", VALE_INI); + write(dir, "styles/Scandit/Banned.yml", BANNED_YML); + write(dir, "docs-schema.yml", SCHEMA_YML); + write(dir, "docs/meta.md", page('"Reads a document."', "Obviously this scans.\n")); + write(dir, "docs/body.md", page('"Writes a document."', "This scans.\n")); + // indent.md carries its banned word INDENTED, which Vale skips as a code + // block. De-indenting it changes no text and makes it lintable, which is why + // the metadata-only test cannot be "the body text is equal after trimming". + write(dir, "docs/indent.md", page('"Indents a document."', " Blatantly this scans.\n")); + + git(dir, ["init", "--quiet"]); + git(dir, ["config", "user.email", "fixture@example.com"]); + git(dir, ["config", "user.name", "Fixture"]); + git(dir, ["config", "commit.gpgsign", "false"]); + git(dir, ["add", "-A"]); + git(dir, ["commit", "--quiet", "-m", "base"]); + // The ratchet resolves `origin/main`; give it one without a remote. + git(dir, ["update-ref", "refs/remotes/origin/main", git(dir, ["rev-parse", "HEAD"]).trim()]); + + switch (mutate) { + case undefined: + break; + + // A banned word in the description of a page whose BODY is byte-identical + // to base. Must be reported: this is the alert the cap exists to keep. + case "metaonly-banned-description": + write(dir, "docs/meta.md", page('"Obviously reads a document."', "Obviously this scans.\n")); + break; + + // The same page, description changed to something clean. Its body still + // carries the banned word from the base commit, and that must NOT be + // reported - it is prose this change did not touch. + case "metaonly-clean-description": + write(dir, "docs/meta.md", page('"Reads two documents."', "Obviously this scans.\n")); + break; + + // A body change, so the file is not metadata-only and Vale runs uncapped. + case "body-changed-banned-word": + write(dir, "docs/body.md", page('"Writes a document."', "This scans obviously well.\n")); + break; + + // Frontmatter-only, and the frontmatter itself is unreadable. Announced as + // skipped rather than charged or capped. + case "metaonly-unreadable-fence": + write(dir, "docs/meta.md", '---\ntitle: T\ndescription: "Reads."\n\nObviously this scans.\n'); + break; + + // A schema violation on a metadata-only page: the structural checks must + // still see it, which is what pagesOnly(files) rather than + // pagesOnly(bodyChanged) is for. + case "metaonly-schema-violation": + write(dir, "docs/meta.md", '---\ntitle: T\n---\n\nObviously this scans.\n'); + break; + + // Leading-edge whitespace only. The body's TEXT is unchanged but its + // indentation is not, and indentation decides whether Markdown calls the + // first block a code block - which Vale skips - or a paragraph, which it + // lints. So this is not a metadata-only change. + case "body-indentation-only": + write(dir, "docs/indent.md", page('"Indents a document."', "Blatantly this scans.\n")); + break; + + default: + throw new Error(`unknown mutation: ${mutate}`); + } + return dir; +} + +module.exports = { build }; diff --git a/scripts/fixtures/verify-frameworks-fixture.cjs b/scripts/fixtures/verify-frameworks-fixture.cjs new file mode 100644 index 0000000000..df32e93977 --- /dev/null +++ b/scripts/fixtures/verify-frameworks-fixture.cjs @@ -0,0 +1,272 @@ +#!/usr/bin/env node +"use strict"; +/** + * A minimal tree that scripts/verify-frameworks.cjs can be run against. + * + * It exists because every check and every report inside that script's `main()` + * was deletable with a fully green unit suite: the readers were pinned, and + * nothing pinned that anything CALLS them. Gutting the frontmatter reader left + * `0 declare a framework` and an `OK`. + * + * `build(dir)` writes a tree that passes cleanly. `mutate` names one thing to + * break, so a test can assert the message rather than the exit code alone. + * + * This covers verify-frameworks only. docs-gate has its own harness now - + * docs-gate-fixture.cjs - built after pinning capAlerts and partitionForVale as + * functions turned out not to pin that main() calls them with the right + * arguments: swapping partitionForVale's two arguments made the gate exit 0 on + * a real alert with every test green. + */ +const fs = require("fs"); +const path = require("path"); + +const write = (dir, rel, text) => { + const file = path.join(dir, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); +}; + +const SCHEMA = (slugs, pluralSlugs) => `properties: + framework: + type: string + enum: [${slugs.join(", ")}] + frameworks: + type: array + items: + type: string + enum: [${(pluralSlugs || slugs).join(", ")}] +`; + +const REGISTRY = (entries) => `export type FrameworkSlug = +${entries.map((e) => ` | "${e.slug}"`).join("\n")}; + +export type FrameworkDef = { + slug: FrameworkSlug; + display: string; + routeSegment: string | null; + agentSkills: boolean; +}; + +export const FRAMEWORKS: FrameworkDef[] = [ +${entries + .map( + (e) => + ` { slug: "${e.slug}", display: "${e.display}", routeSegment: ` + + `${e.routeSegment === null ? "null" : `"${e.routeSegment}"`}, ` + + `agentSkills: ${e.agentSkills} },`, + ) + .join("\n")} +]; +`; + +const ENUM = (displays) => `export enum FrameworksName { +${displays.map((d, i) => ` m${i} = "${d}",`).join("\n")} +} +`; + +const SEARCHBAR = (displays) => `const API_FRAMEWORK_LABELS = { +${displays.map((d, i) => ` k${i}: "${d}",`).join("\n")} +}; + +export default function SearchBar() { + return API_FRAMEWORK_LABELS; +} +`; + +const SWITCHER = (entries) => `const FRAMEWORKS = [ +${entries + .filter((e) => e.routeSegment !== null) + .map((e) => ` { label: "${e.display}", slug: "${e.routeSegment}" },`) + .join("\n")} +]; + +export function useFrameworkItems() { + return FRAMEWORKS; +} +`; + +const BASE_ENTRIES = [ + { slug: "ios", display: "iOS", routeSegment: "ios", agentSkills: true }, + { slug: "web", display: "Web", routeSegment: "web", agentSkills: false }, + { slug: "hosted", display: "Hosted", routeSegment: null, agentSkills: false }, +]; + +/** + * Writes the fixture tree. `mutate` is one of the keys below, or undefined for + * a tree that passes. + */ +function build(dir, mutate) { + fs.rmSync(dir, { recursive: true, force: true }); + const entries = BASE_ENTRIES.map((e) => ({ ...e })); + const displays = entries.map((e) => e.display); + const slugs = entries.map((e) => e.slug); + + // --- docs --------------------------------------------------------------- + write(dir, "docs/a.md", `---\nframework: ios\n---\n\nBody.\n`); + write(dir, "docs/b.md", `---\nframeworks: [web, hosted]\n---\n\nBody.\n`); + if (mutate === "docs-bogus-framework") { + write(dir, "docs/c.md", `---\nframework: bogus-slug\n---\n\nBody.\n`); + } + // The three frontmatter sentinels. Each was a silent pass once - a page + // whose frontmatter cannot be read must be REPORTED, never counted as + // declaring nothing. + if (mutate === "docs-unterminated-fence") { + write(dir, "docs/c.md", `---\nframework: ios\n\nBody with no closing fence.\n`); + } + if (mutate === "docs-empty-framework") { + write(dir, "docs/c.md", `---\nframework:\n---\n\nBody.\n`); + } + if (mutate === "docs-nonstring-framework") { + write(dir, "docs/c.md", `---\nframework: 5\n---\n\nBody.\n`); + } + // No docs/ at all, which the env-overridable ROOT makes reachable: it used to + // throw a raw ENOENT stack instead of a sentence. + if (mutate === "docs-absent") { + fs.rmSync(path.join(dir, "docs"), { recursive: true, force: true }); + } + if (mutate === "docs-empty-dir") { + fs.rmSync(path.join(dir, "docs"), { recursive: true, force: true }); + fs.mkdirSync(path.join(dir, "docs"), { recursive: true }); + } + if (mutate === "docs-none-declare") { + write(dir, "docs/a.md", `---\ntitle: A\n---\n\nBody.\n`); + write(dir, "docs/b.md", `---\ntitle: B\n---\n\nBody.\n`); + } + + // --- schema ------------------------------------------------------------- + // `schema-extra-slug` pins the COVERAGE loop (a vocabulary word the registry + // does not define); `schema-enums-differ` pins that main() surfaces + // enumSlugs' own errors, which nothing else reports. + let schemaSlugs = slugs; + let pluralSlugs = null; + if (mutate === "schema-drift") schemaSlugs = slugs.slice(0, 2); + if (mutate === "schema-extra-slug") schemaSlugs = [...slugs, "ghost"]; + if (mutate === "schema-enums-differ") pluralSlugs = slugs.slice(0, 2); + // DRIFT on its own: a registry slug the vocabulary omits, with no page using + // it - otherwise the CONTENT check fires first and the DRIFT loop could be + // deleted without anything noticing. + if (mutate === "schema-omits-registry-slug") { + schemaSlugs = slugs.filter((s) => s !== "hosted"); + write(dir, "docs/b.md", `--- +frameworks: [web] +--- + +Body. +`); + } + write(dir, "docs-schema.yml", SCHEMA(schemaSlugs, pluralSlugs)); + + // --- registry ----------------------------------------------------------- + const registryEntries = entries.map((e) => ({ ...e })); + if (mutate === "registry-agentskills-no-route") { + registryEntries[2].agentSkills = true; + } + let registry = REGISTRY(registryEntries); + // Additive, not a replacement. Replacing an entry left the FrameworkSlug + // union listing a slug the literal no longer had, so the row passed on the + // UNION check and the `other` reporting loop could be deleted with every row + // still green - it certified a check it never exercised. + if (mutate === "registry-spread-entry") { + registry = registry.replace("];", " ...EXTRA_FRAMEWORKS,\n];"); + } + // A spread INSIDE an entry, which at runtime can supply the very field the + // invariant reads. One brace deeper than the case above, and invisible until + // entryPairs reported it. + if (mutate === "registry-spread-inside-entry") { + registry = registry.replace( + ` { slug: "hosted", display: "Hosted", routeSegment: null, agentSkills: false },`, + ` { slug: "hosted", display: "Hosted", routeSegment: null, ...HOSTED_EXTRAS },`, + ); + } + if (mutate === "registry-renamed") { + registry = registry.replace("export const FRAMEWORKS", "export const FRAMEWORK_DEFS"); + } + // A union member with no entry. Repurposing the spread row moved the union + // expectation onto the `other` message and left this direction uncovered - + // and it is the only check that catches it, since `tsc` accepts an extra + // member and FRAMEWORK_BY_SLUG then resolves undefined at runtime. + if (mutate === "registry-extra-union-slug") { + registry = registry.replace(` | "hosted";`, ` | "hosted"\n | "bogus";`); + } + if (mutate === "registry-union-removed") { + registry = registry.replace(/export type FrameworkSlug =[^;]*;/, "export type FrameworkSlug = string;"); + } + if (mutate === "registry-nested-routesegment") { + registry = registry + .replace( + ` { slug: "hosted", display: "Hosted", routeSegment: null, agentSkills: false },`, + ` { slug: "hosted", display: "Hosted", routeSegment: null, agentSkills: true, meta: { routeSegment: "id-bolt" } },`, + ); + } + if (mutate === "registry-missing-union-member") { + registry = registry.replace(` | "hosted"`, ""); + } + write(dir, "src/constants/frameworks.ts", registry); + + // --- the three UI copies ------------------------------------------------- + write( + dir, + "src/components/constants/frameworksName.ts", + ENUM(mutate === "enum-bogus-display" ? [...displays.slice(0, 2), "Bogus Display"] : displays), + ); + let searchbar = SEARCHBAR( + mutate === "searchbar-bogus-display" ? [...displays.slice(0, 2), "Bogus Display"] : displays, + ); + if (mutate === "searchbar-renamed-map") { + searchbar = searchbar.replace(/API_FRAMEWORK_LABELS/g, "LABELS_BY_TOKEN"); + } + write(dir, "src/theme/SearchBar/index.js", searchbar); + let switcher = SWITCHER(entries); + if (mutate === "switcher-bogus-label") { + switcher = switcher.replace(`label: "Web"`, `label: "Bogus Display"`); + } + if (mutate === "switcher-bogus-route") { + switcher = switcher.replace(`slug: "web"`, `slug: "bogus-route"`); + } + if (mutate === "switcher-spread-entry") { + switcher = switcher.replace(` { label: "Web", slug: "web" },`, " ...MORE_ENTRIES,"); + } + if (mutate === "switcher-spread-inside-entry") { + switcher = switcher.replace( + ` { label: "Web", slug: "web" },`, + ` { label: "Web", ...OVERRIDES },`, + ); + } + write(dir, "src/utils/useFrameworkItems.js", switcher); + + // --- data --------------------------------------------------------------- + const availability = Object.fromEntries(displays.map((d) => [d, { version: "1.0" }])); + write(dir, "src/data/products.json", JSON.stringify([{ key: "p", frameworks: availability }], null, 2)); + write( + dir, + "src/data/features.json", + JSON.stringify( + [ + { + name: "f", + frameworks: + mutate === "data-bogus-display" + ? { "Bogus Display": { version: "1.0" } } + : availability, + }, + ], + null, + 2, + ), + ); + write( + dir, + "src/data/skills.json", + JSON.stringify( + { + frameworks: availability, + products: { p: mutate === "data-empty-product" ? {} : availability }, + }, + null, + 2, + ), + ); + return dir; +} + +module.exports = { build }; diff --git a/scripts/test-docs-gate.cjs b/scripts/test-docs-gate.cjs new file mode 100644 index 0000000000..6253b53d80 --- /dev/null +++ b/scripts/test-docs-gate.cjs @@ -0,0 +1,1237 @@ +#!/usr/bin/env node +"use strict"; +/** + * Table-driven tests for the readers inside the gate scripts. + * + * scripts/test-frameworks.cjs pins the registry and the path parsers. It does + * not touch the gate scripts themselves - and every defect found in six rounds + * of review on this change lived there: a regex that could not see a + * single-quoted entry, a fence test stricter than its three siblings, a `!fw` + * that read "nothing to check" as "nothing wrong", a brace pattern that could + * not match an entry containing a nested object. Each made a gate print OK + * while a check went unperformed, which is the one failure a gate cannot have. + * + * So the tables are organised around that failure rather than around the + * functions: every case states what a reader must NOT do silently. + * + * Every case drives its reader with FIXTURE text, not with the repo's real + * files. The first version of this suite read the real registry, which happens + * to contain no nested object - so it passed identically with the brace-matching + * fix reverted, and pinned nothing. Where a test does read a real file, it says + * so and asserts a property of that file rather than of the reader. + * + * Usage: node scripts/test-docs-gate.cjs (or: yarn test:docs-gate) + */ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const assert = require("assert"); +const { execFileSync } = require("child_process"); + +const ROOT = path.join(__dirname, ".."); +// docs-gate/index.cjs takes its ROOT from process.cwd(), so the suite has to +// agree with it or every path-taking reader resolves against the wrong tree. +process.chdir(ROOT); + +const verify = require("./verify-frameworks.cjs"); +const gate = require("./docs-gate/index.cjs"); +const frontmatter = require("./docs-gate/frontmatter.cjs"); + +let passed = 0; +const failures = []; + +function check(name, fn) { + try { + fn(); + passed += 1; + console.log(` ok ${name}`); + } catch (e) { + failures.push({ name, message: e.message }); + console.log(` FAIL ${name}\n ${String(e.message).split("\n")[0]}`); + } +} + +/** A scratch directory that is always removed, even when a case throws. */ +function withTempDir(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "docs-gate-test-")); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +/** + * A scratch directory INSIDE the repo, for readers that take a ROOT-relative + * path. Suffixed with the pid so two runs cannot collide, and gitignored so a + * hard kill leaves nothing that looks like a source file. + */ +function withRepoTempDir(fn) { + const rel = `scripts/.test-docs-gate-tmp-${process.pid}`; + const dir = path.join(ROOT, rel); + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); + try { + return fn(dir, rel); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +const { UNREADABLE, UNTERMINATED, EMPTY } = verify; + +/** + * declaredFrameworks: what each frontmatter shape must yield. + * + * `want` is the declared values in order. A sentinel means the page has a + * frontmatter-level problem and must be REPORTED, never read as "declares no + * framework" - that reading is what let a bogus `framework:` pass with an OK. + */ +const DECLARED = [ + // --- the ordinary shapes ------------------------------------------------- + ["plain scalar", "---\nframework: ios\n---\n\nBody\n", ["ios"]], + ["double-quoted", '---\nframework: "ios"\n---\n\nBody\n', ["ios"]], + ["single-quoted", "---\nframework: 'ios'\n---\n\nBody\n", ["ios"]], + ["plural list", "---\nframeworks:\n - ios\n - android\n---\n\nBody\n", ["ios", "android"]], + ["plural flow list", "---\nframeworks: [ios, android]\n---\n\nBody\n", ["ios", "android"]], + ["both fields", "---\nframework: ios\nframeworks: [android]\n---\n\nBody\n", ["ios", "android"]], + ["no frontmatter", "Just body text.\n", []], + ["frontmatter without the field", "---\ntitle: T\n---\n\nBody\n", []], + ["CRLF throughout", "---\r\nframework: ios\r\n---\r\n\r\nBody\r\n", ["ios"]], + + // --- shapes that must be READ, not silently skipped ---------------------- + // Each of these read as "declares nothing" at some point in this change's + // history, which is a silent pass on whatever the page actually declared. + ["BOM then fence", "\uFEFF---\nframework: ios\n---\n\nBody\n", ["ios"]], + ["trailing space on opening fence", "--- \nframework: ios\n---\n\nBody\n", ["ios"]], + ["tab on opening fence", "---\t\nframework: ios\n---\n\nBody\n", ["ios"]], + ["`----` closing fence", "---\nframework: ios\n----\n\nBody\n", ["ios"]], + ["thematic break in body", "---\nframework: ios\n---\n\nBody\n\n---\n\nMore\n", ["ios"]], + + // --- shapes that must be REPORTED --------------------------------------- + ["unterminated fence", "---\nframework: ios\n\nBody\n", [UNTERMINATED]], + ["unparseable YAML", "---\nframework: [ios\n---\n\nBody\n", [UNREADABLE]], + ["singular with no value", "---\nframework:\n---\n\nBody\n", [EMPTY]], + ["singular tilde", "---\nframework: ~\n---\n\nBody\n", [EMPTY]], + ["empty plural list", "---\nframeworks: []\n---\n\nBody\n", [EMPTY]], + ["null item in plural list", "---\nframeworks:\n - ios\n -\n---\n\nBody\n", ["ios", EMPTY]], + ["non-string value", "---\nframework: 5\n---\n\nBody\n", [UNREADABLE]], + ["nested map value", "---\nframework:\n slug: ios\n---\n\nBody\n", [UNREADABLE]], +]; + +check("declaredFrameworks reads every frontmatter shape without a silent skip", () => { + withTempDir((dir) => { + for (const [name, text, want] of DECLARED) { + const file = path.join(dir, "page.md"); + fs.writeFileSync(file, text); + const got = verify.declaredFrameworks(file).map((d) => d.value); + assert.deepStrictEqual(got, want, `${name}: got ${JSON.stringify(got)}`); + } + }); +}); + +/** + * The fence readers must agree about where the frontmatter ends. + * + * The Vale cap rests on exactly this: alerts above the cap are the change's, + * alerts below it sit in the region bodyOf compares against base. If + * frontmatterEndLine and bodyOf disagree about the boundary, the cap either + * drops a real alert or charges an untouched one. An earlier version of + * frontmatterEndLine was stricter than bodyOf on a `----` fence and dropped the + * file out of Vale entirely. + * + * `schemaSees` is what frontmatter.cjs must make of the same text: a page + * gray-matter parses must not be reported as having no frontmatter, because + * that diagnostic names the wrong problem and the page renders fine. + */ +const FENCES = [ + ["exact fences", "---\ntitle: T\n---\n\nBody\n", 3, true], + ["`----` closing fence", "---\ntitle: T\n----\n\nBody\n", 3, true], + ["`-----` closing fence", "---\ntitle: T\n-----\n\nBody\n", 3, true], + ["trailing space on closing fence", "---\ntitle: T\n--- \n\nBody\n", 3, true], + ["trailing space on opening fence", "--- \ntitle: T\n---\n\nBody\n", 3, true], + ["tab on opening fence", "---\t\ntitle: T\n---\n\nBody\n", 3, true], + ["BOM then fence", "\uFEFF---\ntitle: T\n---\n\nBody\n", 3, true], + ["CRLF", "---\r\ntitle: T\r\n---\r\n\r\nBody\r\n", 3, true], + ["block scalar containing `---`", "---\ntitle: |\n a\n---\n\nBody\n", 4, true], + ["no frontmatter", "Just body.\n", 0, false], + ["unterminated", "---\ntitle: T\n\nBody\n", -1, false], +]; + +check("frontmatterEndLine agrees with bodyOf about where the frontmatter ends", () => { + withRepoTempDir((dir, rel) => { + for (const [name, text, want] of FENCES) { + const file = path.join(dir, "page.md"); + fs.writeFileSync(file, text); + const end = gate.frontmatterEndLine(`${rel}/page.md`); + assert.strictEqual(end, want, `${name}: end=${end}`); + if (end <= 0) continue; + // The property the cap rests on: everything bodyOf returns lies at or + // below the cap line, so "above the cap" and "outside bodyOf's output" + // describe the same region. Stated as a suffix rather than as a line + // number because a `----` fence leaves bodyOf a `-` from the fence line + // itself (indexOf("\n---") cuts three characters into four) - harmless, + // since base and head carry the same remainder, but it means the body + // does not always begin on a line boundary. + const norm = text.replace(/\r\n/g, "\n").replace(/^\uFEFF/, ""); + const capLineStart = norm.split("\n").slice(0, end - 1).join("\n").length; + const body = gate.bodyOf(text); + assert.ok( + norm.slice(capLineStart).endsWith(body), + `${name}: bodyOf output is not contained at or below cap line ${end}\n` + + ` from cap: ${JSON.stringify(norm.slice(capLineStart))}\n` + + ` bodyOf: ${JSON.stringify(body)}`, + ); + } + }); +}); + +check("frontmatter.cjs reads the same fences as the cap does", () => { + // withTempDir: validateFile takes an absolute path, so this one needs no + // scratch directory inside the repo. + withTempDir((dir) => { + // Only `required` matters here; the point is whether the fence is FOUND, + // not whether the page satisfies the schema. + const schema = { required: [], properties: {} }; + for (const [name, text, , schemaSees] of FENCES) { + const file = path.join(dir, "page.md"); + fs.writeFileSync(file, text); + const errs = frontmatter.validateFile(file, schema); + const missing = errs.some((e) => /missing or invalid frontmatter/.test(e.msg)); + assert.strictEqual( + !missing, + schemaSees, + `${name}: validateFile ${missing ? "reported" : "accepted"}, expected ` + + `${schemaSees ? "accepted" : "reported"}`, + ); + } + }); +}); + +check("frontmatterEndLine returns 0 rather than throwing on an unreadable path", () => { + assert.strictEqual(gate.frontmatterEndLine("scripts/does-not-exist-xyz.md"), 0); +}); + +/** + * dataFileFrameworkNames: a per-part miss must be reported. + * + * These files are several independent maps each. Losing one of them left the + * rest supplying names, so the total count was unchanged and the gate printed + * OK - three rounds running, at three different granularities: the top-level + * key, the array item, and the individual product. + */ +const DATA = [ + [ + "array file, every item declares", + [{ key: "a", frameworks: { iOS: {} } }, { key: "b", frameworks: ["Android"] }], + { names: ["iOS", "Android"], missing: [] }, + ], + [ + "array file, ONE item's key renamed", + [{ key: "a", frameworks: { iOS: {} } }, { key: "b", platforms: ["Android"] }], + { names: ["iOS"], missing: ['entry "b"'] }, + ], + [ + "array file, ONE item's map emptied", + [{ key: "a", frameworks: { iOS: {} } }, { key: "b", frameworks: {} }], + { names: ["iOS"], missing: ['entry "b"'] }, + ], + [ + "array file, frameworks is a string", + [{ key: "a", frameworks: "iOS" }], + { names: [], missing: ['entry "a"'] }, + ], + [ + "array file, item identified by name when it has no key", + [{ name: "Feature One" }], + { names: [], missing: ['entry "Feature One"'] }, + ], + [ + "array file, item with neither key nor name", + [{ frameworks: [] }], + { names: [], missing: ['entry "#0"'] }, + ], + [ + "array file, a null item", + [null, { key: "a", frameworks: ["iOS"] }], + { names: ["iOS"], missing: ['entry "#0"'] }, + ], + [ + "object file, both maps present", + { frameworks: { iOS: {} }, products: { p: { Android: {} } } }, + { names: ["iOS", "Android"], missing: [] }, + ], + [ + "object file, ONLY `frameworks` renamed", + { platforms: { iOS: {} }, products: { p: { Android: {} } } }, + { names: ["Android"], missing: ['the "frameworks" map'] }, + ], + [ + "object file, ONLY `products` renamed", + { frameworks: { iOS: {} }, suites: { p: { Android: {} } } }, + { names: ["iOS"], missing: ['the "products" map'] }, + ], + [ + "object file, ONE product's map emptied", + { frameworks: { iOS: {} }, products: { p: { Android: {} }, q: {} } }, + { names: ["iOS", "Android"], missing: ['the "products.q" map'] }, + ], + [ + "object file, ONE product is not a map", + { frameworks: { iOS: {} }, products: { p: { Android: {} }, q: "oops" } }, + { names: ["iOS", "Android"], missing: ['the "products.q" map'] }, + ], + [ + "object file, every product emptied", + { frameworks: { iOS: {} }, products: { p: {}, q: {} } }, + { + names: ["iOS"], + missing: ['the "products.p" map', 'the "products.q" map', 'the "products" map'], + }, + ], +]; + +check("dataFileFrameworkNames reports a per-part miss the total count cannot show", () => { + withRepoTempDir((dir, rel) => { + for (const [name, value, want] of DATA) { + fs.writeFileSync(path.join(dir, "d.json"), JSON.stringify(value, null, 2)); + const got = verify.dataFileFrameworkNames(`${rel}/d.json`); + assert.ok(!got.error, `${name}: unexpected error ${got.error}`); + assert.deepStrictEqual([...got.names], want.names, `${name}: names`); + assert.deepStrictEqual(got.missing, want.missing, `${name}: missing`); + } + }); +}); + +check("dataFileFrameworkNames names the problem instead of throwing", () => { + withRepoTempDir((dir, rel) => { + fs.writeFileSync(path.join(dir, "d.json"), "{ not json"); + const bad = verify.dataFileFrameworkNames(`${rel}/d.json`); + assert.ok(bad.error && /does not parse as JSON/.test(bad.error), bad.error); + + fs.writeFileSync(path.join(dir, "d.json"), '"a string"'); + const wrong = verify.dataFileFrameworkNames(`${rel}/d.json`); + assert.ok(wrong.error && /neither a list/.test(wrong.error), wrong.error); + + const gone = verify.dataFileFrameworkNames(`${rel}/absent.json`); + assert.ok(gone.error && /missing/.test(gone.error), gone.error); + }); +}); + + +/** + * Quoting must not decide whether a value is checked, and a value a reader + * cannot read must be counted. + * + * No formatter is configured in this repo, so both quote styles occur. While + * these readers were double-quote-only, a single-quoted entry was skipped - and + * because the miss is partial, the zero-entries guard never fired. Nor was a + * value that is not a plain literal reported at all. + */ +check("enumMemberValues reads every member or reports the one it cannot", () => { + const cases = [ + ["double-quoted", `enum E {\n ios = "iOS",\n}`, ["iOS"], []], + ["single-quoted", `enum E {\n ios = 'iOS',\n}`, ["iOS"], []], + [ + "final member without a trailing comma", + `enum E {\n ios = "iOS",\n web = "Web"\n}`, + ["iOS", "Web"], + [], + ], + [ + "value containing an apostrophe", + `enum E {\n legacy = "iOS's Legacy",\n}`, + ["iOS's Legacy"], + [], + ], + [ + "empty value", + `enum E {\n ios = "iOS",\n linux = "",\n}`, + ["iOS"], + ['member "linux" has no plain string value'], + ], + [ + "value that is not a literal", + `enum E {\n ios = "iOS",\n linux = LINUX_DISPLAY,\n}`, + ["iOS"], + ['member "linux" has no plain string value'], + ], + ["absent enum", `const x = 1;`, null, null], + ]; + for (const [name, src, values, missing] of cases) { + const got = verify.enumMemberValues(src, "E"); + if (values === null) { + assert.strictEqual(got, null, `${name}: got ${JSON.stringify(got)}`); + continue; + } + assert.deepStrictEqual(got.values, values, `${name}: values`); + assert.deepStrictEqual(got.missing, missing, `${name}: missing`); + } +}); + +check("entryFieldValues reads a field off every entry or reports the entry", () => { + const cases = [ + // [name, source, field, values, missing]. The field is stated, not + // inferred from the fixture text - it used to be picked by testing the + // source for `slug: SLUG`, so a future case containing that string would + // silently switch which field the case exercises. + ["double-quoted", `const T = [{ label: "iOS" }];`, "label", ["iOS"], []], + ["single-quoted", `const T = [{ label: 'iOS' }];`, "label", ["iOS"], []], + [ + "mixed quoting across entries", + `const T = [{ label: "iOS" }, { label: 'Web' }];`, + "label", + ["iOS", "Web"], + [], + ], + [ + "value containing an apostrophe read whole", + `const T = [{ label: "iOS's Legacy" }];`, + "label", + ["iOS's Legacy"], + [], + ], + [ + "value containing a quote read whole", + `const T = [{ label: 'say "hi"' }];`, + "label", + ['say "hi"'], + [], + ], + [ + "field absent from one entry", + `const T = [{ label: "iOS" }, { slug: "web" }];`, + "label", + ["iOS"], + ["entry #1 has no plain `label` literal"], + ], + [ + "field is not a literal, entry named by its label", + `const T = [{ label: "iOS", slug: SLUG }];`, + "slug", + [], + ['entry "iOS" has no plain `slug` literal'], + ], + [ + "whitespace before the colon", + `const T = [{ label : "iOS" }];`, + "label", + ["iOS"], + [], + ], + // A quoted key is legitimate TypeScript, and objectLiteralValues already + // accepts one. Rejecting it here reported "no plain `slug` literal" about + // an entry that plainly has one. + [ + "double-quoted key", + `const T = [{ "label": "iOS" }];`, + "label", + ["iOS"], + [], + ], + [ + "single-quoted key", + `const T = [{ 'label': 'iOS' }];`, + "label", + ["iOS"], + [], + ], + // The round-7 critical: the match ran over the whole entry text, so a + // NESTED field answered for the entry's own. The switcher entry that lost + // its top-level `slug` that way builds `/undefined/add-sdk`, and the gate + // printed OK. + [ + "a nested field is not the entry's", + `const T = [{ label: "Linux", meta: { slug: "linux" } }];`, + "slug", + [], + ['entry "Linux" has no plain `slug` literal'], + ], + [ + "the entry's own field wins over a nested decoy", + `const T = [{ label: "L", slug: "linux", meta: { slug: "BOGUS" } }];`, + "slug", + ["linux"], + [], + ], + [ + "a field in a nested ARRAY is not the entry's", + `const T = [{ label: "L", alts: [{ slug: "BOGUS" }] }];`, + "slug", + [], + ['entry "L" has no plain `slug` literal'], + ], + [ + "a field declared twice is reported, not guessed", + `const T = [{ slug: "ios", extra: 1, slug: "BOGUS" }];`, + "slug", + [], + ["entry #0 declares `slug` 2 times"], + ], + [ + // Lower-case, so the case-sensitive pattern COULD match it. `mySlug` was + // camelCase, which never matched with or without the guard - the case + // passed either way and pinned nothing. + "a longer identifier cannot answer for the field", + `const T = [{ myslug: "BOGUS", label: "iOS" }];`, + "slug", + [], + ['entry "iOS" has no plain `slug` literal'], + ], + [ + "a shorter identifier cannot answer either", + `const T = [{ slugs: "BOGUS", label: "iOS" }];`, + "slug", + [], + ['entry "iOS" has no plain `slug` literal'], + ], + ["empty array", `const T = [];`, "label", null, null], + ]; + for (const [name, src, field, values, missing] of cases) { + const got = verify.entryFieldValues(src, "T", field); + if (values === null) { + assert.strictEqual(got, null, `${name}: got ${JSON.stringify(got)}`); + continue; + } + assert.deepStrictEqual(got.values, values, `${name}: values`); + assert.deepStrictEqual(got.missing, missing, `${name}: missing`); + } +}); + +check("objectLiteralValues stays inside its literal and reads both quote styles", () => { + const src = + `const LABELS = {\n ios: "iOS",\n web: 'Web',\n "react-native": "React Native",\n};\n` + + `const OTHER = { stray: "Should Not Be Read" };\n`; + const got = verify.objectLiteralValues(src, "LABELS"); + assert.deepStrictEqual(got.values, ["iOS", "Web", "React Native"], JSON.stringify(got)); + assert.deepStrictEqual(got.missing, [], JSON.stringify(got.missing)); + + const partial = verify.objectLiteralValues(`const L = {\n ios: "iOS",\n web: WEB,\n};`, "L"); + assert.deepStrictEqual(partial.values, ["iOS"]); + assert.deepStrictEqual(partial.missing, ['key "web" has no plain string value']); + + assert.strictEqual(verify.objectLiteralValues(src, "ABSENT"), null); +}); + +check("registryValues reads both quote styles and matches the quote", () => { + const src = `const FRAMEWORKS: Def[] = [\n { slug: "ios" },\n { slug: 'web' },\n];`; + assert.deepStrictEqual(verify.registryValues("slug", src), ["ios", "web"]); + assert.deepStrictEqual( + verify.registryValues("display", `const FRAMEWORKS = [{ display: "iOS's" }];`), + ["iOS's"], + ); + assert.strictEqual(verify.registryValues("slug", `const OTHER = [];`), null); +}); + +// The one deliberate real-file assertion: the entry count the agentSkills +// invariant relies on has to hold for the registry as it actually is. + +check("bodyOf strips frontmatter identically across fence shapes", () => { + assert.strictEqual(gate.bodyOf("---\ntitle: T\n---\n\nreal body\n").trim(), "real body"); + assert.strictEqual(gate.bodyOf("\uFEFF---\ntitle: T\n---\n\nreal body\n").trim(), "real body"); + assert.strictEqual(gate.bodyOf("---\r\ntitle: T\r\n---\r\n\r\nreal body\r\n").trim(), "real body"); + assert.strictEqual(gate.bodyOf("no frontmatter here\n").trim(), "no frontmatter here"); +}); + +/** + * The reporting steps, pinned by their MESSAGES. + * + * This is where round 7 found the structural gap: the readers' `missing` arrays + * were well tested and nothing tested that anyone reports them. Five separate + * decisions in main() could each be deleted with a green suite - the uiErrors + * missing loop, the data-file missing loop, the entry-count assertion, and both + * halves of the whitespace-tolerant agentSkills scan. They are pure functions + * now, so each one's output is checkable directly. + */ +check("uiCopyErrors reports what a reader could not read", () => { + const partial = { values: ["iOS"], missing: ['member "linux" has no plain string value'] }; + const errs = verify.uiCopyErrors("display name", "ui.ts", partial, ["iOS"], []); + assert.strictEqual(errs.length, 1, JSON.stringify(errs)); + assert.match(errs[0], /^ui\.ts: member "linux" has no plain string value - it is unchecked/); + + // A value outside the registry is still reported alongside. + const both = verify.uiCopyErrors( + "display name", + "ui.ts", + { values: ["Bogus"], missing: ["key \"x\" has no plain string value"] }, + ["iOS"], + [], + ); + assert.strictEqual(both.length, 2, JSON.stringify(both)); + assert.ok(both.some((e) => /is not in the registry/.test(e)), JSON.stringify(both)); + + // An allowed extra is not an error. + assert.deepStrictEqual( + verify.uiCopyErrors("label", "ui.ts", { values: ["Xamarin iOS"], missing: [] }, [], ["Xamarin iOS"]), + [], + ); + // Unreadable and empty are each their own message. + assert.match(verify.uiCopyErrors("label", "ui.ts", null, [], [])[0], /could not read its framework list/); + assert.match( + verify.uiCopyErrors("label", "ui.ts", { values: [], missing: [] }, [], [])[0], + /parsed zero framework entries/, + ); +}); + +check("dataFileErrors reports a per-part miss and counts what it checked", () => { + const names = new Set(["iOS"]); + const withMiss = verify.dataFileErrors( + "d.json", + { names, missing: ['the "products.q" map'] }, + ["iOS"], + ); + assert.strictEqual(withMiss.namesChecked, 1); + assert.strictEqual(withMiss.errors.length, 1, JSON.stringify(withMiss.errors)); + assert.match(withMiss.errors[0], /^d\.json: the "products\.q" map is absent or empty/); + + // Zero names short-circuits, and says the file is unchecked rather than clean. + const empty = verify.dataFileErrors("d.json", { names: new Set(), missing: [] }, ["iOS"]); + assert.strictEqual(empty.namesChecked, 0); + assert.match(empty.errors[0], /parsed zero framework names/); + + // A name the registry does not know. + const unknown = verify.dataFileErrors("d.json", { names: new Set(["Nope"]), missing: [] }, ["iOS"]); + assert.match(unknown.errors[0], /framework "Nope" is not a display name in the registry/); + + // A read error passes straight through. + assert.deepStrictEqual( + verify.dataFileErrors("d.json", { error: "d.json: boom" }, ["iOS"]), + { errors: ["d.json: boom"], namesChecked: 0 }, + ); + + // Clean input reports nothing. + assert.deepStrictEqual( + verify.dataFileErrors("d.json", { names, missing: [] }, ["iOS"]), + { errors: [], namesChecked: 1 }, + ); +}); + + +check("topLevelOnly blanks nested spans and topLevelPairs splits at depth 0", () => { + // Asserted as properties, not as an exact padding width: the point is that + // nothing readable survives inside the nested span and the depth-0 text is + // untouched, not how many spaces replaced it. + const blanked = verify.topLevelOnly(`a: "x", b: { c: "y" }`); + assert.strictEqual(blanked.length, `a: "x", b: { c: "y" }`.length, "length preserved"); + assert.ok(blanked.startsWith(`a: "x", b: {`), JSON.stringify(blanked)); + assert.ok(!blanked.includes(`"y"`), `nested string survived: ${JSON.stringify(blanked)}`); + assert.deepStrictEqual( + verify.topLevelPairs(`a: "x, y", b: { c: 1, d: 2 }, e: "z"`).map((s) => s.trim().split(":")[0]), + ["a", "b", "e"], + ); + assert.deepStrictEqual( + verify.topLevelPairs(`a: "x, y", b: { c: 1, d: 2 }, e: "z"`) + .filter((s) => /["']/.test(s)) + .map((s) => s.trim()), + [`a: "x, y"`, `e: "z"`], + ); + // A comma inside a string is not a split point. + assert.strictEqual(verify.topLevelPairs(`a: "x, y"`).length, 1); + // A nested array counts as nested. + assert.deepStrictEqual( + verify.topLevelPairs(`a: [1, 2], b: "z"`).map((s) => s.trim().split(":")[0]), + ["a", "b"], + ); +}); + +check("stripComments leaves code alone and reports a scan it cannot finish", () => { + const BS = String.fromCharCode(92); + const untouched = [ + ["regex ending in an escaped slash", + `const m = s.match(/${BS}/a${BS}/([^/]+)${BS}//);`], + ["regex in a replace", `const t = f.replace(/^${BS}/sdks${BS}//, "");`], + ["char class containing a slash", `const r = /[a${BS}/b]/.test(x);`], + ["division", "const r = a / b / c;"], + ["url in a string", 'const u = "https://x";'], + ["block-comment marker inside a string", 'const g = "src/**/*.ts";'], + ["JSX self-closing after a spread", "return ;"], + ]; + for (const [name, src] of untouched) { + assert.strictEqual(verify.stripComments(src), src, name); + } + assert.strictEqual(verify.stripComments("const a = 1; // note {x}").trimEnd(), "const a = 1;"); + assert.strictEqual(verify.stripComments("const a = /* {x} */ 1;").replace(/\s+/g, " "), "const a = 1;"); + // Losing track must be null, so the callers report the file unchecked rather + // than reading a mis-stripped source. + assert.strictEqual(verify.stripComments('const a = "oops;'), null); + assert.strictEqual(verify.stripComments("const r = (/abc;"), null); + assert.strictEqual(verify.arrayEntries('const T = [{ slug: "a" }]; const b = "oops;', "T"), null); +}); + + +check("objectLiteralValues does not substitute a nested string for a key's value", () => { + const cases = [ + [ + "nested object value is reported", + `const L = { a: { b: "c" }, d: "Web" };`, + ["Web"], + ['key "a" has no plain string value'], + ], + [ + "a spread is reported", + `const L = { ios: "iOS", ...EXTRA };`, + ["iOS"], + ["`...EXTRA` is not a `key: value` pair"], + ], + [ + "a comma inside a value is not a false report", + `const L = { a: "x, y", b: "Web" };`, + ["x, y", "Web"], + [], + ], + ["a quoted key still reads", `const L = { "react-native": "RN" };`, ["RN"], []], + ["a nested array value is reported", `const L = { a: ["x"], b: "Web" };`, ["Web"], + ['key "a" has no plain string value']], + ]; + for (const [name, src, values, missing] of cases) { + const got = verify.objectLiteralValues(src, "L"); + assert.deepStrictEqual(got.values, values, `${name}: values`); + assert.deepStrictEqual(got.missing, missing, `${name}: missing`); + } +}); + +check("enumMemberValues reads a single-line enum", () => { + // Split on commas, not on lines: a one-line enum matched no member, so the + // first was reported and the rest were dropped with no output. + assert.deepStrictEqual( + verify.enumMemberValues(`enum E { ios = "iOS", web = "Web" }`, "E"), + { values: ["iOS", "Web"], missing: [] }, + ); + assert.deepStrictEqual( + verify.enumMemberValues(`enum E { ios = "iOS", web = BOGUS }`, "E"), + { values: ["iOS"], missing: ['member "web" has no plain string value'] }, + ); +}); + +check("unionSlugs matches its quotes", () => { + assert.deepStrictEqual( + verify.unionSlugs(`export type FrameworkSlug = | "iOS's" | "web";`), + ["iOS's", "web"], + ); +}); + +check("enumSlugs reports a broken vocabulary instead of throwing", () => { + const got = verify.enumSlugs(); + assert.ok(got && got.slugs instanceof Set, JSON.stringify(got)); + assert.deepStrictEqual(got.errors, [], JSON.stringify(got.errors)); + assert.ok(got.slugs.size > 5, `only ${got.slugs.size} slugs`); +}); + +/** + * arrayEntries: brace matching, driven by fixtures, and every depth-0 chunk + * accounted for. + * + * The version this replaced kept only what returned to brace depth 0 and + * dropped the rest in silence. Measured: rewriting the switcher's Linux entry + * as `...LINUX_SWITCHER_ENTRIES,` left its `label` unchecked against the + * registry displays and its `slug` unchecked against `routeSegment`, and the + * gate printed OK. + */ +const ENTRIES = [ + ["flat entries", `const T = [{ slug: "a" }, { slug: "b" }];`, 2, []], + ["entry with a nested object", `const T = [{ slug: "a", meta: { x: 1 } }];`, 1, []], + ["two nested objects", `const T = [{ slug: "a", m: { x: { y: 1 } } }, { slug: "b" }];`, 2, []], + ["type annotation before the `=`", `const T: Def[] = [{ slug: "a" }];`, 1, []], + ["a brace in a line comment", `const T = [\n // note {x}\n { slug: "a" },\n];`, 1, []], + ["a brace in a block comment", `const T = [\n /* {x} */\n { slug: "a" },\n];`, 1, []], + ["a template placeholder in a comment", `const T = [\n // \${a}/\${b}\n { slug: "a" },\n];`, 1, []], + ["a `//` inside a string is not a comment", `const T = [{ slug: "https://x", n: 1 }];`, 1, []], + ["trailing comma", `const T = [{ slug: "a" },];`, 1, []], + ["empty array", `const T = [];`, 0, []], + // The round-8 critical: a chunk that is not an object literal. + ["a spread", `const T = [{ slug: "a" }, ...MORE];`, 1, ["...MORE"]], + ["an identifier reference", `const T = [{ slug: "a" }, OTHER_ENTRY];`, 1, ["OTHER_ENTRY"]], + ["a conditional spread", `const T = [{ slug: "a" }, ...(x ? y : [])];`, 1, ["...(x ? y : [])"]], + ["a `]` inside a string does not end the array", `const T = [{ slug: "a]" }, ...M];`, 1, ["...M"]], +]; + +check("arrayEntries accounts for every depth-0 chunk, not just the objects", () => { + for (const [name, src, wantEntries, wantOther] of ENTRIES) { + const got = verify.arrayEntries(src, "T"); + assert.ok(got !== null, `${name}: got null`); + assert.strictEqual(got.entries.length, wantEntries, `${name}: entries ${JSON.stringify(got.entries)}`); + assert.deepStrictEqual(got.other, wantOther, `${name}: other`); + } + assert.strictEqual(verify.arrayEntries(`const OTHER = [{ slug: "a" }];`, "T"), null); + + // The nested-object entry must come back WHOLE, or the invariant that reads + // `agentSkills:` off it still misses what it needs. + const { entries } = verify.arrayEntries( + `const T = [{ slug: "a", meta: { x: 1 }, agentSkills: true }];`, + "T", + ); + assert.ok(/agentSkills:\s*true/.test(entries[0]), entries[0]); + assert.ok(/meta: \{ x: 1 \}/.test(entries[0]), entries[0]); +}); + +check("an unreadable array chunk is reported, not skipped", () => { + // entryFieldValues sees it... + const read = verify.entryFieldValues(`const T = [{ label: "iOS" }, ...MORE];`, "T", "label"); + assert.deepStrictEqual(read.values, ["iOS"]); + assert.deepStrictEqual(read.missing, ["`...MORE` is not an entry this can read"]); + // ...and so does the registry invariant. + const errs = verify.registryInvariantErrors( + { entries: [`{ slug: "ios", routeSegment: "ios" }`], other: ["...MORE"] }, + ["ios"], + "r.ts", + ); + assert.strictEqual(errs.length, 1, JSON.stringify(errs)); + assert.match(errs[0], /`\.\.\.MORE` is not an entry this can read/); +}); + +check("declStart does not match a longer identifier", () => { + const src = `const FRAMEWORKS_ORDER = [{ slug: "zzz" }];\nconst FRAMEWORKS = [{ slug: "ios" }];`; + assert.deepStrictEqual(verify.arrayEntries(src, "FRAMEWORKS").entries, ['{ slug: "ios" }']); + assert.deepStrictEqual(verify.registryValues("slug", src), ["ios"]); + assert.strictEqual(verify.declStart(src, "ABSENT"), -1); +}); + +check("registryValues reads each entry's own top level", () => { + const src = `const FRAMEWORKS: Def[] = [\n { slug: "ios" },\n { slug: 'web' },\n];`; + assert.deepStrictEqual(verify.registryValues("slug", src), ["ios", "web"]); + assert.deepStrictEqual( + verify.registryValues("display", `const FRAMEWORKS = [{ display: "iOS's" }];`), + ["iOS's"], + ); + // A nested occurrence must NOT widen the allowed set: it used to make + // `display: "Bogus Name"` acceptable in every data file. + assert.deepStrictEqual( + verify.registryValues( + "display", + `const FRAMEWORKS = [{ display: "iOS", meta: { display: "Bogus Name" } }];`, + ), + ["iOS"], + ); + assert.strictEqual(verify.registryValues("slug", `const OTHER = [];`), null); +}); + +check("registryInvariantErrors reads each entry's own top level", () => { + const ok = { entries: [`{ slug: "ios", routeSegment: "ios", agentSkills: true }`], other: [] }; + assert.deepStrictEqual(verify.registryInvariantErrors(ok, ["ios"], "r.ts"), []); + + // The invariant, with and without a space before either colon. + for (const entry of [ + `{ slug: "hosted", routeSegment: null, agentSkills: true }`, + `{ slug: "hosted", routeSegment: null, agentSkills : true }`, + `{ slug: "hosted", routeSegment: null, agentSkills:true }`, + `{ slug: "hosted", routeSegment : null, agentSkills: true }`, + // The round-8 critical: a NESTED routeSegment must not satisfy it. + `{ slug: "hosted", routeSegment: null, agentSkills: true, meta: { routeSegment: "id-bolt" } }`, + ]) { + const errs = verify.registryInvariantErrors({ entries: [entry], other: [] }, ["hosted"], "r.ts"); + assert.strictEqual(errs.length, 1, `${entry}: ${JSON.stringify(errs)}`); + assert.match(errs[0], /"hosted" has agentSkills: true but no routeSegment/); + } + + // Quote style must not decide, on either field. + for (const entry of [ + `{ slug: 'ios', routeSegment: 'ios', agentSkills: true }`, + `{ slug: "ios", routeSegment : "ios", agentSkills: true }`, + ]) { + assert.deepStrictEqual( + verify.registryInvariantErrors({ entries: [entry], other: [] }, ["ios"], "r.ts"), + [], + entry, + ); + } + + // The count assertion: an entry with no top-level slug. + const mismatch = verify.registryInvariantErrors(ok, ["ios", "web"], "r.ts"); + assert.strictEqual(mismatch.length, 1, JSON.stringify(mismatch)); + assert.match(mismatch[0], /read 1 entries but 2 top-level `slug` value\(s\)/); + + // No entries at all, and an unreadable literal, are each their own message. + assert.match( + verify.registryInvariantErrors({ entries: [], other: [] }, [], "r.ts")[0], + /read zero FRAMEWORKS entries/, + ); + assert.match( + verify.registryInvariantErrors(null, [], "r.ts")[0], + /could not read the FRAMEWORKS entries/, + ); +}); + +check("the real registry reads one top-level slug per entry", () => { + const read = verify.registryEntries(); + const slugs = verify.registryValues("slug"); + assert.ok(read && read.entries.length, "no entries read"); + assert.deepStrictEqual(read.other, [], `unreadable chunks: ${JSON.stringify(read.other)}`); + assert.strictEqual( + read.entries.length, + slugs.length, + `${read.entries.length} entries, ${slugs.length} slugs`, + ); + assert.deepStrictEqual([...slugs].sort(), [...verify.unionSlugs()].sort(), + "registry and FrameworkSlug union disagree"); +}); + +check("a value must be the whole of its pair, not a prefix of an expression", () => { + // `"iOS" + SUFFIX` used to read as "iOS" and report nothing. + assert.deepStrictEqual( + verify.objectLiteralValues(`const L = { ios: "iOS" + SUFFIX };`, "L"), + { values: [], missing: ['key "ios" has no plain string value'] }, + ); + assert.deepStrictEqual( + verify.enumMemberValues(`enum E { ios = "iOS" + SUFFIX }`, "E"), + { values: [], missing: ['member "ios" has no plain string value'] }, + ); + assert.deepStrictEqual( + verify.entryFieldValues(`const T = [{ label: "iOS" + SUFFIX }];`, "T", "label"), + { values: [], missing: ["entry #0 has no plain `label` literal"] }, + ); + // And an escaped quote inside the value is not a truncation to read. + assert.deepStrictEqual( + verify.entryFieldValues(`const T = [{ label: "a\\"b" }];`, "T", "label"), + { values: [], missing: ["entry #0 has no plain `label` literal"] }, + ); +}); + +check("a computed key is named by its source, not by the blanked text", () => { + const got = verify.objectLiteralValues(`const L = { [KEY]: "BOGUS", ios: "iOS" };`, "L"); + assert.deepStrictEqual(got.values, ["iOS"]); + assert.strictEqual(got.missing.length, 1, JSON.stringify(got.missing)); + assert.match(got.missing[0], /^`\[KEY\]: "BOGUS"` is not a `key: value` pair$/); +}); + +check("every stripComments-null call site reports it as unreadable", () => { + const broken = `const T = [{ slug: "a" }];\nconst L = { ios: "iOS" };\nenum E { ios = "iOS" }\nconst x = "oops;`; + assert.strictEqual(verify.stripComments(broken), null, "fixture must defeat the scan"); + assert.strictEqual(verify.arrayEntries(broken, "T"), null, "arrayEntries"); + assert.strictEqual(verify.objectLiteralValues(broken, "L"), null, "objectLiteralValues"); + assert.strictEqual(verify.enumMemberValues(broken, "E"), null, "enumMemberValues"); + // And a null reader is reported, not treated as clean. + assert.match( + verify.uiCopyErrors("label", "ui.ts", null, [], [])[0], + /could not read its framework list, so it is unchecked/, + ); +}); + +check("topLevelOnly stays sane when a brace sits inside a string", () => { + // A `}` or `]` inside a string used to drive the depth negative, after which + // nothing was blanked for the rest of the input. + const flat = verify.topLevelOnly(` label: "Linux ]", meta: { slug: "linux" } `); + assert.ok(flat.includes(`"Linux ]"`), `depth-0 text changed: ${JSON.stringify(flat)}`); + assert.ok(!flat.includes(`"linux"`), `nested value survived: ${JSON.stringify(flat)}`); + assert.strictEqual(flat.length, ` label: "Linux ]", meta: { slug: "linux" } `.length); + // And the entry reader agrees. + assert.deepStrictEqual( + verify.entryFieldValues(`const T = [{ label: "Linux ]", meta: { slug: "x" } }];`, "T", "slug"), + { values: [], missing: ['entry "Linux ]" has no plain `slug` literal'] }, + ); +}); + +/** + * The whole script, run against a fixture tree. + * + * This is the check the last two rounds were missing. The readers were pinned + * and the reporting functions were pinned, but nothing pinned that main() CALLS + * them: twelve separate decisions in it - including `declaredFrameworks`, the + * check the gate is named for - could each be deleted with a fully green suite + * and the gate still printed OK. Each row below breaks one thing in the fixture + * and asserts the message, so deleting the corresponding call fails here. + */ +const FIXTURE_CASES = [ + [undefined, null], + ["docs-bogus-framework", /docs\/c\.md: framework "bogus-slug" is not in the enum/], + ["docs-none-declare", /docs scanned and not one declares a framework/], + ["schema-drift", /is not in the enum/], + ["schema-extra-slug", /docs-schema\.yml allows "ghost", the registry does not define it/], + ["schema-omits-registry-slug", /registry defines "hosted", docs-schema\.yml does not/], + ["schema-enums-differ", /`framework` and `frameworks` enums differ in docs-schema\.yml/], + ["registry-agentskills-no-route", /has agentSkills: true but no routeSegment/], + ["registry-nested-routesegment", /has agentSkills: true but no routeSegment/], + ["registry-spread-entry", /`\.\.\.EXTRA_FRAMEWORKS` is not an entry this can read/], + ["registry-spread-inside-entry", /`\.\.\.HOSTED_EXTRAS` inside an entry is not a `key: value` pair/], + ["registry-renamed", /could not read the FRAMEWORKS registry/], + ["registry-union-removed", /the FrameworkSlug union is missing or unreadable/], + ["registry-extra-union-slug", /FrameworkSlug lists "bogus", the registry has no such entry/], + ["switcher-spread-inside-entry", /`\.\.\.OVERRIDES` is not a `key: value` pair/], + ["searchbar-renamed-map", /SearchBar\/index\.js: could not read its framework list/], + ["docs-unterminated-fence", /frontmatter opens with `---` but never closes/], + ["docs-empty-framework", /framework is declared with nothing in it/], + ["docs-nonstring-framework", /framework must be a string/], + ["docs-empty-dir", /no \.md or \.mdx files found under docs\//], + ["docs-absent", /docs\/ could not be read/], + ["registry-missing-union-member", /registry defines "hosted", FrameworkSlug omits it/], + ["enum-bogus-display", /frameworksName\.ts: display name "Bogus Display" is not in the registry/], + ["searchbar-bogus-display", /SearchBar\/index\.js: display name "Bogus Display" is not in the registry/], + ["switcher-bogus-label", /useFrameworkItems\.js: label "Bogus Display" is not in the registry/], + ["switcher-bogus-route", /useFrameworkItems\.js: route "bogus-route" is not in the registry/], + ["switcher-spread-entry", /`\.\.\.MORE_ENTRIES` is not an entry this can read/], + ["data-bogus-display", /features\.json: framework "Bogus Display" is not a display name/], + ["data-empty-product", /skills\.json: the "products\.p" map is absent or empty/], +]; + +check("verify-frameworks catches each break in a fixture tree", () => { + const { build } = require("./fixtures/verify-frameworks-fixture.cjs"); + const script = path.join(ROOT, "scripts", "verify-frameworks.cjs"); + withTempDir((dir) => { + const tree = path.join(dir, "tree"); + for (const [mutation, expected] of FIXTURE_CASES) { + build(tree, mutation); + let code = 0; + let out = ""; + try { + out = execFileSync(process.execPath, [script], { + env: { + ...process.env, + VERIFY_FRAMEWORKS_ROOT: tree, + // Both, deliberately: the script ignores the root without this, so a + // stray environment value cannot point the real gate at another tree. + VERIFY_FRAMEWORKS_FIXTURE: "1", + }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (e) { + code = e.status; + out = (e.stdout || "") + (e.stderr || ""); + } + const label = mutation || "(clean)"; + if (expected === null) { + assert.strictEqual(code, 0, `${label}: expected a clean pass, got ${code}\n${out}`); + assert.match(out, /OK: every framework identifier/, label); + continue; + } + assert.notStrictEqual(code, 0, `${label}: expected a failure, got exit 0\n${out}`); + assert.match(out, expected, `${label}: message\n${out}`); + } + }); +}); + +check("a regex literal is recognised after a keyword or an arrow", () => { + // Without these, `return /["']/.test(s)` opened a phantom string on the + // quote inside the character class and the whole file came back unreadable, + // so an ordinary regex anywhere in SearchBar turned the gate red. + const Q = String.fromCharCode(34); + const untouched = [ + ["after return", `function f(s) { return /[${Q}']/.test(s); }`], + ["after an arrow", `const f = (s) => /[${Q}']/.test(s);`], + ["after typeof", `const b = typeof x === "s" ? /a/ : /b/;`], + ["after && ", `const b = x && /a/.test(y);`], + ["division after a call", "const r = f(x)/2;"], + // A PROPERTY named like a keyword is not the keyword. Without the dot + // surviving into `word`, this read as bare `in`, opened a regex on the + // division, and made the whole file unreadable - a false positive on valid + // JS, which is what the keyword test was added to remove. + ["property named like a keyword", "const half = counts.in / 2;"], + ["property named `of`", "const n = list.of / 2;"], + ["property named `return`", "const n = fn.return / 2;"], + ]; + for (const [name, src] of untouched) { + assert.strictEqual(verify.stripComments(src), src, name); + } +}); + +check("unionSlugs reads through the comment scan", () => { + assert.deepStrictEqual( + verify.unionSlugs('export type FrameworkSlug =\n | "ios"\n // | "bogus"\n | "android";'), + ["ios", "android"], + "a commented-out member is not a slug", + ); + assert.deepStrictEqual( + verify.unionSlugs('export type FrameworkSlug =\n | "ios" // see note; below\n | "android";'), + ["ios", "android"], + "a `;` inside a comment does not truncate the union", + ); +}); + +/** + * The two docs-gate decisions the Vale cap rests on. + * + * Nothing drove either of them: the cap could be made to drop every alert, or + * a metadata-only file could be dropped from the Vale list entirely, and all + * tests stayed green. Those are precisely the two regressions this branch + * already fixed once each - a frontmatter-only skip that disabled a blocking + * check, and a cap that failed open. + */ +check("capAlerts keeps frontmatter alerts and drops untouched body prose", () => { + const json = { + "C:/repo/docs/a.md": [ + { Line: 2, Severity: "error", Check: "X.Y", Message: "in the frontmatter" }, + { Line: 12, Severity: "error", Check: "X.Y", Message: "in the body" }, + ], + "C:/repo/docs/b.md": [ + { Line: 40, Severity: "error", Check: "X.Y", Message: "body of a body-changed file" }, + ], + }; + const capped = gate.capAlerts(json, new Map([["docs/a.md", 3]]), "C:/repo"); + assert.deepStrictEqual( + capped.map((f) => f.msg), + ["in the frontmatter (line 2)", "body of a body-changed file (line 40)"], + JSON.stringify(capped), + ); + + // No ceiling means no dropping at all. + assert.strictEqual(gate.capAlerts(json, new Map(), "C:/repo").length, 3); + // A ceiling of 0 is not "no ceiling": `frontmatterEndLine` returns 0 for a + // file with no frontmatter, and such a file must not be capped to nothing. + assert.strictEqual(gate.capAlerts(json, new Map([["docs/a.md", 0]]), "C:/repo").length, 1); + // Windows separators in Vale's output still match the map's forward slashes. + assert.strictEqual( + gate.capAlerts({ "C:\\repo\\docs\\a.md": json["C:/repo/docs/a.md"] }, new Map([["docs/a.md", 3]]), "C:\\repo") + .length, + 1, + ); + assert.deepStrictEqual(gate.capAlerts(null, new Map(), "C:/repo"), []); +}); + +check("partitionForVale sends every changed file to Vale, capped or not", () => { + withRepoTempDir((dir, rel) => { + const write = (name, text) => fs.writeFileSync(path.join(dir, name), text); + write("meta.md", "---\ntitle: T\n---\n\nBody\n"); + write("body.md", "---\ntitle: T\n---\n\nBody\n"); + write("plain.md", "Just body.\n"); + write("broken.md", "---\ntitle: T\n\nBody with no closing fence\n"); + const files = ["meta", "body", "plain", "broken"].map((n) => `${rel}/${n}.md`); + const got = gate.partitionForVale(files, [`${rel}/body.md`]); + + // Every file except the unreadable one reaches Vale. Dropping a + // metadata-only file here is what disabled the check before. + assert.deepStrictEqual( + got.valeFiles, + [`${rel}/meta.md`, `${rel}/body.md`, `${rel}/plain.md`], + JSON.stringify(got.valeFiles), + ); + assert.deepStrictEqual(got.skippedUnreadable, [`${rel}/broken.md`]); + // ...and the metadata-only one carries a ceiling, while the body-changed + // and no-frontmatter ones do not. + assert.deepStrictEqual([...got.frontmatterOnly.entries()], [[`${rel}/meta.md`, 3]]); + }); +}); + +check("VERIFY_FRAMEWORKS_ROOT alone is ignored", () => { + // The root is honored only alongside the fixture flag. Without that, one + // stray environment value points a blocking gate at another tree and it + // prints OK for that tree. + const { build } = require("./fixtures/verify-frameworks-fixture.cjs"); + const script = path.join(ROOT, "scripts", "verify-frameworks.cjs"); + withTempDir((dir) => { + const tree = path.join(dir, "tree"); + build(tree, "docs-bogus-framework"); + let out = ""; + try { + out = execFileSync(process.execPath, [script], { + env: { ...process.env, VERIFY_FRAMEWORKS_ROOT: tree, VERIFY_FRAMEWORKS_FIXTURE: "" }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (e) { + out = (e.stdout || "") + (e.stderr || ""); + } + // It scanned the repo, not the three-page fixture, and said nothing about + // the fixture's deliberately broken page. + assert.doesNotMatch(out, /bogus-slug/, out.slice(0, 400)); + assert.match(out, /\d{3,} docs scanned/, out.slice(0, 400)); + assert.doesNotMatch(out, /FIXTURE ROOT/, out.slice(0, 400)); + }); +}); + +/** + * The whole docs-gate script, run against a fixture repository. + * + * Pinning capAlerts and partitionForVale as functions did not pin that main() + * calls them with the right arguments. Measured on the previous HEAD: swapping + * partitionForVale's two arguments, or passing an empty bodyChanged list, each + * made the gate print `0 error(s)` and exit 0 on prose the change introduced - + * with all 35 tests green. These rows drive the wiring instead of the leaves. + * + * Vale is required. Without it the gate prints "Vale not installed" and skips + * the prose checks, which would make the rows pass for the wrong reason, so the + * check fails rather than silently degrading. + */ +const DOCS_GATE_CASES = [ + [undefined, null], + // The alert the cap exists to KEEP: a banned word in the description of a + // page whose body is byte-identical to base. + ["metaonly-banned-description", /vale:Scandit\.Banned\] Avoid 'Obviously'/], + // The alert the cap exists to DROP: the same page's pre-existing body prose, + // with only its description changed. Must pass. + ["metaonly-clean-description", null], + // A body change means no cap at all. + ["body-changed-banned-word", /vale:Scandit\.Banned\] Avoid 'obviously'/], + // The structural checks must see a metadata-only page too - that is + // pagesOnly(files) rather than pagesOnly(bodyChanged). + ["metaonly-schema-violation", /missing required field 'description'/], + ["metaonly-unreadable-fence", /missing or invalid frontmatter/], + // Leading whitespace is not cosmetic in Markdown: de-indenting the first body + // line turns an indented code block, which Vale skips, into a paragraph, + // which it lints. Comparing bodies with .trim() on both ends classified this + // metadata-only and dropped the new alert. + ["body-indentation-only", /vale:Scandit\.Banned\] Avoid 'Blatantly'/], +]; + +check("docs-gate catches each break in a fixture repository", () => { + let valePresent = true; + try { + execFileSync("vale", ["-v"], { stdio: "ignore" }); + } catch { + valePresent = false; + } + assert.ok( + valePresent, + "vale is not on PATH; these rows assert prose alerts and would pass vacuously without it", + ); + const { build } = require("./fixtures/docs-gate-fixture.cjs"); + const script = path.join(ROOT, "scripts", "docs-gate", "index.cjs"); + // Scoped, not inherited. The gate resolves its ratchet base from + // GITHUB_BASE_REF, and the fixture creates exactly one remote-tracking ref: + // refs/remotes/origin/main. On a PR into release/**, which build-docs.yml and + // docs-gate.yml both trigger on, GitHub sets GITHUB_BASE_REF=release/8.6, the + // gate looks for origin/release/8.6, finds nothing, and exits 2 - so the + // (clean) row fails on a tree that is clean. Inheriting the ambient value made + // every row depend on which branch the PR happened to target. + // + // CI is pinned rather than cleared so the gate takes the same path here as it + // does in the job: an unresolvable base exits 2 instead of silently falling + // back to a working-tree diff, which in a clean checkout is empty and reads as + // a pass. If the fixture ever stops creating origin/main, that must be loud - + // a vacuous pass is the one failure this suite cannot afford. + const gateEnv = { ...process.env, CI: "1" }; + delete gateEnv.GITHUB_BASE_REF; + withTempDir((dir) => { + const repo = path.join(dir, "repo"); + for (const [mutation, expected] of DOCS_GATE_CASES) { + build(repo, mutation); + let code = 0; + let out = ""; + try { + out = execFileSync(process.execPath, [script], { + cwd: repo, + env: gateEnv, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (e) { + code = e.status; + out = (e.stdout || "") + (e.stderr || ""); + } + const label = mutation || "(clean)"; + if (expected === null) { + assert.strictEqual(code, 0, `${label}: expected a clean pass, got ${code}\n${out}`); + continue; + } + assert.notStrictEqual(code, 0, `${label}: expected a failure, got exit 0\n${out}`); + assert.match(out, expected, `${label}: message\n${out}`); + } + }); +}); + +check("sameBody ignores a trailing newline but not leading indentation", () => { + const withFence = (body) => `---\ntitle: T\n---\n\n${body}`; + // A missing final newline is not a body change anyone needs to review. + assert.ok(gate.sameBody(withFence("Text.\n"), withFence("Text."))); + assert.ok(gate.sameBody(withFence("Text.\n\n"), withFence("Text.\n"))); + // Leading indentation is: it decides code block versus paragraph. + assert.ok(!gate.sameBody(withFence(" Text.\n"), withFence("Text.\n"))); + assert.ok(!gate.sameBody(withFence("Text.\n"), withFence(" Text.\n"))); + // And a real body edit is still a body edit. + assert.ok(!gate.sameBody(withFence("Text.\n"), withFence("Other.\n"))); +}); + +if (failures.length) { + console.error(`\n${failures.length} failed, ${passed} passed\n`); + for (const f of failures) console.error(` ${f.name}\n ${f.message}\n`); + process.exit(1); +} +console.log(`\n${passed} passed\n`); diff --git a/scripts/test-frameworks.cjs b/scripts/test-frameworks.cjs new file mode 100644 index 0000000000..b40e8b5c59 --- /dev/null +++ b/scripts/test-frameworks.cjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node +"use strict"; +/** + * Membership test for the framework maps. + * + * Five hand-written copies of the framework set were collapsed onto one + * registry (src/constants/frameworks.ts). The danger in that refactor is not a + * crash - it is a map quietly gaining or losing an entry, which changes what the + * site resolves without any error. `linux` missing from FRAMEWORK_MAPPING is + * exactly that bug, and it survived unnoticed for the life of the page. + * + * So this pins the expected membership of every derived map against what the + * hand-written maps contained before the refactor. It loads the real modules + * (transpiled through the TypeScript API, with @site/* stubbed) rather than + * re-deriving anything, so it fails if the registry changes shape. + */ +const fs = require("fs"); +const path = require("path"); +const assert = require("assert"); +const Module = require("module"); +const ts = require("typescript"); + +const ROOT = path.join(__dirname, ".."); + +const STUBS = { + "@site/src/constants/docsPaths": { + withCurrentDocsPath: (p) => p, + CURRENT_DOCS_PATH: "", + }, +}; + +const cache = new Map(); + +function loadTs(relPath) { + if (cache.has(relPath)) return cache.get(relPath); + const full = path.join(ROOT, relPath); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 }, + }).outputText; + + const exports = {}; + const localRequire = (id) => { + if (STUBS[id]) return STUBS[id]; + if (id.startsWith("@site/")) return loadTs(id.replace("@site/", "") + ".ts"); + if (id.startsWith(".")) { + const base = path.join(path.dirname(relPath), id); + return loadTs(base.split(path.sep).join("/") + ".ts"); + } + return require(id); + }; + cache.set(relPath, exports); + new Function("exports", "require", "module", "__filename", js)( + exports, localRequire, { exports }, full, + ); + return exports; +} + +const utils = loadTs("src/components/utils/frameworks.ts"); +const registry = loadTs("src/constants/frameworks.ts"); +const unreleased = loadTs("src/constants/unreleasedFrameworks.ts"); + +// Exactly what the hand-written maps held on origin/main, plus the `linux` +// entry added when the gate found it missing. +const EXPECTED_FRAMEWORK_MAPPING = { + ios: "iOS", android: "Android", web: "Web", "react-native": "React Native", + flutter: "Flutter", cordova: "Cordova", capacitor: "Capacitor", + kmp: "Kotlin Multiplatform", "net-ios": ".NET iOS", "net-android": ".NET Android", + titanium: "Titanium", linux: "Linux", +}; +const EXPECTED_QUERY_TO_PATH = { + ios: "ios", android: "android", web: "web", "react-native": "react-native", + flutter: "flutter", cordova: "cordova", capacitor: "capacitor", kmp: "kmp", + "net-ios": "net/ios", "net-android": "net/android", +}; +const EXPECTED_ALIASES = { react: "react-native", netios: "net-ios", netandroid: "net-android" }; + +let passed = 0; +const check = (label, fn) => { fn(); passed += 1; console.log(` ok ${label}`); }; +const sorted = (o) => Object.fromEntries(Object.entries(o).sort()); + +console.log("\nframework registry\n"); + +check("FRAMEWORK_MAPPING membership unchanged by the refactor", () => { + assert.deepStrictEqual(sorted(utils.FRAMEWORK_MAPPING), sorted(EXPECTED_FRAMEWORK_MAPPING)); +}); + +check("QUERY_FRAMEWORK_TO_PATH membership unchanged", () => { + assert.deepStrictEqual(sorted(utils.QUERY_FRAMEWORK_TO_PATH), sorted(EXPECTED_QUERY_TO_PATH)); +}); + +check("homepage aliases still resolve", () => { + for (const [raw, slug] of Object.entries(EXPECTED_ALIASES)) { + assert.strictEqual(utils.normalizeFrameworkQuery(raw), slug, `${raw} -> ${slug}`); + } +}); + +check("hosted is not an /sdks/ route", () => { + assert.ok(!("hosted" in utils.FRAMEWORK_MAPPING)); + assert.ok(!("hosted" in utils.QUERY_FRAMEWORK_TO_PATH)); +}); + +check("frameworkFromPath resolves the two-segment .NET routes", () => { + const { frameworkFromPath } = registry; + assert.strictEqual(frameworkFromPath("/sdks/net/ios/add-sdk").slug, "net-ios"); + assert.strictEqual(frameworkFromPath("/sdks/net/android/add-sdk").slug, "net-android"); + // The bug: a single-segment match yielded `net`, which is not a framework. + assert.strictEqual(frameworkFromPath("/sdks/net/ios/add-sdk").display, ".NET iOS"); +}); + +check("frameworkFromPath keeps single-segment routes working", () => { + const { frameworkFromPath } = registry; + assert.strictEqual(frameworkFromPath("/sdks/ios/barcode-capture/get-started").slug, "ios"); + assert.strictEqual(frameworkFromPath("/sdks/react-native/add-sdk").slug, "react-native"); + assert.strictEqual(frameworkFromPath("/sdks/linux/overview").slug, "linux"); +}); + +check("frameworkFromPath tolerates a docs-version prefix", () => { + const { frameworkFromPath } = registry; + assert.strictEqual(frameworkFromPath("/next/sdks/net/ios/add-sdk").slug, "net-ios"); + assert.strictEqual(frameworkFromPath("/7.6.14/sdks/ios/add-sdk").slug, "ios"); +}); + +check("frameworkFromPath resolves nothing outside /sdks/", () => { + const { frameworkFromPath } = registry; + assert.strictEqual(frameworkFromPath("/hosted/id-bolt/overview"), undefined); + assert.strictEqual(frameworkFromPath("/"), undefined); + // `net` alone is a path prefix, not a framework. + assert.strictEqual(frameworkFromPath("/sdks/net/"), undefined); +}); + +// What parseSdksRoute answers, pinned branch by branch: two-segment .NET +// routes, version prefixes, URL_PRODUCT_MAPPING rewrites, the mandatory product +// segment, an unregistered framework keeping its product, and `framework` / +// `lastSegment` being ABSENT rather than undefined when they do not resolve. +// +// Two honest caveats about what this table is and is not. +// +// It is written against the CURRENT shape, not captured from the pre-registry +// regex: that regex always returned all three keys, so a row copied from it +// would fail deepStrictEqual on key presence alone. The rows therefore prove +// the two parsers agree TODAY; they cannot prove the shape never changed. +// +// And `/foo/sdks/ios/x/y` is now ACCEPTED, not rejected. Anchoring was dropped +// deliberately - the site base url is a prefix, so PR previews serve from +// /data-capture-documentation/pr-preview/pr-N/ and useLocation().pathname +// includes it. The rows below assert the acceptance. +const PARSE_SDKS_ROUTE_BASELINE = [ + ["/sdks/ios/barcode-capture/get-started", { framework: "iOS", product: "barcode-capture", lastSegment: "get-started" }], + ["/sdks/net/ios/sparkscan/intro", { framework: ".NET iOS", product: "sparkscan", lastSegment: "intro" }], + ["/sdks/net/android/matrixscan/intro", { framework: ".NET Android", product: "matrixscan-batch", lastSegment: "intro" }], + ["/sdks/linux/barcode-capture/intro", { framework: "Linux", product: "barcode-capture", lastSegment: "intro" }], + ["/next/sdks/ios/label-capture/intro", { framework: "iOS", product: "smart-label-capture", lastSegment: "intro" }], + ["/7.6.14/sdks/net/ios/add-sdk", { framework: ".NET iOS", product: "add-sdk" }], + ["/sdks/ios/matrixscan/intro", { framework: "iOS", product: "matrixscan-batch", lastSegment: "intro" }], + ["/sdks/web/label-capture/label-definitions", { framework: "Web", product: "smart-label-capture", lastSegment: "label-definitions" }], + ["/sdks/titanium/core-concepts/x", { framework: "Titanium", product: "core-concepts", lastSegment: "x" }], + ["/sdks/ios/", {}], + ["/sdks/ios", {}], + ["/sdks/net/", {}], + ["/hosted/id-bolt/overview", {}], + ["/", {}], + // A prefix before /sdks/ is ACCEPTED, because the site base url is one: + // PR previews serve from /data-capture-documentation/pr-preview/pr-N/ and + // useLocation().pathname includes it. This case previously asserted {}, + // pinning in a divergence from frameworkFromPath that made every preview + // page fall through to the shared Agent Skills callout, defaulted to iOS. + ["/foo/sdks/ios/x/y", { framework: "iOS", product: "x", lastSegment: "y" }], + [ + "/data-capture-documentation/pr-preview/pr-430/sdks/ios/x/y", + { framework: "iOS", product: "x", lastSegment: "y" }, + ], + [ + "/data-capture-documentation/pr-preview/pr-430/next/sdks/net/ios/x/y", + { framework: ".NET iOS", product: "x", lastSegment: "y" }, + ], + // An UNREGISTERED framework segment keeps its product. Nothing in the repo + // routes /sdks/newsdk/ today - that is the point: this is the shape a new SDK + // tree has for as long as it exists before its registry entry lands. Dropping + // the product here flips DocItem's isKnownProductPage to false and serves the + // shared Agent Skills callout, which defaults to iOS, on every page of it. + ["/sdks/newsdk/barcode-capture/intro", { product: "barcode-capture", lastSegment: "intro" }], + ["/sdks/newsdk/barcode-capture", { product: "barcode-capture" }], + // ...and the product segment stays mandatory for unregistered frameworks too. + ["/sdks/newsdk/", {}], +]; + +check("parseSdksRoute is unchanged by sharing the registry matcher", () => { + for (const [input, expected] of PARSE_SDKS_ROUTE_BASELINE) { + assert.deepStrictEqual(utils.parseSdksRoute(input), expected, input); + } +}); + +check("both path parsers agree on every routed framework", () => { + const { frameworkFromPath } = registry; + for (const f of registry.ROUTED_FRAMEWORKS) { + const p = `/sdks/${f.routeSegment}/some-product/some-page`; + assert.strictEqual(frameworkFromPath(p).slug, f.slug, `frameworkFromPath ${p}`); + assert.strictEqual(utils.parseSdksRoute(p).framework, f.display, `parseSdksRoute ${p}`); + } +}); + +check("linux resolves a framework (the bug the gate found)", () => { + assert.strictEqual(utils.parseSdksRoute("/sdks/linux/barcode-capture/intro").framework, "Linux"); +}); + +check(".NET keeps its two-segment route", () => { + assert.strictEqual(utils.parseSdksRoute("/sdks/net/ios/sparkscan/intro").framework, ".NET iOS"); + assert.strictEqual(utils.QUERY_FRAMEWORK_TO_PATH["net-ios"], "net/ios"); +}); + +check("skill-less frameworks are titanium and linux, on every docs version", () => { + const { frameworkFromPath } = registry; + const skillLess = registry.FRAMEWORKS.filter( + (f) => !f.agentSkills && f.routeSegment !== null, + ).map((f) => f.slug); + // The literal list DocItem carried before it resolved through the registry. + assert.deepStrictEqual(skillLess.slice().sort(), ["linux", "titanium"]); + + // The banner must stay hidden on versioned paths too. A startsWith() over + // '/sdks/titanium/' returned false for these, so the callout appeared on + // frameworks that have no Agent Skills page. + for (const p of [ + "/sdks/titanium/core-concepts", + "/next/sdks/titanium/core-concepts", + "/7.6.14/sdks/titanium/core-concepts", + "/sdks/linux/overview", + "/next/sdks/linux/overview", + ]) { + assert.strictEqual(frameworkFromPath(p).agentSkills, false, p); + } + // And still shown where Agent Skills do exist. + for (const p of ["/sdks/ios/add-sdk", "/next/sdks/net/ios/add-sdk"]) { + assert.strictEqual(frameworkFromPath(p).agentSkills, true, p); + } +}); + +check("frameworks without Agent Skills are still hidden", () => { + for (const raw of ["xamarin", "xamarinios", "xamarinandroid", "xamarinforms", "titanium", "linux", "net"]) { + assert.strictEqual(utils.frameworkHasAgentSkills(raw), false, raw); + } + for (const raw of ["ios", "netios", "netandroid", "kmp"]) { + assert.strictEqual(utils.frameworkHasAgentSkills(raw), true, raw); + } +}); + +check("UNRELEASED_FRAMEWORK_SLUGS still derives to kmp", () => { + assert.deepStrictEqual(unreleased.UNRELEASED_FRAMEWORK_SLUGS, ["kmp"]); +}); + +check("every registry slug is unique", () => { + const slugs = registry.FRAMEWORK_SLUGS; + assert.strictEqual(new Set(slugs).size, slugs.length); +}); + +console.log(`\n${passed} passed\n`); diff --git a/scripts/verify-frameworks.cjs b/scripts/verify-frameworks.cjs new file mode 100644 index 0000000000..77281204cc --- /dev/null +++ b/scripts/verify-frameworks.cjs @@ -0,0 +1,1393 @@ +#!/usr/bin/env node +"use strict"; +/** + * Framework identifier gate. + * + * docs-schema.yml is the single source of truth for framework slugs. The docs + * gate validates frontmatter against it, but only for files a PR changed - which + * is why 37 pages carrying `kmp` sat outside the enum for months without anyone + * seeing a single error. A ratchet cannot report what nobody touched. + * + * SCOPE: docs/ only, NOT versioned_docs/. That is a deliberate exemption, stated + * here because the header used to claim "the whole corpus" and did not mean it. + * The frozen snapshots hold ~693 `framework:` declarations, ~470 of them written + * in the pre-rename vocabulary this enum replaced - `react` x60, `netIos` x59, + * `netAndroid` x59, `xamarinIos`, `xamarinAndroid`, `xamarinForms`, `express` + * x13. They are frozen releases; rewriting their frontmatter to satisfy a gate + * would edit published history, and the field is metadata that no component + * reads. So they are out of scope, and docs-gate's ratchet is likewise scoped to + * `-- docs`. If a snapshot ever needs checking, this needs an explicit + * legacy-slug allowlist first. + * + * This runs over docs/, and over the code maps as well, because the + * field and the maps that consume it drift independently: + * + * 1. CONTENT no page under docs/ may set `framework` / `frameworks` + * outside the enum. + * 2. DRIFT no code map may key off a framework the enum does not define. + * This is the error that lets two maps disagree silently. + * 3. COVERAGE enum slugs absent from a code map are reported, so a gap is a + * known gap rather than a surprise at runtime. + * 3b. UNION the FrameworkSlug union in the registry must list exactly the + * same slugs as the registry entries. The union is a hand-written + * second copy (deriving it with `as const` breaks the optional + * fields), so it is guarded rather than trusted. + * 5. UI COPIES three more places still spell the vocabulary out by hand - + * the FrameworksName enum, SearchBar's display map, and + * useFrameworkItems' switcher list. They are NOT moved into the + * registry: FrameworksName carries umbrella members (`net`, + * `xamarin`) that are card groupings rather than frameworks, + * SearchBar's tokens are dictated by the API reference's own + * naming (`dotnet.ios`), and useFrameworkItems must keep Xamarin + * for the 6.28 / 7.6 versioned docs that still exist. Moving + * them would change behaviour; checking them cannot. So they are + * guarded here the same way the schema enum is. + * 4. DATA products.json and features.json state per-framework + * availability keyed by DISPLAY name, not by slug - a second + * vocabulary the enum cannot see. Every name they use must be a + * registry display. Found `.Net iOS` / `.Net Android` against the + * registry's `.NET iOS` / `.NET Android` on its first run. + * + * Usage: node scripts/verify-frameworks.cjs + */ + +const fs = require("fs"); +const path = require("path"); +const yaml = require("js-yaml"); + +// Overridable so scripts/test-docs-gate.cjs can run this whole script against +// a fixture tree - which is what makes the decisions inside main() testable at +// all, rather than only the readers they call. +// +// It covers the decisions the fixture rows exercise, not every line of main() +// by construction. Each round that found a deletable decision here found it by +// mutation testing, so the honest statement is: if you add a check, add a row - +// nothing structural stops the next one going unpinned. +// +// Both variables are required, and the resolved root is echoed whenever it is +// not the repo. This is a blocking gate: one stray environment value would +// otherwise point it at another tree and print OK for that tree, with only the +// `N docs scanned` line to give it away. +const FIXTURE_ROOT = + process.env.VERIFY_FRAMEWORKS_FIXTURE === "1" && process.env.VERIFY_FRAMEWORKS_ROOT + ? path.resolve(process.env.VERIFY_FRAMEWORKS_ROOT) + : null; +const ROOT = FIXTURE_ROOT || path.join(__dirname, ".."); +const DOCS = path.join(ROOT, "docs"); +/** A `frameworks:` declaration written in a shape this gate cannot read. */ +const UNREADABLE = "\0unreadable"; +/** Declared, but with nothing in it - a schema violation, not a value. */ +const EMPTY = "\0empty"; +/** Opening `---` with no closing one: nothing was handed to the parser. */ +const UNTERMINATED = "\0unterminated"; + +// The registry is now the only hand-written list of framework slugs in the +// code; every map derives from it (src/constants/frameworks.ts). So this gate +// checks the registry against the schema enum rather than each map: if those +// two agree, every derived map agrees by construction. +const REGISTRY_FILE = "src/constants/frameworks.ts"; + +// Per-framework availability data. Keyed by display name because that is what +// the tables render, so these files cannot be checked against the slug enum - +// they are checked against the registry's `display` values instead. +const DATA_FILES = [ + "src/data/products.json", + "src/data/features.json", + "src/data/skills.json", +]; + +// Hand-written copies of the vocabulary that stay where they are, checked from +// here. See check 5 in the header for why each one cannot simply be derived. +const ENUM_FILE = "src/components/constants/frameworksName.ts"; +const SEARCHBAR_FILE = "src/theme/SearchBar/index.js"; +const SWITCHER_FILE = "src/utils/useFrameworkItems.js"; + +// Members of FrameworksName that are deliberately not frameworks: they label +// grouping cards on the homepage. Anything else in that enum must be a registry +// display name. +// Values these deliberately-not-a-framework members carry: the two umbrella +// cards, plus the three Xamarin entries that only exist in versioned_docs. +const ENUM_ALLOWED_EXTRA_DISPLAYS = [ + ".NET", + "Xamarin", + "Xamarin iOS", + "Xamarin Android", + "Xamarin Forms", +]; + +// Xamarin is documented only in versioned_docs (6.28.11 / 7.6.14) and is absent +// from the registry on purpose. The switcher still has to offer it there, so its +// route forms are exempt rather than errors. +const LEGACY_ROUTE_SEGMENTS = ["xamarin/ios", "xamarin/android", "xamarin/forms"]; + +/** + * The framework vocabulary from docs-schema.yml, plus anything wrong with it. + * + * Returns rather than throws, for the reason dataFileFrameworkNames was + * changed: a raw Node stack trace fails closed but tells the author to read a + * parser instead of a sentence, and every other failure in this file is a + * sentence. + */ +function enumSlugs() { + const errors = []; + let schema; + try { + schema = yaml.load(fs.readFileSync(path.join(ROOT, "docs-schema.yml"), "utf8")); + } catch (e) { + return { + slugs: new Set(), + errors: [ + `docs-schema.yml could not be read or parsed (${e.message.split("\n")[0]}) - ` + + `the framework vocabulary is unknown, so nothing below is checked`, + ], + }; + } + const props = (schema && schema.properties) || {}; + const singular = props.framework && props.framework.enum; + const plural = props.frameworks && props.frameworks.items && props.frameworks.items.enum; + if (!singular) errors.push("docs-schema.yml defines no `framework` enum"); + if (!plural) errors.push("docs-schema.yml defines no `frameworks` enum"); + if (singular && plural) { + const a = JSON.stringify([...singular].sort()); + const b = JSON.stringify([...plural].sort()); + if (a !== b) { + errors.push( + "`framework` and `frameworks` enums differ in docs-schema.yml. " + + "One page states one platform, another states several - the vocabulary " + + "must be the same set.", + ); + } + } + // Zero slugs is a shape change, not an empty vocabulary: `enum: []` is + // truthy, so it passed the two guards above and every downstream check then + // had nothing to compare against. Every other reader here has this guard. + if (singular && !singular.length) { + errors.push( + "docs-schema.yml `framework` enum parsed to zero slugs - the vocabulary " + + "is empty, so nothing below is actually checked", + ); + } + return { slugs: new Set(singular || []), errors }; +} + +function walk(dir, out = []) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full, out); + // `_`-prefixed files skipped, as in scripts/docs-gate/index.cjs: partials + // are imported into pages rather than being routes, so they declare no + // framework - and one that opens with a `---` thematic break was reported + // here as "frontmatter does not parse as YAML", pointing the author at the + // wrong gate for a file with no frontmatter at all. + else if (/\.mdx?$/i.test(e.name) && !e.name.startsWith("_")) out.push(full); + } + return out; +} + +/** + * `framework` / `frameworks` values declared in a page's frontmatter. + * + * Parsed with js-yaml, which this file already imports. It was hand-parsed with + * regexes on the argument that the gate walks 676 files on every CI run - and + * that argument was simply wrong: parsing all 676 with js-yaml takes 60 ms. + * + * What the hand-parse cost instead was three rounds of holes, each one a legal + * YAML shape that the gate either failed on or, worse, passed in silence: + * + * - `framework: "ios"` failed with `framework ""ios"" is not in the enum`, + * because the value regex captured the quotes. + * - `framework: ios # note` was SKIPPED, not flagged. + * - `framework: "ios" # note` - quotes AND a comment - failed the build, since + * comment-stripping was skipped for quoted values and the unquote regex then + * needed the value to end with its quote. + * - a one-item `frameworks:` list was skipped, and a two-item list had its + * LAST item skipped. + * - an inline `frameworks: [ios, bogus]` matched neither pattern. + * - a block list failed on CRLF - which is EVERY file in a Windows checkout, + * and this repo's docs are all CRLF here - and on a blank line or a comment + * line between the key and the items. The error told the author to use a + * block list, which is exactly what they had written. + * - `framework:` with the value on the NEXT line passed unchecked, and the + * unreadable-shape sentinel covered only the plural key, so the singular + * field this gate is named for could be defeated by pressing Enter. + * + * None of those shapes needs handling now: the parser handles them because it + * is the parser. A value that is not a string still yields UNREADABLE, so a + * `framework: [ios]` or a number is reported rather than coerced. + */ +function declaredFrameworks(file) { + // BOM stripped first. With it, `startsWith("---")` was false and this + // function returned [] - a page opening with U+FEFF could declare any + // framework and the gate printed OK. gray-matter strips it, so Docusaurus + // renders such a page normally and nothing else would have noticed. + const text = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, ""); + // Anchored like scripts/docs-gate/frontmatter.cjs: `---` must be a line of + // its own, not merely the first three characters - but trailing whitespace on + // that line is allowed, because gray-matter accepts `--- ` and `---\t` and + // therefore Docusaurus renders such a page normally. Without the `[ \t]*` this + // check skipped it in silence while its `framework:` stayed live. + if (!/^---[ \t]*\r?\n/.test(text)) return []; + const end = text.indexOf("\n---", 3); + // An opening fence with no closing one is not "no framework field" - it is a + // page whose frontmatter cannot be read, which is exactly what the sentinel + // below is for. Returning [] here let `framework: unity` pass with an OK. + if (end === -1) return [{ field: "frontmatter", value: UNTERMINATED }]; + + let fm; + try { + fm = yaml.load(text.slice(3, end)); + } catch { + // Frontmatter that does not parse is the docs gate's business, not this + // one's - but it must not read as "declares no framework", which is how a + // broken page would slip past the check silently. + return [{ field: "frontmatter", value: UNREADABLE }]; + } + if (!fm || typeof fm !== "object" || Array.isArray(fm)) return []; + + const found = []; + const take = (field, value) => { + if (typeof value === "string") { + const v = value.trim(); + if (v) found.push({ field, value: v }); + else found.push({ field, value: EMPTY }); + return; + } + // Present with no value - `framework:`, `framework: ~`, or a `-` with + // nothing after it. docs-schema.yml forbids it (`type: string`), but + // frontmatter.cjs only checks files a PR touched, and closing that gap for + // the whole corpus is what this gate is for. + if (value === null || value === undefined) { + found.push({ field, value: EMPTY }); + return; + } + // A number, a boolean, a nested map, a list where a string belongs: legal + // YAML, not a framework identifier, and not something to guess at. + found.push({ field, value: UNREADABLE }); + }; + + if ("framework" in fm) take("framework", fm.framework); + if ("frameworks" in fm) { + const list = fm.frameworks; + if (Array.isArray(list)) { + // `minItems: 1` in the schema, unchecked corpus-wide until now. + if (!list.length) found.push({ field: "frameworks", value: EMPTY }); + for (const item of list) take("frameworks", item); + } else take("frameworks", list); + } + return found; +} + +/** + * Values of `:` inside the FRAMEWORKS registry literal, or null if its + * shape changed. Used for both `slug` and `display`. + */ +function readSource(rel) { + try { + return fs.readFileSync(path.join(ROOT, rel), "utf8"); + } catch { + return null; + } +} +/** + * Values of `:` on the top level of each FRAMEWORKS entry, or null if + * the literal's shape changed. + * + * Per entry and at depth 0, not every occurrence in the literal. Counting + * nested ones widened the allowed set with nothing to notice it: a nested + * `display: "Bogus Name"` made that name acceptable in products.json, + * features.json and skills.json. + */ +function registryValues(field, source) { + const read = arrayEntries( + source === undefined ? readSource(REGISTRY_FILE) : source, + "FRAMEWORKS", + ); + if (read === null) return null; + const values = []; + for (const entry of read.entries) values.push(...entryField(entry, field)); + return values; +} + +/** The FRAMEWORKS registry entries. See arrayEntries for the how and why. */ +function registryEntries(source) { + const src = source === undefined ? readSource(REGISTRY_FILE) : source; + return arrayEntries(src, "FRAMEWORKS"); +} + +/** + * Slugs listed in the `FrameworkSlug` union, or null if it is absent. Read from + * source text for the same reason the registry is: this gate must not import + * TypeScript. + */ +function unionSlugs(source) { + const raw = source === undefined ? readSource(REGISTRY_FILE) : source; + if (raw === null || raw === undefined) return null; + // Through stripComments, like every sibling reader. Without it a + // commented-out member was read as live (`// | "bogus"` became a slug) and a + // `;` inside a trailing comment truncated the union early. + const src = stripComments(raw); + if (src === null) return null; + const start = src.indexOf("export type FrameworkSlug"); + if (start === -1) return null; + const end = src.indexOf(";", start); + if (end === -1) return null; + // Quote-matched, like every sibling reader here. The character class at + // each end truncated a value at an apostrophe and returned the truncation + // plus a fragment of separator - harmless for URL segments, but this file + // states the rule and this was the one reader still breaking it. + const values = []; + const rx = new RegExp(QUOTED, "g"); + let m; + while ((m = rx.exec(src.slice(start, end)))) values.push(m.groups.v); + return values; +} + +/** Framework display names each data file keys its availability map by. */ +function dataFileFrameworkNames(rel) { + const full = path.join(ROOT, rel); + if (!fs.existsSync(full)) + return { error: `${rel}: missing - per-framework data is unchecked` }; + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(full, "utf8")); + } catch (e) { + // Named, like every other failure here. Unguarded, this threw a Node stack + // trace, which fails closed but tells the author to read a parser. + return { + error: + `${rel}: does not parse as JSON (${e.message.split("\n")[0]}) - ` + + `per-framework data is unchecked`, + }; + } + const names = new Set(); + // skills.json is an OBJECT, not a list, and keys both `frameworks.` + // and `products..` by display name - the same vocabulary in + // which this check already found `.Net iOS`. It was not covered at all: a typo + // there leaves productSkills?.[resolvedFramework] undefined in SkillsCallout, + // so the product callout returns null and simply vanishes for that framework. + if (!Array.isArray(parsed)) { + if (!parsed || typeof parsed !== "object") + return { + error: + `${rel}: neither a list of items nor an object with ` + + `frameworks/products - per-framework data is unchecked`, + }; + // Per key, not unioned into one Set. Unioned, renaming `frameworks` to + // `platforms` still produced names through products.* - 31 of them, the + // same count as before - so the zero-names guard saw nothing wrong while + // the frameworks map went unchecked. + const perKey = { frameworks: new Set(), products: new Set() }; + for (const n of Object.keys(parsed.frameworks || {})) perKey.frameworks.add(n); + const missing = []; + // Per PRODUCT, not per `products`. The top-level key was made granular in + // one round and the array branch in the next, but this layer still folded + // all 8 products into one Set - so emptying `products["matrixscan-pick"]` + // left the other seven supplying names, the total still reading 31, and the + // gate printing OK. Which is precisely the failure named above: an empty + // product map makes the callout vanish for every framework of that product. + // + // Content may vary - matrixscan-pick lists 2 frameworks today and that is + // legitimate - so only an empty or non-object map is reported. + for (const [key, product] of Object.entries(parsed.products || {})) { + if (!product || typeof product !== "object" || !Object.keys(product).length) { + missing.push(`the "products.${key}" map`); + continue; + } + for (const n of Object.keys(product)) perKey.products.add(n); + } + for (const [key, set] of Object.entries(perKey)) { + if (!set.size) missing.push(`the "${key}" map`); + for (const n of set) names.add(n); + } + return { names, missing }; + } + // Per ITEM, for the same reason the object branch above is per key. `if + // (!fw) continue` treated a row that declares no frameworks as nothing to + // check, so renaming ONE of features.json's 16 `frameworks` keys - or + // emptying one products.json item's map - left the other rows supplying the + // union, the total still reading 31, and the gate printing OK. Every item in + // both files carries the key today, so an absent one is a shape change, not + // an exemption; if a row ever legitimately omits it, that belongs here as a + // decision rather than as silence. + const missing = []; + parsed.forEach((item, i) => { + const label = `entry "${(item && (item.key || item.name)) || `#${i}`}"`; + const fw = item && item.frameworks; + // products.json maps name -> {version, apiUrl}; features.json has both + // shapes across its history, so accept a plain list too. + const found = Array.isArray(fw) + ? fw + : fw && typeof fw === "object" + ? Object.keys(fw) + : null; + if (!found || !found.length) { + missing.push(label); + return; + } + for (const n of found) names.add(n); + }); + return { names, missing }; +} + +/* + * The three hand-written UI copies are read from source text, not imported: + * this gate must not depend on the TypeScript toolchain. + * + * Two rules hold for every reader below, both learned from a silent pass: + * + * 1. Quotes are MATCHED, not a character class at each end. `['"]([^'"]+)['"]` + * truncated `label: "iOS's Legacy"` at the apostrophe and then checked the + * truncation - which happens to be a valid display name, so the gate + * printed OK on a value it never saw whole. + * 2. Whatever a reader could not read is COUNTED and reported. A partial miss + * keeps `found.length` non-zero, so the zero-entries guard never fires: + * `linux = ""` and `linux = LINUX_DISPLAY` both left that member unchecked + * while the gate said OK. + * + * Each takes its source text as an argument so scripts/test-docs-gate.cjs can + * pin it against a fixture. Reading the real file only proves the reader agrees + * with today's content, which is why the first version of that test passed + * identically with the bug reverted. + */ + +/** + * A quoted literal whose closing quote matches its opening one. + * + * NAMED groups, not numbered: a numbered backreference shifts when this is + * concatenated after another group, so `^(\w+)\s*=\s*` + QUOTED made + * `\1` point at the member name instead of at the opening quote. Named + * references are position-independent, which is the only reason this composes. + */ +const QUOTED = "(?['\"])(?(?:(?!\\k).)+)\\k"; + +/** + * `src` with comments removed. + * + * One pass that tracks a string, a regex literal, a character class inside + * that regex, and both comment forms - all of it, because two earlier + * shortcuts each blanked real code: + * + * - Block comments were removed by a regex BEFORE any quote awareness, so + * an opening block-comment marker inside a STRING ate everything up to + * the next closing one. Measured: a glob string containing a star-slash + * sequence lost six characters. + * - A regex literal ending in an escaped slash reads as a `//` comment. + * Measured against TypeScript's own comment ranges, this blanked 163 + * characters of live code in src/theme/SearchBar/index.js - two lines that + * happen to sit below the literal this file reads, so nothing was + * unchecked, but the failure mode is silent rather than loud. + * + * Newlines inside block comments are kept so line structure survives. + */ +function stripComments(src) { + // Characters after which a `/` opens a regex rather than dividing. Empty + // means start of input. + // + // `{` and `}` are deliberately NOT here. SearchBar is JSX, and a + // self-closing tag after a spread - `{...config} />` - puts a `/` right + // after `}`; treating that as a regex opener swallowed the rest of the file, + // including real comments, which then survived into the output. A regex + // literal directly after a brace (`{ /re/ }`) does not occur in the four + // files this reads; every real one here follows `(`, `=`, `,`, `:` or `[`. + const REGEX_AFTER = /^$|^[(,=:[!&|?;+\-*%~^>]$/; + // ...and after a keyword, which the single-character test above cannot see. + // Without these, `return /["']/.test(s)` opened a phantom string on the + // quote inside the class, and the whole file came back unreadable - so an + // ordinary regex added anywhere in SearchBar turned the gate red with a + // "shape changed" message. `>` covers `=>` for the same reason. + // `[^.\w$]` rather than `[^\w$]`: a PROPERTY named like a keyword is not the + // keyword. `counts.in / 2` read as `in`, opened a regex on the division, and + // made the whole file unreadable - fail-closed, but a false positive on valid + // JS, which is the shape this test exists to remove. Depends on `emit` + // keeping the dot in `word`; without that this pattern matches anyway. + const REGEX_AFTER_WORD = /(?:^|[^.\w$])(?:return|typeof|case|in|of|new|delete|void|yield|await|do|else)$/; + let out = ""; + let quote = null; + let regex = false; + let charClass = false; + let prev = ""; + // The identifier run ending at the current position, prefixed by `.` when a + // dot precedes it, so the property guard below can tell `counts.in` from the + // keyword `in`. + // + // Whitespace does NOT reset it - deliberately, so `return /re/` is seen. The + // cost is that a keyword separated from an identifier by whitespace alone + // reads as part of that identifier: `o.p` then a newline then `return` gives + // `.preturn`, and a regex there is not recognised. Fail-closed (the file + // reports as unreadable), loud, and no shape like it exists in the four files + // this reads. + let word = ""; + let i = 0; + const emit = (s) => { + out += s; + const t = s.trimEnd(); + if (t) prev = t[t.length - 1]; + for (const ch of s) { + if (/[\w$]/.test(ch)) word += ch; + // The dot is KEPT, or the property guard below cannot see it: resetting + // to "" left `counts.in` looking like the bare keyword `in`, so the + // guard was inert and `counts.in / 2` still made the file unreadable. + else if (ch === ".") word = "."; + else if (!/\s/.test(ch)) word = ""; + } + }; + while (i < src.length) { + const c = src[i]; + const next = src[i + 1]; + if (quote) { + if (c === "\\") { emit(c + (next === undefined ? "" : next)); i += 2; continue; } + emit(c); + if (c === quote) quote = null; + i += 1; + continue; + } + if (regex) { + if (c === "\\") { emit(c + (next === undefined ? "" : next)); i += 2; continue; } + emit(c); + if (c === "[") charClass = true; + else if (c === "]") charClass = false; + else if (c === "/" && !charClass) regex = false; + i += 1; + continue; + } + if (c === "/" && next === "/") { + while (i < src.length && src[i] !== "\n") i += 1; + continue; + } + if (c === "/" && next === "*") { + i += 2; + while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) { + if (src[i] === "\n") out += "\n"; + i += 1; + } + i += 2; + continue; + } + if (c === '"' || c === "'" || c === "`") { quote = c; emit(c); i += 1; continue; } + if (c === "/" && (REGEX_AFTER.test(prev) || REGEX_AFTER_WORD.test(word))) { + regex = true; + emit(c); + i += 1; + continue; + } + if (c === "\n") { out += c; i += 1; continue; } + emit(c); + i += 1; + } + // Ending mid-regex or mid-string means the heuristic above lost track, and + // a mis-stripped source silently mis-reads whatever the caller wanted from + // it. Report it as unreadable instead: the callers turn null into + // "could not read its framework list, so it is unchecked", which is loud. + if (regex || quote) return null; + return out; +} + +/** The span of the balanced bracket pair opening at `from`, or null. */ +/** + * Index of the `const ` declaration, or -1. + * + * `indexOf(`const ${name}`)` is a PREFIX match: with `const FRAMEWORKS_ORDER` + * declared above `const FRAMEWORKS`, both arrayEntries and registryValues read + * the wrong array. For the registry that is loud (the drift and union checks + * fire); for the switcher it is not, so a different array's labels would be + * validated while the real list went unchecked. + * + * The trailing `\s*(?::|=)` is what fixes that - the name must be followed by + * an annotation or an assignment. The leading `(?:^|[^\w$])` is separate and + * cheaper: it stops `const` matching as the tail of a longer identifier. + */ +function declStart(src, constName) { + const m = new RegExp(`(?:^|[^\\w$])const\\s+${constName}\\s*(?::|=)`).exec(src); + return m ? m.index : -1; +} + +/** + * The span of the balanced bracket pair opening at `from`, or null. + * + * Quote-aware: a closing bracket inside a string used to end the span early, + * so `[{ slug: "a]" }, ...M]` was read as ending at the `]` in the value. The + * array then looked empty and the reader returned null, which is loud - but a + * truncated span is the wrong reason for it. + */ +function balanced(src, from, openCh, closeCh) { + let depth = 0; + let quote = null; + for (let i = from; i < src.length; i += 1) { + const c = src[i]; + if (quote) { + if (c === "\\") i += 1; + else if (c === quote) quote = null; + continue; + } + if (c === '"' || c === "'" || c === "`") quote = c; + else if (c === openCh) depth += 1; + else if (c === closeCh) { + depth -= 1; + if (depth === 0) return { open: from, close: i }; + } + } + return null; +} + +/** + * The body text of the named array literal, or null if its shape changed. + * + * Anchored on `const `, which also matches `export const `, and on + * the assignment rather than on the first `[`: a type annotation such as + * `: FrameworkDef[]` puts a stray pair of brackets before it, and reading those + * yields zero entries - a gate that checks nothing while reporting success. + */ +function arrayBody(src, constName) { + if (src === null || src === undefined) return null; + const clean = stripComments(src); + // null means the comment scan lost track of the source, so anything read + // from it would be a guess. Unchecked and loud beats mis-read and quiet. + if (clean === null) return null; + const start = declStart(clean, constName); + if (start === -1) return null; + const eq = clean.indexOf("=", start); + if (eq === -1) return null; + const open = clean.indexOf("[", eq); + if (open === -1) return null; + const span = balanced(clean, open, "[", "]"); + if (!span) return null; + return clean.slice(span.open + 1, span.close); +} + +/** + * The array literal's top-level chunks, classified - or null if its shape + * changed. + * + * `entries` are the `{...}` object literals. `other` is every depth-0 chunk + * that is not one: a spread, an identifier reference, a conditional. + * + * Both halves are returned because keeping only what returned to brace depth 0 + * dropped the rest without a word. Measured: rewriting the switcher's Linux + * entry as `...LINUX_SWITCHER_ENTRIES,` left its `label` unchecked against the + * registry displays and its `slug` unchecked against `routeSegment`, and the + * gate printed OK - the same input that makes useFrameworkItems build + * `${linkVersion}/undefined/add-sdk`. + */ +function arrayEntries(src, constName) { + const body = arrayBody(src, constName); + if (body === null) return null; + const entries = []; + const other = []; + for (const chunk of splitTopLevel(body)) { + const t = chunk.trim(); + if (t.startsWith("{") && t.endsWith("}")) entries.push(chunk); + else other.push(t); + } + return { entries, other }; +} + +/** One field, read off an entry's own top level. */ +function entryField(entry, field) { + // The key may be quoted, as objectLiteralValues already allows: `"slug": + // "ios"` is legitimate TypeScript, and rejecting it reported "no plain + // `slug` literal" about an entry that has one. + const rx = new RegExp( + `^\\s*(?:['\"])?${field}(?:['\"])?\\s*:\\s*${QUOTED}\\s*$`, + ); + const hits = []; + for (const pair of entryPairs(entry).pairs) { + const m = rx.exec(pair); + if (m) hits.push(m.groups.v); + } + return hits; +} + +/** + * An entry's own `key: value` pairs, plus every depth-0 chunk of it that is not + * one. + * + * The second half is the point. arrayEntries reports the chunks of the ARRAY it + * cannot read and objectLiteralValues reports the PAIRS it cannot read; reading + * a field off an entry did neither, so a spread one brace deeper was invisible. + * Measured: `{ slug: "hosted", display: "Hosted", routeSegment: null, + * ...HOSTED_EXTRAS }` with `HOSTED_EXTRAS = { agentSkills: true }` is + * `agentSkills: true` with a null route at runtime - the exact input the + * invariant below exists to catch - and the gate printed OK. It type-checks, + * too, so nothing else was going to notice. + */ +function entryPairs(entry) { + const inner = entry.trim().replace(/^{/, " ").replace(/}$/, " "); + const pairs = []; + const unreadable = []; + // A key, quoted or not. Anything else at this depth - a spread, a shorthand, + // a method, a getter, a computed key, or a quoted key containing a space - is + // something this cannot read a value from, so it is reported rather than + // dropped. + // + // A computed key deliberately does NOT match: topLevelOnly blanks the inside + // of `[KEY]` but leaves the brackets, so accepting `[` here would put + // `[ ]: "ios"` into `pairs`, where entryField's anchored match then rejects + // it - read by nothing and reported by nothing. + const keyRx = /^\s*["']?[\w$.-]+["']?\s*:/; + for (const { flat, raw } of topLevelPairsWithSource(inner)) { + if (keyRx.test(flat)) pairs.push(flat); + else unreadable.push(raw.trim()); + } + return { pairs, unreadable }; +} + +/** + * `body` with every nested `{...}` and `[...]` span blanked out, so a field + * regex can only match at the top level of `body` itself. + * + * Matching the whole entry text found a field inside a NESTED object and + * attributed it to the entry. Measured: rewriting the switcher's Linux entry + * as `{ label: "Linux", meta: { slug: "linux" } }` left it with no top-level + * `slug`, so useFrameworkItems builds `${linkVersion}/undefined/add-sdk` - and + * the gate printed OK, because the nested one was what got read. + * + * Quote-aware, which is what actually fixes `label: "Linux ]", meta: { slug: + * "x" }` - a `]` inside a string used to drive the depth negative, after which + * nothing was blanked at all. The `Math.max(0, ...)` clamp below is unreachable + * given that, and kept only so a future non-quote-aware caller cannot + * reintroduce the same failure silently. + * + * Blanked rather than deleted so offsets and length survive. + */ +function topLevelOnly(body) { + const out = body.split(""); + let depth = 0; + let quote = null; + for (let i = 0; i < body.length; i += 1) { + const c = body[i]; + if (quote) { + if (depth > 0) out[i] = " "; + if (c === "\\") { + if (depth > 0 && i + 1 < body.length) out[i + 1] = " "; + i += 1; + } else if (c === quote) { + quote = null; + } + continue; + } + if (c === '"' || c === "'" || c === "`") { + quote = c; + if (depth > 0) out[i] = " "; + continue; + } + if (c === "{" || c === "[") { + if (depth > 0) out[i] = " "; + depth += 1; + } else if (c === "}" || c === "]") { + depth = Math.max(0, depth - 1); + if (depth > 0) out[i] = " "; + } else if (depth > 0 && c !== "\n") { + out[i] = " "; + } + } + return out.join(""); +} + +/** + * Chunks of `text`, split only at commas that are at depth 0 and outside a + * string. Nested spans are left as they are. + * + * `text.split(",")` was wrong on both counts. A value containing a comma + * (`"a, b"`) was reported as having no string value; worse, a key whose value + * was a nested object kept that object's commas, so the chunk still contained a + * quoted string, the key was reported as fine, and the NESTED string is what + * got checked against the registry. + */ +function splitTopLevel(text) { + const chunks = []; + let from = 0; + let depth = 0; + let quote = null; + for (let i = 0; i < text.length; i += 1) { + const c = text[i]; + if (quote) { + if (c === "\\") i += 1; + else if (c === quote) quote = null; + continue; + } + if (c === '"' || c === "'" || c === "`") quote = c; + else if (c === "{" || c === "[") depth += 1; + else if (c === "}" || c === "]") depth = Math.max(0, depth - 1); + else if (c === "," && depth === 0) { + chunks.push(text.slice(from, i)); + from = i + 1; + } + } + chunks.push(text.slice(from)); + return chunks.filter((chunk) => chunk.trim().length); +} + +/** + * `key: value` chunks of an object-literal body: split at depth 0, with every + * nested span blanked so a match cannot come from inside one. + */ +function topLevelPairs(body) { + return splitTopLevel(body).map((chunk) => topLevelOnly(chunk)); +} + +/** The same chunks, paired with their original text for error messages. */ +function topLevelPairsWithSource(body) { + return splitTopLevel(body).map((chunk) => ({ flat: topLevelOnly(chunk), raw: chunk })); +} + +/** + * One field read off the top level of every entry of a named array literal, + * plus everything this cannot read: entries where the field is absent, is not + * a plain quoted literal, or is declared more than once, and any depth-0 chunk + * of the array that is not an object literal at all. + */ +function entryFieldValues(src, constName, field) { + const read = arrayEntries(src, constName); + if (read === null || !read.entries.length) return null; + const values = []; + const missing = []; + // A chunk the entry splitter could not read as an object. Reported for the + // same reason objectLiteralValues reports a spread: it is one entry's worth + // of unchecked, and the others still parse so no count shows it. + for (const chunk of read.other) { + missing.push(`\`${chunk.slice(0, 40)}\` is not an entry this can read`); + } + read.entries.forEach((entry, i) => { + const hits = entryField(entry, field); + const named = entryField(entry, "label"); + const id = named.length === 1 ? `"${named[0]}"` : `#${i}`; + // A chunk of the entry itself that is not a `key: value` pair. A spread + // here can supply the very field being read, so silence is a silent pass. + for (const chunk of entryPairs(entry).unreadable) { + missing.push(`entry ${id}: \`${chunk.slice(0, 40)}\` is not a \`key: value\` pair`); + } + if (!hits.length) { + missing.push(`entry ${id} has no plain \`${field}\` literal`); + return; + } + if (hits.length > 1) { + // Two declarations means the later one wins at runtime and the earlier + // one is what this would have checked. Neither is safe to assume. + missing.push(`entry ${id} declares \`${field}\` ${hits.length} times`); + return; + } + values.push(hits[0]); + }); + return { values, missing }; +} + +/** + * Values of the members of a named enum, plus the members whose value is not a + * simple quoted literal. + */ +function enumMemberValues(src, enumName) { + if (src === null || src === undefined) return null; + const clean = stripComments(src); + // null means the comment scan lost track of the source, so anything read + // from it would be a guess. Unchecked and loud beats mis-read and quiet. + if (clean === null) return null; + const decl = clean.indexOf(`enum ${enumName}`); + if (decl === -1) return null; + const open = clean.indexOf("{", decl); + if (open === -1) return null; + const span = balanced(clean, open, "{", "}"); + if (!span) return null; + const values = []; + const missing = []; + // Split on top-level commas rather than on lines. A single-line enum put + // every member on one line, so the line-anchored regex matched none of them: + // the first was reported and the rest were dropped without a word. Trailing + // comma optional too - dropping it on the final member is valid TS, and + // requiring it exempted that member from the check. + const rx = new RegExp(`^\\s*(\\w+)\\s*=\\s*${QUOTED}\\s*$`); + const namedRx = /^\s*(\w+)\s*=/; + for (const member of topLevelPairs(clean.slice(span.open + 1, span.close))) { + const m = rx.exec(member); + if (m) { + values.push(m.groups.v); + continue; + } + const named = namedRx.exec(member); + missing.push( + named + ? `member "${named[1]}" has no plain string value` + : `\`${member.trim().slice(0, 40)}\` is not an enum member`, + ); + } + return { values, missing }; +} + +/** + * Values of one named object literal, plus the keys whose value this cannot + * read and any depth-0 chunk that is not a `key: value` pair at all. + * + * An earlier version claimed there was nothing to report here, "because every + * `key: value` pair inside it is read". That was false in two ways, both + * measured on the real SearchBar map: a key whose value is a nested object was + * reported as fine while the nested string was checked in its place, and a + * spread (`...EXTRA_LABELS`) was not reported at all. + * + * Scoped by brace matching rather than by a line regex: SearchBar carries other + * `key: "value"` shapes (analytics payloads, query tokens) that a file-wide + * scan picks up as framework names. + */ +function objectLiteralValues(src, constName) { + if (src === null || src === undefined) return null; + const clean = stripComments(src); + // null means the comment scan lost track of the source, so anything read + // from it would be a guess. Unchecked and loud beats mis-read and quiet. + if (clean === null) return null; + const start = declStart(clean, constName); + if (start === -1) return null; + const open = clean.indexOf("{", start); + if (open === -1) return null; + const span = balanced(clean, open, "{", "}"); + if (!span) return null; + const values = []; + const missing = []; + // Anchored to the whole chunk, so `ios: "iOS" + SUFFIX` is reported rather + // than read as "iOS". + const pairRx = new RegExp( + `^\\s*(?:['\"])?([\\w.$-]+)(?:['\"])?\\s*:\\s*${QUOTED}\\s*$`, + ); + const keyRx = /^\s*(?:['\"])?([\w.$-]+)(?:['\"])?\s*:/; + for (const { flat, raw } of topLevelPairsWithSource(clean.slice(span.open + 1, span.close))) { + const m = pairRx.exec(flat); + if (m) { + values.push(m.groups.v); + continue; + } + const key = keyRx.exec(flat); + // `raw`, not `flat`: the blanked text reads as `[ ]: "x"` and names + // nothing the author would recognise. + missing.push( + key + ? `key "${key[1]}" has no plain string value` + : `\`${raw.trim().slice(0, 40)}\` is not a \`key: value\` pair`, + ); + } + return { values, missing }; +} + +/** + * What one UI copy's reader found, as error strings. + * + * Pure, and exported, because this is where two rounds of review found + * escapes: the readers' `missing` arrays were well tested and NOTHING tested + * that anyone reports them. Deleting the loop below left the suite green. + */ +function uiCopyErrors(label, file, read, allowed, extra) { + const errors = []; + if (read === null) { + errors.push(`${file}: could not read its framework list, so it is unchecked`); + return errors; + } + const found = read.values; + if (found.length === 0) { + errors.push(`${file}: parsed zero framework entries - its shape changed`); + return errors; + } + // What the reader could not read. Without this a partial miss was free: + // `linux = ""` and `linux = LINUX_DISPLAY` each left one member unchecked + // while `found.length` stayed non-zero and the gate said OK. + for (const what of read.missing) { + errors.push( + `${file}: ${what} - it is unchecked, and the others still parse so the ` + + `entry count does not show it`, + ); + } + for (const value of found) { + if (!allowed.includes(value) && !(extra || []).includes(value)) { + errors.push(`${file}: ${label} "${value}" is not in the registry`); + } + } + return errors; +} + +/** + * One data file's reader output, as error strings plus the number of names it + * actually checked. + */ +function dataFileErrors(rel, parsedData, registryDisplays) { + const errors = []; + if (parsedData.error) return { errors: [parsedData.error], namesChecked: 0 }; + const { names, missing } = parsedData; + // Zero names is a shape change, not a clean file: renaming skills.json's + // `frameworks` key to `platforms` left this check reporting "3 checked" + // and OK. Every sibling check fails loudly on parsing zero entries; this + // one used to be the exception because an empty Set is truthy. + if (!names.size) { + errors.push( + `${rel}: parsed zero framework names - its shape changed, so this ` + + `file is unchecked rather than clean`, + ); + return { errors, namesChecked: 0 }; + } + // A per-part miss, which a total count cannot show: every one of these + // files is several independent maps, and losing one of them left the rest + // supplying names and the gate reporting clean. + for (const label of missing) { + errors.push( + `${rel}: ${label} is absent or empty - that part of the file is ` + + `unchecked, and the rest still supplies names so the total count ` + + `does not show it`, + ); + } + for (const name of names) { + if (!registryDisplays.includes(name)) { + errors.push( + `${rel}: framework "${name}" is not a display name in the registry - the ` + + `row renders but no component can match it`, + ); + } + } + return { errors, namesChecked: names.size }; +} + +/** + * The registry invariants that do not fit the value checks: that the splitter + * read every chunk of the literal, that every entry declares a slug, and that + * `agentSkills: true` implies a `routeSegment` to build a URL from. + * + * Both consumers cast the null away - QUERY_FRAMEWORK_TO_PATH in + * src/components/utils/frameworks.ts and FRAMEWORK_URL_PATH in + * src/components/SkillsCallout/index.tsx both do `routeSegment as string` - so + * such an entry silently produces `/sdks/undefined/agent-skills`. Not + * hypothetical: `hosted` is the entry with routeSegment: null, and skills.json + * already carries an id-bolt skill, so flipping hosted.agentSkills is the + * natural next edit. + * + * Read at each entry's own top level. Testing the whole entry text let a + * NESTED `routeSegment` satisfy the invariant: `{ slug: "hosted", + * routeSegment: null, agentSkills: true, meta: { routeSegment: "id-bolt" } }` + * exited 0. + */ +function registryInvariantErrors(read, registrySlugs, file) { + const errors = []; + if (read === null) { + errors.push( + `${file}: could not read the FRAMEWORKS entries, so the ` + + `agentSkills/routeSegment invariant is unchecked`, + ); + return errors; + } + const { entries, other } = read; + if (!entries.length) { + errors.push( + `${file}: read zero FRAMEWORKS entries, so the ` + + `agentSkills/routeSegment invariant is unchecked`, + ); + } + // A chunk the splitter could not read as an entry - a spread, a reference, + // a conditional. One entry's worth of unchecked, and the others still parse + // so no count shows it. + for (const chunk of other) { + errors.push( + `${file}: \`${chunk.slice(0, 40)}\` is not an entry this can read, so ` + + `whatever it contributes to the registry is unchecked`, + ); + } + // Counted, like every other reader here. registrySlugs is the depth-0 `slug` + // values of these same entries, so a mismatch means an entry declares none + // where this can see it. It is no longer an independent cross-check the way + // counting every occurrence in the literal was: an entry declaring `slug` + // twice would contribute two and balance a miss elsewhere. `tsc` is what + // rules that out - a duplicate key in an object literal is TS1117. + if (registrySlugs && entries.length !== registrySlugs.length) { + errors.push( + `${file}: read ${entries.length} entries but ${registrySlugs.length} ` + + `top-level \`slug\` value(s) - at least one entry declares none where ` + + `this can see it, so it is unchecked rather than clean`, + ); + } + for (const entry of entries) { + // Same reporting as entryFieldValues: a spread inside the entry can supply + // `agentSkills` or `routeSegment`, and this scan would not see either. + for (const chunk of entryPairs(entry).unreadable) { + errors.push( + `${file}: \`${chunk.slice(0, 40)}\` inside an entry is not a ` + + `\`key: value\` pair, so what it contributes is unchecked`, + ); + } + // Depth 0 only, and `\s*:` on each test: no formatter is configured, so + // `agentSkills : true` is a shape this file has to expect - and a space + // before the colon skipped the entry without tripping the count assertion, + // because the entry was still read. + const flat = topLevelOnly(entry.trim().replace(/^{/, " ").replace(/}$/, " ")); + if (!/agentSkills\s*:\s*true/.test(flat)) continue; + if (!entryField(entry, "routeSegment").length) { + const slug = entryField(entry, "slug"); + errors.push( + `${file}: "${slug[0] || "?"}" has agentSkills: true but no routeSegment - ` + + `resolveAgentSkillsUrl would build /sdks/undefined/agent-skills`, + ); + } + } + return errors; +} + +function main() { + if (FIXTURE_ROOT) console.log(`framework gate: FIXTURE ROOT ${FIXTURE_ROOT}\n`); + const vocabulary = enumSlugs(); + const allowed = vocabulary.slugs; + const errors = [...vocabulary.errors]; + + // 1. CONTENT + let files = []; + try { + files = walk(DOCS); + } catch (e) { + // A sentence, like every other failure here. ROOT is env-overridable now, + // so a tree with no docs/ is reachable rather than hypothetical. + errors.push( + `docs/ could not be read (${e.message.split("\n")[0]}) - the content ` + + `check is unperformed rather than clean`, + ); + } + let pagesWithField = 0; + for (const file of files) { + const decls = declaredFrameworks(file); + // Only a real declaration counts. A page reported for a frontmatter-level + // problem declares nothing, and counting it inflated "N declare a + // framework" with pages that may declare none. + const SENTINELS = [UNREADABLE, UNTERMINATED, EMPTY]; + if (decls.some((d) => !SENTINELS.includes(d.value))) { + pagesWithField += 1; + } + for (const { field, value } of decls) { + const rel = path.relative(ROOT, file).split(path.sep).join("/"); + if (value === UNTERMINATED) { + errors.push( + `${rel}: frontmatter opens with \`---\` but never closes, so its ` + + `framework declaration could not be read - add the closing \`---\``, + ); + continue; + } + if (value === EMPTY) { + errors.push( + `${rel}: ${field} is declared with nothing in it - ` + + `docs-schema.yml requires a string` + + (field === "frameworks" ? ` and at least one item` : "") + + `, and frontmatter.cjs only sees files a PR touched`, + ); + continue; + } + if (value === UNREADABLE) { + errors.push( + field === "frontmatter" + ? `${rel}: frontmatter does not parse as YAML, so its framework ` + + `declaration could not be checked` + : `${rel}: ${field} must be a string` + + (field === "frameworks" ? ` or a list of strings` : "") + + `, and is not - a number, a map or a nested list is not a framework ` + + `identifier`, + ); + continue; + } + if (!allowed.has(value)) { + errors.push( + `${rel}: ${field} "${value}" is not in the enum`, + ); + } + } + } + + // 2. DRIFT + 3. COVERAGE, checked against the registry the maps derive from. + const registrySlugs = registryValues("slug"); + if (!registrySlugs) { + errors.push( + `${REGISTRY_FILE}: could not read the FRAMEWORKS registry - its shape changed, ` + + `so this gate is no longer checking it`, + ); + } else if (registrySlugs.length === 0) { + errors.push(`${REGISTRY_FILE}: FRAMEWORKS parsed to zero entries`); + } else { + for (const slug of registrySlugs) { + if (!allowed.has(slug)) { + errors.push(`${REGISTRY_FILE}: registry defines "${slug}", docs-schema.yml does not`); + } + } + for (const slug of allowed) { + if (!registrySlugs.includes(slug)) { + errors.push( + `docs-schema.yml allows "${slug}", the registry does not define it - a page ` + + `may use it and every component will silently resolve nothing`, + ); + } + } + + // 3b. UNION + const union = unionSlugs(); + if (!union || union.length === 0) { + errors.push( + `${REGISTRY_FILE}: the FrameworkSlug union is missing or unreadable, so code ` + + `naming a framework is back to unchecked \`string\``, + ); + } else { + for (const slug of union) { + if (!registrySlugs.includes(slug)) { + errors.push( + `${REGISTRY_FILE}: FrameworkSlug lists "${slug}", the registry has no such entry`, + ); + } + } + for (const slug of registrySlugs) { + if (!union.includes(slug)) { + errors.push( + `${REGISTRY_FILE}: registry defines "${slug}", FrameworkSlug omits it - code ` + + `cannot name that framework without a cast`, + ); + } + } + } + } + + // 5. UI COPIES - read the three hand-written lists out of source text. + const uiErrors = (label, file, read, allowed, extra) => + errors.push(...uiCopyErrors(label, file, read, allowed, extra)); + + + // 4. DATA + const registryDisplays = registryValues("display"); + let dataNamesChecked = 0; + if (!registryDisplays || registryDisplays.length === 0) { + errors.push( + `${REGISTRY_FILE}: could not read \`display\` values, so the data files are unchecked`, + ); + } else { + for (const rel of DATA_FILES) { + const read = dataFileErrors( + rel, + dataFileFrameworkNames(rel), + registryDisplays, + ); + errors.push(...read.errors); + dataNamesChecked += read.namesChecked; + } + } + // Deliberately one-directional: a product or feature need not support every + // framework, so a registry display missing from a data file is not an error. + // Only a name the registry does not know is. + + if (registryDisplays && registryDisplays.length) { + // FrameworksName: `ios = "iOS"` - the VALUE must be a registry display. + uiErrors( + "display name", + ENUM_FILE, + enumMemberValues(readSource(ENUM_FILE), "FrameworksName"), + registryDisplays, + ENUM_ALLOWED_EXTRA_DISPLAYS, + ); + // SearchBar's display map: `"react-native": "React Native",` - same rule on + // the value. Its KEYS are API-side tokens (`dotnet.ios`) and are not checked. + uiErrors( + "display name", + SEARCHBAR_FILE, + objectLiteralValues(readSource(SEARCHBAR_FILE), "API_FRAMEWORK_LABELS"), + registryDisplays, + [], + ); + // useFrameworkItems: `label: "iOS"` on each switcher entry. + uiErrors( + "label", + SWITCHER_FILE, + entryFieldValues(readSource(SWITCHER_FILE), "FRAMEWORKS", "label"), + registryDisplays, + ["Xamarin iOS", "Xamarin Android", "Xamarin Forms"], + ); + } + + // And the switcher's route forms, against `routeSegment`. + const registrySegments = registryValues("routeSegment"); + if (registrySegments) { + uiErrors( + "route", + SWITCHER_FILE, + entryFieldValues(readSource(SWITCHER_FILE), "FRAMEWORKS", "slug"), + registrySegments, + LEGACY_ROUTE_SEGMENTS, + ); + } + + // An entry with `agentSkills: true` must have a route segment to build a URL + // from. Nothing checked it, and both consumers cast the null away - + // QUERY_FRAMEWORK_TO_PATH in src/components/utils/frameworks.ts and + // FRAMEWORK_URL_PATH in src/components/SkillsCallout/index.tsx both do + // `routeSegment as string` - so such an entry silently produces + // `/sdks/undefined/agent-skills`. Not hypothetical: `hosted` is the entry with + // routeSegment: null, and skills.json already carries an id-bolt skill, so + // flipping hosted.agentSkills is the natural next edit. + // The invariants that do not fit the value checks. See + // registryInvariantErrors for why each exists. + errors.push( + ...registryInvariantErrors(registryEntries(), registrySlugs, REGISTRY_FILE), + ); + + // The counters are part of the check, not decoration. A reader that breaks + // outright reports nothing and every downstream count reads zero, which is + // indistinguishable from a clean corpus: gutting declaredFrameworks left + // "0 declare a framework" and an OK. Nothing asserted it, so nothing failed. + if (!files.length) { + errors.push( + `no .md or .mdx files found under docs/ - the walk found nothing, so the ` + + `content check is unperformed rather than clean`, + ); + } else if (!pagesWithField) { + errors.push( + `${files.length} docs scanned and not one declares a framework - the ` + + `frontmatter reader is broken, not the corpus`, + ); + } + // Belt and braces, and deliberately so: any route that actually leaves this + // at zero is already reported by dataFileErrors ("parsed zero framework + // names"). It exists for the route that is not - the loop not running at all. + if (!dataNamesChecked) { + errors.push( + `${DATA_FILES.length} data file(s) checked and zero framework names ` + + `resolved - the data readers are unperformed rather than clean`, + ); + } + + console.log( + `\nframework gate: ${files.length} docs scanned, ${pagesWithField} declare a framework`, + ); + console.log( + `data files: ${DATA_FILES.length} checked, ${dataNamesChecked} framework name(s) resolved`, + ); + console.log(`enum (${allowed.size}): ${[...allowed].join(", ")}\n`); + + if (errors.length) { + console.error(`FAIL: ${errors.length} framework identifier problem(s).\n`); + for (const e of errors.slice(0, 40)) console.error(` ${e}`); + if (errors.length > 40) console.error(` ... and ${errors.length - 40} more`); + console.error(""); + process.exit(1); + } + + console.log("OK: every framework identifier in docs, code and data resolves.\n"); +} + +if (require.main === module) main(); + +module.exports = { + declaredFrameworks, + dataFileFrameworkNames, + dataFileErrors, + declStart, + enumSlugs, + registryInvariantErrors, + entryField, + entryPairs, + splitTopLevel, + topLevelPairsWithSource, + topLevelOnly, + topLevelPairs, + uiCopyErrors, + arrayBody, + arrayEntries, + entryFieldValues, + enumMemberValues, + objectLiteralValues, + stripComments, + registryEntries, + registryValues, + unionSlugs, + UNREADABLE, + UNTERMINATED, + EMPTY, +}; diff --git a/src/components/FeatureList/index.tsx b/src/components/FeatureList/index.tsx index 17c600922a..570cf628df 100644 --- a/src/components/FeatureList/index.tsx +++ b/src/components/FeatureList/index.tsx @@ -6,7 +6,7 @@ import { FeatureListProps, Feature, Product, FilteredFeature } from './types'; // Import data files import productsData from '@site/src/data/products.json'; import featuresData from '@site/src/data/features.json'; -import { FRAMEWORK_MAPPING } from '../utils/frameworks'; +import { frameworkFromPath } from '@site/src/constants/frameworks'; import { withCurrentDocsPath } from '@site/src/constants/docsPaths'; // Function to render description with inline code formatting @@ -38,14 +38,12 @@ const FeatureList: React.FC = ({ const detectFramework = (): string | undefined => { if (framework) return framework; - // Try to detect from URL path + // Try to detect from URL path. Resolved through the registry rather than a + // local regex: features.json is keyed by DISPLAY name, and a single-segment + // match returned `net` for /sdks/net/ios/, which is not a framework, so + // every feature was filtered out on both .NET platforms. if (typeof window !== 'undefined') { - const path = window.location.pathname; - const frameworkMatch = path.match(/\/sdks\/([^\/]+)\//); - if (frameworkMatch) { - const frameworkKey = frameworkMatch[1]; - return FRAMEWORK_MAPPING[frameworkKey] || frameworkKey; - } + return frameworkFromPath(window.location.pathname)?.display; } return undefined; diff --git a/src/components/SkillsCallout/index.tsx b/src/components/SkillsCallout/index.tsx index 264e263e77..92f600e0b0 100644 --- a/src/components/SkillsCallout/index.tsx +++ b/src/components/SkillsCallout/index.tsx @@ -14,6 +14,7 @@ import { FRAMEWORK_CHANGE_EVENT, } from '../utils/frameworks'; import { withCurrentDocsPath } from '@site/src/constants/docsPaths'; +import { AGENT_SKILL_FRAMEWORKS } from '@site/src/constants/frameworks'; import { capturePostHogEvent } from './analytics'; import InstallCommand from './InstallCommand'; import styles from './styles.module.css'; @@ -43,32 +44,18 @@ interface ProductEntry { name: string; } -const FRAMEWORK_URL_PATH: Record = { - iOS: 'ios', - Android: 'android', - Web: 'web', - Cordova: 'cordova', - Capacitor: 'capacitor', - Flutter: 'flutter', - 'Kotlin Multiplatform': 'kmp', - 'React Native': 'react-native', - '.NET iOS': 'net/ios', - '.NET Android': 'net/android', -}; +// Both derived from the framework registry (src/constants/frameworks.ts). +// These used to be two hand-written maps keyed by display name, a third and +// fourth copy of the same set - which is how `linux` went missing from one map +// and nowhere else without anyone noticing. +const FRAMEWORK_URL_PATH: Record = Object.fromEntries( + AGENT_SKILL_FRAMEWORKS.map((f) => [f.display, f.routeSegment as string]), +); // Analytics-friendly slug for the framework, used as the data-skills-callout-framework attribute. -const FRAMEWORK_SLUG: Record = { - iOS: 'ios', - Android: 'android', - Web: 'web', - Cordova: 'cordova', - Capacitor: 'capacitor', - Flutter: 'flutter', - 'Kotlin Multiplatform': 'kmp', - 'React Native': 'react-native', - '.NET iOS': 'net-ios', - '.NET Android': 'net-android', -}; +const FRAMEWORK_SLUG: Record = Object.fromEntries( + AGENT_SKILL_FRAMEWORKS.map((f) => [f.display, f.slug]), +); // Resolves the framework for the shared callout, preferring the framework in // the current path (so "More info" keeps the reader on the framework they are diff --git a/src/components/utils/frameworks.ts b/src/components/utils/frameworks.ts index 244d236cf6..8c4f26b9bb 100644 --- a/src/components/utils/frameworks.ts +++ b/src/components/utils/frameworks.ts @@ -1,23 +1,21 @@ import { withCurrentDocsPath } from '@site/src/constants/docsPaths'; +import { + FRAMEWORKS, + ROUTED_FRAMEWORKS, + AGENT_SKILL_FRAMEWORKS, + frameworkFromRouteTail, +} from '@site/src/constants/frameworks'; // localStorage key the homepage framework selector writes and the shared // Agent Skills banner reads back. Both sides must use this constant so the // contract can't drift (a rename would otherwise silently fall back to iOS). export const FRAMEWORK_STORAGE_KEY = 'framework'; -export const FRAMEWORK_MAPPING: { [urlSlug: string]: string } = { - 'ios': 'iOS', - 'android': 'Android', - 'cordova': 'Cordova', - 'react-native': 'React Native', - 'flutter': 'Flutter', - 'kmp': 'Kotlin Multiplatform', - 'capacitor': 'Capacitor', - 'titanium': 'Titanium', - 'web': 'Web', - 'net-ios': '.NET iOS', - 'net-android': '.NET Android', -}; +// Derived from the framework registry - see src/constants/frameworks.ts. +// `hosted` is absent because it is not an /sdks/ route. +export const FRAMEWORK_MAPPING: { [urlSlug: string]: string } = Object.fromEntries( + ROUTED_FRAMEWORKS.map((f) => [f.slug, f.display]), +); export const URL_PRODUCT_MAPPING: { [urlSlug: string]: string } = { 'label-capture': 'smart-label-capture', @@ -48,47 +46,76 @@ export interface SdksRouteInfo { } export function parseSdksRoute(pathname: string): SdksRouteInfo { - // Tolerate a leading docs-version segment (/next/, /7.6.14/, ...): only the - // version served at the site root has none, so matching on /sdks/ alone would - // silently fail on every other version. - const match = pathname.match( - /^(?:\/(?:next|\d+\.\d+\.\d+))?\/sdks\/((?:net\/)?[^\/]+)\/([^\/]+)(?:\/([^\/]+))?/, - ); + // An optional docs-version segment (/next/, /7.6.14/, ...) - only the version + // served at the site root has none - and an optional SITE BASE URL in front of + // it. The base url is why this cannot stay anchored at `^/`: PR previews build + // with `base_url=/data-capture-documentation/pr-preview/pr-N/` + // (.github/workflows/docs-preview.yml), and `useLocation().pathname` includes + // it. Anchored, this returned {} on every preview page, so DocItem computed + // `isKnownProductPage === false` everywhere and fell through to the shared + // Agent Skills callout, which then defaulted to iOS for every framework - + // while frameworkFromPath, matching `(?:^|\/)sdks\/`, resolved the same path + // correctly. Two parsers, one answer: the divergence showed up in exactly the + // previews reviewers look at. + const match = /(?:^|\/)(?:(?:next|\d+\.\d+\.\d+)\/)?sdks\/(.+)$/.exec(pathname); if (!match) return {}; - const rawFramework = match[1]; - const rawProduct = match[2]; - const last = match[3]; + // Which framework the tail belongs to is the registry's business - it owns + // `routeSegment`, including the two-segment .NET routes this function used to + // hardcode as `(?:net\/)?` and then undo with `.replace('/', '-')`. That copy + // is why FeatureList's own regex could disagree with this one. + const def = frameworkFromRouteTail(match[1]); + // An UNREGISTERED framework segment still has to yield `product`. + // + // Returning {} here drops it, and that flips DocItem's `isKnownProductPage` + // to false, falling the page through to - + // which defaults to iOS. So adding docs/sdks/// before its + // registry entry lands would put an iOS-pointing Agent Skills banner on every + // page of the new tree, with nothing red anywhere to say so. Resolving the + // framework to undefined while keeping the product is what the regex this + // replaced did: the product callout then renders nothing, because it bails on + // a missing framework. No banner is the right answer for a tree the registry + // has never heard of; the wrong framework's banner is not. + // + // One segment is the right assumption in the fallback: every multi-segment + // routeSegment (net/ios, net/android) is a registry entry by construction, so + // a tail that matched none of them cannot be one. + const routeSegment = def?.routeSegment ?? null; + const rest = + routeSegment !== null + ? match[1].slice(routeSegment.length).replace(/^\//, '') + : match[1].split('/').slice(1).join('/'); + const [rawProduct, last] = rest.split('/'); + // A product segment is required: /sdks/ios/ on its own is not a product page. + if (!rawProduct) return {}; - const frameworkSlug = rawFramework.replace('/', '-'); - const framework = FRAMEWORK_MAPPING[frameworkSlug]; const product = URL_PRODUCT_MAPPING[rawProduct] || rawProduct; - - return { framework, product, lastSegment: last }; + // `framework` and `lastSegment` are OMITTED rather than set to undefined. + // + // Not what the pre-registry regex did - it always returned all three keys, + // with undefined values where nothing resolved - so this is a deliberate + // shape change, not a preservation. It matters because assert.deepStrictEqual + // distinguishes an absent key from one holding undefined, which is what + // PARSE_SDKS_ROUTE_BASELINE pins. Every consumer reads the fields rather than + // enumerating keys, so both shapes behave identically at runtime. + const base = + def && routeSegment !== null ? { framework: def.display, product } : { product }; + return last ? { ...base, lastSegment: last } : base; } -// Maps the ?framework= query slug used on the homepage to an agent-skills URL path. -export const QUERY_FRAMEWORK_TO_PATH: Record = { - ios: 'ios', - android: 'android', - web: 'web', - cordova: 'cordova', - capacitor: 'capacitor', - flutter: 'flutter', - kmp: 'kmp', - 'react-native': 'react-native', - 'net-ios': 'net/ios', - 'net-android': 'net/android', -}; +// Maps a framework slug to its agent-skills URL path. Only frameworks that +// actually have an Agent Skills page appear here - derived from the registry. +export const QUERY_FRAMEWORK_TO_PATH: Record = Object.fromEntries( + AGENT_SKILL_FRAMEWORKS.map((f) => [f.slug, f.routeSegment as string]), +); -// The homepage framework selector uses its own identifiers (see frameworkCardsArr) -// that differ from the QUERY_FRAMEWORK_TO_PATH keys. Map them so -// ?framework=react / netIos / netAndroid resolve correctly. -const HOMEPAGE_FRAMEWORK_ALIASES: Record = { - react: 'react-native', - netios: 'net-ios', - netandroid: 'net-android', -}; +// The homepage framework selector uses its own identifiers (see +// frameworkCardsArr) that differ from the canonical slugs - ?framework=react, +// netIos and netAndroid are the ones that exist today. Declared as `aliases` on +// each registry entry so a new spelling is added in one place. +const HOMEPAGE_FRAMEWORK_ALIASES: Record = Object.fromEntries( + FRAMEWORKS.flatMap((f) => (f.aliases || []).map((a) => [a, f.slug])), +); // Normalizes a raw ?framework= value to a QUERY_FRAMEWORK_TO_PATH key, or '' // when it maps to no agent-skills page. @@ -120,19 +147,25 @@ export function readSelectedFrameworkRaw(): string { } // Homepage selector slugs the shared product-picker banner hides for, because -// there is no Agent Skill to route to. Xamarin (and its variants), Titanium and -// Linux have no skills at all. Bare ".NET" is also hidden: it has no general -// skill — the reader must pick a platform — so the banner stays hidden until -// ".NET iOS" or ".NET Android" is selected (those slugs, netios/netandroid, are -// intentionally NOT in this set, so the banner returns for them). -const FRAMEWORKS_WITHOUT_AGENT_SKILLS = new Set([ +// there is no Agent Skill to route to. +// +// Two sources, deliberately: the frameworks we document but have no skill for +// come from the registry (so adding a skill flips one flag, not two lists), and +// the selector-only identifiers below are spellings that are not canonical +// frameworks at all - Xamarin is removed from 8.x and has no docs tree, and bare +// ".NET" has no general skill because the reader must pick a platform first +// (netios/netandroid are intentionally absent, so the banner returns for them). +const SELECTOR_ONLY_WITHOUT_AGENT_SKILLS = [ 'xamarin', 'xamarinios', 'xamarinandroid', 'xamarinforms', - 'titanium', - 'linux', 'net', +]; + +const FRAMEWORKS_WITHOUT_AGENT_SKILLS = new Set([ + ...FRAMEWORKS.filter((f) => !f.agentSkills).map((f) => f.slug), + ...SELECTOR_ONLY_WITHOUT_AGENT_SKILLS, ]); // True unless the selected framework has no Agent Skills at all. Unknown/empty diff --git a/src/constants/frameworks.ts b/src/constants/frameworks.ts new file mode 100644 index 0000000000..31ca2fe7ce --- /dev/null +++ b/src/constants/frameworks.ts @@ -0,0 +1,168 @@ +/** + * THE framework registry. Every framework identifier in this repo comes from + * here. + * + * Before this file the same set was written out five times - FRAMEWORK_MAPPING, + * QUERY_FRAMEWORK_TO_PATH and HOMEPAGE_FRAMEWORK_ALIASES in + * components/utils/frameworks.ts, plus FRAMEWORK_URL_PATH and FRAMEWORK_SLUG in + * SkillsCallout - each keyed slightly differently. Nothing tied them together, + * so they could disagree without anyone noticing, and they did: `linux` was + * missing from FRAMEWORK_MAPPING, so every /sdks/linux/ page resolved to no + * framework at all. Silently. For as long as the page had existed. + * + * `slug` must match the `framework` / `frameworks` enum in docs-schema.yml. + * `yarn verify:frameworks` fails the build if the two ever diverge, so this + * registry and the frontmatter vocabulary cannot drift apart again. + * + * Kept free of imports so docusaurus.config.ts can read it: the config is loaded + * by Node, where webpack's `@generated` alias does not resolve. + */ +/** + * Every canonical framework slug, as a type. + * + * Deliberately spelled out rather than derived from FRAMEWORKS: deriving it + * needs `as const`, which turns the registry into 13 exact tuple members and + * drops the optional `aliases` / `unreleased` keys from the ones that omit + * them, breaking every consumer that reads those fields. So this is a second + * copy of the vocabulary - guarded the same way the schema enum is, by + * `yarn verify:frameworks`, which compares union members, registry slugs and + * the docs-schema.yml enum three ways and fails if any pair diverges. + * + * Use this, not `string`, anywhere code names a framework. `string` is what let + * `netIos` and `react` drift into the frontmatter in the first place, and it is + * why a typo in a component - `['Web']` for `['web']` - still compiles and then + * silently matches nothing. The gate cannot see inside a component's props; + * the type can. + */ +export type FrameworkSlug = + | "ios" + | "android" + | "web" + | "react-native" + | "flutter" + | "cordova" + | "capacitor" + | "kmp" + | "net-ios" + | "net-android" + | "titanium" + | "linux" + | "hosted"; + +export interface FrameworkDef { + /** Canonical slug: the docs-schema.yml enum value, and the /sdks// segment. */ + slug: FrameworkSlug; + /** Human-readable name. Used as a map key by SkillsCallout, so it is load-bearing. */ + display: string; + /** + * Path under /sdks/. Usually the slug; .NET splits across two segments. + * `null` means the framework is not an /sdks/ route at all. + */ + routeSegment: string | null; + /** An Agent Skills page exists at /sdks//agent-skills. */ + agentSkills: boolean; + /** Documented only in the in-development docs version (no versioned snapshot). */ + unreleased?: boolean; + /** + * Other spellings that must resolve to this framework. The homepage selector + * uses its own identifiers, and `?framework=` carries them into the URL. + */ + aliases?: string[]; +} + +export const FRAMEWORKS: FrameworkDef[] = [ + { slug: "ios", display: "iOS", routeSegment: "ios", agentSkills: true }, + { slug: "android", display: "Android", routeSegment: "android", agentSkills: true }, + { slug: "web", display: "Web", routeSegment: "web", agentSkills: true }, + { + slug: "react-native", + display: "React Native", + routeSegment: "react-native", + agentSkills: true, + aliases: ["react"], + }, + { slug: "flutter", display: "Flutter", routeSegment: "flutter", agentSkills: true }, + { slug: "cordova", display: "Cordova", routeSegment: "cordova", agentSkills: true }, + { slug: "capacitor", display: "Capacitor", routeSegment: "capacitor", agentSkills: true }, + { + slug: "kmp", + display: "Kotlin Multiplatform", + routeSegment: "kmp", + agentSkills: true, + unreleased: true, + }, + { + slug: "net-ios", + display: ".NET iOS", + routeSegment: "net/ios", + agentSkills: true, + aliases: ["netios"], + }, + { + slug: "net-android", + display: ".NET Android", + routeSegment: "net/android", + agentSkills: true, + aliases: ["netandroid"], + }, + // Documented, but with no Agent Skills page to route a reader to. + { slug: "titanium", display: "Titanium", routeSegment: "titanium", agentSkills: false }, + { slug: "linux", display: "Linux", routeSegment: "linux", agentSkills: false }, + // Not an /sdks/ route: the hosted products live under /hosted//. + { slug: "hosted", display: "Hosted", routeSegment: null, agentSkills: false }, +]; + +/** Canonical slugs, in registry order. */ +export const FRAMEWORK_SLUGS: string[] = FRAMEWORKS.map((f) => f.slug); + +/** Frameworks that are an /sdks/ route (everything except `hosted` today). */ +export const ROUTED_FRAMEWORKS: FrameworkDef[] = FRAMEWORKS.filter( + (f) => f.routeSegment !== null, +); + +/** Frameworks with an Agent Skills page. */ +export const AGENT_SKILL_FRAMEWORKS: FrameworkDef[] = FRAMEWORKS.filter( + (f) => f.agentSkills, +); + +/** + * Routed frameworks, longest `routeSegment` first, so `net/ios` is tested + * before `net`. A one-segment framework must never shadow a two-segment one. + */ +const BY_ROUTE_DEPTH: FrameworkDef[] = ROUTED_FRAMEWORKS.slice().sort( + (a, b) => (b.routeSegment as string).length - (a.routeSegment as string).length, +); + +/** + * The framework a path tail after `/sdks/` belongs to, or undefined. + * + * THE one place that knows how a route segment maps to a framework. Both path + * parsers call it: FeatureList via `frameworkFromPath`, and `parseSdksRoute` on + * the tail its own anchored regex captured. They used to answer this question + * separately, with different regexes - one of which captured a single segment, + * so `/sdks/net/ios/...` resolved to `net`, which is not a framework, and + * FeatureList rendered an empty table on both .NET platforms for the life of + * those pages. + * + * Derived from `routeSegment`, longest first, so a future multi-segment + * framework needs no change in either caller. + */ +export function frameworkFromRouteTail(tail: string): FrameworkDef | undefined { + return BY_ROUTE_DEPTH.find( + (f) => tail === f.routeSegment || tail.startsWith(`${f.routeSegment}/`), + ); +} + +/** + * The framework an `/sdks/` path belongs to, or undefined. + * + * Anchors on `sdks/` rather than the start of the path, so docs-version + * prefixes (`/next/`, `/7.6.14/`) work. Use `parseSdksRoute` instead when the + * product segment is needed too - but NOT for anchoring: it was unanchored in + * the same change and for the same reason, so neither function rejects a path + * with something before `/sdks/`. scripts/test-frameworks.cjs pins that. + */ +export function frameworkFromPath(pathname: string): FrameworkDef | undefined { + const m = /(?:^|\/)sdks\/(.+)$/.exec(pathname); + return m ? frameworkFromRouteTail(m[1]) : undefined; +} diff --git a/src/constants/unreleasedFrameworks.ts b/src/constants/unreleasedFrameworks.ts index ab0c4b92a7..008541c855 100644 --- a/src/constants/unreleasedFrameworks.ts +++ b/src/constants/unreleasedFrameworks.ts @@ -11,10 +11,20 @@ * Remove a slug from this list once it ships in a release and gets a * versioned_docs snapshot. * - * Kept free of imports so docusaurus.config.ts can read it too: the config is - * loaded by Node, where webpack's `@generated` alias does not resolve. + * Readable by docusaurus.config.ts, which Node loads directly - so nothing on + * this file's import chain may use a webpack alias (`@site`, `@generated`), + * since Node cannot resolve one. It imports ./frameworks, which is itself + * alias-free today; keep it that way, or the config stops loading rather than + * some browser bundle breaking. */ -export const UNRELEASED_FRAMEWORK_SLUGS = ["kmp"]; +import { FRAMEWORKS } from "./frameworks"; + +// Annotated `string[]`, not `FrameworkSlug[]`: this is a membership-test array +// queried with arbitrary route segments, and narrowing it would make +// `.includes(someString)` a type error at every call site. +export const UNRELEASED_FRAMEWORK_SLUGS: string[] = FRAMEWORKS.filter( + (f) => f.unreleased, +).map((f) => f.slug); /** True when `slug` is documented only in the current docs version. */ export function isUnreleasedFramework( diff --git a/src/data/features.json b/src/data/features.json index 8070d0a940..3310382e8d 100644 --- a/src/data/features.json +++ b/src/data/features.json @@ -14,8 +14,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/serial-number-barcode.html#serial-number-barcode" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/serial-number-barcode.html#serial-number-barcode" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/serial-number-barcode.html#serial-number-barcode" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/serial-number-barcode.html#serial-number-barcode" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/serial-number-barcode.html#serial-number-barcode" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/serial-number-barcode.html#serial-number-barcode" } } }, { @@ -33,8 +33,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/part-number-barcode.html#part-number-barcode" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/part-number-barcode.html#part-number-barcode" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/part-number-barcode.html#part-number-barcode" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/part-number-barcode.html#part-number-barcode" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/part-number-barcode.html#part-number-barcode" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/part-number-barcode.html#part-number-barcode" } } }, { @@ -52,8 +52,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/imei-one-barcode.html#imei-one-barcode" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/imei-one-barcode.html#imei-one-barcode" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/imei-one-barcode.html#imei-one-barcode" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/imei-one-barcode.html#imei-one-barcode" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/imei-one-barcode.html#imei-one-barcode" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/imei-one-barcode.html#imei-one-barcode" } } }, { @@ -71,8 +71,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/imei-two-barcode.html#imei-two-barcode" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/imei-two-barcode.html#imei-two-barcode" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/imei-two-barcode.html#imei-two-barcode" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/imei-two-barcode.html#imei-two-barcode" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/imei-two-barcode.html#imei-two-barcode" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/imei-two-barcode.html#imei-two-barcode" } } }, { @@ -90,8 +90,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/unit-price-text.html#unit-price-text" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/unit-price-text.html#unit-price-text" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/unit-price-text.html#unit-price-text" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/unit-price-text.html#unit-price-text" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/unit-price-text.html#unit-price-text" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/unit-price-text.html#unit-price-text" } } }, { @@ -109,8 +109,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/total-price-text.html#total-price-text" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/total-price-text.html#total-price-text" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/total-price-text.html#total-price-text" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/total-price-text.html#total-price-text" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/total-price-text.html#total-price-text" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/total-price-text.html#total-price-text" } } }, { @@ -128,8 +128,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/weight-text.html#weight-text" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/weight-text.html#weight-text" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/weight-text.html#weight-text" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/weight-text.html#weight-text" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/weight-text.html#weight-text" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/weight-text.html#weight-text" } } }, { @@ -147,8 +147,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/packing-date-text.html#packing-date-text" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/packing-date-text.html#packing-date-text" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/packing-date-text.html#packing-date-text" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/packing-date-text.html#packing-date-text" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/packing-date-text.html#packing-date-text" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/packing-date-text.html#packing-date-text" } } }, { @@ -166,8 +166,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/expiry-date-text.html#expiry-date-text" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/expiry-date-text.html#expiry-date-text" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/expiry-date-text.html#expiry-date-text" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/expiry-date-text.html#expiry-date-text" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/expiry-date-text.html#expiry-date-text" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/expiry-date-text.html#expiry-date-text" } } }, { @@ -185,8 +185,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.3", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/date-text.html#date-text" }, "Cordova": { "version": "8.3", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/date-text.html#date-text" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/date-text.html#date-text" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/date-text.html#date-text" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/date-text.html#date-text" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/date-text.html#date-text" } } }, { @@ -204,8 +204,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.PriceCaptureDefinitionWithName" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.PriceCaptureDefinitionWithName" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.PriceCaptureDefinitionWithName" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.PriceCaptureDefinitionWithName" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.PriceCaptureDefinitionWithName" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.PriceCaptureDefinitionWithName" } } }, { @@ -223,8 +223,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.VinLabelDefinitionWithName" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.VinLabelDefinitionWithName" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.VinLabelDefinitionWithName" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.VinLabelDefinitionWithName" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.VinLabelDefinitionWithName" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.VinLabelDefinitionWithName" } } }, { @@ -242,8 +242,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.SevenSegmentDisplayLabelDefinitionWithName" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.SevenSegmentDisplayLabelDefinitionWithName" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.SevenSegmentDisplayLabelDefinitionWithName" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.SevenSegmentDisplayLabelDefinitionWithName" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.SevenSegmentDisplayLabelDefinitionWithName" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/label-definition.html#method-scandit.datacapture.label.LabelDefinition.SevenSegmentDisplayLabelDefinitionWithName" } } }, { @@ -261,8 +261,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/receipt-scanning-result.html#receipt-scanning-result" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/receipt-scanning-result.html#receipt-scanning-result" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/receipt-scanning-result.html#receipt-scanning-result" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/receipt-scanning-result.html#receipt-scanning-result" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/receipt-scanning-result.html#receipt-scanning-result" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/receipt-scanning-result.html#receipt-scanning-result" } } }, { @@ -280,8 +280,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/custom-barcode.html#custom-barcode" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/custom-barcode.html#custom-barcode" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/custom-barcode.html#custom-barcode" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/custom-barcode.html#custom-barcode" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/custom-barcode.html#custom-barcode" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/custom-barcode.html#custom-barcode" } } }, { @@ -299,8 +299,8 @@ "Kotlin Multiplatform": { "version": "8.6" }, "Capacitor": { "version": "8.0", "apiUrl": "https://docs.scandit.com/data-capture-sdk/capacitor/label-capture/api/custom-text.html#custom-text" }, "Cordova": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/cordova/label-capture/api/custom-text.html#custom-text" }, - ".Net Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/custom-text.html#custom-text" }, - ".Net iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/custom-text.html#custom-text" } + ".NET Android": { "version": "8.1", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/android/label-capture/api/custom-text.html#custom-text" }, + ".NET iOS": { "version": "8.2", "apiUrl": "https://docs.scandit.com/data-capture-sdk/net/ios/label-capture/api/custom-text.html#custom-text" } } } ] diff --git a/src/theme/DocItem/Content/index.tsx b/src/theme/DocItem/Content/index.tsx index a0d760158a..72431b3d07 100644 --- a/src/theme/DocItem/Content/index.tsx +++ b/src/theme/DocItem/Content/index.tsx @@ -7,27 +7,37 @@ import type { WrapperProps } from '@docusaurus/types'; import SkillsCallout from '@site/src/components/SkillsCallout'; import skillsData from '@site/src/data/skills.json'; import { parseSdksRoute } from '@site/src/components/utils/frameworks'; +import { frameworkFromPath } from '@site/src/constants/frameworks'; import { isOnFallbackDenylist } from '@site/src/components/SkillsCallout/routes'; type Props = WrapperProps; const KNOWN_PRODUCTS = new Set(Object.keys(skillsData.products)); -// Frameworks with no Agent Skills at all — never show the callout there. -const SKILL_LESS_FRAMEWORK_PREFIXES = ['/sdks/titanium/', '/sdks/linux/']; - export default function ContentWrapper(props: Props): JSX.Element { const { pathname } = useLocation(); const route = parseSdksRoute(pathname); const isKnownProductPage = !!route.product && KNOWN_PRODUCTS.has(route.product); - const isSkillLessFramework = SKILL_LESS_FRAMEWORK_PREFIXES.some((prefix) => - pathname.startsWith(prefix), - ); + // Frameworks with no Agent Skills page — never show the callout there. + // Resolved through the registry rather than by prefix: this was a + // startsWith() over ['/sdks/titanium/', '/sdks/linux/'], which a docs-version + // segment defeats, so /next/sdks/titanium/ and /7.6.14/sdks/linux/ showed the + // Agent Skills banner on frameworks that have no Agent Skills at all. + const routeFramework = frameworkFromPath(pathname); + const isSkillLessFramework = !!routeFramework && !routeFramework.agentSkills; - // ID Bolt docs live under /hosted/, outside the /sdks/ product routes, so - // the route-driven callout never fires there. Surface its skill explicitly. + // ID Bolt docs live under /hosted/, outside the /sdks/ product routes, so the + // route-driven callout never fires there. Surface its skill explicitly. + // + // Matched anywhere in the path, not with startsWith, for the same reason the + // titanium/linux check above was rewritten: a prefix defeats an anchor. Two of + // them occur here - a docs-version segment once an ID Bolt snapshot is cut, and + // the site base url, which PR previews set to + // /data-capture-documentation/pr-preview/pr-N/ - and on a preview this silently + // rendered the generic shared callout instead of the ID Bolt one. const isIdBoltPage = - pathname.startsWith('/hosted/id-bolt/') && !isOnFallbackDenylist(pathname); + /(?:^|\/)(?:next\/|\d+\.\d+\.\d+\/)?hosted\/id-bolt\//.test(pathname) && + !isOnFallbackDenylist(pathname); let callout: JSX.Element | null; if (isSkillLessFramework) {