diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
index e3ba9294ad..c7b453c3f2 100644
--- a/.github/workflows/build-docs.yml
+++ b/.github/workflows/build-docs.yml
@@ -77,6 +77,16 @@ jobs:
- name: Verify product registry
run: yarn verify:registry
+ # The chooser renders nothing until it has approved text, so a build
+ # never reaches its link code; this pins the links it will build.
+ - name: Test Product Chooser links
+ run: yarn test:product-chooser
+
+ # The build transpiles TypeScript without checking types, so a type
+ # error in a component or plugin only fails here.
+ - name: Typecheck
+ run: yarn typecheck
+
# Pins where the 404 page sends a broken link into the C API reference.
- name: Test C API 404 fallback
run: yarn test:not-found
diff --git a/docs/partials/intro/_about-matrixscan-ar.mdx b/docs/partials/intro/_about-matrixscan-ar.mdx
index 46ded1c113..1e79f05b2e 100644
--- a/docs/partials/intro/_about-matrixscan-ar.mdx
+++ b/docs/partials/intro/_about-matrixscan-ar.mdx
@@ -1,8 +1,14 @@
+import ProductChooser from '@site/src/components/ProductChooser';
+
MatrixScan AR enables you to build applications and workflows involving augmented reality overlays to highlight barcodes and display additional information over them.
-MatrixScan is powered by the `BarcodeAr` functionality of the Scandit Smart Data Capture SDK. It can be used to add the following customizabe UI elements to your workflows:
+MatrixScan AR is powered by the `BarcodeAr` functionality of the Scandit Smart Data Capture SDK.
+
+
+
+MatrixScan AR can be used to add the following customizable UI elements to your workflows:
## Highlights
diff --git a/docs/partials/intro/_about-matrixscan-count.mdx b/docs/partials/intro/_about-matrixscan-count.mdx
index 2a3f6a2b7e..846eced380 100644
--- a/docs/partials/intro/_about-matrixscan-count.mdx
+++ b/docs/partials/intro/_about-matrixscan-count.mdx
@@ -1,7 +1,11 @@
+import ProductChooser from '@site/src/components/ProductChooser';
+
MatrixScan Count is our pre-built scan and count solution for counting and receiving multiple items at once. It fits on top of any smartphone application, providing an intuitive user interface for simple, fast and ergonomic scanning. MatrixScan Count enables the accurate scanning and counting of multiple items at once via smart devices, speeding counting workflows by up to 10 times. The solution is designed to boost worker productivity, reduce human error and maintain accurate stock levels.
MatrixScan Count bundles multiple scanning features together and addresses many common challenges associated with scanning on smart devices. It is designed to be easily integrated into any application, and can be customized to fit your specific needs.
+
+
## UI Overview
MatrixScan Count includes pre-built and pre-tested user interface (UI) elements and interactions in workflows. These UI elements are intentionally minimalistic, meant to be overlayed on any application without the need to adapt the existing app while offering the best user experience.
diff --git a/docs/partials/intro/_about-matrixscan-find.mdx b/docs/partials/intro/_about-matrixscan-find.mdx
index 8897935f39..4ef930e75f 100644
--- a/docs/partials/intro/_about-matrixscan-find.mdx
+++ b/docs/partials/intro/_about-matrixscan-find.mdx
@@ -1,9 +1,13 @@
+import ProductChooser from '@site/src/components/ProductChooser';
+
MatrixScan Find is our pre-built UI that uses augmented reality overlays to highlight items that match predefined criteria. This is distinct from MatrixScan AR that is fully customizable, MatrixScan Find enables you to add a search and find experience with augmented reality to an existing native app with just a few lines of code.
MatrixScan Find bundles multiple scanning features together and addresses many common challenges associated with scanning on smart devices. It is designed to be easily integrated into any application, and can be customized to fit your specific needs.
+
+
## UI Overview
MatrixScan Find includes pre-built and pre-tested user interface (UI) elements and interactions in workflows. These UI elements are intentionally minimalistic, meant to be overlayed on any application without the need to adapt the existing app while offering the best user experience.
diff --git a/docs/partials/intro/_about-matrixscan-pick.mdx b/docs/partials/intro/_about-matrixscan-pick.mdx
index 9ad509cd2e..5836d01bae 100644
--- a/docs/partials/intro/_about-matrixscan-pick.mdx
+++ b/docs/partials/intro/_about-matrixscan-pick.mdx
@@ -1,3 +1,4 @@
+import ProductChooser from '@site/src/components/ProductChooser';
import Link from '@docusaurus/Link';
import ApiLink from '@site/src/components/ApiLink';
@@ -5,6 +6,8 @@ MatrixScan Pick is a pre-built UI that uses augmented reality overlays to highli
MatrixScan Pick is implemented through functionality provided by BarcodePick.
+
+
## UI Overview
* MatrixScan Pick is inspired by the familiar paradigm of a camera, including a shutter button that the user operates in order start and pause the scanning view. The Finish button is used at any time to exit the workflow.
diff --git a/docs/partials/intro/_about-matrixscan.mdx b/docs/partials/intro/_about-matrixscan.mdx
index c8bd818cb1..06714c7ef4 100644
--- a/docs/partials/intro/_about-matrixscan.mdx
+++ b/docs/partials/intro/_about-matrixscan.mdx
@@ -1,9 +1,13 @@
+import ProductChooser from '@site/src/components/ProductChooser';
+
MatrixScan Batch enables you to build applications and workflows involving highlighting and/or interacting with multiple barcodes within the same frame.
MatrixScan Batch is powered by the `BarcodeBatch` functionality of the Scandit Smart Data Capture SDK.
+
+
## Limitations
MatrixScan Batch does not support the following symbologies:
diff --git a/docs/partials/intro/_about-sparkscan.mdx b/docs/partials/intro/_about-sparkscan.mdx
index e9aa1dc186..06f4adbefe 100644
--- a/docs/partials/intro/_about-sparkscan.mdx
+++ b/docs/partials/intro/_about-sparkscan.mdx
@@ -1,9 +1,12 @@
+import ProductChooser from '@site/src/components/ProductChooser';
import Link from '@docusaurus/Link';
SparkScan is our pre-built smartphone scanning interface designed for high-performance barcode scanning. It fits on top of any smartphone application, providing an intuitive user interface for simple, fast and ergonomic scanning in scan-intensive workflows, such as inventory management in retail, or goods receiving in logistics.
SparkScan bundles multiple scanning features together and addresses many common challenges associated with scanning on smart devices. It is designed to be easily integrated into any application, and can be customized to fit your specific needs.
+
+
## UI Overview
The UI elements in SparkScan are intentionally minimalistic, meant to be overlayed on any application without the need to adapt the existing app while offering the best user experience.
diff --git a/docs/sdks/kmp/matrixscan-ar/intro.md b/docs/sdks/kmp/matrixscan-ar/intro.md
index cbae300958..fb4b40baf2 100644
--- a/docs/sdks/kmp/matrixscan-ar/intro.md
+++ b/docs/sdks/kmp/matrixscan-ar/intro.md
@@ -8,6 +8,8 @@ keywords:
- kmp
---
+import ProductChooser from '@site/src/components/ProductChooser';
+
# About MatrixScan AR
MatrixScan AR is Scandit's most advanced and flexible API for building custom augmented-reality workflows that scan and track multiple barcodes at once. It is a superset of [MatrixScan Batch](../matrixscan/intro.md): it tracks the same set of barcodes across frames, but adds a prebuilt AR view with configurable highlights, tap-driven annotations, torch/zoom/camera-switch controls, and audio/haptic feedback.
@@ -19,6 +21,8 @@ MatrixScan AR is implemented through two elements, both in the `com.kmp.datacapt
- `BarcodeAr`—the data capture mode driving scan-and-track recognition. Unlike most other modes, `BarcodeAr` is not a `DataCaptureMode`: it attaches itself to a `DataCaptureContext` when constructed via `BarcodeAr.forContext(...)`, so you never call `addMode()`/`removeMode()` for it.
- `BarcodeArView`—the prebuilt AR UI that renders highlights and annotations over tracked barcodes and hosts the camera preview.
+
+
## Highlights
Used to enable the user to identify the scanned barcode. They are shown on top of the respective barcode and displayed as soon as the barcode is scanned.
diff --git a/docs/sdks/kmp/matrixscan-count/intro.md b/docs/sdks/kmp/matrixscan-count/intro.md
index e473824636..7b4606cbca 100644
--- a/docs/sdks/kmp/matrixscan-count/intro.md
+++ b/docs/sdks/kmp/matrixscan-count/intro.md
@@ -8,6 +8,8 @@ keywords:
- kmp
---
+import ProductChooser from '@site/src/components/ProductChooser';
+
# About MatrixScan Count
MatrixScan Count is our pre-built scan and count solution for counting and receiving multiple items at once. It fits on top of any Kotlin Multiplatform application, providing an intuitive user interface for simple, fast and ergonomic scanning. MatrixScan Count enables the accurate scanning and counting of multiple items at once via smart devices, speeding up counting workflows considerably. The solution is designed to boost worker productivity, reduce human error and maintain accurate stock levels.
@@ -16,6 +18,8 @@ MatrixScan Count bundles multiple scanning features together and addresses many
On Kotlin Multiplatform, MatrixScan Count is exposed through the `BarcodeCount` data capture mode and the `BarcodeCountView` built-in UI, both of which are shared between Android and iOS.
+
+
## UI Overview
MatrixScan Count includes pre-built and pre-tested user interface (UI) elements and interactions. These UI elements are intentionally minimalistic, meant to be overlaid on any application without the need to adapt the existing app while offering the best user experience.
diff --git a/docs/sdks/kmp/matrixscan-find/intro.md b/docs/sdks/kmp/matrixscan-find/intro.md
index da7698779b..0643da2042 100644
--- a/docs/sdks/kmp/matrixscan-find/intro.md
+++ b/docs/sdks/kmp/matrixscan-find/intro.md
@@ -8,6 +8,8 @@ keywords:
- kmp
---
+import ProductChooser from '@site/src/components/ProductChooser';
+
# About MatrixScan Find
MatrixScan Find is our pre-built UI that uses augmented reality overlays to highlight items that match predefined criteria. It enables you to add a search-and-find experience with augmented reality to an existing Kotlin Multiplatform app with just a few lines of code.
@@ -18,6 +20,8 @@ MatrixScan Find bundles multiple scanning features together and addresses many c
On Kotlin Multiplatform, MatrixScan Find is exposed through the `BarcodeFind` data capture mode and the `BarcodeFindView` built-in UI, both of which are shared between Android and iOS.
+
+
## UI Overview
MatrixScan Find includes pre-built and pre-tested user interface (UI) elements and interactions. These UI elements are intentionally minimalistic, meant to be overlaid on any application without the need to adapt the existing app while offering the best user experience.
diff --git a/docs/sdks/kmp/matrixscan-pick/intro.md b/docs/sdks/kmp/matrixscan-pick/intro.md
index e219a25a17..530a5ed365 100644
--- a/docs/sdks/kmp/matrixscan-pick/intro.md
+++ b/docs/sdks/kmp/matrixscan-pick/intro.md
@@ -8,12 +8,16 @@ keywords:
- kmp
---
+import ProductChooser from '@site/src/components/ProductChooser';
+
# About MatrixScan Pick
MatrixScan Pick is a pre-built UI that uses augmented reality overlays to highlight specific items that need to be picked. Whereas MatrixScan AR is fully customizable, MatrixScan Pick is a pre-built solution that allows you to add a scan and pick experience with augmented reality to an existing app, with just a few lines of code.
MatrixScan Pick is implemented through functionality provided by `BarcodePick`.
+
+
## UI Overview
- MatrixScan Pick is inspired by the familiar paradigm of a camera, including a shutter button that the user operates to start and pause the scanning view. The Finish button is used at any time to exit the workflow.
diff --git a/docs/sdks/kmp/matrixscan/intro.md b/docs/sdks/kmp/matrixscan/intro.md
index e9b3f9c8fa..212bb7d03d 100644
--- a/docs/sdks/kmp/matrixscan/intro.md
+++ b/docs/sdks/kmp/matrixscan/intro.md
@@ -8,6 +8,8 @@ keywords:
- kmp
---
+import ProductChooser from '@site/src/components/ProductChooser';
+
# About MatrixScan Batch
MatrixScan Batch is Scandit's low-level API for scanning and tracking multiple barcodes at once, without augmented-reality overlays. It is implemented through the `BarcodeBatch` data capture mode, in the `com.kmp.datacapture.barcode.batch` package of the `barcode` module.
@@ -20,6 +22,8 @@ Typical use cases include:
- Receiving and put-away workflows
- Any scenario where you need to recognize and track several barcodes in the camera's field of view at the same time, without guiding the user through a check-off flow
+
+
## How It Works
`BarcodeBatch` tracks barcodes across frames, assigning each one a stable tracking identifier via [`TrackedBarcode`](./get-started.md#track-the-results). You can either:
diff --git a/docs/sdks/kmp/sparkscan/intro.md b/docs/sdks/kmp/sparkscan/intro.md
index 69e0ac0481..daf010f233 100644
--- a/docs/sdks/kmp/sparkscan/intro.md
+++ b/docs/sdks/kmp/sparkscan/intro.md
@@ -9,12 +9,16 @@ keywords:
- kmp
---
+import ProductChooser from '@site/src/components/ProductChooser';
+
# About SparkScan
SparkScan is our pre-built smartphone scanning interface designed for high-performance barcode scanning. It fits on top of any smartphone application, providing an intuitive user interface for simple, fast and ergonomic scanning in scan-intensive workflows, such as inventory management in retail, or goods receiving in logistics.
SparkScan bundles multiple scanning features together and addresses many common challenges associated with scanning on smart devices. It is designed to be easily integrated into any application, and can be customized to fit your specific needs.
+
+
## UI Overview
The UI elements in SparkScan are intentionally minimalistic, meant to be overlaid on any application without the need to adapt the existing app while offering the best user experience.
diff --git a/docusaurus.config.ts b/docusaurus.config.ts
index 9a5ba94934..711f360d16 100644
--- a/docusaurus.config.ts
+++ b/docusaurus.config.ts
@@ -8,6 +8,7 @@ import { version } from "react";
import remarkHideComments from "./src/plugins/remark-hide-comments";
import remarkOffloadPreviewMedia from "./src/plugins/remark-offload-preview-media";
import stripPreviewMediaPlugin from "./src/plugins/plugin-strip-preview-media";
+import productRegistryPlugin from "./src/plugins/plugin-product-registry";
import { UNRELEASED_FRAMEWORK_SLUGS } from "./src/constants/unreleasedFrameworks";
import { ROUTED_FRAMEWORKS } from "./src/constants/frameworks";
dotenv.config(); // Load environment variables from .env file
@@ -1364,6 +1365,9 @@ const config: Config = {
},
],
...(isPreviewBuild ? [stripPreviewMediaPlugin] : []),
+ // The product registry and approved Product Chooser text, as global data
+ // for components (usePluginData("product-registry")).
+ productRegistryPlugin,
// Publish the exact tag set the built widget filters on, so the search-tag
// gate can check a real build artifact instead of re-deriving the same
// assumption from this file and agreeing with itself.
diff --git a/package.json b/package.json
index 5b210c6ffc..194da5f00a 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"test:api-link": "node scripts/test-api-link.cjs",
"verify:registry": "node scripts/verify-registry.cjs",
"test:registry": "node scripts/test-registry.cjs",
+ "test:product-chooser": "node scripts/test-product-chooser.cjs",
"test:not-found": "node scripts/test-not-found.cjs",
"test:docs-gate": "node scripts/test-docs-gate.cjs",
"verify:api-reference-seo": "node scripts/verify-api-reference-seo.cjs",
diff --git a/scripts/test-product-chooser.cjs b/scripts/test-product-chooser.cjs
new file mode 100644
index 0000000000..2e660033ea
--- /dev/null
+++ b/scripts/test-product-chooser.cjs
@@ -0,0 +1,125 @@
+#!/usr/bin/env node
+"use strict";
+/**
+ * Tests for the Product Chooser's links and the alternatives it shows
+ * (src/components/ProductChooser/links.ts).
+ *
+ * The chooser renders nothing until content/registry/product-chooser.yml has
+ * an approved entry, so a build does not reach this code yet. These cases pin
+ * what it links to: the doc paths the docs plugin publishes carry the base
+ * URL, and a preview build serves the site under /data-capture-documentation/
+ * pr-preview/pr-N/, so the prefix cut from the pathname must line up with them.
+ *
+ * Transpiles the TypeScript source with the repo's own `typescript`, so it
+ * runs on the Node version CI uses, which cannot load .ts files.
+ *
+ * Usage: node scripts/test-product-chooser.cjs
+ */
+
+const assert = require("assert");
+const path = require("path");
+const fs = require("fs");
+const { createRequire } = require("module");
+const ts = require("typescript");
+
+function loadTs(file) {
+ const { outputText } = ts.transpileModule(fs.readFileSync(file, "utf8"), {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2019 },
+ fileName: file,
+ });
+ const mod = { exports: {} };
+ // `require` relative to the loaded file, not to this script.
+ new Function("module", "exports", "require", outputText)(mod, mod.exports, createRequire(file));
+ return mod.exports;
+}
+
+let passed = 0;
+function test(name, fn) {
+ try {
+ fn();
+ passed++;
+ } catch (e) {
+ console.error(`✗ ${name}\n ${e.message}`);
+ process.exitCode = 1;
+ }
+}
+
+const { docsPrefix, overviewUrl, visibleAlternatives } = loadTs(path.join(__dirname, "../src/components/ProductChooser/links.ts"));
+
+const PREVIEW = "/data-capture-documentation/pr-preview/pr-1";
+// Doc paths as the docs plugin publishes them, base URL included. Some with a
+// trailing slash and some without, as both occur.
+const docs = (base) =>
+ [
+ "/sdks/ios/sparkscan/intro/",
+ "/sdks/ios/matrixscan/intro",
+ "/sdks/ios/barcode-capture/get-started/",
+ "/sdks/ios/barcode-generator/",
+ "/sdks/kmp/matrixscan/intro/",
+ "/sdks/net/ios/matrixscan/intro/",
+ "/hosted/express/overview/",
+ ].map((p) => ({ path: `${base}${p}` }));
+const sdk = (folder) => ({ kind: "sdk", folder });
+const hosted = (folder) => ({ kind: "hosted", folder });
+
+test("docsPrefix is everything before /sdks/", () => {
+ assert.strictEqual(docsPrefix("/sdks/ios/sparkscan/intro/"), "");
+ assert.strictEqual(docsPrefix(`${PREVIEW}/sdks/ios/sparkscan/intro/`), PREVIEW);
+ assert.strictEqual(docsPrefix("/next/sdks/ios/sparkscan/intro/"), "/next");
+ assert.strictEqual(docsPrefix("/hosted/express/overview/"), "");
+});
+
+for (const [label, base] of [["at the site root", ""], ["on a preview build", PREVIEW]]) {
+ const prefix = docsPrefix(`${base}/sdks/ios/sparkscan/intro/`);
+ test(`${label}: links to an intro, with or without a trailing slash`, () => {
+ assert.strictEqual(overviewUrl(docs(base), prefix, "ios", sdk("sparkscan")), `${base}/sdks/ios/sparkscan/intro/`);
+ assert.strictEqual(overviewUrl(docs(base), prefix, "ios", sdk("matrixscan")), `${base}/sdks/ios/matrixscan/intro/`);
+ });
+ test(`${label}: falls back to get-started, then to a flat page`, () => {
+ assert.strictEqual(
+ overviewUrl(docs(base), prefix, "ios", sdk("barcode-capture")),
+ `${base}/sdks/ios/barcode-capture/get-started/`,
+ );
+ assert.strictEqual(
+ overviewUrl(docs(base), prefix, "ios", sdk("barcode-generator")),
+ `${base}/sdks/ios/barcode-generator/`,
+ );
+ });
+ test(`${label}: no page on this framework gives null`, () => {
+ assert.strictEqual(overviewUrl(docs(base), prefix, "android", sdk("sparkscan")), null);
+ assert.strictEqual(overviewUrl(docs(base), prefix, "ios", sdk("smart-label-capture")), null);
+ });
+ test(`${label}: a hosted product links to its one overview`, () => {
+ assert.strictEqual(overviewUrl(docs(base), prefix, "ios", hosted("express")), `${base}/hosted/express/overview/`);
+ assert.strictEqual(overviewUrl(docs(base), prefix, "ios", hosted("id-bolt")), null);
+ });
+ test(`${label}: uses the route segment, not the slug`, () => {
+ assert.strictEqual(overviewUrl(docs(base), prefix, "kmp", sdk("matrixscan")), `${base}/sdks/kmp/matrixscan/intro/`);
+ assert.strictEqual(
+ overviewUrl(docs(base), prefix, "net/ios", sdk("matrixscan")),
+ `${base}/sdks/net/ios/matrixscan/intro/`,
+ );
+ assert.strictEqual(overviewUrl(docs(base), prefix, "net-ios", sdk("matrixscan")), null);
+ });
+}
+
+const notFor = [
+ { when: "count many", use: "matrixscan-count" },
+ { when: "hosted flow", use: "express" },
+];
+test("shows only the alternatives available on the framework, in order", () => {
+ const onWeb = (key) => key !== "matrixscan-count";
+ assert.deepStrictEqual(visibleAlternatives(notFor, onWeb), [notFor[1]]);
+ assert.deepStrictEqual(visibleAlternatives(notFor, () => true), notFor);
+});
+test("no available alternative leaves an empty list (one column)", () => {
+ assert.deepStrictEqual(visibleAlternatives(notFor, () => false), []);
+ assert.deepStrictEqual(visibleAlternatives([], () => true), []);
+});
+test("asks about each alternative's product key", () => {
+ const asked = [];
+ visibleAlternatives(notFor, (key) => (asked.push(key), true));
+ assert.deepStrictEqual(asked, ["matrixscan-count", "express"]);
+});
+
+console.log(`test-product-chooser: ${passed} passed${process.exitCode ? ", some FAILED" : ""}`);
diff --git a/src/components/FeatureList/index.tsx b/src/components/FeatureList/index.tsx
index 1c5a16051d..338f18c554 100644
--- a/src/components/FeatureList/index.tsx
+++ b/src/components/FeatureList/index.tsx
@@ -1,27 +1,15 @@
import React, { useState, useEffect } from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import styles from './styles.module.css';
-import { FeatureListProps, Feature, Product, FilteredFeature } from './types';
+import { FeatureListProps, Feature, FilteredFeature } from './types';
// Import data files
-import productsData from '@site/src/data/products.json';
import featuresData from '@site/src/data/features.json';
import { frameworkFromPath } from '@site/src/constants/frameworks';
import { apiReferenceUrl } from '@site/src/constants/docsPaths';
import { featureExistsIn } from './availability';
import { useActiveDocContext } from '@docusaurus/plugin-content-docs/client';
-
-// Function to render description with inline code formatting
-const renderDescription = (description: string) => {
- // Convert backticks to inline code elements
- const parts = description.split(/(`[^`]+`)/g);
- return parts.map((part, index) => {
- if (part.startsWith('`') && part.endsWith('`')) {
- return {part.slice(1, -1)};
- }
- return part;
- });
-};
+import { renderInlineCode as renderDescription } from '@site/src/components/utils/inlineCode';
const FeatureList: React.FC = ({
product,
diff --git a/src/components/ProductChooser/index.tsx b/src/components/ProductChooser/index.tsx
new file mode 100644
index 0000000000..e7064ff2b7
--- /dev/null
+++ b/src/components/ProductChooser/index.tsx
@@ -0,0 +1,124 @@
+import React, { useId, useMemo } from 'react';
+import clsx from 'clsx';
+import Link from '@docusaurus/Link';
+import { useLocation } from '@docusaurus/router';
+import { usePluginData } from '@docusaurus/useGlobalData';
+import { useActivePluginAndVersion } from '@docusaurus/plugin-content-docs/client';
+import { parseSdksRoute } from '@site/src/components/utils/frameworks';
+import { renderInlineCode } from '@site/src/components/utils/inlineCode';
+import { isSupportedVersion, PRODUCTS_BY_KEY } from '@site/src/components/utils/products';
+import { FRAMEWORKS } from '@site/src/constants/frameworks';
+import type { ProductRegistryData } from '@site/src/plugins/plugin-product-registry';
+import { docsPrefix, overviewUrl, visibleAlternatives } from './links';
+import styles from './styles.module.css';
+
+/**
+ * ProductChooser: a "Choose this if / Consider another product if" block plus
+ * the frameworks the product is available on, for product overview pages.
+ *
+ *
+ *
+ * Everything comes from single sources, so the block cannot drift:
+ * - the page's product and framework come from its URL
+ * (/sdks///...), read the same way as the rest
+ * of the site reads them (parseSdksRoute);
+ * - the text comes from content/registry/product-chooser.yml, through the
+ * product-registry plugin, which passes on APPROVED entries only;
+ * - "Available on" comes from src/data/products.json.
+ *
+ * It renders nothing until the product has an approved entry, so it can sit in
+ * a shared intro partial before product owners have signed the text off.
+ */
+
+// Whether products.json lists `key` as available on a framework (display name).
+// A product products.json doesn't cover (hosted) is treated as available.
+function availableOnFramework(key: string, framework: string | undefined): boolean {
+ const json = PRODUCTS_BY_KEY.get(key);
+ if (!json?.frameworks || !framework) return true;
+ return isSupportedVersion(json.frameworks[framework]);
+}
+
+// "since 6.8", or "since C API 4.6" where the framework counts its own
+// releases (see `versionScheme` in the framework registry).
+function sinceLabel(display: string, version: string): string {
+ const scheme = FRAMEWORKS.find((f) => f.display === display)?.versionScheme;
+ return scheme ? `since ${scheme} ${version}` : `since ${version}`;
+}
+
+export default function ProductChooser(): JSX.Element | null {
+ const { pathname } = useLocation();
+ const matrixId = useId();
+ const activeVersion = useActivePluginAndVersion()?.activeVersion;
+ const data = usePluginData('product-registry') as ProductRegistryData | undefined;
+ const byKey = useMemo(() => new Map((data?.products ?? []).map((p) => [p.key, p])), [data]);
+ const { framework, product: productKey } = parseSdksRoute(pathname);
+ const entry = productKey ? data?.chooser[productKey] : undefined;
+ const routeSegment = FRAMEWORKS.find((f) => f.display === framework)?.routeSegment;
+ if (!data || !productKey || !entry || !routeSegment) return null;
+
+ // The in-development docs ('current') only. The chooser text and
+ // products.json are not versioned: they describe what docs/ describes. On
+ // any snapshot they would show another release's facts, including the stable
+ // one served at the site root during a beta, where products.json already
+ // lists the beta's frameworks and versions. No snapshot imports these
+ // partials today, but scripts/update-version.py copies docs/ verbatim,
+ // partials included, so the next one will.
+ if (activeVersion?.name !== 'current') return null;
+
+ const prefix = docsPrefix(pathname);
+ const name = byKey.get(productKey)?.name ?? productKey;
+ // With the version: "since 6.8" answers a question "iOS" alone doesn't.
+ const availableOn = Object.entries(PRODUCTS_BY_KEY.get(productKey)?.frameworks ?? {})
+ .filter(([, e]) => isSupportedVersion(e))
+ .map(([display, e]) => ({ display, version: String(e.version).trim() }));
+ const alternatives = visibleAlternatives(entry.not_for, (use) => availableOnFramework(use, framework));
+ const hasAlternatives = alternatives.length > 0;
+
+ return (
+
+ );
+}
diff --git a/src/components/ProductChooser/links.ts b/src/components/ProductChooser/links.ts
new file mode 100644
index 0000000000..6a62b97cb0
--- /dev/null
+++ b/src/components/ProductChooser/links.ts
@@ -0,0 +1,55 @@
+import type { ChooserEntry, RegistryProduct } from '../../plugins/plugin-product-registry';
+
+// Pure functions with type-only imports: scripts/test-product-chooser.cjs
+// transpiles this file with the repo's typescript and runs it in plain Node,
+// which knows neither Docusaurus aliases nor the site's modules.
+
+const withSlash = (path: string) => (path.endsWith('/') ? path : `${path}/`);
+
+/**
+ * Everything in a /sdks/ pathname before `/sdks/`: the base URL plus the docs
+ * version path, if any, without a trailing slash
+ * (`/data-capture-documentation/pr-preview/pr-1`, `/next`). The docs plugin's
+ * doc paths carry the same prefix.
+ */
+export function docsPrefix(pathname: string): string {
+ const i = pathname.indexOf('/sdks/');
+ return i < 0 ? '' : pathname.slice(0, i);
+}
+
+/**
+ * The other product's overview on the same framework, in the same docs
+ * version: its intro page, else its get-started page (Barcode Capture has no
+ * intro), else its single page (Barcode Generator is one flat page), else
+ * null, and the name is shown as plain text. A hosted product has one
+ * overview, under /hosted/. `routeSegment` is the framework's URL path, which
+ * is not its slug on .NET (`net/ios`, not `net-ios`). `docs` are the active
+ * version's docs, whose paths carry the base URL, as the docs plugin
+ * publishes them.
+ */
+export function overviewUrl(
+ docs: readonly { path: string }[],
+ prefix: string,
+ routeSegment: string,
+ target: Pick,
+): string | null {
+ const base = `${prefix}/sdks/${routeSegment}/${target.folder}/`;
+ const candidates =
+ target.kind === 'hosted'
+ ? [`${prefix}/hosted/${target.folder}/overview/`]
+ : [`${base}intro/`, `${base}get-started/`, base];
+ return candidates.find((c) => docs.some((d) => withSlash(d.path) === c)) ?? null;
+}
+
+/**
+ * The "Consider another product if" rows to show: only those whose product the
+ * reader can use on this framework. The column offers answers, and a product
+ * missing here is a dead end, not one. With none left, the block shows one
+ * column.
+ */
+export function visibleAlternatives(
+ notFor: ChooserEntry['not_for'],
+ isAvailable: (productKey: string) => boolean,
+): ChooserEntry['not_for'] {
+ return notFor.filter(({ use }) => isAvailable(use));
+}
diff --git a/src/components/ProductChooser/styles.module.css b/src/components/ProductChooser/styles.module.css
new file mode 100644
index 0000000000..bd56bbc032
--- /dev/null
+++ b/src/components/ProductChooser/styles.module.css
@@ -0,0 +1,120 @@
+.chooser {
+ margin: 1.5rem 0 2rem;
+ border: 1px solid var(--ifm-color-emphasis-200);
+ border-radius: 10px;
+ overflow: hidden;
+ background: var(--ifm-background-surface-color);
+}
+
+.decide {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1px;
+ background: var(--ifm-color-emphasis-200);
+}
+
+/* No "consider another product" column: don't leave half the box blank. */
+.decide.single {
+ grid-template-columns: 1fr;
+}
+
+@media (max-width: 700px) {
+ .decide {
+ grid-template-columns: 1fr;
+ }
+}
+
+.col {
+ background: var(--ifm-background-surface-color);
+ padding: 1rem 1.25rem 1.1rem;
+}
+
+.colTitle {
+ margin: 0 0 0.6rem;
+ font-size: 0.82rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ /* The body text colour: the green and amber shades fail WCAG AA contrast for
+ text this small. The title text carries the meaning; the dot repeats it. */
+ color: var(--ifm-font-color-base);
+}
+
+.colTitle::before {
+ content: '';
+ width: 0.7rem;
+ height: 0.7rem;
+ border-radius: 50%;
+ flex: none;
+}
+
+.good .colTitle::before {
+ background: var(--ifm-color-success);
+}
+
+.bad .colTitle::before {
+ /* The default amber is about 1.7:1 on white, under the 3:1 for graphics. */
+ background: var(--ifm-color-warning-darkest);
+}
+[data-theme='dark'] .bad .colTitle::before {
+ background: var(--ifm-color-warning);
+}
+
+.col ul {
+ margin: 0;
+ padding-left: 1.1rem;
+}
+
+/* (0,2,1) beats Infima's `.markdown li + li` (0,1,2) whatever the load order. */
+.chooser .col li {
+ margin: 0.35rem 0;
+ font-size: 0.92rem;
+ line-height: 1.45;
+}
+
+.matrix {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem 0.6rem;
+ padding: 0.8rem 1.25rem;
+ border-top: 1px solid var(--ifm-color-emphasis-200);
+}
+
+.matrixLabel {
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ /* emphasis-600 is about 3:1 on white, too faint for text this small. */
+ color: var(--ifm-color-emphasis-700);
+}
+
+.chips {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+
+.chips .chip {
+ /* Beats Infima's `.markdown li + li` top margin, which would stagger the row. */
+ margin: 0;
+ font-size: 0.78rem;
+ font-weight: 600;
+ padding: 0.15rem 0.55rem;
+ border-radius: 999px;
+ background: var(--ifm-color-emphasis-100);
+ color: var(--ifm-color-emphasis-800);
+ border: 1px solid var(--ifm-color-emphasis-200);
+}
+
+.since {
+ font-weight: 400;
+ color: var(--ifm-color-emphasis-700);
+}
diff --git a/src/components/utils/inlineCode.tsx b/src/components/utils/inlineCode.tsx
new file mode 100644
index 0000000000..bc845a84ba
--- /dev/null
+++ b/src/components/utils/inlineCode.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+
+/**
+ * Renders backticked spans (`BarcodeBatch`) in data-file text as inline code
+ * and leaves the rest as plain text. Shared by every component that shows
+ * prose from src/data or content/registry, so the same text renders the same
+ * way everywhere.
+ */
+export function renderInlineCode(text: string): React.ReactNode[] {
+ // split() with a capture group puts the captured spans at the odd indices.
+ return text
+ .split(/(`[^`]+`)/g)
+ .map((part, i) => (i % 2 === 1 ? {part.slice(1, -1)} : part));
+}
diff --git a/src/components/utils/products.ts b/src/components/utils/products.ts
new file mode 100644
index 0000000000..c5eccb76d2
--- /dev/null
+++ b/src/components/utils/products.ts
@@ -0,0 +1,20 @@
+import productsData from '@site/src/data/products.json';
+import type { FrameworkInfo, Product } from '@site/src/components/FeatureList/types';
+
+/**
+ * products.json, by product key. Built once: the file is fixed at build time.
+ */
+export const PRODUCTS_BY_KEY: ReadonlyMap = new Map(
+ (productsData as Product[]).map((p) => [p.key, p]),
+);
+
+/**
+ * Whether a products.json or features.json framework entry means "supported".
+ * Both files mark a framework the product does NOT support with version "n/a".
+ * Used by ProductChooser for products.json rows. FeatureList checks features
+ * with featureExistsIn, which also compares the docs version.
+ */
+export function isSupportedVersion(info: Partial | undefined): boolean {
+ const v = String(info?.version ?? '').trim().toLowerCase();
+ return v !== '' && v !== 'n/a';
+}
diff --git a/src/constants/frameworks.ts b/src/constants/frameworks.ts
index cdc0882bb2..a905831993 100644
--- a/src/constants/frameworks.ts
+++ b/src/constants/frameworks.ts
@@ -59,6 +59,12 @@ export interface FrameworkDef {
* `null` means the framework is not an /sdks/ route at all.
*/
routeSegment: string | null;
+ /**
+ * The release line this framework's products.json versions count in, when it
+ * is not the SDK's. Linux ships the C API, numbered 4.x/5.x, so a bare "4.6"
+ * next to iOS "6.0" would read as two majors earlier.
+ */
+ versionScheme?: string;
/** An Agent Skills page exists at /sdks//agent-skills. */
agentSkills: boolean;
/**
@@ -111,7 +117,14 @@ export const FRAMEWORKS: FrameworkDef[] = [
},
// Documented, but with no Agent Skills page to route a reader to.
{ slug: "titanium", display: "Titanium", routeSegment: "titanium", agentSkills: false, apiReferenceSegment: null },
- { slug: "linux", display: "Linux", routeSegment: "linux", agentSkills: false, apiReferenceSegment: null },
+ {
+ slug: "linux",
+ display: "Linux",
+ routeSegment: "linux",
+ agentSkills: false,
+ apiReferenceSegment: null,
+ versionScheme: "C API",
+ },
// Not an /sdks/ route: the hosted products live under /hosted//.
{ slug: "hosted", display: "Hosted", routeSegment: null, agentSkills: false, apiReferenceSegment: null },
];
diff --git a/src/plugins/plugin-product-registry.ts b/src/plugins/plugin-product-registry.ts
new file mode 100644
index 0000000000..8b4780ed0f
--- /dev/null
+++ b/src/plugins/plugin-product-registry.ts
@@ -0,0 +1,86 @@
+import { promises as fs } from 'fs';
+import path from 'path';
+import type { LoadContext, Plugin } from '@docusaurus/types';
+
+const REGISTRY_FILE = 'content/registry/products.yml';
+const CHOOSER_FILE = 'content/registry/product-chooser.yml';
+
+export interface RegistryProduct {
+ key: string;
+ name: string;
+ kind: 'sdk' | 'hosted' | 'topic';
+ /** The product's folder under /sdks//: its first alias, else its key. */
+ folder: string;
+}
+
+export interface ChooserEntry {
+ choose_if: string[];
+ not_for: { when: string; use: string }[];
+}
+
+export interface ProductRegistryData {
+ products: RegistryProduct[];
+ /** APPROVED chooser entries only, by product key. Drafts never reach the client. */
+ chooser: Record;
+}
+
+/**
+ * Hands the product registry to components as global data
+ * (`usePluginData('product-registry')`).
+ *
+ * The files are validated by `yarn verify:registry` in CI; this reads them as
+ * they are. The chooser file is optional, and only `status: approved` entries
+ * are passed on, so draft text cannot appear on a page by accident.
+ */
+export default function productRegistryPlugin(context: LoadContext): Plugin {
+ const registryPath = path.join(context.siteDir, REGISTRY_FILE);
+ const chooserPath = path.join(context.siteDir, CHOOSER_FILE);
+ return {
+ name: 'product-registry',
+ getPathsToWatch() {
+ return [registryPath, chooserPath];
+ },
+ async loadContent(): Promise {
+ // The same checks `yarn verify:registry` runs in CI, so a malformed
+ // entry fails here with a readable message instead of breaking pages in
+ // a local or preview build that skipped CI. That covers draft entries
+ // too: a draft with a typo in `use` fails every docs build, on purpose,
+ // so a broken entry is fixed when it is written, not when it is approved.
+ // An empty or comments-only file is not malformed: it has no entries yet.
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const check = require(path.join(context.siteDir, 'scripts/verify-registry.cjs')) as {
+ readRegistry: (text: string) => { key: string; name: string; kind: RegistryProduct['kind']; aliases: string[] }[];
+ registryErrors: (products: unknown[]) => string[];
+ readChooser: (text: string) => unknown;
+ chooserErrors: (products: unknown[], doc: unknown) => string[];
+ };
+ const registry = check.readRegistry(await fs.readFile(registryPath, 'utf8'));
+ let chooserDoc: { products?: Record } | null | undefined;
+ try {
+ chooserDoc = check.readChooser(await fs.readFile(chooserPath, 'utf8')) as typeof chooserDoc;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
+ }
+ const errors = [...check.registryErrors(registry), ...check.chooserErrors(registry, chooserDoc)];
+ if (errors.length) {
+ throw new Error(`product-registry: ${REGISTRY_FILE} or ${CHOOSER_FILE} is invalid:\n ${errors.join('\n ')}`);
+ }
+ const products = registry.map((p) => ({
+ key: p.key,
+ name: p.name,
+ kind: p.kind,
+ folder: p.aliases[0] ?? p.key,
+ }));
+ const raw = chooserDoc?.products ?? {};
+ const chooser: Record = {};
+ for (const [key, entry] of Object.entries(raw)) {
+ if (entry?.status !== 'approved') continue;
+ chooser[key] = { choose_if: entry.choose_if ?? [], not_for: entry.not_for ?? [] };
+ }
+ return { products, chooser };
+ },
+ async contentLoaded({ content, actions }) {
+ actions.setGlobalData(content);
+ },
+ };
+}