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..fbb8551baa 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, `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 ### 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/product-chooser.yml b/content/registry/product-chooser.yml new file mode 100644 index 0000000000..6dd7092996 --- /dev/null +++ b/content/registry/product-chooser.yml @@ -0,0 +1,36 @@ +# 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. 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, +# 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/content/registry/products.yml b/content/registry/products.yml new file mode 100644 index 0000000000..d07581ec6e --- /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: MatrixScan 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..d785e5d86a --- /dev/null +++ b/scripts/test-registry.cjs @@ -0,0 +1,350 @@ +#!/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(), { 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("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 }), []); +}); +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 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"; + 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/"], +}; +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(); + 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..5515728b10 --- /dev/null +++ b/scripts/verify-registry.cjs @@ -0,0 +1,439 @@ +#!/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 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`). + * 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"]; + +// 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(stripBom(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; +} + +/** + * 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(stripBom(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 + * shows the shape. + */ +function chooserErrors(products, chooserDoc) { + // 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`]; + } + 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`); + // 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`); + } + 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 = stripBom(text); + 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) ? 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) => ({ + 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, + readChooser, + chooserErrors, + schemaErrors, + readObjectKeys, + dataErrors, + readUrlMap, + urlMapErrors, + fileProducts, + contentErrors, + productFolders, + folderErrors, + FOLDER_EXCEPTIONS, + UNPARSEABLE, +}; 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" },