diff --git a/.github/workflows/docs-retrieval-evals.yml b/.github/workflows/docs-retrieval-evals.yml new file mode 100644 index 000000000..8e8b26ef3 --- /dev/null +++ b/.github/workflows/docs-retrieval-evals.yml @@ -0,0 +1,53 @@ +name: Docs Retrieval Evals + +# Measures retrieval quality of the build-generated knowledge index against a +# gold set of real queries (success@k / precision@k / MRR). Runs on PRs that +# touch docs, the extractor, or the gold set — a regression in descriptions or +# partials that hurts retrieval fails here. Also runs on push to main, so the +# deploy branch is checked too (an empty index from a cause outside the PR +# paths — e.g. a Docusaurus upgrade renaming article selectors — is caught here +# as well as by the plugin's own fail-loud floor check during the build). + +on: + pull_request: + branches: + - main + paths: + - 'docs/**' + - 'src/plugins/knowledge-extractor/**' + - 'scripts/retrieval-evals/**' + - '.github/workflows/docs-retrieval-evals.yml' + push: + branches: + - main + +jobs: + retrieval-evals: + name: Docs Retrieval Evals + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build docs (generates the knowledge retrieval index) + run: yarn build + + - name: Run retrieval evals + run: node scripts/retrieval-evals/run.cjs --report reports/retrieval-evals.json + + - name: Upload eval report + if: always() + uses: actions/upload-artifact@v4 + with: + name: retrieval-evals-report + path: reports/retrieval-evals.json + if-no-files-found: ignore diff --git a/docusaurus.config.ts b/docusaurus.config.ts index db796f49b..b37e60ba8 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -4,6 +4,7 @@ import type * as Preset from "@docusaurus/preset-classic"; import * as dotenv from 'dotenv'; import { version } from "react"; import remarkHideComments from "./src/plugins/remark-hide-comments"; +import knowledgeExtractor from "./src/plugins/knowledge-extractor"; dotenv.config(); // Load environment variables from .env file /** @@ -410,6 +411,9 @@ const config: Config = { ignoreFiles: llmsIgnoreFiles, }, ], + // Build-generate AI layer: emits /assets/knowledge-retrieval-index.json and + // /assets/knowledge-graph.jsonld from the rendered HTML (see src/plugins/knowledge-extractor). + knowledgeExtractor, ], presets: [ diff --git a/package-lock.json b/package-lock.json index 5bfeaf51a..e77b07319 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@docusaurus/plugin-client-redirects": "3.4.0", "@docusaurus/preset-classic": "^3.4.0", "@mdx-js/react": "^3.0.0", + "cheerio": "1.0.0-rc.12", "classnames": "^2.5.1", "clsx": "^1.2.1", "docusaurus-plugin-sass": "^0.2.5", @@ -20,7 +21,8 @@ "react": "^18.0.0", "react-dom": "^18.0.0", "react-player": "^2.16.0", - "sass": "^1.77.2" + "sass": "^1.77.2", + "search-insights": "^2.17.3" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.4.0", @@ -12918,10 +12920,10 @@ } }, "node_modules/search-insights": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.14.0.tgz", - "integrity": "sha512-OLN6MsPMCghDOqlCtsIsYgtsC0pnwVTyT9Mu6A3ewOj1DxvzZF6COrn2g86E/c05xbktB0XN04m/t1Z+n+fTGw==", - "peer": true + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "license": "MIT" }, "node_modules/section-matter": { "version": "1.0.0", diff --git a/package.json b/package.json index 8baf78ff4..685014773 100644 --- a/package.json +++ b/package.json @@ -13,16 +13,19 @@ "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", - "typecheck": "tsc" + "typecheck": "tsc", + "docs:retrieval-evals": "node scripts/retrieval-evals/run.cjs" }, "dependencies": { "@docusaurus/core": "^3.4.0", "@docusaurus/plugin-client-redirects": "3.4.0", "@docusaurus/preset-classic": "^3.4.0", "@mdx-js/react": "^3.0.0", + "cheerio": "1.0.0-rc.12", "classnames": "^2.5.1", "clsx": "^1.2.1", "docusaurus-plugin-sass": "^0.2.5", + "gray-matter": "4.0.3", "prism-react-renderer": "^2.1.0", "react": "^18.0.0", "react-dom": "^18.0.0", diff --git a/scripts/retrieval-evals/gold-set.json b/scripts/retrieval-evals/gold-set.json new file mode 100644 index 000000000..7743f6cda --- /dev/null +++ b/scripts/retrieval-evals/gold-set.json @@ -0,0 +1,25 @@ +{ + "_comment": "Retrieval-eval gold set. Each entry is a real user query (seeded from Algolia top-queries + the product/intent taxonomy) mapped to the URL/path substrings a correct answer should live under. A retrieved knowledge module counts as relevant if its `url` contains ANY of `expect`. Add entries as new products/pages land; keep queries in the user's words.", + "queries": [ + { "query": "barcode capture get started", "expect": ["/barcode-capture/get-started"] }, + { "query": "sparkscan", "expect": ["/sparkscan/"] }, + { "query": "spark scan get started", "expect": ["/sparkscan/get-started", "/sparkscan/intro"] }, + { "query": "matrixscan count", "expect": ["/matrixscan-count/"] }, + { "query": "matrixscan find", "expect": ["/matrixscan-find/", "/matrixscan-pick/"] }, + { "query": "label capture", "expect": ["/label-capture/"] }, + { "query": "smart label capture", "expect": ["/label-capture/"] }, + { "query": "id capture supported documents", "expect": ["/id-capture/supported-documents", "/id-capture/", "id-documents"] }, + { "query": "id bolt", "expect": ["/id-bolt/"] }, + { "query": "parser", "expect": ["/parser/"] }, + { "query": "barcode selection", "expect": ["/barcode-selection/"] }, + { "query": "add the sdk", "expect": ["/add-sdk"] }, + { "query": "how to initialize data capture context", "expect": ["/add-sdk", "/get-started"] }, + { "query": "supported symbologies", "expect": ["barcode-symbologies", "symbology-properties"] }, + { "query": "qr code scanning", "expect": ["barcode-symbologies", "/barcode-capture/"] }, + { "query": "data matrix", "expect": ["barcode-symbologies"] }, + { "query": "ai powered barcode scanning", "expect": ["ai-powered-barcode-scanning"] }, + { "query": "scandit express inventory count", "expect": ["/express/", "inventory-count"] }, + { "query": "release notes", "expect": ["/release-notes"] }, + { "query": "agent skills", "expect": ["/agent-skills"] } + ] +} diff --git a/scripts/retrieval-evals/run.cjs b/scripts/retrieval-evals/run.cjs new file mode 100644 index 000000000..635ffb2c3 --- /dev/null +++ b/scripts/retrieval-evals/run.cjs @@ -0,0 +1,241 @@ +#!/usr/bin/env node +"use strict"; +/** + * Retrieval-quality evals for the build-generated knowledge retrieval index + * (docs/assets/knowledge-retrieval-index.json, emitted by the knowledge-extractor + * plugin at build time). + * + * Scores each gold-set query with the same token-overlap ranking the index is + * designed for, then reports: + * - success@k : fraction of queries with a relevant module in the top k + * - precision@k : mean fraction of the top k that are relevant + * - MRR : mean reciprocal rank of the first relevant module + * + * A retrieved module is "relevant" when its `url` contains ANY of the query's + * `expect` substrings (a path-class, so the gold set stays stable even though + * module ids are auto-generated). Fails (exit 1) if success@k or MRR drops below + * the thresholds — so a regression in descriptions/partials is caught in CI. + * + * Adapted from the bundle's run_retrieval_evals.py (token mode), pure Node stdlib. + * + * Usage: node scripts/retrieval-evals/run.cjs [--index ] [--k 3] + * [--min-success 0.8] [--min-mrr 0.6] [--report ] + */ +const fs = require("fs"); +const path = require("path"); + +function arg(name, def) { + const i = process.argv.indexOf(`--${name}`); + return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : def; +} + +const INDEX = arg("index", "build/assets/knowledge-retrieval-index.json"); +const GOLD = arg("gold", path.join(__dirname, "gold-set.json")); +const K = parseInt(arg("k", "3"), 10); +const MIN_SUCCESS = parseFloat(arg("min-success", "0.8")); +const MIN_PRECISION = parseFloat(arg("min-precision", "0.6")); +const MIN_MRR = parseFloat(arg("min-mrr", "0.6")); +// --auto: corpus-wide self-retrieval over EVERY module (not just the 20-query +// gold set) — each module becomes a query built from its own title+summary and +// must retrieve itself in the top k. Measures coverage across all docs we +// create/edit. --auto-limit caps it; --min-auto-success gates it. +const AUTO = process.argv.includes("--auto"); +const AUTO_LIMIT = parseInt(arg("auto-limit", "0"), 10); +const MIN_AUTO_SUCCESS = parseFloat(arg("min-auto-success", "0")); +const REPORT = arg("report", ""); + +const TOKEN = /[a-z0-9]{2,}/gi; +const tokenize = (s) => new Set((String(s || "").toLowerCase().match(TOKEN) || [])); + +function docText(d) { + return [ + d.title, + d.summary, + d.docs_excerpt, + d.assistant_excerpt, + (d.keywords || []).join(" "), + (d.intents || []).join(" "), + ].join(" "); +} + +function score(queryTokens, doc) { + const dt = tokenize(docText(doc)); + if (!queryTokens.size || !dt.size) return 0; + let overlap = 0; + for (const t of queryTokens) if (dt.has(t)) overlap++; + return overlap / Math.sqrt(queryTokens.size * dt.size); +} + +function search(index, query, k) { + const qt = tokenize(query); + return index + .map((d) => ({ id: d.id, url: String(d.url || d.source_site || ""), s: score(qt, d) })) + .sort((a, b) => b.s - a.s || a.id.localeCompare(b.id)) + .slice(0, k); +} + +function relevant(hit, expect) { + const u = hit.url.toLowerCase(); + return expect.some((e) => u.includes(e.toLowerCase())); +} + +function main() { + if (!fs.existsSync(INDEX)) { + console.error(`retrieval-evals: index not found at ${INDEX} — run \`yarn build\` first.`); + process.exit(1); + } + const index = JSON.parse(fs.readFileSync(INDEX, "utf8")); + const gold = JSON.parse(fs.readFileSync(GOLD, "utf8")).queries || []; + if (!Array.isArray(index) || !index.length) { + console.error("retrieval-evals: empty or invalid index."); + process.exit(1); + } + if (AUTO) { + // Corpus-wide, PAGE-LEVEL self-retrieval over every doc: for each page, + // query with a chunk's own title+summary and check that a chunk from the + // SAME page (same url) lands in the top k. Page-level (not exact-chunk) is + // the meaningful coverage metric here, because the index holds many + // near-duplicate chunks per page (shared prose across frameworks, "Part N" + // splits) — so "did we surface the right page for this doc's content?" is + // what matters, not "did this exact chunk outrank its own siblings". + const docs = index.map((m) => ({ + id: String(m.id || ""), + url: String(m.url || ""), + tokens: tokenize(docText(m)), + query: [m.title, m.summary].filter(Boolean).join(" ").trim() || String(m.id || ""), + })); + // One representative query per unique page (first chunk seen). + const seen = new Set(); + let rows = docs.filter((d) => d.url && !seen.has(d.url) && seen.add(d.url)); + if (AUTO_LIMIT > 0) rows = rows.slice(0, AUTO_LIMIT); + const fastScore = (qt, dt) => { + if (!qt.size || !dt.size) return 0; + let o = 0; + for (const t of qt) if (dt.has(t)) o++; + return o / Math.sqrt(qt.size * dt.size); + }; + let ok = 0, + rrSum = 0; + const misses = []; + for (const r of rows) { + const qt = tokenize(r.query); + // best score among chunks of the SAME page + let bestSame = 0; + for (const d of docs) if (d.url === r.url) bestSame = Math.max(bestSame, fastScore(qt, d.tokens)); + // rank of that best same-page chunk = # of OTHER-page chunks scoring higher + let better = 0; + for (const d of docs) { + if (d.url === r.url) continue; + if (fastScore(qt, d.tokens) > bestSame) { + better++; + if (better >= K) break; + } + } + if (better < K) { + ok++; + rrSum += 1 / (better + 1); + } else { + misses.push(r.url.replace(/^https?:\/\/[^/]+/, "")); + } + } + const metrics = { + mode: "auto-page-self-retrieval", + pages: rows.length, + modules: docs.length, + k: K, + page_success_at_k: +(ok / rows.length).toFixed(4), + page_mrr: +(rrSum / rows.length).toFixed(4), + }; + console.log(`\nRetrieval self-eval (AUTO, page-level over all docs): ${rows.length} pages / ${docs.length} modules, k=${K}`); + console.log(` page-success@${K} = ${metrics.page_success_at_k} (min ${MIN_AUTO_SUCCESS})`); + console.log(` page-MRR = ${metrics.page_mrr}`); + console.log(` ${misses.length} page(s) not surfaced in top ${K} by their own content.`); + misses.slice(0, 10).forEach((m) => console.log(` ✗ ${m}`)); + if (REPORT) { + fs.mkdirSync(path.dirname(REPORT), { recursive: true }); + fs.writeFileSync( + REPORT, + JSON.stringify({ status: metrics.page_success_at_k < MIN_AUTO_SUCCESS ? "breach" : "ok", metrics, misses: misses.slice(0, 200) }, null, 2) + "\n", + ); + } + process.exit(metrics.page_success_at_k < MIN_AUTO_SUCCESS ? 1 : 0); + } + + if (!gold.length) { + console.error("retrieval-evals: empty gold set."); + process.exit(1); + } + + // NOTE on recall: this gold set uses path-classes (a query maps to "any page + // under /sparkscan/", not one specific page), and every such class has more + // than K relevant modules in the corpus. A capped recall@k + // (relCount / min(totalRelevant, k)) therefore reduces algebraically to + // precision@k (relCount / k) — the same number query by query, not just on + // average. Reporting both would be one signal printed twice, and a + // "min-recall" gate could only ever fail when precision already had. So we + // report precision@k with an honestly-named MIN_PRECISION floor and DO NOT + // report a redundant recall. A real recall metric needs single-page gold + // entries (small, exact totalRelevant) or an uncapped denominator at a large + // k — a future gold-set change, not a rename. + let successSum = 0, + precisionSum = 0, + rrSum = 0; + const rows = []; + for (const g of gold) { + const hits = search(index, g.query, K); + const rel = hits.map((h) => relevant(h, g.expect)); + const firstRel = rel.indexOf(true); + const relCount = rel.filter(Boolean).length; + const success = firstRel !== -1 ? 1 : 0; + const precision = relCount / Math.max(hits.length, 1); + const rr = firstRel !== -1 ? 1 / (firstRel + 1) : 0; + successSum += success; + precisionSum += precision; + rrSum += rr; + rows.push({ + query: g.query, + success, + precision: +precision.toFixed(3), + rr: +rr.toFixed(3), + top: hits.map((h) => h.url.replace(/^https?:\/\/[^/]+/, "")), + }); + } + + const n = gold.length; + const metrics = { + success_at_k: +(successSum / n).toFixed(4), + precision_at_k: +(precisionSum / n).toFixed(4), + mrr: +(rrSum / n).toFixed(4), + k: K, + query_count: n, + index_size: index.length, + }; + + console.log(`\nRetrieval evals (token mode, k=${K}, ${n} queries over ${index.length} modules)`); + console.log(` success@${K} = ${metrics.success_at_k} (min ${MIN_SUCCESS})`); + console.log(` precision@${K} = ${metrics.precision_at_k} (min ${MIN_PRECISION})`); + console.log(` MRR = ${metrics.mrr} (min ${MIN_MRR})\n`); + for (const r of rows) { + if (!r.success) console.log(` ✗ MISS "${r.query}" → top: ${r.top.join(" , ") || "(none)"}`); + } + + const breaches = []; + if (metrics.success_at_k < MIN_SUCCESS) breaches.push(`success@${K}=${metrics.success_at_k} < ${MIN_SUCCESS}`); + if (metrics.precision_at_k < MIN_PRECISION) breaches.push(`precision@${K}=${metrics.precision_at_k} < ${MIN_PRECISION}`); + if (metrics.mrr < MIN_MRR) breaches.push(`MRR=${metrics.mrr} < ${MIN_MRR}`); + + if (REPORT) { + fs.mkdirSync(path.dirname(REPORT), { recursive: true }); + fs.writeFileSync(REPORT, JSON.stringify({ status: breaches.length ? "breach" : "ok", metrics, breaches, rows }, null, 2) + "\n"); + } + + if (breaches.length) { + console.log("Retrieval quality below threshold:"); + for (const b of breaches) console.log(` breach: ${b}`); + process.exit(1); + } + console.log("Retrieval quality OK."); + process.exit(0); +} + +main(); diff --git a/src/plugins/knowledge-extractor/index.ts b/src/plugins/knowledge-extractor/index.ts new file mode 100644 index 000000000..4ef00708e --- /dev/null +++ b/src/plugins/knowledge-extractor/index.ts @@ -0,0 +1,730 @@ +/** + * knowledge-extractor — build-generate AI layer. + * + * Runs in Docusaurus `postBuild`, so it reads the FINAL rendered HTML (which + * already has all partials/MDX components inlined) rather than raw Markdown. + * That means pages whose prose lives in imported partials are captured in full, + * and every chunk gets the real, user-facing URL and the frontmatter-derived + * `` as its summary. + * + * It splits each CURRENT-version page into small self-contained knowledge + * modules (chunks, ~1400 chars) with rule-based metadata, preserving link URLs + * inside the prose (so citations — including external API-reference links — + * survive), then emits the two artifacts an assistant / in-docs search consume: + * - /assets/knowledge-retrieval-index.json (fast lookup) + * - /assets/knowledge-graph.jsonld (enriched concept graph) + * + * The graph is not just faceting: alongside intent/audience/channel/framework + * it mines real edges from the content — product membership, cites-API, + * see-also (internal links), and per-product availability (from "not available" + * stubs). Per-module intermediates are held in memory only, never committed. + * + * Faithful port of the bundle's Python pipeline, adapted to the Scandit repo. + */ + +import fs from "node:fs"; +import path from "node:path"; +import * as cheerio from "cheerio"; +import matter from "gray-matter"; + +const CHUNK_TARGET_CHARS = 1400; +const OWNER = "docsops-auto"; + +type Chunk = { heading: string; content: string }; + +// --------------------------------------------------------------------------- +// small helpers (ported) +// --------------------------------------------------------------------------- +function slug(value: string): string { + const clean = String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return clean.replace(/-{2,}/g, "-") || "module"; +} + +function firstHeading(text: string): string { + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^\s*#{2,6}\s+(.+?)\s*$/); + if (m) return m[1].trim(); + } + return ""; +} + +/** Split body into ~chunkTarget-sized chunks at H2/H3 boundaries (ported). */ +function chunkBody(body: string, target: number): Chunk[] { + const parts = body.split(/\n(?=##\s|###\s)/); + const chunks: Chunk[] = []; + let current = ""; + let currentHeading = ""; + for (let part of parts) { + part = part.trim(); + if (!part) continue; + const partHeading = firstHeading(part); + const candidate = current ? `${current}\n\n${part}`.trim() : part; + if (candidate.length <= target) { + current = candidate; + if (!currentHeading) currentHeading = partHeading; + continue; + } + if (current) chunks.push({ heading: currentHeading, content: current }); + if (part.length <= target) { + current = part; + currentHeading = partHeading; + continue; + } + let para = ""; + let paraHeading = partHeading; + for (let p of part.split("\n\n")) { + p = p.trim(); + if (!p) continue; + const cand = para ? `${para}\n\n${p}`.trim() : p; + if (cand.length <= target) { + para = cand; + if (!paraHeading) paraHeading = firstHeading(p); + } else { + if (para) chunks.push({ heading: paraHeading, content: para }); + para = p; + paraHeading = firstHeading(p); + } + } + current = para; + currentHeading = paraHeading; + } + if (current) chunks.push({ heading: currentHeading, content: current }); + return chunks; +} + +function pickIntents(contentType: string, title: string, body: string): string[] { + const text = `${title} ${body}`.toLowerCase(); + const intents: string[] = []; + if (contentType === "tutorial" || contentType === "how-to" || text.includes("configure")) intents.push("configure"); + if (contentType === "troubleshooting" || text.includes("error") || text.includes("fix")) intents.push("troubleshoot"); + if (contentType === "reference" || contentType === "concept" || text.includes("integrat")) intents.push("integrate"); + if (text.includes("secure") || text.includes(" auth")) intents.push("secure"); + if (intents.length === 0) intents.push("configure"); + return Array.from(new Set(intents)).sort(); +} + +function pickAudiences(contentType: string): string[] { + if (contentType === "tutorial") return ["beginner", "practitioner"]; + if (contentType === "reference" || contentType === "concept") return ["developer", "operator"]; + if (contentType === "troubleshooting") return ["support", "operator"]; + return ["practitioner", "developer"]; +} + +function extractSummary(description: string, bodyChunk: string): string { + const desc = (description || "").trim(); + if (desc) return desc.slice(0, 240); + let text = bodyChunk.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\s+/g, " ").trim(); + if (text.length < 30) text = `${text} This module is auto-generated from docs content for retrieval and assistant context.`; + return text.slice(0, 240); +} + +// --------------------------------------------------------------------------- +// HTML -> markdown-ish text (link URLs preserved so citations survive) +// --------------------------------------------------------------------------- +const HEADING_LEVEL: Record = { h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6 }; + +/** Serialize inline content, keeping `[label](href)` for links and `code` spans. */ +function serializeInline($: cheerio.CheerioAPI, node: any): string { + let out = ""; + $(node) + .contents() + .each((_i, n: any) => { + if (n.type === "text") { + out += n.data || ""; + } else if (n.type === "tag") { + const tag = String(n.name || "").toLowerCase(); + if (tag === "a") { + const href = String($(n).attr("href") || ""); + const label = serializeInline($, n).replace(/\s+/g, " ").trim(); + if (!label) return; + out += href && !href.startsWith("#") ? `[${label}](${href})` : label; + } else if (tag === "code") { + out += "`" + $(n).text() + "`"; + } else if (tag === "br") { + out += " "; + } else { + out += serializeInline($, n); + } + } + }); + return out; +} + +function inlineText($: cheerio.CheerioAPI, el: any): string { + return serializeInline($, el).replace(/​/g, "").replace(/\s+/g, " ").trim(); +} + +function tableToMd($: cheerio.CheerioAPI, el: any): string { + const rows: string[] = []; + $(el) + .find("tr") + .each((_i, tr) => { + const cells: string[] = []; + $(tr) + .children("th,td") + .each((_j, c) => cells.push(inlineText($, c))); + if (cells.length) rows.push(`| ${cells.join(" | ")} |`); + }); + return rows.join("\n"); +} + +function blockToMd($: cheerio.CheerioAPI, el: any): string { + const tag = String(el.tagName || el.name || "").toLowerCase(); + if (HEADING_LEVEL[tag]) { + const t = inlineText($, el).replace(/^#+\s*/, ""); + return t ? `${"#".repeat(HEADING_LEVEL[tag])} ${t}` : ""; + } + if (tag === "p") return inlineText($, el); + if (tag === "ul" || tag === "ol") { + const items: string[] = []; + $(el) + .children("li") + .each((_i, li) => { + const t = inlineText($, li); + if (t) items.push(`- ${t}`); + }); + return items.join("\n"); + } + if (tag === "pre") { + const code = $(el).text().replace(/\s+$/g, ""); + return code ? "```\n" + code + "\n```" : ""; + } + if (tag === "table") return tableToMd($, el); + if (tag === "blockquote") return inlineText($, el); + if (tag === "div" || tag === "section" || tag === "details" || tag === "article" || tag === "aside") { + const parts: string[] = []; + $(el) + .children() + .each((_i, c) => { + const t = blockToMd($, c); + if (t && t.trim()) parts.push(t.trim()); + }); + return parts.join("\n\n"); + } + return inlineText($, el); +} + +function extractMarkdownish($: cheerio.CheerioAPI, root: any): string { + const parts: string[] = []; + $(root) + .children() + .each((_i, el) => { + const t = blockToMd($, el); + if (t && t.trim()) parts.push(t.trim()); + }); + return parts.join("\n\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +// --------------------------------------------------------------------------- +// repo-specific derivations +// --------------------------------------------------------------------------- +function pathSegments(pathname: string): string[] { + return pathname.split("/").filter(Boolean); +} + +/** /sdks/ios/... -> "ios"; /sdks/net/ios/... -> "net-ios"; else "". */ +function detectFramework(pathname: string): string { + const p = pathSegments(pathname); + if (p[0] !== "sdks") return ""; + if (p[1] === "net" && (p[2] === "ios" || p[2] === "android")) return `net-${p[2]}`; + return p[1] || ""; +} + +/** Product a page belongs to (sparkscan, matrixscan, id-capture, ...) or "core". */ +function detectProduct(pathname: string): string { + const p = pathSegments(pathname); + if (p[0] === "sdks") { + const i = p[1] === "net" ? 3 : 2; // first segment after the framework + const rest = p.slice(i); + return rest.length >= 2 ? slug(rest[0]) : "core"; // product dir vs framework-level page + } + return p[0] ? slug(p[0]) : "general"; +} + +function detectContentType(pathname: string, title: string): string { + const p = pathname.toLowerCase(); + const t = title.toLowerCase(); + if (p.includes("/api/") || p.endsWith("/api/") || p.includes("api-reference")) return "reference"; + if (p.includes("get-started") || p.includes("installation") || t.startsWith("get started")) return "tutorial"; + if (p.includes("troubleshoot") || t.includes("troubleshoot")) return "troubleshooting"; + if (p.includes("/intro") || p.includes("/concepts/") || p.includes("overview") || t.startsWith("about ")) return "concept"; + return "docs"; +} + +function isAvailabilityStub(title: string, body: string): boolean { + return /\bnot available\b/i.test(title) || /is not available (on|for) the/i.test(body); +} + +/** Classify links found in chunk prose into internal doc paths and API-ref URLs. */ +function classifyLinks(chunkMarkdown: string, site: string): { internal: string[]; api: string[] } { + const internal = new Set(); + const api = new Set(); + const re = /\[[^\]]*\]\(([^)]+)\)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(chunkMarkdown))) { + let href = m[1].trim(); + if (!href) continue; + if (href.includes("/data-capture-sdk/")) { + api.add(href.split("#")[0]); + continue; + } + // normalize same-host absolute URLs to a site path + if (site && href.startsWith(site)) href = href.slice(site.length) || "/"; + if (href.startsWith("/")) { + const p = href.split(/[?#]/)[0]; + if (p.startsWith("/img") || p.startsWith("/assets") || /\.(png|jpe?g|gif|svg|mp4|pdf|zip)$/i.test(p)) continue; + internal.add(p.endsWith("/") ? p : `${p}/`); + } + } + return { internal: Array.from(internal), api: Array.from(api) }; +} + +// --------------------------------------------------------------------------- +// frontmatter ingestion — the CURATED signal, read straight from source .md +// (the rendered HTML only carries description/keywords/title, so the rich +// extended-schema fields must be read from the source frontmatter itself) +// --------------------------------------------------------------------------- +const TOPIC_TYPE_TO_CONTENT: Record = { + "get-started": "tutorial", + tutorial: "tutorial", + "how-to": "how-to", + howto: "how-to", + reference: "reference", + concept: "concept", + about: "concept", + overview: "concept", + troubleshooting: "troubleshooting", +}; + +function fmStringArray(v: unknown): string[] { + if (Array.isArray(v)) return v.map((x) => String(x).trim()).filter(Boolean); + if (typeof v === "string" && v.trim()) return [v.trim()]; + return []; +} + +function fmFirst(v: unknown): string { + if (Array.isArray(v)) return v.length ? String(v[0]).trim() : ""; + return v == null ? "" : String(v).trim(); +} + +/** + * Best-effort: map a built page pathname back to its CURRENT-version source + * markdown and return the parsed frontmatter. Returns {} when there is no 1:1 + * source file (custom `slug:`, generated category page, redirect stub) — callers + * then fall back to path/heuristic derivation, so this NEVER breaks extraction. + */ +function readFrontMatter(siteDir: string, pathname: string): Record { + const rel = pathname.replace(/^\/+|\/+$/g, ""); + const base = path.join(siteDir, "docs"); + const candidates = rel + ? [ + path.join(base, `${rel}.md`), + path.join(base, `${rel}.mdx`), + path.join(base, rel, "index.md"), + path.join(base, rel, "index.mdx"), + path.join(base, rel, "README.md"), + ] + : [path.join(base, "index.md"), path.join(base, "intro.md"), path.join(base, "index.mdx")]; + for (const file of candidates) { + try { + if (!fs.existsSync(file)) continue; + return (matter(fs.readFileSync(file, "utf8")).data || {}) as Record; + } catch { + /* unreadable / malformed frontmatter — try next candidate, else heuristics */ + } + } + return {}; +} + +function buildModule(args: { + pathname: string; + url: string; + sourceSite: string; + site: string; + title: string; + description: string; + chunk: Chunk; + idx: number; + framework: string; + product: string; + products: string[]; + contentType: string; + version: string; + updatedAt: string; + notAvailable: boolean; + fm: Record; +}) { + const { pathname, url, sourceSite, site, title: rawTitle, description, chunk, idx, framework, product, products, contentType, version, updatedAt, notAvailable, fm } = args; + const chunkClean = chunk.content.trim(); + const title = (rawTitle || pathname).trim(); + const displayTitle = idx === 1 ? title : `${title} (Part ${idx})`; + const summary = extractSummary(description, chunkClean); + const resolvedHeading = (chunk.heading || displayTitle).trim(); + const links = classifyLinks(chunkClean, site); + // Curated signal read from the page's own frontmatter (empty when absent). + const userIntents = fmStringArray(fm.user_intents); + const notFor = fmStringArray(fm.not_for); + const canonicalId = fmFirst(fm.canonical_id); + const topicType = fmFirst(fm.topic_type); + const fmKeywords = fmStringArray(fm.keywords); + const curatedApplied = Boolean(userIntents.length || fmFirst(fm.product) || topicType); + + // NB: user_intents / not_for are emitted as dedicated, UN-truncated fields + // (see the return + toIndexRecord). We deliberately do NOT inline them into + // assistant_context, because the index only ships a 300-char assistant_excerpt + // — inlining them would crowd out the summary. The curated signal lives in the + // structured fields; the excerpt stays a clean title+summary preview. + const assistantContext = + `Use this module when answering questions related to: ${displayTitle}. ` + + `Source path: ${pathname}. ` + + `Summary: ${summary}\n\n${chunkClean}`; + const moduleId = slug(`auto-${pathname}-${idx}`); + const intents = pickIntents(contentType, displayTitle, chunkClean); + const audiences = pickAudiences(contentType); + const stemSlug = slug(pathSegments(pathname).pop() || "module"); + const tags = Array.from( + new Set(["auto-extracted", contentType || "docs", framework, product, stemSlug].filter(Boolean)), + ).sort(); + // user_intents ARE folded into keywords (untruncated + conventionally searched), + // so the curated intent is retrievable even by a consumer that only searches + // keywords. not_for is deliberately EXCLUDED here — putting "…use MatrixScan + // Count" into searchable text would make this page falsely match that product; + // not_for stays a structured field for a reranker to demote against. + const keywords = Array.from( + new Set( + [stemSlug.replace(/-/g, " "), product.replace(/-/g, " "), contentType || "docs", framework.replace(/-/g, " "), ...fmKeywords, ...userIntents].filter(Boolean), + ), + ).sort(); + return { + id: moduleId, + title: displayTitle.slice(0, 90), + summary: summary.slice(0, 240), + intents, + audiences, + channels: ["docs", "assistant", "automation"], + priority: 60, + status: "active", + owner: OWNER, + last_verified: updatedAt.slice(0, 10), + dependencies: [] as string[], + tags, + user_intents: userIntents, + not_for: notFor, + metadata: { + url, + title: displayTitle.slice(0, 90), + heading: resolvedHeading.slice(0, 180), + framework, + product, + products, + version, + updated_at: updatedAt, + source_site: sourceSite, + source_path: pathname, + canonical_id: canonicalId, + topic_type: topicType, + not_available: notAvailable, + }, + semantic: { + topic: resolvedHeading.slice(0, 120), + intent: intents[0], + audience: audiences[0], + keywords, + status: curatedApplied ? "frontmatter_augmented" : "rule_based", + }, + references: links.internal.slice(0, 20), + api_refs: links.api.slice(0, 20), + content: { + docs_markdown: chunkClean, + assistant_context: assistantContext, + }, + }; +} + +type KModule = ReturnType; + +// --------------------------------------------------------------------------- +// consumable artifacts +// --------------------------------------------------------------------------- +function toIndexRecord(m: KModule) { + return { + objectID: m.id, + id: m.id, + title: m.title, + summary: m.summary, + status: m.status, + priority: m.priority, + owner: m.owner, + last_verified: m.last_verified, + intents: m.intents, + audiences: m.audiences, + channels: m.channels, + dependencies: m.dependencies, + tags: m.tags, + docs_excerpt: m.content.docs_markdown.slice(0, 400), + assistant_excerpt: m.content.assistant_context.slice(0, 300), + url: m.metadata.url, + heading: m.metadata.heading, + framework: m.metadata.framework, + product: m.metadata.product, + products: m.metadata.products, + version: m.metadata.version, + updated_at: m.metadata.updated_at, + source_site: m.metadata.source_site, + not_available: m.metadata.not_available, + references: m.references, + api_refs: m.api_refs, + topic: m.semantic.topic, + semantic_intent: m.semantic.intent, + semantic_audience: m.semantic.audience, + keywords: m.semantic.keywords, + user_intents: m.user_intents, + not_for: m.not_for, + canonical_id: m.metadata.canonical_id, + topic_type: m.metadata.topic_type, + semantic_status: m.semantic.status, + }; +} + +/** Enriched JSON-LD graph: facets + mined product / api / see-also / availability edges. */ +function buildGraph(modules: KModule[], site: string) { + const indexedPaths = new Set(modules.map((m) => m.metadata.source_path)); + const uniq = (vals: string[]) => Array.from(new Set(vals.filter((v) => v && v.trim()))).sort(); + + const moduleNodes = modules.map((m) => ({ + "@id": `urn:module:${m.id}`, + "@type": "KnowledgeModule", + name: m.title || m.id, + description: m.summary || "", + status: m.status || "active", + priority: Number(m.priority || 0), + intents: m.intents, + audiences: m.audiences, + channels: m.channels, + framework: m.metadata.framework, + product: m.metadata.product, + products: m.metadata.products, + version: m.metadata.version, + url: m.metadata.url, + userIntents: m.user_intents, + notFor: m.not_for, + canonicalId: m.metadata.canonical_id, + topicType: m.metadata.topic_type, + lastVerified: m.last_verified || "", + })); + + const intents = uniq(modules.flatMap((m) => m.intents)); + const audiences = uniq(modules.flatMap((m) => m.audiences)); + const channels = uniq(modules.flatMap((m) => m.channels)); + const frameworks = uniq(modules.map((m) => m.metadata.framework)); + const products = uniq(modules.flatMap((m) => (m.metadata.products && m.metadata.products.length ? m.metadata.products : [m.metadata.product]))); + const apiRefs = uniq(modules.flatMap((m) => m.api_refs)); + const docPaths = uniq(modules.map((m) => m.metadata.source_path)); + + const conceptNodes: any[] = [ + ...intents.map((v) => ({ "@id": `urn:intent:${v}`, "@type": "Intent", name: v })), + ...audiences.map((v) => ({ "@id": `urn:audience:${v}`, "@type": "Audience", name: v })), + ...channels.map((v) => ({ "@id": `urn:channel:${v}`, "@type": "Channel", name: v })), + ...frameworks.map((v) => ({ "@id": `urn:framework:${v}`, "@type": "Framework", name: v })), + ...products.map((v) => ({ "@id": `urn:product:${v}`, "@type": "Product", name: v })), + ...apiRefs.map((v) => ({ "@id": `urn:api:${v}`, "@type": "ApiReference", url: v })), + ...docPaths.map((v) => ({ "@id": `urn:doc:${v}`, "@type": "Doc", url: `${site}${v}` })), + ]; + + const edges: any[] = []; + const edgeSeen = new Set(); + const addEdge = (id: string, type: string, src: string, tgt: string) => { + if (edgeSeen.has(id)) return; + edgeSeen.add(id); + edges.push({ "@id": id, "@type": type, source: { "@id": src }, target: { "@id": tgt } }); + }; + + // product-level availability, aggregated across a product's modules + const available = new Map>(); // product -> frameworks present + const unavailable = new Map>(); // product -> frameworks with a "not available" stub + + for (const m of modules) { + const src = `urn:module:${m.id}`; + for (const v of m.intents) addEdge(`${src}#intent:${v}`, "HasIntent", src, `urn:intent:${v}`); + for (const v of m.audiences) addEdge(`${src}#audience:${v}`, "HasAudience", src, `urn:audience:${v}`); + for (const v of m.channels) addEdge(`${src}#channel:${v}`, "HasChannel", src, `urn:channel:${v}`); + if (m.metadata.framework) addEdge(`${src}#framework:${m.metadata.framework}`, "HasFramework", src, `urn:framework:${m.metadata.framework}`); + for (const p of (m.metadata.products && m.metadata.products.length ? m.metadata.products : [m.metadata.product]).filter(Boolean)) addEdge(`${src}#product:${p}`, "BelongsToProduct", src, `urn:product:${p}`); + for (const a of m.api_refs) addEdge(`${src}#api:${a}`, "CitesApi", src, `urn:api:${a}`); + for (const ref of m.references) { + if (indexedPaths.has(ref)) addEdge(`${src}#see:${ref}`, "SeeAlso", src, `urn:doc:${ref}`); + } + // record availability + const prod = m.metadata.product; + const fw = m.metadata.framework; + if (prod && fw) { + if (m.metadata.not_available) { + if (!unavailable.has(prod)) unavailable.set(prod, new Set()); + unavailable.get(prod)!.add(fw); + } else { + if (!available.has(prod)) available.set(prod, new Set()); + available.get(prod)!.add(fw); + } + } + } + + // product <-> framework availability edges (directly answers "what's available where") + for (const [prod, fws] of available) { + for (const fw of fws) addEdge(`urn:product:${prod}#avail:${fw}`, "AvailableOn", `urn:product:${prod}`, `urn:framework:${fw}`); + } + for (const [prod, fws] of unavailable) { + for (const fw of fws) addEdge(`urn:product:${prod}#navail:${fw}`, "NotAvailableOn", `urn:product:${prod}`, `urn:framework:${fw}`); + } + + return { + "@context": { + "@vocab": "https://docsops.scandit.com/schema#", + name: "http://schema.org/name", + description: "http://schema.org/description", + url: "http://schema.org/url", + status: "https://docsops.scandit.com/schema#status", + source: { "@id": "https://docsops.scandit.com/schema#source", "@type": "@id" }, + target: { "@id": "https://docsops.scandit.com/schema#target", "@type": "@id" }, + }, + "@graph": [...moduleNodes, ...conceptNodes, ...edges], + }; +} + +// --------------------------------------------------------------------------- +// filesystem walk +// --------------------------------------------------------------------------- +function walkHtml(dir: string, skipDir: (name: string) => boolean): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (skipDir(entry.name)) continue; + out.push(...walkHtml(full, skipDir)); + } else if (entry.isFile() && entry.name === "index.html") { + out.push(full); + } + } + return out; +} + +// --------------------------------------------------------------------------- +// plugin +// --------------------------------------------------------------------------- +export default function knowledgeExtractor(context: any, _options: any) { + const siteDir: string = context?.siteDir || process.cwd(); + return { + name: "knowledge-extractor", + async postBuild({ siteConfig, outDir }: { siteConfig: any; outDir: string }) { + const site = String(siteConfig?.url || "").replace(/\/+$/, ""); + const sourceSite = site ? new URL(site).hostname.toLowerCase() : ""; + const updatedAt = new Date().toISOString(); + const version = "current"; + + // Index the CURRENT docs version only. Frozen versions (versions.json) + // are archived duplicates; the external API reference (data-capture-sdk) + // is a separate tool; *.html dirs are client-redirect stubs. + let frozenVersions: string[] = []; + try { + const parsed = JSON.parse(fs.readFileSync(path.join(siteDir, "versions.json"), "utf8")); + if (Array.isArray(parsed)) frozenVersions = parsed.map(String); + } catch { + /* no versions.json */ + } + const excluded = new Set([...frozenVersions, "data-capture-sdk", "assets", "img", "fonts", "search"]); + const skipDir = (name: string) => excluded.has(name) || name.endsWith(".html"); + + const files = walkHtml(outDir, skipDir); + const modules: KModule[] = []; + let pagesProcessed = 0; + let pageErrors = 0; + + for (const file of files) { + // Per-page failures are non-fatal — skip the one bad page, keep going. + try { + const html = fs.readFileSync(file, "utf8"); + const $ = cheerio.load(html); + const root = $("article .markdown").first().length + ? $("article .markdown").first() + : $(".theme-doc-markdown").first().length + ? $(".theme-doc-markdown").first() + : $("article").first(); + if (!root.length) continue; // not a doc page + + const relDir = path.relative(outDir, path.dirname(file)).split(path.sep).join("/"); + const pathname = relDir ? `/${relDir}/` : "/"; + const url = `${site}${pathname}`; + const title = ($("h1").first().text() || $("title").text() || "").replace(/​/g, "").trim(); + const description = ($('meta[name="description"]').attr("content") || "").trim(); + const bodyMd = extractMarkdownish($, root); + if (!bodyMd.trim()) continue; + + const chunks = chunkBody(bodyMd, CHUNK_TARGET_CHARS); + if (!chunks.length) continue; + pagesProcessed += 1; + + const fm = readFrontMatter(siteDir, pathname); + const framework = detectFramework(pathname); + // Frontmatter is authoritative when present; fall back to path/heuristics. + const fmProducts = fmStringArray(fm.product).map(slug).filter(Boolean); + const products = fmProducts.length ? Array.from(new Set(fmProducts)) : [detectProduct(pathname)]; + const product = products[0]; + const fmTopic = fmFirst(fm.topic_type).toLowerCase(); + const contentType = TOPIC_TYPE_TO_CONTENT[fmTopic] || detectContentType(pathname, title); + const notAvailable = isAvailabilityStub(title, bodyMd); + chunks.forEach((chunk, i) => { + modules.push( + buildModule({ pathname, url, sourceSite, site, title, description, chunk, idx: i + 1, framework, product, products, contentType, version, updatedAt, notAvailable, fm }), + ); + }); + } catch (err) { + pageErrors += 1; + console.warn(`[knowledge-extractor] skipped page ${path.relative(outDir, file)}: ${(err as Error)?.message || err}`); + } + } + + const active = modules.filter((m) => m.status === "active"); + const index = active.map(toIndexRecord); + const graph = buildGraph(active, site); + + // Fail LOUD on empty extraction — matches the config's onBrokenLinks:"throw" + // convention. "Non-fatal" covers one bad page, not "extracted nothing at + // all": selector drift (theme upgrade renames .markdown/.theme-doc-markdown) + // must not silently publish an empty index + node-less graph over a green + // build. Throwing here fails `docusaurus build`, so the regression is seen. + if (pagesProcessed === 0 || index.length === 0) { + throw new Error( + `[knowledge-extractor] extracted 0 modules from ${files.length} HTML file(s) ` + + `(${pageErrors} page error(s)). Page selectors likely drifted — refusing to ` + + `overwrite the AI-layer artifacts with empty output.`, + ); + } + + // Write both artifacts atomically: emit to temp files, then rename, so a + // failure between the two writes can never ship an index without a matching + // graph (or vice versa). + const assetsDir = path.join(outDir, "assets"); + fs.mkdirSync(assetsDir, { recursive: true }); + const idxPath = path.join(assetsDir, "knowledge-retrieval-index.json"); + const graphPath = path.join(assetsDir, "knowledge-graph.jsonld"); + fs.writeFileSync(idxPath + ".tmp", JSON.stringify(index, null, 2) + "\n", "utf8"); + fs.writeFileSync(graphPath + ".tmp", JSON.stringify(graph, null, 2) + "\n", "utf8"); + fs.renameSync(idxPath + ".tmp", idxPath); + fs.renameSync(graphPath + ".tmp", graphPath); + + const edgeTypes: Record = {}; + for (const n of graph["@graph"] as any[]) { + if ("source" in n && "target" in n) edgeTypes[n["@type"]] = (edgeTypes[n["@type"]] || 0) + 1; + } + const edgeSummary = Object.entries(edgeTypes) + .map(([k, v]) => `${k}=${v}`) + .join(" "); + console.log( + `[knowledge-extractor] ${index.length} modules from ${pagesProcessed} pages ` + + `(${pageErrors} page error(s)) | graph: ${graph["@graph"].length} nodes | edges: ${edgeSummary} -> /assets/`, + ); + }, + }; +} diff --git a/yarn.lock b/yarn.lock index 24f98e4da..0f1b18915 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2938,7 +2938,7 @@ cheerio-select@^2.1.0: domhandler "^5.0.3" domutils "^3.0.1" -cheerio@^1.0.0-rc.12: +cheerio@1.0.0-rc.12, cheerio@^1.0.0-rc.12: version "1.0.0-rc.12" resolved "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz" integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== @@ -4172,7 +4172,7 @@ fs.realpath@^1.0.0: fsevents@~2.3.2: version "2.3.3" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== function-bind@^1.1.2: @@ -4326,7 +4326,7 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== -gray-matter@^4.0.3: +gray-matter@4.0.3, gray-matter@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz" integrity sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q== @@ -7382,7 +7382,7 @@ schema-utils@^4.0.0, schema-utils@^4.0.1: search-insights@^2.17.3: version "2.17.3" - resolved "https://registry.yarnpkg.com/search-insights/-/search-insights-2.17.3.tgz#8faea5d20507bf348caba0724e5386862847b661" + resolved "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz" integrity sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ== section-matter@^1.0.0: