diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c81f0c3..56c8684 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,7 +99,30 @@ jobs: path: playwright-report/ retention-days: 7 - # ── 4. Dependency audit ──────────────────────────────────────────────────── + # ── 4. publish-single-page-docs action self-test ─────────────────────────── + # + # actions/publish-single-page-docs/ ships its own pinned dependency tree, so it is not + # covered by the root `npm ci` or by the Playwright suites (which stay hermetic + # and must not depend on the action's node_modules). This job renders a sample + # markdown file through the real pipeline and pins the validation messages — + # they are the action's user interface for onboarding repos. + publish-single-page-docs: + name: publish-single-page-docs action self-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + # Matches the node-version the composite action pins in action.yml. + node-version: '20' + cache: npm + cache-dependency-path: actions/publish-single-page-docs/package-lock.json + - run: npm ci + working-directory: actions/publish-single-page-docs + - run: npm run selftest + working-directory: actions/publish-single-page-docs + + # ── 5. Dependency audit ──────────────────────────────────────────────────── audit: name: npm audit runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 3b3b074..3d7fd98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,8 +72,18 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`, `--path-p - `src/components/Chrome.astro` — 56px fixed top bar (standalone mode only) - `src/components/Masthead.astro` — Persistent Knowledge base header + Library/current-app sub-nav (all pages, both modes) - `src/templates/chrome.js` — Inline theme script + shadow-DOM compat styles injected by the layout +- `src/utils/single-page.js` — Bundle manifest reading/validation + registry expansion, shared by both fetch paths and by Astro - `scripts/build-vite.js` — Build orchestrator (3-step pipeline) - `scripts/fetch-apps.js` — GitHub Release artifact downloader +- `actions/publish-single-page-docs/` — Reusable GitHub Action that turns a repo's markdown into a single-page bundle + +### Three Onboarding Types + +An `apps.json` entry is one of: + +- **default (packaged)** — a repo publishes a headless static site as `dist.tar.gz` plus `marketplace.json`. Every HTML file becomes a route. +- **`type: "iframe"`** — no artifact; a single route renders a full-viewport ` + ) : props.singlePage ? ( + /* Single-page docs bring no navigation of their own — no sidebar, no in-app + chrome — so the marketplace supplies the reading column instead. */ +
) : ( )} diff --git a/src/pages/index.astro b/src/pages/index.astro index b5c1120..1e3e08f 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,16 +1,15 @@ --- // src/pages/index.astro -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; import Base from '../layouts/Base.astro'; import Masthead from '../components/Masthead.astro'; import AppCard from '../components/AppCard.astro'; +import { loadRegistry } from '../utils/apps.js'; const HEADLESS = process.env.MP_HEADLESS !== 'false'; -// Load apps from apps.json at the repo root -const appsPath = resolve('apps.json'); -const apps = JSON.parse(readFileSync(appsPath, 'utf-8')); +// The effective registry: apps.json, with every single-page bundle replaced by +// the docs it expanded into, so each one gets its own catalog card. +const apps = loadRegistry(process.cwd()); const buildDate = new Date().toISOString().slice(0, 10); --- diff --git a/src/styles/marketplace.css b/src/styles/marketplace.css index f524aca..7195d05 100644 --- a/src/styles/marketplace.css +++ b/src/styles/marketplace.css @@ -232,6 +232,24 @@ a.mp-masthead-link:hover { color: var(--text-heading); } color: var(--color-kb-500); } +/* ── Single-page docs (issue #35) ────────────────────────────────────────── */ +/* A single-page doc is one markdown file rendered by actions/publish-single-page-docs: no + sidebar, no in-app navigation, nothing but prose. The marketplace owns the + measure so every such doc reads identically regardless of which repo published + it — ~800px of content, centred, with room to breathe underneath. The prose + itself is styled by the artifact's own assets/doc.css, scoped to .mp-doc. */ +.mp-single-page { + flex: 1 1 auto; + width: 100%; + max-width: 53rem; /* 848px − 2×1.5rem padding = 800px of content */ + margin: 0 auto; + padding: 3rem 1.5rem 5rem; + box-sizing: border-box; +} +@media (max-width: 640px) { + .mp-single-page { padding: 2rem 1.25rem 3.5rem; } +} + /* ── Tag pills ───────────────────────────────────────────────────────────── */ .mp-tag { display: inline-flex; diff --git a/src/utils/apps.js b/src/utils/apps.js index cda9a19..42a1bcf 100644 --- a/src/utils/apps.js +++ b/src/utils/apps.js @@ -4,6 +4,27 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative, dirname } from 'node:path'; +import { isSinglePage, readExpansionMap, resolveRegistry } from './single-page.js'; + +/** + * Reads the effective app registry. + * + * apps.json is the source of truth, except for `type: "single-page"` entries: + * those point at a bundle whose docs are only known after the build fetched it, + * so each one is replaced by the apps it expanded into (see single-page.js). + * + * @param {string} cwd - project root (process.cwd()) + */ +export function loadRegistry(cwd) { + const appsJson = join(cwd, 'apps.json'); + const registry = existsSync(appsJson) + ? JSON.parse(readFileSync(appsJson, 'utf-8')) + : []; + + return resolveRegistry(registry, readExpansionMap(cwd), (msg) => { + process.stderr.write(` \x1b[33m⚠\x1b[0m ${msg}\n`); + }); +} /** Recursively collect every .html file under a directory. */ export function collectHtmlFiles(dir) { @@ -26,12 +47,8 @@ export function collectHtmlFiles(dir) { * @param {boolean} headless - global headless default */ export function getAppPages(cwd, headless) { - const appsDir = join(cwd, 'apps'); - const appsJson = join(cwd, 'apps.json'); - - const apps = existsSync(appsJson) - ? JSON.parse(readFileSync(appsJson, 'utf-8')) - : []; + const appsDir = join(cwd, 'apps'); + const apps = loadRegistry(cwd); const pages = []; @@ -57,6 +74,24 @@ export function getAppPages(cwd, headless) { continue; } + if (isSinglePage(app)) { + // Single-page onboarding (issue #35): the artifact holds exactly one + // document per app, so there is nothing to crawl — emit its single route + // and let the catchall render it in the centred reading column. + pages.push({ + routePath: app.slug, + file: join(appDir, app.entryPoint ?? 'index.html'), + slug: app.slug, + fileRelDir: '', + appHeadless, + apps, + title: app.name ?? app.slug, + section: null, + singlePage: true, + }); + continue; + } + if (Array.isArray(app.pages) && app.pages.length > 0) { // Manifest-driven routing: use the pages array from marketplace.json for (const page of app.pages) { diff --git a/src/utils/single-page.js b/src/utils/single-page.js new file mode 100644 index 0000000..58953c9 --- /dev/null +++ b/src/utils/single-page.js @@ -0,0 +1,254 @@ +// src/utils/single-page.js +// +// Shared helpers for the "single-page" onboarding type (issue #35). +// +// A single-page entry in apps.json points at ONE release artifact that contains +// MANY docs — the repo publishes them with actions/publish-single-page-docs. The registry +// entry therefore carries no per-doc metadata: +// +// { "repo": "org/repo", "type": "single-page", "version": "latest" } +// +// Everything the catalog needs lives in the artifact's bundle.json manifest, so +// onboarding a new doc never touches this repository. The build "expands" one +// registry entry into N marketplace apps, one per doc. +// +// Imported by both scripts/fetch-apps.js (GitHub path) and scripts/build-vite.js +// (prebuilt/local path) so the two never drift, and by src/utils/apps.js so Astro +// sees the expanded registry. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +export const SINGLE_PAGE_TYPE = 'single-page'; +/** Manifest at the root of a single-page artifact. */ +export const BUNDLE_MANIFEST = 'bundle.json'; +/** Where the build records what each bundle expanded into, for Astro to read. */ +export const EXPANSION_FILE = join('apps', '.single-page.json'); + +/** Icon set — kept in sync with contract/schema.json. */ +const ICONS = [ + 'book-open', 'cube', 'chip', 'chart-bar', 'shield', + 'cog', 'terminal', 'globe', 'layers', 'lightning-bolt', + 'document', 'collection', 'puzzle', 'database', +]; +const DEFAULT_ICON = 'book-open'; +const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function isSinglePage(app) { + return app?.type === SINGLE_PAGE_TYPE; +} + +/** + * Stable identity of a single-page registry entry. + * + * Used to key the expansion map, so Astro can splice each bundle's docs back + * into the registry at exactly the position its entry occupies. + */ +export function bundleKey(app) { + const key = app.repo ?? app.prebuilt ?? app.localPath; + if (!key) { + throw new Error( + `apps.json: a "${SINGLE_PAGE_TYPE}" entry needs one of "repo", "prebuilt" or "localPath"`, + ); + } + return key; +} + +/** Filesystem-safe form of a bundle key, for staging directory names. */ +export function bundleDirName(key) { + return key.replace(/[^a-z0-9._-]+/gi, '__'); +} + +/** + * Locates the bundle root inside an extracted artifact. + * + * Bundles are packed with bundle.json at the archive root, but a repo may also + * publish one wrapped in dist/ — accept both rather than fail on a detail the + * onboarding repo cannot see. + */ +export function findBundleRoot(stageDir) { + for (const candidate of [stageDir, join(stageDir, 'dist')]) { + if (existsSync(join(candidate, BUNDLE_MANIFEST))) return candidate; + } + return null; +} + +/** + * Reads and validates a bundle manifest. + * + * @param {string} bundleRoot - directory containing bundle.json + * @param {string} source - human-readable origin, used in error messages + */ +export function readBundleManifest(bundleRoot, source) { + const file = join(bundleRoot, BUNDLE_MANIFEST); + let manifest; + try { + manifest = JSON.parse(readFileSync(file, 'utf8')); + } catch (err) { + throw new Error(`${source}: ${BUNDLE_MANIFEST} is not valid JSON — ${err.message}`); + } + + if (manifest.marketplaceVersion !== '1') { + throw new Error( + `${source}: ${BUNDLE_MANIFEST} has marketplaceVersion ` + + `${JSON.stringify(manifest.marketplaceVersion)} — this knowledge base understands "1".`, + ); + } + if (manifest.type !== SINGLE_PAGE_TYPE) { + throw new Error( + `${source}: ${BUNDLE_MANIFEST} declares type ${JSON.stringify(manifest.type)}, ` + + `expected "${SINGLE_PAGE_TYPE}".`, + ); + } + if (!Array.isArray(manifest.docs) || manifest.docs.length === 0) { + throw new Error(`${source}: ${BUNDLE_MANIFEST} lists no docs.`); + } + return manifest; +} + +/** + * Turns a bundle manifest into marketplace app entries — one per doc. + * + * Each returned entry looks like a normal apps.json app (slug/name/description/ + * icon/tags) so every downstream consumer — catalog cards, masthead, routing — + * needs no knowledge of bundles. `docDir` is the source directory to copy into + * apps/{slug}/ and is stripped before the entry is persisted. + * + * @param {object} app - the apps.json entry the bundle came from + * @param {object} manifest - output of readBundleManifest + * @param {string} bundleRoot - directory the manifest was read from + */ +export function expandBundle(app, manifest, bundleRoot) { + const source = bundleKey(app); + const seen = new Set(); + const docs = []; + + for (const [i, doc] of manifest.docs.entries()) { + const where = `${source}: ${BUNDLE_MANIFEST} docs[${i}]`; + + if (typeof doc?.slug !== 'string' || !SLUG_RE.test(doc.slug)) { + throw new Error(`${where} has an invalid slug ${JSON.stringify(doc?.slug)} — expected lowercase kebab-case.`); + } + if (seen.has(doc.slug)) { + throw new Error(`${where} repeats slug "${doc.slug}" — slugs must be unique within a bundle.`); + } + seen.add(doc.slug); + + if (typeof doc.title !== 'string' || doc.title.trim() === '') { + throw new Error(`${where} ("${doc.slug}") is missing a title.`); + } + if (typeof doc.description !== 'string' || doc.description.trim() === '') { + throw new Error(`${where} ("${doc.slug}") is missing a description.`); + } + + const entryPoint = doc.entryPoint ?? 'index.html'; + const docDir = join(bundleRoot, doc.slug); + if (!existsSync(join(docDir, entryPoint))) { + throw new Error( + `${where} ("${doc.slug}") declares entryPoint "${entryPoint}" but ` + + `${doc.slug}/${entryPoint} is not in the artifact.`, + ); + } + + docs.push({ + slug: doc.slug, + name: doc.title.trim(), + description: doc.description.trim(), + icon: ICONS.includes(doc.icon) ? doc.icon : DEFAULT_ICON, + tags: Array.isArray(doc.tags) ? doc.tags.slice(0, 5) : [], + type: SINGLE_PAGE_TYPE, + entryPoint, + docDir, + }); + } + + return docs; +} + +/** Drops build-only fields so the expansion map stays a plain registry fragment. */ +export function toRegistryEntry({ docDir, ...entry }) { + return entry; +} + +// ── Expansion map ───────────────────────────────────────────────────────────── +// The committed apps.json cannot list the expanded docs (they are discovered from +// the artifact), and rewriting the registry in place would make a build mutate a +// checked-in file. Instead the build drops a map next to the artifacts and Astro +// splices it back in at read time. + +export function readExpansionMap(cwd) { + const file = join(cwd, EXPANSION_FILE); + if (!existsSync(file)) return {}; + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch { + return {}; + } +} + +export function writeExpansionMap(cwd, map) { + const file = join(cwd, EXPANSION_FILE); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(map, null, 2) + '\n'); +} + +/** + * Replaces every single-page registry entry with the docs it expanded into. + * + * Non-single-page entries pass through untouched and always come straight from + * apps.json, so editing the registry never goes stale against the map. + * + * @param {Array} registry - raw apps.json contents + * @param {object} map - output of readExpansionMap + * @param {(msg: string) => void} [warn] + */ +export function resolveRegistry(registry, map, warn = () => {}) { + const resolved = []; + + for (const app of registry) { + if (!isSinglePage(app)) { resolved.push(app); continue; } + + const key = bundleKey(app); + const docs = map[key]; + if (!docs || docs.length === 0) { + // `optional` entries are expected to be missing whenever their artifact is + // not checked out (see build-vite.js) — that is not worth a warning. + if (!app.optional) { + warn(`${key}: single-page bundle has not been prepared yet — run a build to populate ${EXPANSION_FILE}`); + } + continue; + } + resolved.push(...docs); + } + + assertUniqueSlugs(resolved); + return resolved; +} + +/** + * Guards against two apps claiming the same URL prefix. + * + * Single-page bundles are the reason this has to be enforced globally: a repo + * chooses its own slugs without seeing the rest of the registry, so a collision + * with an existing app would otherwise silently overwrite apps/{slug}/. + */ +export function assertUniqueSlugs(apps) { + const seen = new Map(); + for (const app of apps) { + if (!app.slug) continue; + if (seen.has(app.slug)) { + throw new Error( + `Duplicate app slug "${app.slug}": claimed by both ${describe(seen.get(app.slug))} and ` + + `${describe(app)}. Slugs are URL prefixes and must be unique across the whole registry — ` + + `rename one of them (for a single-page bundle, change the slug in the publishing repo).`, + ); + } + seen.set(app.slug, app); + } + return apps; +} + +function describe(app) { + if (isSinglePage(app)) return `single-page doc "${app.name ?? app.slug}"`; + return `apps.json entry "${app.name ?? app.slug}"`; +} diff --git a/tests/build-integrity.spec.js b/tests/build-integrity.spec.js index 6706aa9..2bb7e9c 100644 --- a/tests/build-integrity.spec.js +++ b/tests/build-integrity.spec.js @@ -119,6 +119,88 @@ test.describe('iframe onboarding', () => { }); }); +// ── single-page onboarding (issue #35) ────────────────────────────────────── +// +// One registry entry (`type: "single-page"`, no per-doc metadata) points at a +// bundle holding two docs; the build must expand it into two independent apps. +test.describe('single-page onboarding', () => { + test('one bundle entry expands into one app per doc', () => { + for (const p of ['platform-overview/index.html', 'release-process/index.html']) { + expect(existsSync(join(DIST, p)), `expected expanded single-page doc ${p}`).toBe(true); + } + // A single doc is one route — nothing is crawled underneath it. + expect(existsSync(join(DIST, 'platform-overview/docs')), 'single-page app must emit exactly one route').toBe(false); + }); + + test('apps.json carries no per-doc metadata — the bundle manifest supplies it', () => { + const registry = JSON.parse(readFileSync(join(ROOT, 'apps.json'), 'utf8')); + const entry = registry.find((a) => a.type === 'single-page'); + expect(entry, 'no single-page entry in apps.json').toBeTruthy(); + expect(entry.slug, 'a single-page entry must not name a slug').toBeUndefined(); + expect(entry.name).toBeUndefined(); + + // …yet the catalog knows both docs, which can only come from bundle.json. + const html = read('index.html'); + expect(html).toContain('Platform Overview'); + expect(html).toContain('Release Process'); + expect(html).toContain('href="/knowledge-base/platform-overview/"'); + expect(html).toContain('href="/knowledge-base/release-process/"'); + }); + + test('renders in the centred reading column, with no sidebar', () => { + const html = read('platform-overview/index.html'); + expect(html).toMatch(/
]*aria-label="Documentation"/); + expect(html).toContain('id="mp-masthead"'); + }); + + test('is re-hosted by the layout like any packaged page', () => { + const html = read('platform-overview/index.html'); + expect(html).toContain('data-mp-headless="true"'); + expect(html).toContain('/knowledge-base/style.css'); + expect(html.match(/ { + const html = read('platform-overview/index.html'); + expect(html).toContain('href="/knowledge-base/platform-overview/assets/doc.css"'); + expect(html).not.toMatch(/href="(?!\/|https?:|mailto:|#|data:)[^"]/); + expect(html).not.toMatch(/ { + const html = read('platform-overview/index.html'); + expect(html).toContain(''); // GFM tables + expect(html).toContain('task-list-item'); // GFM task lists + expect(html).toContain('
');          // fenced code
+    expect(html).toContain('hljs-keyword');                   // syntax highlighting
+    expect(html).toMatch(/
flowchart LR/); // mermaid source survives
+  });
+
+  test('mermaid is vendored into the artifact, never fetched from a CDN', () => {
+    const html = read('platform-overview/index.html');
+    expect(html).toContain('src="/knowledge-base/platform-overview/assets/mermaid.min.js"');
+    expect(html).not.toMatch(/src="https?:\/\/[^"]*mermaid/);
+    expect(existsSync(join(DIST, 'platform-overview/assets/mermaid.min.js')), 'vendored mermaid missing').toBe(true);
+  });
+
+  test('the other onboarding types are unaffected', () => {
+    expect(existsSync(join(DIST, 'user-guide/docs/index.html'))).toBe(true);
+    expect(existsSync(join(DIST, 'guide-mirror/docs/index.html'))).toBe(true);
+    expect(existsSync(join(DIST, 'external-docs/index.html'))).toBe(true);
+  });
+});
+
 // ── Persistent masthead (issue #24) ─────────────────────────────────────────
 test.describe('Masthead', () => {
   const STRAPLINE = 'Browse and access all documentation sites';
@@ -159,6 +241,8 @@ test.describe('Masthead', () => {
   test('the crumb tracks the app being viewed', () => {
     expect(nav(read('guide-mirror/docs/index.html'))).toContain('Guide Mirror');
     expect(nav(read('external-docs/index.html'))).toContain('External Docs');
+    // Expanded single-page docs are ordinary apps as far as the masthead cares.
+    expect(nav(read('platform-overview/index.html'))).toContain('Platform Overview');
   });
 
   test('all masthead links are absolute /knowledge-base/ paths', () => {
diff --git a/tests/fixtures/single-page-bundle/bundle.json b/tests/fixtures/single-page-bundle/bundle.json
new file mode 100644
index 0000000..5b49be8
--- /dev/null
+++ b/tests/fixtures/single-page-bundle/bundle.json
@@ -0,0 +1,27 @@
+{
+  "marketplaceVersion": "1",
+  "type": "single-page",
+  "docs": [
+    {
+      "slug": "platform-overview",
+      "title": "Platform Overview",
+      "description": "How the platform is wired together, endpoint by endpoint.",
+      "icon": "layers",
+      "tags": [
+        "platform",
+        "reference"
+      ],
+      "entryPoint": "index.html"
+    },
+    {
+      "slug": "release-process",
+      "title": "Release Process",
+      "description": "How a release is cut and how the docs bundle gets attached.",
+      "icon": "cog",
+      "tags": [
+        "process"
+      ],
+      "entryPoint": "index.html"
+    }
+  ]
+}
diff --git a/tests/fixtures/single-page-bundle/platform-overview/assets/doc.css b/tests/fixtures/single-page-bundle/platform-overview/assets/doc.css
new file mode 100644
index 0000000..35f6104
--- /dev/null
+++ b/tests/fixtures/single-page-bundle/platform-overview/assets/doc.css
@@ -0,0 +1,186 @@
+/* Generated by AbsaOSS/knowledge-base/actions/publish-single-page-docs — do not edit. */
+
+/* ── Design tokens (contract/STYLE_GUIDE.md) ──────────────────────────────── */
+:root {
+  --color-kb-25:  #fdf8f9;
+  --color-kb-50:  #f8eaee;
+  --color-kb-100: #f0d0da;
+  --color-kb-400: #d4547a;
+  --color-kb-500: #af144b;
+  --color-kb-600: #93103f;
+  --color-kb-950: #1b0e12;
+
+  --font-sans: Inter, 'Noto Sans', ui-sans-serif, system-ui, sans-serif;
+  --font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', ui-monospace, monospace;
+
+  --bg-page:       var(--color-kb-25);
+  --bg-card:       #ffffff;
+  --bg-strong:     #f9fafb;
+  --bg-subtle:     #f3f4f6;
+  --border:        #e5e7eb;
+  --border-subtle: #f3e7eb;
+  --text-heading:  var(--color-kb-950);
+  --text-body:     #4b5563;
+  --text-muted:    #6b7280;
+
+  --radius-sm: 6px;
+  --radius-md: 12px;
+  --radius-lg: 16px;
+  --radius-xl: 20px;
+}
+
+body { margin: 0; font-family: var(--font-sans); color: var(--text-body); }
+
+/* ── Reading column ───────────────────────────────────────────────────────── */
+/* Standalone only. Inside the marketplace the .mp-single-page wrapper owns the
+   measure, so this stays a max-width rather than a fixed layout. */
+.mp-doc {
+  max-width: 50rem;
+  margin: 0 auto;
+  padding: 2.5rem 1.5rem 5rem;
+  font-size: 1rem;
+  line-height: 1.75;
+  color: var(--text-body);
+  overflow-wrap: break-word;
+}
+.mp-single-page > .mp-doc { padding: 0; max-width: none; }
+
+/* ── Lede (rendered when the markdown has no top-level heading) ───────────── */
+.mp-doc-lede { margin: 0 0 2.5rem; }
+.mp-doc-lede p { font-size: 1.125rem; color: var(--text-muted); margin: 0.5rem 0 0; }
+
+/* ── Headings ─────────────────────────────────────────────────────────────── */
+.mp-doc h1, .mp-doc h2, .mp-doc h3,
+.mp-doc h4, .mp-doc h5, .mp-doc h6 {
+  color: var(--text-heading);
+  font-weight: 700;
+  letter-spacing: -0.02em;
+  line-height: 1.25;
+  scroll-margin-top: 2rem;
+}
+.mp-doc h1 { font-size: 2.25rem; margin: 0 0 1.25rem; }
+.mp-doc h2 { font-size: 1.5rem;  margin: 3rem 0 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); }
+.mp-doc h3 { font-size: 1.1875rem; margin: 2.25rem 0 0.75rem; }
+.mp-doc h4 { font-size: 1rem;    margin: 1.75rem 0 0.5rem; }
+.mp-doc h5, .mp-doc h6 { font-size: 0.9375rem; margin: 1.5rem 0 0.5rem; }
+
+/* Heading anchors — revealed on hover, invisible to the reading eye otherwise */
+.mp-doc .mp-anchor {
+  color: var(--text-hint, #9ca3af);
+  text-decoration: none;
+  margin-left: 0.4rem;
+  opacity: 0;
+  transition: opacity 0.2s;
+}
+.mp-doc h1:hover .mp-anchor, .mp-doc h2:hover .mp-anchor,
+.mp-doc h3:hover .mp-anchor, .mp-doc h4:hover .mp-anchor { opacity: 1; }
+
+/* ── Flow ─────────────────────────────────────────────────────────────────── */
+.mp-doc p  { margin: 0 0 1.25rem; }
+.mp-doc ul, .mp-doc ol { margin: 0 0 1.25rem; padding-left: 1.5rem; }
+.mp-doc li { margin: 0.375rem 0; }
+.mp-doc li > ul, .mp-doc li > ol { margin: 0.375rem 0; }
+.mp-doc hr { border: 0; border-top: 1px solid var(--border); margin: 2.5rem 0; }
+.mp-doc img, .mp-doc video { max-width: 100%; height: auto; border-radius: var(--radius-md); }
+
+.mp-doc a { color: var(--color-kb-500); text-decoration: none; }
+.mp-doc a:hover { color: var(--color-kb-600); text-decoration: underline; }
+
+.mp-doc strong { color: var(--text-heading); font-weight: 600; }
+
+/* ── Task lists (GFM) ─────────────────────────────────────────────────────── */
+.mp-doc .contains-task-list { list-style: none; padding-left: 0.25rem; }
+.mp-doc .task-list-item { display: flex; align-items: flex-start; gap: 0.5rem; }
+.mp-doc .task-list-item-checkbox { margin-top: 0.45rem; accent-color: var(--color-kb-500); }
+
+/* ── Blockquotes ──────────────────────────────────────────────────────────── */
+.mp-doc blockquote {
+  margin: 0 0 1.25rem;
+  padding: 0.75rem 1rem;
+  border-left: 4px solid var(--color-kb-500);
+  background: var(--bg-subtle);
+  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
+  color: var(--text-body);
+}
+.mp-doc blockquote > :last-child { margin-bottom: 0; }
+
+/* ── Tables ───────────────────────────────────────────────────────────────── */
+.mp-doc .mp-table-wrap { overflow-x: auto; margin: 0 0 1.5rem; }
+.mp-doc table {
+  border-collapse: collapse;
+  width: 100%;
+  font-size: 0.9375rem;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  overflow: hidden;
+}
+.mp-doc thead { background: var(--bg-strong); }
+.mp-doc th, .mp-doc td { padding: 0.625rem 0.875rem; text-align: left; border-bottom: 1px solid var(--border); }
+.mp-doc th { color: var(--text-heading); font-weight: 600; }
+.mp-doc tbody tr:last-child td { border-bottom: 0; }
+.mp-doc tbody tr:hover { background: var(--bg-strong); }
+
+/* ── Code ─────────────────────────────────────────────────────────────────── */
+.mp-doc code {
+  font-family: var(--font-mono);
+  background: var(--bg-strong);
+  border: 1px solid var(--border);
+  border-radius: 4px;
+  padding: 1px 5px;
+  font-size: 0.85em;
+}
+.mp-doc pre.mp-code {
+  margin: 0 0 1.5rem;
+  padding: 1rem 1.125rem;
+  background: #0f172a;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  overflow-x: auto;
+}
+.mp-doc pre.mp-code code {
+  background: none;
+  border: 0;
+  padding: 0;
+  font-size: 0.8125rem;
+  line-height: 1.7;
+  color: #e2e8f0;
+}
+
+/* highlight.js token colours, tuned for the slate code surface above */
+.mp-doc .hljs-comment, .mp-doc .hljs-quote { color: #64748b; font-style: italic; }
+.mp-doc .hljs-keyword, .mp-doc .hljs-selector-tag, .mp-doc .hljs-built_in { color: #c4b5fd; }
+.mp-doc .hljs-string, .mp-doc .hljs-regexp, .mp-doc .hljs-addition { color: #86efac; }
+.mp-doc .hljs-number, .mp-doc .hljs-literal, .mp-doc .hljs-deletion { color: #fca5a5; }
+.mp-doc .hljs-title, .mp-doc .hljs-title.class_, .mp-doc .hljs-title.function_,
+.mp-doc .hljs-section, .mp-doc .hljs-name { color: #93c5fd; }
+.mp-doc .hljs-attr, .mp-doc .hljs-attribute, .mp-doc .hljs-variable,
+.mp-doc .hljs-template-variable, .mp-doc .hljs-property { color: #fcd34d; }
+.mp-doc .hljs-type, .mp-doc .hljs-meta, .mp-doc .hljs-symbol, .mp-doc .hljs-bullet { color: #67e8f9; }
+.mp-doc .hljs-emphasis { font-style: italic; }
+.mp-doc .hljs-strong { font-weight: 700; }
+
+/* ── Mermaid ──────────────────────────────────────────────────────────────── */
+/* Pre-render the source is hidden; mermaid swaps in an  in its place. */
+.mp-doc pre.mermaid {
+  margin: 0 0 1.5rem;
+  padding: 1.25rem;
+  background: var(--bg-card);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  text-align: center;
+  overflow-x: auto;
+  font-family: var(--font-mono);
+  font-size: 0.8125rem;
+  color: var(--text-muted);
+}
+.mp-doc pre.mermaid svg { max-width: 100%; height: auto; }
+
+/* ── Footnotes (GFM) ──────────────────────────────────────────────────────── */
+.mp-doc .footnotes {
+  margin-top: 3rem;
+  padding-top: 1.5rem;
+  border-top: 1px solid var(--border);
+  font-size: 0.875rem;
+  color: var(--text-muted);
+}
+.mp-doc .footnotes hr { display: none; }
diff --git a/tests/fixtures/single-page-bundle/platform-overview/assets/mermaid.min.js b/tests/fixtures/single-page-bundle/platform-overview/assets/mermaid.min.js
new file mode 100644
index 0000000..84051bd
--- /dev/null
+++ b/tests/fixtures/single-page-bundle/platform-overview/assets/mermaid.min.js
@@ -0,0 +1,2 @@
+/* Test fixture stand-in for the vendored mermaid bundle. */
+window.mermaid = { initialize: function () {}, run: function () {} };
diff --git a/tests/fixtures/single-page-bundle/platform-overview/index.html b/tests/fixtures/single-page-bundle/platform-overview/index.html
new file mode 100644
index 0000000..a7024d2
--- /dev/null
+++ b/tests/fixtures/single-page-bundle/platform-overview/index.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+Platform Overview
+
+
+
+
+
+

Platform Overview #

+

The platform runs every service behind a single gateway. See +the handbook for the long version.

+

Endpoints #

+
+
+ + + + + + + +
EndpointMethodNotes
/healthGETLiveness probe
/v1/itemsPOSTCreates an item
+ +

Configuration #

+
export const port = Number(process.env.PORT ?? 8080);
+

Rollout status #

+
    +
  • Contract published
  • +
  • Load-tested
  • +
+

Request flow #

+
flowchart LR
+  client --> gateway --> service
+ + + + + diff --git a/tests/fixtures/single-page-bundle/release-process/assets/doc.css b/tests/fixtures/single-page-bundle/release-process/assets/doc.css new file mode 100644 index 0000000..35f6104 --- /dev/null +++ b/tests/fixtures/single-page-bundle/release-process/assets/doc.css @@ -0,0 +1,186 @@ +/* Generated by AbsaOSS/knowledge-base/actions/publish-single-page-docs — do not edit. */ + +/* ── Design tokens (contract/STYLE_GUIDE.md) ──────────────────────────────── */ +:root { + --color-kb-25: #fdf8f9; + --color-kb-50: #f8eaee; + --color-kb-100: #f0d0da; + --color-kb-400: #d4547a; + --color-kb-500: #af144b; + --color-kb-600: #93103f; + --color-kb-950: #1b0e12; + + --font-sans: Inter, 'Noto Sans', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', ui-monospace, monospace; + + --bg-page: var(--color-kb-25); + --bg-card: #ffffff; + --bg-strong: #f9fafb; + --bg-subtle: #f3f4f6; + --border: #e5e7eb; + --border-subtle: #f3e7eb; + --text-heading: var(--color-kb-950); + --text-body: #4b5563; + --text-muted: #6b7280; + + --radius-sm: 6px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; +} + +body { margin: 0; font-family: var(--font-sans); color: var(--text-body); } + +/* ── Reading column ───────────────────────────────────────────────────────── */ +/* Standalone only. Inside the marketplace the .mp-single-page wrapper owns the + measure, so this stays a max-width rather than a fixed layout. */ +.mp-doc { + max-width: 50rem; + margin: 0 auto; + padding: 2.5rem 1.5rem 5rem; + font-size: 1rem; + line-height: 1.75; + color: var(--text-body); + overflow-wrap: break-word; +} +.mp-single-page > .mp-doc { padding: 0; max-width: none; } + +/* ── Lede (rendered when the markdown has no top-level heading) ───────────── */ +.mp-doc-lede { margin: 0 0 2.5rem; } +.mp-doc-lede p { font-size: 1.125rem; color: var(--text-muted); margin: 0.5rem 0 0; } + +/* ── Headings ─────────────────────────────────────────────────────────────── */ +.mp-doc h1, .mp-doc h2, .mp-doc h3, +.mp-doc h4, .mp-doc h5, .mp-doc h6 { + color: var(--text-heading); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.25; + scroll-margin-top: 2rem; +} +.mp-doc h1 { font-size: 2.25rem; margin: 0 0 1.25rem; } +.mp-doc h2 { font-size: 1.5rem; margin: 3rem 0 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); } +.mp-doc h3 { font-size: 1.1875rem; margin: 2.25rem 0 0.75rem; } +.mp-doc h4 { font-size: 1rem; margin: 1.75rem 0 0.5rem; } +.mp-doc h5, .mp-doc h6 { font-size: 0.9375rem; margin: 1.5rem 0 0.5rem; } + +/* Heading anchors — revealed on hover, invisible to the reading eye otherwise */ +.mp-doc .mp-anchor { + color: var(--text-hint, #9ca3af); + text-decoration: none; + margin-left: 0.4rem; + opacity: 0; + transition: opacity 0.2s; +} +.mp-doc h1:hover .mp-anchor, .mp-doc h2:hover .mp-anchor, +.mp-doc h3:hover .mp-anchor, .mp-doc h4:hover .mp-anchor { opacity: 1; } + +/* ── Flow ─────────────────────────────────────────────────────────────────── */ +.mp-doc p { margin: 0 0 1.25rem; } +.mp-doc ul, .mp-doc ol { margin: 0 0 1.25rem; padding-left: 1.5rem; } +.mp-doc li { margin: 0.375rem 0; } +.mp-doc li > ul, .mp-doc li > ol { margin: 0.375rem 0; } +.mp-doc hr { border: 0; border-top: 1px solid var(--border); margin: 2.5rem 0; } +.mp-doc img, .mp-doc video { max-width: 100%; height: auto; border-radius: var(--radius-md); } + +.mp-doc a { color: var(--color-kb-500); text-decoration: none; } +.mp-doc a:hover { color: var(--color-kb-600); text-decoration: underline; } + +.mp-doc strong { color: var(--text-heading); font-weight: 600; } + +/* ── Task lists (GFM) ─────────────────────────────────────────────────────── */ +.mp-doc .contains-task-list { list-style: none; padding-left: 0.25rem; } +.mp-doc .task-list-item { display: flex; align-items: flex-start; gap: 0.5rem; } +.mp-doc .task-list-item-checkbox { margin-top: 0.45rem; accent-color: var(--color-kb-500); } + +/* ── Blockquotes ──────────────────────────────────────────────────────────── */ +.mp-doc blockquote { + margin: 0 0 1.25rem; + padding: 0.75rem 1rem; + border-left: 4px solid var(--color-kb-500); + background: var(--bg-subtle); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + color: var(--text-body); +} +.mp-doc blockquote > :last-child { margin-bottom: 0; } + +/* ── Tables ───────────────────────────────────────────────────────────────── */ +.mp-doc .mp-table-wrap { overflow-x: auto; margin: 0 0 1.5rem; } +.mp-doc table { + border-collapse: collapse; + width: 100%; + font-size: 0.9375rem; + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} +.mp-doc thead { background: var(--bg-strong); } +.mp-doc th, .mp-doc td { padding: 0.625rem 0.875rem; text-align: left; border-bottom: 1px solid var(--border); } +.mp-doc th { color: var(--text-heading); font-weight: 600; } +.mp-doc tbody tr:last-child td { border-bottom: 0; } +.mp-doc tbody tr:hover { background: var(--bg-strong); } + +/* ── Code ─────────────────────────────────────────────────────────────────── */ +.mp-doc code { + font-family: var(--font-mono); + background: var(--bg-strong); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; + font-size: 0.85em; +} +.mp-doc pre.mp-code { + margin: 0 0 1.5rem; + padding: 1rem 1.125rem; + background: #0f172a; + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow-x: auto; +} +.mp-doc pre.mp-code code { + background: none; + border: 0; + padding: 0; + font-size: 0.8125rem; + line-height: 1.7; + color: #e2e8f0; +} + +/* highlight.js token colours, tuned for the slate code surface above */ +.mp-doc .hljs-comment, .mp-doc .hljs-quote { color: #64748b; font-style: italic; } +.mp-doc .hljs-keyword, .mp-doc .hljs-selector-tag, .mp-doc .hljs-built_in { color: #c4b5fd; } +.mp-doc .hljs-string, .mp-doc .hljs-regexp, .mp-doc .hljs-addition { color: #86efac; } +.mp-doc .hljs-number, .mp-doc .hljs-literal, .mp-doc .hljs-deletion { color: #fca5a5; } +.mp-doc .hljs-title, .mp-doc .hljs-title.class_, .mp-doc .hljs-title.function_, +.mp-doc .hljs-section, .mp-doc .hljs-name { color: #93c5fd; } +.mp-doc .hljs-attr, .mp-doc .hljs-attribute, .mp-doc .hljs-variable, +.mp-doc .hljs-template-variable, .mp-doc .hljs-property { color: #fcd34d; } +.mp-doc .hljs-type, .mp-doc .hljs-meta, .mp-doc .hljs-symbol, .mp-doc .hljs-bullet { color: #67e8f9; } +.mp-doc .hljs-emphasis { font-style: italic; } +.mp-doc .hljs-strong { font-weight: 700; } + +/* ── Mermaid ──────────────────────────────────────────────────────────────── */ +/* Pre-render the source is hidden; mermaid swaps in an in its place. */ +.mp-doc pre.mermaid { + margin: 0 0 1.5rem; + padding: 1.25rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + text-align: center; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 0.8125rem; + color: var(--text-muted); +} +.mp-doc pre.mermaid svg { max-width: 100%; height: auto; } + +/* ── Footnotes (GFM) ──────────────────────────────────────────────────────── */ +.mp-doc .footnotes { + margin-top: 3rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + font-size: 0.875rem; + color: var(--text-muted); +} +.mp-doc .footnotes hr { display: none; } diff --git a/tests/fixtures/single-page-bundle/release-process/index.html b/tests/fixtures/single-page-bundle/release-process/index.html new file mode 100644 index 0000000..661fc0f --- /dev/null +++ b/tests/fixtures/single-page-bundle/release-process/index.html @@ -0,0 +1,25 @@ + + + + + +Release Process + + + + +
+

Release Process #

+

Releases are cut from master on demand.

+
+

A release is only complete once the docs bundle is attached to it.

+
+

Steps #

+
    +
  1. Tag the commit.
  2. +
  3. Publish the GitHub Release.
  4. +
  5. The publish-single-page-docs action attaches dist.tar.gz.
  6. +
+
+ + diff --git a/tests/web-fragment.spec.js b/tests/web-fragment.spec.js index c88bcdb..819d3b6 100644 --- a/tests/web-fragment.spec.js +++ b/tests/web-fragment.spec.js @@ -188,12 +188,51 @@ test.describe('Cross-app navigation', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// Single-page docs (issue #35): one bundle in apps.json, one card and one route +// per doc, rendered in the marketplace's own reading column. +test.describe('Single-page docs', () => { + test('expanded docs appear as ordinary catalog cards', async ({ page }) => { + await gotoFragment(page, '/knowledge-base/'); + const text = await getFragmentText(page); + expect(text).toContain('Platform Overview'); + expect(text).toContain('Release Process'); + + const hrefs = await shadowHrefs(page, '.mp-card'); + expect(hrefs).toContain('/knowledge-base/platform-overview/'); + expect(hrefs).toContain('/knowledge-base/release-process/'); + }); + + test('clicking a doc card navigates into it without reloading the host', async ({ page }) => { + await gotoFragment(page, '/knowledge-base/'); + await clickFragmentLink(page, '.mp-card[href="/knowledge-base/platform-overview/"]'); + + await waitForFragmentText(page, /Platform Overview/i); + expect(await hostStillAlive(page)).toBe(true); + }); + + test('renders in the centred column with the masthead and no sidebar', async ({ page }) => { + await gotoFragment(page, '/knowledge-base/platform-overview/'); + + const column = await queryInShadow(page, 'main.mp-single-page'); + expect(column, 'centred reading column missing from fragment').not.toBeNull(); + expect(column.text).toMatch(/Platform Overview/i); + + // Nothing but the masthead: a single-page doc ships no navigation of its own. + expect(await queryInShadow(page, 'nav#sidebar'), 'single-page doc must not render a sidebar').toBeNull(); + expect(await queryInShadow(page, '#mp-masthead'), 'masthead missing').not.toBeNull(); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── test.describe('Asset loading on the host origin', () => { test('no 404s for fragment assets while browsing', async ({ page }) => { const bad = collectBadResponses(page); await gotoFragment(page, '/knowledge-base/'); + // Includes a single-page doc, whose stylesheet and vendored mermaid bundle + // must resolve on the host origin like any other sub-app asset. + await gotoFragment(page, '/knowledge-base/platform-overview/'); await gotoFragment(page, '/knowledge-base/user-guide/docs/'); await clickFragmentLink(page, 'nav#sidebar a[href*="customising"]'); await waitForFragmentText(page, /Customising/i);