Part of #73. Depends on #74.
Problem
apps.json duplicates everything the docs repo already declares:
{ "slug": "user-guide", "name": "User Guide", "description": "…", "icon": "book-open", "tags": ["guide"], "repo": "AbsaOSS/knowledge-base-docs-example", "version": "latest" }
Worse, the duplication is not even resolved the way the docs say. scripts/fetch-apps.js merges marketplace.json over the registry entry ("manifest wins"), but scripts/build-vite.js throws that result away for packaged apps — only single-page expansions are recorded — and Astro's loadRegistry() reads apps.json alone. So for a packaged app:
- the catalog card shows whatever the knowledge-base PR typed, not what the repo published;
marketplace.json's pages navigation manifest never reaches getAppPages(); the Array.isArray(app.pages) branch in src/utils/apps.js is dead code and every packaged app is filesystem-crawled.
Meanwhile the single-page type already has the right shape: the registry entry is { "repo", "type", "version" } and every display field comes from the artifact.
Proposal
apps.json says where an artifact comes from. kb-docs.json (#74) says what it is.
Registry entry (v2)
Allowed keys on an artifact entry: exactly one of repo / prebuilt / localPath; optional version (only with repo), headless, optional. No slug, name, description, icon, tags, entryPoint, type: "single-page" — the build fails with a message pointing at kb-docs.json if any of them is present, so the duplication cannot creep back through an onboarding PR.
iframe entries are the one exception: there is no artifact, so they keep their display fields and stay marked temporary.
One install path
Every artifact entry, whatever its source, goes through the same steps:
- obtain
kb-docs.tar.gz (download from the release / copy the prebuilt path / run the repo's pack command in localPath mode);
- extract with the existing traversal/symlink guards (
scripts/artifacts.js);
- read and validate
kb-docs.json against the schema;
- for each app: copy
<slug>/ → apps/{slug}/, verify entryPoint exists and carries the headless attribute;
- record the expanded apps in the expansion map, keyed by the entry's source (
repo, prebuilt or localPath).
src/utils/single-page.js becomes src/utils/registry.js (or similar): bundleKey, expandBundle, readBundleManifest, resolveRegistry, assertUniqueSlugs generalise to every artifact entry rather than only type: "single-page". apps/.single-page.json becomes apps/.registry.json. The "manifest wins" merge and the TMP_DIR legacy mirror in fetch-apps.js go away.
localPath mode today runs npm run build:headless inside the checkout, which only works for Node repos (the example is Python/mkdocs). Replace with an explicit per-entry "pack": "bash scripts/pack.sh --headless" command (default: npm run pack:kb) that must leave a kb-docs.tar.gz in the checkout root; the result then takes the same path as prebuilt. Same behaviour, one fewer special case.
Astro side
loadRegistry() resolves every artifact entry from the expansion map, so getAppPages() finally sees pages and manifest-driven routing works. Rendering picks the reading-column layout when an app has exactly one page and no pages manifest (today keyed on type === 'single-page') — or, if that heuristic proves fragile, on an explicit kind field added to the contract.
Registry path is configurable
The deployment repo (#78) owns its own registry. Read the path from KB_REGISTRY (default apps.json), plumbed through src/utils/config.js like PATH_PREFIX and isHeadlessBuild(), and used by both scripts/build-vite.js and loadRegistry().
Scope
Non-goals
Renaming data-mp-headless and friends is #77; the packaging action is #76.
Part of #73. Depends on #74.
Problem
apps.jsonduplicates everything the docs repo already declares:{ "slug": "user-guide", "name": "User Guide", "description": "…", "icon": "book-open", "tags": ["guide"], "repo": "AbsaOSS/knowledge-base-docs-example", "version": "latest" }Worse, the duplication is not even resolved the way the docs say.
scripts/fetch-apps.jsmergesmarketplace.jsonover the registry entry ("manifest wins"), butscripts/build-vite.jsthrows that result away for packaged apps — only single-page expansions are recorded — and Astro'sloadRegistry()readsapps.jsonalone. So for a packaged app:marketplace.json'spagesnavigation manifest never reachesgetAppPages(); theArray.isArray(app.pages)branch insrc/utils/apps.jsis dead code and every packaged app is filesystem-crawled.Meanwhile the single-page type already has the right shape: the registry entry is
{ "repo", "type", "version" }and every display field comes from the artifact.Proposal
apps.jsonsays where an artifact comes from.kb-docs.json(#74) says what it is.Registry entry (v2)
[ { "repo": "AbsaOSS/knowledge-base-docs-example", "version": "latest" }, // GitHub Release asset kb-docs.tar.gz { "repo": "AbsaOSS/some-service", "version": "v2.1.0", "headless": false }, { "prebuilt": "tests/fixtures/docs-example.kb-docs.tar.gz" }, // tarball or unpacked dir { "localPath": "../knowledge-base-docs-example", "optional": true }, // sibling checkout { "type": "iframe", "slug": "external-docs", "url": "https://…", "name": "…", "description": "…", "icon": "book-open", "tags": [], "temporary": true } ]Allowed keys on an artifact entry: exactly one of
repo/prebuilt/localPath; optionalversion(only withrepo),headless,optional. Noslug,name,description,icon,tags,entryPoint,type: "single-page"— the build fails with a message pointing atkb-docs.jsonif any of them is present, so the duplication cannot creep back through an onboarding PR.iframeentries are the one exception: there is no artifact, so they keep their display fields and stay markedtemporary.One install path
Every artifact entry, whatever its source, goes through the same steps:
kb-docs.tar.gz(download from the release / copy the prebuilt path / run the repo's pack command inlocalPathmode);scripts/artifacts.js);kb-docs.jsonagainst the schema;<slug>/→apps/{slug}/, verifyentryPointexists and carries the headless attribute;repo,prebuiltorlocalPath).src/utils/single-page.jsbecomessrc/utils/registry.js(or similar):bundleKey,expandBundle,readBundleManifest,resolveRegistry,assertUniqueSlugsgeneralise to every artifact entry rather than onlytype: "single-page".apps/.single-page.jsonbecomesapps/.registry.json. The "manifest wins" merge and theTMP_DIRlegacy mirror infetch-apps.jsgo away.localPathmode today runsnpm run build:headlessinside the checkout, which only works for Node repos (the example is Python/mkdocs). Replace with an explicit per-entry"pack": "bash scripts/pack.sh --headless"command (default:npm run pack:kb) that must leave akb-docs.tar.gzin the checkout root; the result then takes the same path asprebuilt. Same behaviour, one fewer special case.Astro side
loadRegistry()resolves every artifact entry from the expansion map, sogetAppPages()finally seespagesand manifest-driven routing works. Rendering picks the reading-column layout when an app has exactly one page and nopagesmanifest (today keyed ontype === 'single-page') — or, if that heuristic proves fragile, on an explicitkindfield added to the contract.Registry path is configurable
The deployment repo (#78) owns its own registry. Read the path from
KB_REGISTRY(defaultapps.json), plumbed throughsrc/utils/config.jslikePATH_PREFIXandisHeadlessBuild(), and used by bothscripts/build-vite.jsandloadRegistry().Scope
build-vite.js(exactly one source, forbidden metadata keys,versiononly withrepo,optionalonly withprebuilt/localPath)repo/prebuilt/localPath;single-pagespecial-casing removed fromfetch-apps.js,build-vite.js,apps.js,[...path].astroKB_REGISTRYinsrc/utils/config.jsloadRegistry()cache stamp updatedpagesnavigation manifest actually honoured; test inbuild-integrity.spec.jsthat a fixture withpagesproduces exactly those routes and no crawled extrasapps.jsonin this repo trimmed to source-only entries; committed fixtures regeneratedkb-docs.tar.gz; missing/invalid manifest linkscontract/ARTIFACT.mdREADME.mdregistry section,CLAUDE.md/AGENTS.md,contract/*.md"ask for the registry entry" sections show the two-line entryNon-goals
Renaming
data-mp-headlessand friends is #77; the packaging action is #76.