From 05f064295f4d0dbaf17e20f9173cc458842b7d32 Mon Sep 17 00:00:00 2001 From: Yevheniia Sorokina Date: Tue, 13 Oct 2026 12:22:49 +0100 Subject: [PATCH 1/6] Add a product registry and check every product name against it Product identifiers were spelled two ways: `matrixscan` and `label-capture` in docs-schema.yml and page frontmatter, `matrixscan-batch` and `smart-label-capture` in products.json and skills.json, with URL_PRODUCT_MAPPING as the only bridge. A component that looked a page's `product` up in products.json found nothing for either schema value, and nothing failed. content/registry/products.yml is now the one list of products (17: 13 SDK products, 2 hosted, 2 cross-product topics). Keys are the products.json keys. Folder names that differ from a key (`matrixscan/`, `label-capture/`) are aliases, valid only as folder names and in URL_PRODUCT_MAPPING. `yarn verify:registry`, run in CI before the build, fails when any of these disagree with the registry: the docs-schema.yml product enum, products.json (entries and names), features.json, both parts of skills.json, the SkillsPage SKILL_DESCRIPTIONS keys, URL_PRODUCT_MAPPING, frontmatter `product` and `product="..."` component props under docs/, and the product folders under docs/sdks//. It also validates content/registry/product-chooser.yml, the Product Chooser text, when that file exists. `yarn test:registry` has a failing case for every rule and an end-to-end drift case per source. The schema enum is now exactly the registry keys, so the three shipping label pages move from `product: label-capture` to `smart-label-capture`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/build-docs.yml | 9 + AGENTS.md | 5 + content/registry/products.yml | 89 ++++ docs-schema.yml | 11 +- .../label-capture/shipping-label-scanning.md | 2 +- .../label-capture/shipping-label-scanning.md | 2 +- .../label-capture/shipping-label-scanning.md | 2 +- package.json | 2 + scripts/test-registry.cjs | 324 ++++++++++++++ scripts/verify-registry.cjs | 414 ++++++++++++++++++ 10 files changed, 854 insertions(+), 6 deletions(-) create mode 100644 content/registry/products.yml create mode 100644 scripts/test-registry.cjs create mode 100644 scripts/verify-registry.cjs diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index c6604a1980..3a0466a932 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -64,6 +64,15 @@ jobs: - name: Test framework registry run: yarn test:frameworks + # One list of products: docs-schema.yml, products.json, features.json, + # skills.json, URL_PRODUCT_MAPPING and page frontmatter must all agree + # with content/registry/products.yml. + - name: Test product registry check + run: yarn test:registry + + - name: Verify product registry + run: yarn verify:registry + - name: Build documentation run: yarn build diff --git a/AGENTS.md b/AGENTS.md index 6bc72e2087..7b6673dfc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -316,6 +316,10 @@ When working on this repository: All SDK platforms are treated equally - no platform gets special priority over others. The full list is the framework registry, `src/constants/frameworks.ts`; `npm run verify:frameworks` fails the build if the docs, the code or the data files use a framework name the registry doesn't know, or if `src/data/products.json` leaves a framework out. +## Products + +The full list of products is the product registry, `content/registry/products.yml`. Use its `key` in frontmatter `product`, in component props and in data files. Its `aliases` (`matrixscan`, `label-capture`) are folder names under `/sdks//` that differ from the key, and are valid only there and in `URL_PRODUCT_MAPPING`. `npm run verify:registry` fails the build if `docs-schema.yml`, the data files, `URL_PRODUCT_MAPPING` or a page's `product` disagree with it. Product Chooser text ("Choose this if / Consider another product if") goes in a separate file next to the registry, product-chooser.yml, once a product owner approves it; the same command checks it. + ## Quick Reference ### File Extensions @@ -343,6 +347,7 @@ npm run serve # Serve built site npm run clear # Clear cache npm run docs:gate # Docs quality gate on changed docs npm run verify:frameworks # Framework names and availability data +npm run verify:registry # Product names against the product registry npm run verify:agents-md # This file's commands, scripts and paths ``` Every script is in `package.json`; check there rather than relying on this list. diff --git a/content/registry/products.yml b/content/registry/products.yml new file mode 100644 index 0000000000..0dba614465 --- /dev/null +++ b/content/registry/products.yml @@ -0,0 +1,89 @@ +# The product registry: one entry per product. +# +# Every product identifier in the docs, the components, the docs gate and the +# Agent Skills data is checked against this file by `yarn verify:registry`: +# +# - `key` is the identifier to use everywhere: frontmatter `product`, +# component props, products.json, features.json and skills.json. +# - `aliases` are folder names under /sdks// that differ from the +# key (`matrixscan/` is MatrixScan Batch). They are valid only as folder +# names and in URL_PRODUCT_MAPPING (src/components/utils/frameworks.ts), +# which must map every alias to its key. +# - `kind`: `sdk` (documented per framework under /sdks/), `hosted` (under +# /hosted/), or `topic` (cross-product pages, not a product you install). +# +# The "Choose this if / Consider another product if" text for overview pages +# lives in content/registry/product-chooser.yml, keyed by these product keys, +# once a product owner has approved it. Per-framework availability and versions +# still live in src/data/products.json until they are generated from SDK data. + +products: + - key: sparkscan + name: SparkScan + kind: sdk + + - key: barcode-capture + name: Barcode Capture + kind: sdk + + - key: matrixscan-batch + name: MatrixScan Batch + kind: sdk + aliases: [matrixscan] + + - key: matrixscan-ar + name: MatrixScan AR + kind: sdk + + - key: matrixscan-count + name: MatrixScan Count + kind: sdk + + - key: matrixscan-find + name: MatrixScan Find + kind: sdk + + - key: matrixscan-pick + name: MatrixScan Pick + kind: sdk + + - key: barcode-selection + name: Barcode Selection + kind: sdk + + - key: smart-label-capture + name: Smart Label Capture + kind: sdk + aliases: [label-capture] + + - key: id-capture + name: ID Capture + kind: sdk + + - key: parser + name: Parser + kind: sdk + + - key: barcode-generator + name: Barcode Generator + kind: sdk + + - key: barcode-sequence + name: Barcode Sequence + kind: sdk + + - key: express + name: Scandit Express + kind: hosted + + - key: id-bolt + name: ID Bolt + kind: hosted + + - key: core + name: Core concepts + kind: topic + + - key: platform + name: Platform + kind: topic diff --git a/docs-schema.yml b/docs-schema.yml index 079573ebed..c48d379148 100644 --- a/docs-schema.yml +++ b/docs-schema.yml @@ -29,11 +29,16 @@ properties: supported-values, configuration-reference, api-overview, deployment, licensing, scanning-performance, sample-catalog, use-case] + # The product keys from content/registry/products.yml. Folder names that + # differ from a key (`matrixscan`, `label-capture`) are aliases and not valid + # here. `yarn verify:registry` fails the build if this list and the registry + # drift. product: type: string - enum: [sparkscan, barcode-capture, matrixscan, matrixscan-find, matrixscan-count, - matrixscan-ar, matrixscan-pick, barcode-selection, barcode-generator, - id-capture, id-bolt, label-capture, parser, express, core, platform] + enum: [sparkscan, barcode-capture, matrixscan-batch, matrixscan-find, + matrixscan-count, matrixscan-ar, matrixscan-pick, barcode-selection, + barcode-generator, barcode-sequence, id-capture, id-bolt, + smart-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 diff --git a/docs/sdks/android/label-capture/shipping-label-scanning.md b/docs/sdks/android/label-capture/shipping-label-scanning.md index 9829db0263..a628372667 100644 --- a/docs/sdks/android/label-capture/shipping-label-scanning.md +++ b/docs/sdks/android/label-capture/shipping-label-scanning.md @@ -4,7 +4,7 @@ description: "Read the tracking ID, receiver address, and carrier from shipping sidebar_position: 4 pagination_next: null framework: android -product: label-capture +product: smart-label-capture topic_type: how-to version_status: beta available_since: "8.6.0" diff --git a/docs/sdks/ios/label-capture/shipping-label-scanning.md b/docs/sdks/ios/label-capture/shipping-label-scanning.md index 26ab56ee51..70b85d6d8b 100644 --- a/docs/sdks/ios/label-capture/shipping-label-scanning.md +++ b/docs/sdks/ios/label-capture/shipping-label-scanning.md @@ -4,7 +4,7 @@ description: "Read the tracking ID, receiver address, and carrier from shipping sidebar_position: 4 pagination_next: null framework: ios -product: label-capture +product: smart-label-capture topic_type: how-to version_status: beta available_since: "8.6.0" diff --git a/docs/sdks/web/label-capture/shipping-label-scanning.md b/docs/sdks/web/label-capture/shipping-label-scanning.md index 7d7e318e23..6b6f352d56 100644 --- a/docs/sdks/web/label-capture/shipping-label-scanning.md +++ b/docs/sdks/web/label-capture/shipping-label-scanning.md @@ -4,7 +4,7 @@ description: "Read the tracking ID, receiver address, and carrier from shipping sidebar_position: 4 pagination_next: null framework: web -product: label-capture +product: smart-label-capture topic_type: how-to version_status: beta available_since: "8.6.0" diff --git a/package.json b/package.json index 0c4fdb8797..53cca015b5 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "verify:frameworks": "node scripts/verify-frameworks.cjs", "verify:agents-md": "node scripts/verify-agents-md.cjs", "test:frameworks": "node scripts/test-frameworks.cjs", + "verify:registry": "node scripts/verify-registry.cjs", + "test:registry": "node scripts/test-registry.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", diff --git a/scripts/test-registry.cjs b/scripts/test-registry.cjs new file mode 100644 index 0000000000..6be0c3f820 --- /dev/null +++ b/scripts/test-registry.cjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node +"use strict"; +/** + * Tests for scripts/verify-registry.cjs. + * + * Two layers. The unit cases give every rule at least one input that must + * fail. The end-to-end cases run main() against a fixture tree, once clean and + * once per source with one drift each, so removing any check from main() + * fails a test too. + * + * Usage: node scripts/test-registry.cjs + */ + +const assert = require("assert"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); +const r = require("./verify-registry.cjs"); + +let passed = 0; +function test(name, fn) { + try { + fn(); + passed++; + } catch (e) { + console.error(`✗ ${name}\n ${e.message}`); + process.exitCode = 1; + } +} +const has = (errors, fragment) => + assert.ok( + errors.some((e) => e.includes(fragment)), + `expected an error containing "${fragment}", got:\n ${errors.join("\n ") || "(none)"}`, + ); + +const base = () => [ + { key: "sparkscan", name: "SparkScan", kind: "sdk", aliases: [] }, + { key: "matrixscan-batch", name: "MatrixScan Batch", kind: "sdk", aliases: ["matrixscan"] }, + { key: "express", name: "Scandit Express", kind: "hosted", aliases: [] }, + { key: "platform", name: "Platform", kind: "topic", aliases: [] }, +]; +const chooser = () => ({ + products: { + sparkscan: { status: "draft", choose_if: ["scan one"], not_for: [{ when: "count many", use: "matrixscan-batch" }] }, + }, +}); + +// --- 1. registry ----------------------------------------------------------- +test("a valid registry has no errors", () => assert.deepStrictEqual(r.registryErrors(base()), [])); +test("readRegistry defaults aliases to []", () => { + assert.deepStrictEqual(r.readRegistry("products:\n - key: a\n name: A\n kind: sdk\n")[0].aliases, []); +}); +test("readRegistry reports a blank entry instead of crashing", () => { + const p = r.readRegistry("products:\n -\n - key: a\n name: A\n kind: sdk\n"); + has(r.registryErrors(p), "invalid key null"); +}); +test("readRegistry rejects a file without a products list", () => { + assert.throws(() => r.readRegistry("items: []\n"), /products/); +}); +test("duplicate key", () => { + const p = base(); p.push({ ...p[2] }); + has(r.registryErrors(p), "`express` is used twice"); +}); +test("alias colliding with a key", () => { + const p = base(); p[2].aliases = ["sparkscan"]; + has(r.registryErrors(p), "`sparkscan` is used twice"); +}); +test("aliases as a scalar, not a list", () => { + const p = base(); p[2].aliases = "ab"; + has(r.registryErrors(p), "aliases must be a list"); +}); +test("an alias that is not a slug", () => { + const p = base(); p[2].aliases = ["Scandit Express"]; + has(r.registryErrors(p), "invalid alias"); +}); +test("invalid key", () => { + const p = base(); p[2].key = "Express"; + has(r.registryErrors(p), "invalid key"); +}); +test("missing name", () => { + const p = base(); p[2].name = " "; + has(r.registryErrors(p), "express: missing name"); +}); +test("unknown kind", () => { + const p = base(); p[2].kind = "app"; + has(r.registryErrors(p), "kind \"app\""); +}); +test("chooser text left in the registry", () => { + const p = base(); p[0].chooser = { status: "draft", choose_if: ["x"] }; + has(r.registryErrors(p), "chooser text belongs in content/registry/product-chooser.yml"); +}); + +// --- 1b. chooser file ------------------------------------------------------ +test("no chooser file is fine", () => assert.deepStrictEqual(r.chooserErrors(base(), undefined), [])); +test("a valid chooser file has no errors", () => assert.deepStrictEqual(r.chooserErrors(base(), chooser()), [])); +test("a chooser file without a products map", () => { + has(r.chooserErrors(base(), { products: [] }), "expected a top-level `products` map"); + has(r.chooserErrors(base(), null), "expected a top-level `products` map"); +}); +test("a chooser for a product that does not exist", () => { + const c = chooser(); c.products.barcodeview = c.products.sparkscan; + has(r.chooserErrors(base(), c), "barcodeview: not a product key"); +}); +test("a chooser on a topic", () => { + const c = chooser(); c.products.platform = { status: "draft", choose_if: ["x"] }; + has(r.chooserErrors(base(), c), "platform: only sdk and hosted products can have a chooser"); +}); +test("not_for pointing to a product that does not exist", () => { + const c = chooser(); c.products.sparkscan.not_for[0].use = "matrixscan"; + has(r.chooserErrors(base(), c), "not_for points to `matrixscan`, which is not a product key"); +}); +test("not_for pointing to itself", () => { + const c = chooser(); c.products.sparkscan.not_for[0].use = "sparkscan"; + has(r.chooserErrors(base(), c), "not_for points to itself"); +}); +test("not_for pointing to a topic", () => { + const c = chooser(); c.products.sparkscan.not_for[0].use = "platform"; + has(r.chooserErrors(base(), c), "which is a topic"); +}); +test("not_for without when", () => { + const c = chooser(); c.products.sparkscan.not_for[0].when = ""; + has(r.chooserErrors(base(), c), "has no `when` text"); +}); +test("not_for that is not a list", () => { + const c = chooser(); c.products.sparkscan.not_for = { when: "x", use: "express" }; + has(r.chooserErrors(base(), c), "not_for must be a list"); +}); +test("status outside draft/approved", () => { + const c = chooser(); c.products.sparkscan.status = "live"; + has(r.chooserErrors(base(), c), "status \"live\""); +}); +test("chooser without choose_if", () => { + const c = chooser(); c.products.sparkscan.choose_if = []; + has(r.chooserErrors(base(), c), "at least one choose_if"); +}); +test("a choose_if entry that is not a string", () => { + const c = chooser(); c.products.sparkscan.choose_if = [{ text: "scan one" }]; + has(r.chooserErrors(base(), c), "every choose_if entry must be a non-empty string"); +}); + +// --- 2. schema ------------------------------------------------------------- +const keys = ["sparkscan", "matrixscan-batch", "express", "platform"]; +test("schema enum equal to the keys", () => assert.deepStrictEqual(r.schemaErrors(base(), keys), [])); +test("schema enum missing a key", () => has(r.schemaErrors(base(), keys.slice(1)), "missing `sparkscan`")); +test("schema enum listing an alias", () => has(r.schemaErrors(base(), [...keys, "matrixscan"]), "has `matrixscan`")); +test("schema without a product enum", () => has(r.schemaErrors(base(), undefined), "no properties.product.enum")); + +// --- 3. data --------------------------------------------------------------- +const data = () => ({ + productsJson: [{ key: "sparkscan", name: "SparkScan" }, { key: "matrixscan-batch", name: "MatrixScan Batch" }], + featuresJson: [{ name: "f", product: "sparkscan" }], + skillsJson: { products: { sparkscan: {} }, frameworks: { ios: [{ slug: "s", product: "sparkscan" }] } }, + skillDescriptionKeys: ["sparkscan"], +}); +test("data that agrees", () => assert.deepStrictEqual(r.dataErrors(base(), data()), [])); +test("products.json missing an sdk product", () => { + const d = data(); d.productsJson.pop(); + has(r.dataErrors(base(), d), "no entry for sdk product `matrixscan-batch`"); +}); +test("products.json listing a product twice", () => { + const d = data(); d.productsJson.push({ key: "sparkscan", name: "SparkScan" }); + has(r.dataErrors(base(), d), "`sparkscan` is listed twice"); +}); +test("products.json listing an unknown product", () => { + const d = data(); d.productsJson.push({ key: "x", name: "X" }); + has(r.dataErrors(base(), d), "`x` is not a registry key"); +}); +test("products.json listing a hosted product", () => { + const d = data(); d.productsJson.push({ key: "express", name: "Scandit Express" }); + has(r.dataErrors(base(), d), "is a hosted product"); +}); +test("products.json name drifting from the registry", () => { + const d = data(); d.productsJson[0].name = "Spark Scan"; + has(r.dataErrors(base(), d), "is named \"Spark Scan\""); +}); +test("features.json using an alias", () => { + const d = data(); d.featuresJson[0].product = "matrixscan"; + has(r.dataErrors(base(), d), "has product `matrixscan`"); +}); +test("skills.json products using an alias", () => { + const d = data(); d.skillsJson.products = { matrixscan: {} }; + has(r.dataErrors(base(), d), "products.`matrixscan`"); +}); +test("skills.json frameworks entry using an alias", () => { + const d = data(); d.skillsJson.frameworks.ios[0].product = "label-capture"; + has(r.dataErrors(base(), d), "frameworks.ios skill \"s\" has product `label-capture`"); +}); +test("SKILL_DESCRIPTIONS with an unknown key", () => { + const d = data(); d.skillDescriptionKeys = ["matrixscan"]; + has(r.dataErrors(base(), d), "SKILL_DESCRIPTIONS key `matrixscan`"); +}); +test("SKILL_DESCRIPTIONS not found", () => { + const d = data(); d.skillDescriptionKeys = null; + has(r.dataErrors(base(), d), "SKILL_DESCRIPTIONS not found"); +}); +test("readObjectKeys reads quoted and unquoted keys, not words in values", () => { + const src = "const SKILL_DESCRIPTIONS: Record = {\n sparkscan: 'Note: SparkScan.',\n 'barcode-capture': 'x',\n // 'old': 'y',\n};"; + assert.deepStrictEqual(r.readObjectKeys(src, "SKILL_DESCRIPTIONS"), ["sparkscan", "barcode-capture"]); +}); + +// --- 4. URL map ------------------------------------------------------------ +test("readUrlMap reads quoted and unquoted keys and ignores comments", () => { + const src = "export const URL_PRODUCT_MAPPING: { [urlSlug: string]: string } = {\n matrixscan: 'matrixscan-batch',\n // 'old': 'x',\n 'label-capture': \"smart-label-capture\",\n};"; + assert.deepStrictEqual(r.readUrlMap(src), { matrixscan: "matrixscan-batch", "label-capture": "smart-label-capture" }); +}); +test("URL map that matches the aliases", () => { + assert.deepStrictEqual(r.urlMapErrors(base(), { matrixscan: "matrixscan-batch" }), []); +}); +test("URL map missing an alias", () => has(r.urlMapErrors(base(), {}), "should map `matrixscan`")); +test("URL map with an extra entry", () => { + has(r.urlMapErrors(base(), { matrixscan: "matrixscan-batch", sparks: "sparkscan" }), "maps `sparks`"); +}); +test("URL map not found", () => has(r.urlMapErrors(base(), null), "not found")); + +// --- 5. content ------------------------------------------------------------ +test("fileProducts reads frontmatter, a BOM, CRLF and props", () => { + assert.strictEqual(r.fileProducts("---\nproduct: express\n---\n").product, "express"); + assert.strictEqual(r.fileProducts("---\r\nproduct: express\r\n---\r\n").product, "express"); + assert.strictEqual(r.fileProducts("# no frontmatter\n").product, undefined); + assert.strictEqual(r.fileProducts("---\nproduct: [unclosed\n---\n").product, r.UNPARSEABLE); + assert.deepStrictEqual(r.fileProducts(` `).props, ["sparkscan", "express", "x"]); + assert.deepStrictEqual(r.fileProducts(`
`).props, []); +}); +test("files using keys pass", () => { + assert.deepStrictEqual(r.contentErrors(base(), [{ file: "a.md", product: "sparkscan", props: ["express"] }, { file: "b.md", product: undefined, props: [] }]), []); +}); +test("frontmatter using an alias names the key", () => { + has(r.contentErrors(base(), [{ file: "docs/a.md", product: "matrixscan", props: [] }]), "is an alias; use the key `matrixscan-batch`"); +}); +test("frontmatter using an unknown product", () => { + has(r.contentErrors(base(), [{ file: "docs/a.md", product: "shelfview", props: [] }]), "docs/a.md: product `shelfview` is not a registry key"); +}); +test("a component prop using an unknown product", () => { + has(r.contentErrors(base(), [{ file: "docs/a.mdx", product: undefined, props: ["label-capture"] }]), "component prop product=\"label-capture\""); +}); +test("frontmatter that does not parse", () => { + has(r.contentErrors(base(), [{ file: "docs/a.md", product: r.UNPARSEABLE, props: [] }]), "frontmatter does not parse"); +}); + +// --- 6. folders ------------------------------------------------------------ +test("folders that are keys, aliases or exceptions pass", () => { + assert.deepStrictEqual(r.folderErrors(base(), [{ framework: "ios", folder: "sparkscan" }, { framework: "ios", folder: "matrixscan" }, { framework: "ios", folder: "img" }]), []); +}); +test("a folder named after a hosted product", () => { + has(r.folderErrors(base(), [{ framework: "ios", folder: "express" }]), "docs/sdks/ios/express/ is not an sdk product"); +}); +test("an unknown product folder", () => { + has(r.folderErrors(base(), [{ framework: "ios", folder: "barcode-ar" }]), "docs/sdks/ios/barcode-ar/ is not an sdk product key or alias"); +}); + +// --- main() end to end ----------------------------------------------------- +const cleanTree = () => ({ + "content/registry/products.yml": + "products:\n" + + " - key: sparkscan\n name: SparkScan\n kind: sdk\n" + + " - key: matrixscan-batch\n name: MatrixScan Batch\n kind: sdk\n aliases: [matrixscan]\n" + + " - key: express\n name: Scandit Express\n kind: hosted\n" + + " - key: platform\n name: Platform\n kind: topic\n", + "content/registry/product-chooser.yml": + "products:\n sparkscan:\n status: draft\n choose_if: [scan one]\n not_for:\n - when: hosted flow\n use: express\n", + "docs-schema.yml": "properties:\n product:\n enum: [sparkscan, matrixscan-batch, express, platform]\n", + "src/data/products.json": JSON.stringify([{ key: "sparkscan", name: "SparkScan" }, { key: "matrixscan-batch", name: "MatrixScan Batch" }]), + "src/data/features.json": JSON.stringify([{ name: "f", product: "sparkscan" }]), + "src/data/skills.json": JSON.stringify({ products: { sparkscan: {} }, frameworks: { ios: [{ slug: "s", product: "sparkscan" }] } }), + "src/components/SkillsPage/index.tsx": "const SKILL_DESCRIPTIONS: Record = {\n sparkscan: 'SparkScan.',\n};\n", + "src/components/utils/frameworks.ts": "export const URL_PRODUCT_MAPPING = {\n 'matrixscan': 'matrixscan-batch',\n};\n", + "docs/hosted/express/a.md": "---\nproduct: express\ndescription: x\n---\n", + "docs/sdks/ios/sparkscan/intro.md": "---\nproduct: sparkscan\n---\n\n", + "docs/sdks/ios/matrixscan/intro.md": "---\ndescription: x\n---\n", + "docs/sdks/ios/img/.keep": "", +}); +function runMain(files) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "verify-registry-")); + try { + for (const [rel, text] of Object.entries(files)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, text); + } + const out = execFileSync(process.execPath, [path.join(__dirname, "verify-registry.cjs")], { + env: { ...process.env, VERIFY_REGISTRY_FIXTURE: "1", VERIFY_REGISTRY_ROOT: root }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { code: 0, out }; + } catch (e) { + return { code: e.status, out: `${e.stdout || ""}${e.stderr || ""}` }; + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} +test("main() passes on a consistent tree and echoes the fixture root", () => { + const res = runMain(cleanTree()); + assert.strictEqual(res.code, 0, res.out); + assert.ok(res.out.includes("checking fixture root"), res.out); +}); +// One drift per source; each must make main() fail with its own message. +const drifts = { + registry: ["content/registry/products.yml", (t) => t.replace("kind: hosted", "kind: app"), "kind \"app\""], + schema: ["docs-schema.yml", (t) => t.replace(", platform]", "]"), "missing `platform`"], + "chooser file": ["content/registry/product-chooser.yml", (t) => t.replace("use: express", "use: platform"), "not_for points to `platform`"], + "products.json": ["src/data/products.json", (t) => t.replace("\"SparkScan\"", "\"Spark Scan\""), "is named \"Spark Scan\""], + "features.json": ["src/data/features.json", (t) => t.replace("sparkscan", "matrixscan"), "has product `matrixscan`"], + "skills.json": ["src/data/skills.json", (t) => t.replace("\"product\":\"sparkscan\"", "\"product\":\"label-capture\""), "has product `label-capture`"], + "SKILL_DESCRIPTIONS": ["src/components/SkillsPage/index.tsx", (t) => t.replace("sparkscan:", "matrixscan:"), "SKILL_DESCRIPTIONS key `matrixscan`"], + "URL map": ["src/components/utils/frameworks.ts", (t) => t.replace("'matrixscan-batch'", "'sparkscan'"), "should map `matrixscan`"], + frontmatter: ["docs/hosted/express/a.md", (t) => t.replace("product: express", "product: matrixscan"), "is an alias; use the key"], + "component prop": ["docs/sdks/ios/sparkscan/intro.md", (t) => t.replace("product=\"sparkscan\"", "product=\"label-capture\""), "component prop product=\"label-capture\""], + folder: ["docs/sdks/ios/img/.keep", null, "docs/sdks/ios/barcode-ar/"], +}; +for (const [name, [file, change, expected]] of Object.entries(drifts)) { + test(`main() fails on drift in ${name}`, () => { + const tree = cleanTree(); + if (change) tree[file] = change(tree[file]); + else tree["docs/sdks/ios/barcode-ar/intro.md"] = "---\ndescription: x\n---\n"; + assert.notStrictEqual(JSON.stringify(tree), JSON.stringify(cleanTree()), `the ${name} drift changed nothing`); + const res = runMain(tree); + assert.strictEqual(res.code, 1, res.out); + assert.ok(res.out.includes(expected), `expected "${expected}" in:\n${res.out}`); + }); +} + +console.log(`test-registry: ${passed} passed${process.exitCode ? ", some FAILED" : ""}`); diff --git a/scripts/verify-registry.cjs b/scripts/verify-registry.cjs new file mode 100644 index 0000000000..ab27b37e3a --- /dev/null +++ b/scripts/verify-registry.cjs @@ -0,0 +1,414 @@ +#!/usr/bin/env node +"use strict"; +/** + * Product registry gate. + * + * content/registry/products.yml is the single list of products. Product + * identifiers were spelled three ways before it existed - `matrixscan` and + * `label-capture` in docs-schema.yml and in page frontmatter, + * `matrixscan-batch` and `smart-label-capture` in products.json and + * skills.json - with URL_PRODUCT_MAPPING as the only bridge. A component that + * looked a page's `product` up in products.json therefore found nothing for + * either schema-valid value, and nothing failed. + * + * Product KEYS are the only identifiers allowed in frontmatter, component + * props and data files. ALIASES exist for one reason: a product's folder + * under /sdks// may differ from its key (`matrixscan/` is + * MatrixScan Batch). They are valid as folder names and in + * URL_PRODUCT_MAPPING, nowhere else. + * + * This checks every place that names a product against the registry: + * + * 1. REGISTRY keys and aliases are slugs, unique and non-colliding; `kind` + * is known. + * 1b. CHOOSER content/registry/product-chooser.yml, when it exists: each + * entry is an sdk or hosted product, its texts are non-empty + * strings, its `status` is draft or approved, and each + * `not_for` names another sdk or hosted product (never a + * topic such as `platform`). + * 2. SCHEMA the docs-schema.yml `product` enum is exactly the keys. + * 3. DATA products.json lists exactly the `sdk` products, under the + * registry's names; features.json `product`, skills.json + * `products` keys and `frameworks[].product`, and the + * SkillsPage SKILL_DESCRIPTIONS keys are all registry keys. + * 4. URL MAP URL_PRODUCT_MAPPING maps exactly the aliases to their keys. + * 5. CONTENT under docs/, frontmatter `product` and component props + * `product="..."` are keys (an alias is reported with its + * key), and frontmatter that does not parse is reported. + * 6. FOLDERS every folder under docs/sdks// is a key, an + * alias, or a listed exception. + * + * docs/ only, not versioned_docs/, for the reason verify-frameworks.cjs gives: + * the snapshots are frozen releases. + * + * Usage: node scripts/verify-registry.cjs + */ + +const fs = require("fs"); +const path = require("path"); +const yaml = require("js-yaml"); + +// Overridable so scripts/test-registry.cjs can run main() against a fixture +// tree. Both variables are required, and the root is echoed when it is not the +// repo, so a stray environment value cannot make this pass for another tree. +const FIXTURE_ROOT = + process.env.VERIFY_REGISTRY_FIXTURE === "1" && process.env.VERIFY_REGISTRY_ROOT + ? path.resolve(process.env.VERIFY_REGISTRY_ROOT) + : null; +const ROOT = FIXTURE_ROOT || path.join(__dirname, ".."); + +const REGISTRY_FILE = "content/registry/products.yml"; +const CHOOSER_FILE = "content/registry/product-chooser.yml"; +const SCHEMA_FILE = "docs-schema.yml"; +const PRODUCTS_FILE = "src/data/products.json"; +const FEATURES_FILE = "src/data/features.json"; +const SKILLS_FILE = "src/data/skills.json"; +const URL_MAP_FILE = "src/components/utils/frameworks.ts"; +const SKILLS_PAGE_FILE = "src/components/SkillsPage/index.tsx"; + +const KINDS = ["sdk", "hosted", "topic"]; +const CHOOSER_STATUSES = ["draft", "approved"]; +const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +// Folders under docs/sdks// that are not products, and why. +const FOLDER_EXCEPTIONS = { + img: "images", + "id-validate": "a 'not available' stub for the ID Validate module (Titanium)", +}; +// Frameworks whose folder holds further framework folders, not products. +const NESTED_FRAMEWORKS = ["net"]; + +/** Registry entries as written; `aliases` defaults to an empty list. */ +function readRegistry(text) { + const doc = yaml.load(text); + if (!doc || !Array.isArray(doc.products)) { + throw new Error(`${REGISTRY_FILE}: expected a top-level \`products\` list`); + } + // A blank `-` item is reported as an invalid key rather than crashing here. + return doc.products.map((p) => + p && typeof p === "object" ? { ...p, aliases: p.aliases === undefined ? [] : p.aliases } : { key: p, aliases: [] }, + ); +} + +const isText = (v) => typeof v === "string" && v.trim() !== ""; +const aliasList = (p) => (Array.isArray(p.aliases) ? p.aliases.filter((a) => typeof a === "string") : []); + +/** Check 1. Returns error strings. */ +function registryErrors(products) { + const errors = []; + const seen = new Map(); // identifier -> what claimed it + const claim = (id, owner) => { + if (seen.has(id)) errors.push(`\`${id}\` is used twice: ${seen.get(id)} and ${owner}`); + else seen.set(id, owner); + }; + for (const p of products) { + if (typeof p.key !== "string" || !SLUG.test(p.key)) { + errors.push(`invalid key ${JSON.stringify(p.key)}`); + continue; + } + claim(p.key, `key of ${p.key}`); + if (!Array.isArray(p.aliases)) { + errors.push(`${p.key}: aliases must be a list, found ${JSON.stringify(p.aliases)}`); + } else { + for (const a of p.aliases) { + if (typeof a !== "string" || !SLUG.test(a)) errors.push(`${p.key}: invalid alias ${JSON.stringify(a)}`); + else claim(a, `alias of ${p.key}`); + } + } + if (!isText(p.name)) errors.push(`${p.key}: missing name`); + if (!KINDS.includes(p.kind)) { + errors.push(`${p.key}: kind ${JSON.stringify(p.kind)} is not one of ${KINDS.join(", ")}`); + } + } + for (const p of products) { + if (p && typeof p === "object" && "chooser" in p) { + errors.push(`${p.key}: chooser text belongs in ${CHOOSER_FILE}, not in the registry`); + } + } + return errors; +} + +/** + * Check 1b: content/registry/product-chooser.yml, when it exists. A map of + * product key -> { status, choose_if, not_for }. + */ +function chooserErrors(products, chooserDoc) { + if (chooserDoc === undefined) return []; + const entries = chooserDoc && chooserDoc.products; + if (!entries || typeof entries !== "object" || Array.isArray(entries)) { + return [`${CHOOSER_FILE}: expected a top-level \`products\` map of product key to chooser`]; + } + const errors = []; + const byKey = new Map(products.map((p) => [p.key, p])); + const pointable = (k) => byKey.has(k) && ["sdk", "hosted"].includes(byKey.get(k).kind); + for (const [key, c0] of Object.entries(entries)) { + const c = c0 || {}; + const at = `${CHOOSER_FILE}: ${key}`; + if (!byKey.has(key)) errors.push(`${at}: not a product key`); + else if (!pointable(key)) errors.push(`${at}: only sdk and hosted products can have a chooser`); + if (!CHOOSER_STATUSES.includes(c.status)) { + errors.push(`${at}: status ${JSON.stringify(c.status)} is not draft or approved`); + } + if (!Array.isArray(c.choose_if) || c.choose_if.length === 0) { + errors.push(`${at}: needs at least one choose_if entry`); + } else if (!c.choose_if.every(isText)) { + errors.push(`${at}: every choose_if entry must be a non-empty string`); + } + if (c.not_for !== undefined && !Array.isArray(c.not_for)) { + errors.push(`${at}: not_for must be a list`); + } + for (const n of Array.isArray(c.not_for) ? c.not_for : []) { + const use = n && n.use; + if (!n || !isText(n.when)) errors.push(`${at}: a not_for entry has no \`when\` text`); + if (!byKey.has(use)) errors.push(`${at}: not_for points to \`${use}\`, which is not a product key`); + else if (use === key) errors.push(`${at}: not_for points to itself`); + else if (!pointable(use)) errors.push(`${at}: not_for points to \`${use}\`, which is a topic, not a product`); + } + } + return errors; +} + +function setDiff(a, b) { + return [...a].filter((x) => !b.has(x)).sort(); +} + +/** Check 2. */ +function schemaErrors(products, schemaEnum) { + if (!Array.isArray(schemaEnum)) return [`${SCHEMA_FILE}: no properties.product.enum`]; + const want = new Set(products.map((p) => p.key)); + const have = new Set(schemaEnum); + const errors = []; + for (const id of setDiff(want, have)) errors.push(`${SCHEMA_FILE} product enum is missing \`${id}\``); + for (const id of setDiff(have, want)) errors.push(`${SCHEMA_FILE} product enum has \`${id}\`, which is not a registry key`); + return errors; +} + +/** The keys of a `const NAME ... = { ... };` object literal in TS source. */ +function readObjectKeys(source, name) { + const body = objectBody(source, name); + if (body === null) return null; + // One entry per line, as the files are written; a key starts its line. + return [...body.matchAll(/^\s*(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$-]*))\s*:/gm)].map((m) => m[1] || m[2]); +} + +/** Body of a `NAME ... = { ... }` literal, comments removed, braces balanced. */ +function objectBody(source, name) { + const start = source.search(new RegExp(`\\b${name}\\b[^=]*=\\s*\\{`)); + if (start === -1) return null; + const open = source.indexOf("{", source.indexOf("=", start)); + let depth = 0; + for (let i = open; i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}" && --depth === 0) { + return source + .slice(open + 1, i) + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/[^\n]*/g, ""); + } + } + return null; +} + +/** Check 3. */ +function dataErrors(products, { productsJson, featuresJson, skillsJson, skillDescriptionKeys }) { + const errors = []; + const byKey = new Map(products.map((p) => [p.key, p])); + const sdkKeys = new Set(products.filter((p) => p.kind === "sdk").map((p) => p.key)); + const listed = new Set(); + for (const e of productsJson) { + if (listed.has(e.key)) errors.push(`${PRODUCTS_FILE}: \`${e.key}\` is listed twice`); + listed.add(e.key); + } + for (const k of setDiff(sdkKeys, listed)) errors.push(`${PRODUCTS_FILE} has no entry for sdk product \`${k}\``); + for (const e of productsJson) { + const p = byKey.get(e.key); + if (!p) errors.push(`${PRODUCTS_FILE}: \`${e.key}\` is not a registry key`); + else if (p.kind !== "sdk") errors.push(`${PRODUCTS_FILE}: \`${e.key}\` is a ${p.kind} product, not sdk`); + else if (e.name !== p.name) errors.push(`${PRODUCTS_FILE}: \`${e.key}\` is named "${e.name}", the registry says "${p.name}"`); + } + for (const f of featuresJson) { + if (!byKey.has(f.product)) errors.push(`${FEATURES_FILE}: feature "${f.name}" has product \`${f.product}\`, which is not a registry key`); + } + const skills = skillsJson || {}; + for (const k of Object.keys(skills.products || {})) { + if (!byKey.has(k)) errors.push(`${SKILLS_FILE}: products.\`${k}\` is not a registry key`); + } + for (const [fw, entries] of Object.entries(skills.frameworks || {})) { + for (const e of Array.isArray(entries) ? entries : []) { + if (!byKey.has(e && e.product)) errors.push(`${SKILLS_FILE}: frameworks.${fw} skill "${e && e.slug}" has product \`${e && e.product}\`, which is not a registry key`); + } + } + if (skillDescriptionKeys === null) { + errors.push(`${SKILLS_PAGE_FILE}: SKILL_DESCRIPTIONS not found`); + } else { + for (const k of skillDescriptionKeys || []) { + if (!byKey.has(k)) errors.push(`${SKILLS_PAGE_FILE}: SKILL_DESCRIPTIONS key \`${k}\` is not a registry key`); + } + } + return errors; +} + +/** URL_PRODUCT_MAPPING as an object; quoted and unquoted keys; comments ignored. */ +function readUrlMap(source) { + const body = objectBody(source, "URL_PRODUCT_MAPPING"); + if (body === null) return null; + const map = {}; + for (const m of body.matchAll(/(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$-]*))\s*:\s*['"]([^'"]+)['"]/g)) { + map[m[1] || m[2]] = m[3]; + } + return map; +} + +/** Check 4. */ +function urlMapErrors(products, urlMap) { + if (!urlMap) return [`${URL_MAP_FILE}: URL_PRODUCT_MAPPING not found`]; + const want = {}; + for (const p of products) for (const a of aliasList(p)) want[a] = p.key; + const errors = []; + for (const [alias, key] of Object.entries(want)) { + if (urlMap[alias] !== key) errors.push(`URL_PRODUCT_MAPPING should map \`${alias}\` to \`${key}\`, found ${JSON.stringify(urlMap[alias])}`); + } + for (const alias of Object.keys(urlMap)) { + if (!(alias in want)) errors.push(`URL_PRODUCT_MAPPING maps \`${alias}\`, which is not a registry alias`); + } + return errors; +} + +/** Every .md/.mdx file under a directory, partials included. */ +function docFiles(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...docFiles(full)); + else if (/\.mdx?$/.test(entry.name)) out.push(full); + } + return out; +} + +const UNPARSEABLE = Symbol("unparseable"); + +/** + * What a file says about products: its frontmatter `product` (undefined when + * absent, UNPARSEABLE when the frontmatter does not parse) and every + * `product="..."` component prop. + */ +function fileProducts(text) { + const body = text.replace(/^/, ""); + let product; + const m = body.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (m) { + try { + const fm = yaml.load(m[1]); + product = fm && typeof fm === "object" ? fm.product : undefined; + } catch { + product = UNPARSEABLE; + } + } + // Not `data-product=` or `x-product=`: a hyphen counts as a word boundary. + const props = [...body.matchAll(/(? x[1] ?? x[2] ?? x[3], + ); + return { product, props }; +} + +/** Check 5. `files` are { file, product, props } from fileProducts. */ +function contentErrors(products, files) { + const keys = new Set(products.map((p) => p.key)); + const aliasOf = new Map(products.flatMap((p) => aliasList(p).map((a) => [a, p.key]))); + const describe = (id) => + aliasOf.has(id) ? `\`${id}\` is an alias; use the key \`${aliasOf.get(id)}\`` : `\`${id}\` is not a registry key`; + const errors = []; + for (const { file, product, props } of files) { + if (product === UNPARSEABLE) errors.push(`${file}: frontmatter does not parse`); + else if (product !== undefined && !keys.has(product)) errors.push(`${file}: product ${describe(product)}`); + for (const id of props) { + if (!keys.has(id)) errors.push(`${file}: component prop product="${id}": ${describe(id)}`); + } + } + return errors; +} + +/** Product folders under docs/sdks/: [{ framework, folder }]. */ +function productFolders(sdksDir) { + const out = []; + const subdirs = (d) => + fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); + for (const fw of subdirs(sdksDir)) { + const fwDirs = NESTED_FRAMEWORKS.includes(fw) + ? subdirs(path.join(sdksDir, fw)).map((n) => `${fw}/${n}`) + : [fw]; + for (const f of fwDirs) { + for (const folder of subdirs(path.join(sdksDir, f))) out.push({ framework: f, folder }); + } + } + return out; +} + +/** Check 6. Only sdk products are documented per framework. */ +function folderErrors(products, folders) { + const ok = new Set(products.filter((p) => p.kind === "sdk").flatMap((p) => [p.key, ...aliasList(p)])); + return folders + .filter(({ folder }) => !ok.has(folder) && !(folder in FOLDER_EXCEPTIONS)) + .map(({ framework, folder }) => `docs/sdks/${framework}/${folder}/ is not an sdk product key or alias (add it to the registry, or to FOLDER_EXCEPTIONS with a reason)`); +} + +function main() { + if (FIXTURE_ROOT) console.log(`verify-registry: checking fixture root ${FIXTURE_ROOT}`); + const read = (f) => fs.readFileSync(path.join(ROOT, f), "utf8"); + const products = readRegistry(read(REGISTRY_FILE)); + const chooserPath = path.join(ROOT, CHOOSER_FILE); + const chooserDoc = fs.existsSync(chooserPath) ? yaml.load(fs.readFileSync(chooserPath, "utf8")) || null : undefined; + const schema = yaml.load(read(SCHEMA_FILE)); + const schemaEnum = schema && schema.properties && schema.properties.product && schema.properties.product.enum; + const files = docFiles(path.join(ROOT, "docs")).map((full) => ({ + file: path.relative(ROOT, full).split(path.sep).join("/"), + ...fileProducts(fs.readFileSync(full, "utf8")), + })); + const sdksDir = path.join(ROOT, "docs", "sdks"); + const folders = fs.existsSync(sdksDir) ? productFolders(sdksDir) : []; + + const errors = [ + ...registryErrors(products), + ...chooserErrors(products, chooserDoc), + ...schemaErrors(products, schemaEnum), + ...dataErrors(products, { + productsJson: JSON.parse(read(PRODUCTS_FILE)), + featuresJson: JSON.parse(read(FEATURES_FILE)), + skillsJson: JSON.parse(read(SKILLS_FILE)), + skillDescriptionKeys: readObjectKeys(read(SKILLS_PAGE_FILE), "SKILL_DESCRIPTIONS"), + }), + ...urlMapErrors(products, readUrlMap(read(URL_MAP_FILE))), + ...contentErrors(products, files), + ...folderErrors(products, folders), + ]; + + if (errors.length) { + console.error(`verify-registry: ${errors.length} error(s)\n`); + for (const e of errors) console.error(` ✗ ${e}`); + process.exit(1); + } + console.log( + `verify-registry: OK - ${products.length} products, ${files.length} docs scanned ` + + `(${files.filter((f) => f.product !== undefined).length} with a product, ` + + `${files.reduce((n, f) => n + f.props.length, 0)} product props), ${folders.length} product folders`, + ); +} + +if (require.main === module) main(); + +module.exports = { + readRegistry, + registryErrors, + chooserErrors, + schemaErrors, + readObjectKeys, + dataErrors, + readUrlMap, + urlMapErrors, + fileProducts, + contentErrors, + productFolders, + folderErrors, + FOLDER_EXCEPTIONS, + UNPARSEABLE, +}; From b1d772876f9c50636997a5dd840b0339bae0ecd1 Mon Sep 17 00:00:00 2001 From: Yevheniia Sorokina Date: Wed, 14 Oct 2026 12:33:01 +0100 Subject: [PATCH 2/6] Accept chooser entries for sdk products only The chooser reads its product from the /sdks/ route, so an approved entry for a hosted product passed the check and then never rendered. not_for may still point to a hosted product: that link works. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/test-registry.cjs | 6 +++++- scripts/verify-registry.cjs | 7 +++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/test-registry.cjs b/scripts/test-registry.cjs index 6be0c3f820..77b6cd8084 100644 --- a/scripts/test-registry.cjs +++ b/scripts/test-registry.cjs @@ -104,7 +104,11 @@ test("a chooser for a product that does not exist", () => { }); test("a chooser on a topic", () => { const c = chooser(); c.products.platform = { status: "draft", choose_if: ["x"] }; - has(r.chooserErrors(base(), c), "platform: only sdk and hosted products can have a chooser"); + has(r.chooserErrors(base(), c), "platform: only sdk products can have a chooser"); +}); +test("a chooser on a hosted product", () => { + const c = chooser(); c.products.express = { status: "approved", choose_if: ["x"] }; + has(r.chooserErrors(base(), c), "express: only sdk products can have a chooser"); }); test("not_for pointing to a product that does not exist", () => { const c = chooser(); c.products.sparkscan.not_for[0].use = "matrixscan"; diff --git a/scripts/verify-registry.cjs b/scripts/verify-registry.cjs index ab27b37e3a..c6b1c9c647 100644 --- a/scripts/verify-registry.cjs +++ b/scripts/verify-registry.cjs @@ -22,7 +22,8 @@ * 1. REGISTRY keys and aliases are slugs, unique and non-colliding; `kind` * is known. * 1b. CHOOSER content/registry/product-chooser.yml, when it exists: each - * entry is an sdk or hosted product, its texts are non-empty + * entry is an sdk product (the chooser renders on /sdks/ + * pages only), its texts are non-empty * strings, its `status` is draft or approved, and each * `not_for` names another sdk or hosted product (never a * topic such as `platform`). @@ -145,7 +146,9 @@ function chooserErrors(products, chooserDoc) { const c = c0 || {}; const at = `${CHOOSER_FILE}: ${key}`; if (!byKey.has(key)) errors.push(`${at}: not a product key`); - else if (!pointable(key)) errors.push(`${at}: only sdk and hosted products can have a chooser`); + // sdk only: the chooser reads its product from the /sdks/ route, so an + // entry for a hosted product would pass here and then never render. + else if (byKey.get(key).kind !== "sdk") errors.push(`${at}: only sdk products can have a chooser`); if (!CHOOSER_STATUSES.includes(c.status)) { errors.push(`${at}: status ${JSON.stringify(c.status)} is not draft or approved`); } From f6084c42ca7bb8f6a14b9e51cada7723cdc0e3d0 Mon Sep 17 00:00:00 2001 From: Yevheniia Sorokina Date: Wed, 14 Oct 2026 13:48:05 +0100 Subject: [PATCH 3/6] Name the sequence product MatrixScan Sequence The product owner confirmed MatrixScan Sequence as the name, and that the product has two flows: capture, which registers the order of barcodes as they are scanned, and verification, which checks that each barcode is in its correct position. The old description covered only the second. The registry and products.json change together because verify-registry requires their names to match. Co-Authored-By: Claude Opus 5.5 (1M context) --- content/registry/products.yml | 2 +- src/data/products.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/registry/products.yml b/content/registry/products.yml index 0dba614465..d07581ec6e 100644 --- a/content/registry/products.yml +++ b/content/registry/products.yml @@ -69,7 +69,7 @@ products: kind: sdk - key: barcode-sequence - name: Barcode Sequence + name: MatrixScan Sequence kind: sdk - key: express diff --git a/src/data/products.json b/src/data/products.json index 36ba79024c..3f8957b2ed 100644 --- a/src/data/products.json +++ b/src/data/products.json @@ -229,8 +229,8 @@ }, { "key": "barcode-sequence", - "name": "Barcode Sequence", - "description": "Capture mode for scanning a predefined sequence of barcodes in order. Validates that barcodes are scanned in the correct sequence, making it ideal for workflows that require ordered item processing, assembly verification, and guided sequential scanning tasks.", + "name": "MatrixScan Sequence", + "description": "Records and verifies the order of barcodes, for example items on a shelf or in a tray. The capture flow registers the order of barcodes as they are scanned. The verification flow checks that each barcode is in its correct position.", "frameworks": { "iOS": { "version": "8.1", "apiUrl": "n/a" }, "Android": { "version": "8.0", "apiUrl": "n/a" }, From b286372a6d64d8df22d296ef699c5af4e182d827 Mon Sep 17 00:00:00 2001 From: Yevheniia Sorokina Date: Thu, 15 Oct 2026 10:22:59 +0100 Subject: [PATCH 4/6] Document the chooser file and accept it before its first entry An empty or comments-only product-chooser.yml, or a bare `products:`, now means "no entries yet" instead of failing verify:registry and the build. The file ships with a header comment that shows each field and how it is displayed, so whoever writes the first entry has the shape in front of them. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- content/registry/product-chooser.yml | 34 ++++++++++++++++++++++++++++ scripts/test-registry.cjs | 13 ++++++++++- scripts/verify-registry.cjs | 12 ++++++---- 4 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 content/registry/product-chooser.yml diff --git a/AGENTS.md b/AGENTS.md index 7b6673dfc9..fbb8551baa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -318,7 +318,7 @@ All SDK platforms are treated equally - no platform gets special priority over o ## Products -The full list of products is the product registry, `content/registry/products.yml`. Use its `key` in frontmatter `product`, in component props and in data files. Its `aliases` (`matrixscan`, `label-capture`) are folder names under `/sdks//` that differ from the key, and are valid only there and in `URL_PRODUCT_MAPPING`. `npm run verify:registry` fails the build if `docs-schema.yml`, the data files, `URL_PRODUCT_MAPPING` or a page's `product` disagree with it. Product Chooser text ("Choose this if / Consider another product if") goes in a separate file next to the registry, product-chooser.yml, once a product owner approves it; the same command checks it. +The full list of products is the product registry, `content/registry/products.yml`. Use its `key` in frontmatter `product`, in component props and in data files. Its `aliases` (`matrixscan`, `label-capture`) are folder names under `/sdks//` that differ from the key, and are valid only there and in `URL_PRODUCT_MAPPING`. `npm run verify:registry` fails the build if `docs-schema.yml`, the data files, `URL_PRODUCT_MAPPING` or a page's `product` disagree with it. Product Chooser text ("Choose this if / Consider another product if") goes in a separate file next to the registry, `content/registry/product-chooser.yml`, whose header comment shows the fields and how each one is displayed. Only entries with `status: approved` appear on pages; the same command checks every entry. ## Quick Reference diff --git a/content/registry/product-chooser.yml b/content/registry/product-chooser.yml new file mode 100644 index 0000000000..8550a61083 --- /dev/null +++ b/content/registry/product-chooser.yml @@ -0,0 +1,34 @@ +# Product Chooser text: the "Choose this if / Consider another product if" +# block at the top of a product's overview page, on every SDK framework. +# +# One entry per product, under `products`, keyed by the product's `key` in +# products.yml. Only `sdk` products can have an entry. `yarn verify:registry` +# checks every entry, draft or approved, and a docs build fails on an invalid +# one, so keep drafts valid too. +# +# status `draft` or `approved`. Only approved entries appear on pages. +# choose_if One or more lines, shown under the heading +# "Choose if you want to", so each line continues +# that sentence: "scan one barcode at a time", not "Scanning...". +# not_for Optional. A list of { when, use } pairs, shown under the +# heading "Consider another product if" as ": ", +# linked to that product's overview. `use` is another product's +# key (`sdk` or `hosted`), never a topic. Leave it out and the +# block shows one column. +# +# Wrap API names in backticks (`BarcodeBatch`) to show them as inline code. +# The frameworks the product is available on come from src/data/products.json, +# not from this file. +# +# Example (an entry is the indented part only, under `products:`): +# +# products: +# sparkscan: +# status: draft +# choose_if: +# - +# not_for: +# - when: +# use: matrixscan-batch + +products: diff --git a/scripts/test-registry.cjs b/scripts/test-registry.cjs index 77b6cd8084..8432ed923a 100644 --- a/scripts/test-registry.cjs +++ b/scripts/test-registry.cjs @@ -96,7 +96,12 @@ test("no chooser file is fine", () => assert.deepStrictEqual(r.chooserErrors(bas test("a valid chooser file has no errors", () => assert.deepStrictEqual(r.chooserErrors(base(), chooser()), [])); test("a chooser file without a products map", () => { has(r.chooserErrors(base(), { products: [] }), "expected a top-level `products` map"); - has(r.chooserErrors(base(), null), "expected a top-level `products` map"); + has(r.chooserErrors(base(), { entries: {} }), "expected a top-level `products` map"); + has(r.chooserErrors(base(), "products"), "expected a top-level `products` map"); +}); +test("an empty chooser file or a bare `products:` means no entries yet", () => { + assert.deepStrictEqual(r.chooserErrors(base(), null), []); + assert.deepStrictEqual(r.chooserErrors(base(), { products: null }), []); }); test("a chooser for a product that does not exist", () => { const c = chooser(); c.products.barcodeview = c.products.sparkscan; @@ -313,6 +318,12 @@ const drifts = { "component prop": ["docs/sdks/ios/sparkscan/intro.md", (t) => t.replace("product=\"sparkscan\"", "product=\"label-capture\""), "component prop product=\"label-capture\""], folder: ["docs/sdks/ios/img/.keep", null, "docs/sdks/ios/barcode-ar/"], }; +test("main() passes with a comments-only chooser file", () => { + const tree = cleanTree(); + tree["content/registry/product-chooser.yml"] = "# no entries yet\n"; + const res = runMain(tree); + assert.strictEqual(res.code, 0, res.out); +}); for (const [name, [file, change, expected]] of Object.entries(drifts)) { test(`main() fails on drift in ${name}`, () => { const tree = cleanTree(); diff --git a/scripts/verify-registry.cjs b/scripts/verify-registry.cjs index c6b1c9c647..df9f3fcea5 100644 --- a/scripts/verify-registry.cjs +++ b/scripts/verify-registry.cjs @@ -131,11 +131,15 @@ function registryErrors(products) { /** * Check 1b: content/registry/product-chooser.yml, when it exists. A map of - * product key -> { status, choose_if, not_for }. + * product key -> { status, choose_if, not_for }; the file's header comment + * shows the shape. */ function chooserErrors(products, chooserDoc) { - if (chooserDoc === undefined) return []; - const entries = chooserDoc && chooserDoc.products; + // No file, an empty or comments-only file (YAML null) and a bare `products:` + // all mean "no entries yet", so the file can exist before its first entry. + if (chooserDoc == null) return []; + const entries = chooserDoc.products; + if (entries === null) return []; if (!entries || typeof entries !== "object" || Array.isArray(entries)) { return [`${CHOOSER_FILE}: expected a top-level \`products\` map of product key to chooser`]; } @@ -360,7 +364,7 @@ function main() { const read = (f) => fs.readFileSync(path.join(ROOT, f), "utf8"); const products = readRegistry(read(REGISTRY_FILE)); const chooserPath = path.join(ROOT, CHOOSER_FILE); - const chooserDoc = fs.existsSync(chooserPath) ? yaml.load(fs.readFileSync(chooserPath, "utf8")) || null : undefined; + const chooserDoc = fs.existsSync(chooserPath) ? yaml.load(fs.readFileSync(chooserPath, "utf8")) : undefined; const schema = yaml.load(read(SCHEMA_FILE)); const schemaEnum = schema && schema.properties && schema.properties.product && schema.properties.product.enum; const files = docFiles(path.join(ROOT, "docs")).map((full) => ({ From be49732d8b67b7f42b95a8d998ad8eb47c13b534 Mon Sep 17 00:00:00 2001 From: Yevheniia Sorokina Date: Thu, 15 Oct 2026 10:28:32 +0100 Subject: [PATCH 5/6] Read the chooser file the same way on js-yaml 4 and 5 js-yaml 5, the version in the lockfile, throws on an empty or comments-only file where 4 returned undefined, so the previous commit still failed there. readChooser uses loadAll, which returns no documents on both, and is exported so the docs plugin parses the file through the same function. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/test-registry.cjs | 6 ++++++ scripts/verify-registry.cjs | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/test-registry.cjs b/scripts/test-registry.cjs index 8432ed923a..e80fc16796 100644 --- a/scripts/test-registry.cjs +++ b/scripts/test-registry.cjs @@ -99,6 +99,12 @@ test("a chooser file without a products map", () => { has(r.chooserErrors(base(), { entries: {} }), "expected a top-level `products` map"); has(r.chooserErrors(base(), "products"), "expected a top-level `products` map"); }); +test("readChooser reads an empty or comments-only file as null", () => { + assert.strictEqual(r.readChooser(""), null); + assert.strictEqual(r.readChooser("# no entries yet\n"), null); + assert.deepStrictEqual(r.readChooser("products:\n"), { products: null }); + assert.throws(() => r.readChooser("products: {}\n---\nproducts: {}\n"), /one YAML document/); +}); test("an empty chooser file or a bare `products:` means no entries yet", () => { assert.deepStrictEqual(r.chooserErrors(base(), null), []); assert.deepStrictEqual(r.chooserErrors(base(), { products: null }), []); diff --git a/scripts/verify-registry.cjs b/scripts/verify-registry.cjs index df9f3fcea5..7d9ff9f6c8 100644 --- a/scripts/verify-registry.cjs +++ b/scripts/verify-registry.cjs @@ -129,6 +129,18 @@ function registryErrors(products) { return errors; } +/** + * Parses product-chooser.yml. An empty or comments-only file is null, not an + * error: js-yaml 5's load() throws on it where 4's returned undefined, and + * loadAll() returns no documents on both. Shared with the docs plugin, so the + * build and this check read the file the same way. + */ +function readChooser(text) { + const docs = yaml.loadAll(text); + if (docs.length > 1) throw new Error(`${CHOOSER_FILE}: expected one YAML document, found ${docs.length}`); + return docs.length ? docs[0] : null; +} + /** * Check 1b: content/registry/product-chooser.yml, when it exists. A map of * product key -> { status, choose_if, not_for }; the file's header comment @@ -364,7 +376,7 @@ function main() { const read = (f) => fs.readFileSync(path.join(ROOT, f), "utf8"); const products = readRegistry(read(REGISTRY_FILE)); const chooserPath = path.join(ROOT, CHOOSER_FILE); - const chooserDoc = fs.existsSync(chooserPath) ? yaml.load(fs.readFileSync(chooserPath, "utf8")) : undefined; + const chooserDoc = fs.existsSync(chooserPath) ? readChooser(fs.readFileSync(chooserPath, "utf8")) : undefined; const schema = yaml.load(read(SCHEMA_FILE)); const schemaEnum = schema && schema.properties && schema.properties.product && schema.properties.product.enum; const files = docFiles(path.join(ROOT, "docs")).map((full) => ({ @@ -406,6 +418,7 @@ if (require.main === module) main(); module.exports = { readRegistry, registryErrors, + readChooser, chooserErrors, schemaErrors, readObjectKeys, From 0f6af2ce5b701463ee74a99e1707cc11b2b2c7f1 Mon Sep 17 00:00:00 2001 From: Yevheniia Sorokina Date: Thu, 15 Oct 2026 10:42:17 +0100 Subject: [PATCH 6/6] Strip a byte-order mark before parsing the registry files js-yaml 5 rejects a BOM followed by a comment, and both registry files open with a comment, so a file saved with a BOM by some Windows editors would fail every build. The chooser file's header also says that rows for a product missing on the reader's framework are hidden. Co-Authored-By: Claude Opus 5.5 (1M context) --- content/registry/product-chooser.yml | 6 ++++-- scripts/test-registry.cjs | 5 +++++ scripts/verify-registry.cjs | 11 ++++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/content/registry/product-chooser.yml b/content/registry/product-chooser.yml index 8550a61083..6dd7092996 100644 --- a/content/registry/product-chooser.yml +++ b/content/registry/product-chooser.yml @@ -13,8 +13,10 @@ # not_for Optional. A list of { when, use } pairs, shown under the # heading "Consider another product if" as ": ", # linked to that product's overview. `use` is another product's -# key (`sdk` or `hosted`), never a topic. Leave it out and the -# block shows one column. +# key (`sdk` or `hosted`), never a topic. A row whose product +# is not available on the reader's framework (per +# src/data/products.json) is not shown there. With no row to +# show, or no not_for at all, the block shows one column. # # Wrap API names in backticks (`BarcodeBatch`) to show them as inline code. # The frameworks the product is available on come from src/data/products.json, diff --git a/scripts/test-registry.cjs b/scripts/test-registry.cjs index e80fc16796..d785e5d86a 100644 --- a/scripts/test-registry.cjs +++ b/scripts/test-registry.cjs @@ -105,6 +105,11 @@ test("readChooser reads an empty or comments-only file as null", () => { assert.deepStrictEqual(r.readChooser("products:\n"), { products: null }); assert.throws(() => r.readChooser("products: {}\n---\nproducts: {}\n"), /one YAML document/); }); +test("a byte-order mark before a leading comment is ignored", () => { + assert.strictEqual(r.readChooser("# no entries yet\n"), null); + assert.deepStrictEqual(r.readChooser("# c\nproducts:\n"), { products: null }); + assert.strictEqual(r.readRegistry("# c\nproducts:\n - key: a\n name: A\n kind: sdk\n")[0].key, "a"); +}); test("an empty chooser file or a bare `products:` means no entries yet", () => { assert.deepStrictEqual(r.chooserErrors(base(), null), []); assert.deepStrictEqual(r.chooserErrors(base(), { products: null }), []); diff --git a/scripts/verify-registry.cjs b/scripts/verify-registry.cjs index 7d9ff9f6c8..5515728b10 100644 --- a/scripts/verify-registry.cjs +++ b/scripts/verify-registry.cjs @@ -79,9 +79,14 @@ const FOLDER_EXCEPTIONS = { // Frameworks whose folder holds further framework folders, not products. const NESTED_FRAMEWORKS = ["net"]; +// js-yaml 5 rejects a byte-order mark before a leading comment ("end of the +// stream or a document separator is expected"), and both registry files open +// with a comment. Some Windows editors save UTF-8 with one. +const stripBom = (text) => text.replace(/^/, ""); + /** Registry entries as written; `aliases` defaults to an empty list. */ function readRegistry(text) { - const doc = yaml.load(text); + const doc = yaml.load(stripBom(text)); if (!doc || !Array.isArray(doc.products)) { throw new Error(`${REGISTRY_FILE}: expected a top-level \`products\` list`); } @@ -136,7 +141,7 @@ function registryErrors(products) { * build and this check read the file the same way. */ function readChooser(text) { - const docs = yaml.loadAll(text); + const docs = yaml.loadAll(stripBom(text)); if (docs.length > 1) throw new Error(`${CHOOSER_FILE}: expected one YAML document, found ${docs.length}`); return docs.length ? docs[0] : null; } @@ -312,7 +317,7 @@ const UNPARSEABLE = Symbol("unparseable"); * `product="..."` component prop. */ function fileProducts(text) { - const body = text.replace(/^/, ""); + const body = stripBom(text); let product; const m = body.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (m) {