Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Orchestrator: `scripts/build-vite.js` (flags: `--local`, `--headless`,
## The registry: `apps.json`

Each entry registers one doc app. A `slug` is required; the source is one of
`repo` (+ optional `version`), `localPath`, or `prebuilt`:
`repo` (+ optional `version`), `localPath`, `prebuilt`, or an `iframe` URL:

```jsonc
[
Expand All @@ -97,6 +97,29 @@ Each entry registers one doc app. A `slug` is required; the source is one of
]
```

### iframe onboarding (temporary)

Teams that already host their docs elsewhere and can't yet produce a headless
package can be listed immediately with an **iframe** entry — no `repo`,
`marketplace.json`, or artifact needed. It renders as a full-viewport `<iframe>`
inside the marketplace chrome and shows an **External** badge in the catalogue.

```jsonc
{
"type": "iframe",
"url": "https://my-team.example.com/docs",
"slug": "my-team",
"name": "My Team Docs",
"description": "...",
"icon": "book-open",
"tags": ["my-team"],
"temporary": true // stopgap — migrate to a headless package when ready
}
```

The external site must permit embedding (its CSP `frame-ancestors` /
`X-Frame-Options` must not block the marketplace origin). See issue #10.

The repo ships an `apps.json` that registers the **vendored docs-example fixture**
twice (`user-guide`, `guide-mirror`) so the build and tests are hermetic out of
the box. Replace it with your own apps for a real deployment.
Expand Down
12 changes: 12 additions & 0 deletions apps.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,17 @@
"cross-app"
],
"prebuilt": "tests/fixtures/docs-example.dist.tar.gz"
},
{
"slug": "external-docs",
"name": "External Docs",
"description": "Iframe-onboarded external documentation site (temporary integration path).",
"icon": "book-open",
"tags": [
"external"
],
"type": "iframe",
"url": "https://example.com/docs",
"temporary": true
}
]
9 changes: 9 additions & 0 deletions contract/HEADLESS_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ Doc apps included in the marketplace must produce **headless HTML output** — p
contain content (sidebar + prose) but **do not** include a site-level top navigation bar.
The marketplace injects its own persistent 56 px chrome at the top of every page.

> **Temporary alternative — iframe onboarding.** Teams that already host their docs
> elsewhere and cannot yet produce a headless package can be listed immediately with an
> `apps.json` entry of `"type": "iframe"` + a `"url"` (no `marketplace.json`, no release
> artifact, no headless build). The page is rendered as a full-viewport `<iframe>` inside
> the marketplace chrome. This is an **explicit stopgap** — such entries should carry
> `"temporary": true` and be migrated to a proper headless package when possible. The
> external site must allow embedding (its CSP `frame-ancestors` / `X-Frame-Options` must
> not block the marketplace origin). See issue #10.

---

## Required: Headless build flag
Expand Down
28 changes: 23 additions & 5 deletions scripts/build-vite.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,32 @@ async function build() {

const registeredApps = JSON.parse(readFileSync(join(ROOT, 'apps.json'), 'utf8'));

// Validate registry entries up front (fail fast with a clear message).
for (const app of registeredApps) {
if (!app.slug) fail('apps.json: an entry is missing "slug"');
if (app.type === 'iframe') {
if (!app.url) fail(app.slug + ': iframe entry requires a "url"');
if (app.temporary) warn(app.slug + ': temporary iframe entry — migrate to a headless package when ready');
} else if (!app.prebuilt && !app.localPath && !app.repo) {
fail(app.slug + ': packaged entry needs one of "repo", "localPath", or "prebuilt"');
}
}

// iframe entries have no artifact to fetch/build — they render a single route
// (see src/pages/[...path].astro). Only packaged apps go through the pipeline.
const iframeApps = registeredApps.filter(a => a.type === 'iframe');
const packagedApps = registeredApps.filter(a => a.type !== 'iframe');

// 1. Prepare apps/ directory
step('1/3 Preparing sub-app artifacts → apps/');
mkdirSync(APPS_DIR, { recursive: true });

for (const app of iframeApps) ok(app.slug + ' registered (iframe → ' + app.url + ')');

// Apps shipping a `prebuilt` artifact are prepared locally regardless of mode
// (hermetic — no network, no per-app build). The rest go through fetch/local.
const prebuiltApps = registeredApps.filter(a => a.prebuilt);
const remainingApps = registeredApps.filter(a => !a.prebuilt);
const prebuiltApps = packagedApps.filter(a => a.prebuilt);
const remainingApps = packagedApps.filter(a => !a.prebuilt);

for (const app of prebuiltApps) preparePrebuilt(app);

Expand Down Expand Up @@ -140,7 +158,7 @@ async function build() {
mkdirSync(PUBLIC_ROOT, { recursive: true });

// Clean only known slug dirs so user-owned public/ files (favicon, robots.txt…) are preserved
for (const app of registeredApps) {
for (const app of packagedApps) {
const slugDir = join(PUBLIC_ROOT, app.slug);
if (existsSync(slugDir)) rmSync(slugDir, { recursive: true });
}
Expand All @@ -155,7 +173,7 @@ async function build() {
}
}

for (const app of registeredApps) {
for (const app of packagedApps) {
const srcDir = join(APPS_DIR, app.slug);
if (!existsSync(srcDir)) { warn(app.slug + ': apps/ dir missing, skipping asset copy'); continue; }
copyAssets(srcDir, join(PUBLIC_ROOT, app.slug));
Expand Down Expand Up @@ -194,7 +212,7 @@ async function build() {
const elapsed = ((Date.now() - startMs) / 1000).toFixed(1);
const slugs = readdirSync(APPS_DIR).filter(d => statSync(join(APPS_DIR, d)).isDirectory());
const appList = slugs.map(s => ' \u2022 \x1b[36m' + s + '\x1b[0m → dist/' + s + '/').join('\n');
console.log('\n\x1b[32m✓\x1b[0m \x1b[1m' + slugs.length + ' app(s) integrated\x1b[0m in ' + elapsed + 's\n\n Apps:\n' + appList + '\n Prefix: \x1b[33m' + PATH_PREFIX + '\x1b[0m\n');
console.log('\n\x1b[32m✓\x1b[0m \x1b[1m' + (slugs.length + iframeApps.length) + ' app(s) integrated\x1b[0m in ' + elapsed + 's\n\n Apps:\n' + appList + '\n Prefix: \x1b[33m' + PATH_PREFIX + '\x1b[0m\n');
}

build().catch(err => {
Expand Down
11 changes: 11 additions & 0 deletions scripts/setup-test-apps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ const apps = [
tags: ['mirror', 'cross-app'],
prebuilt: artifact,
},
{
// iframe onboarding mode (issue #10): no artifact, renders an <iframe>.
slug: 'external-docs',
name: 'External Docs',
description: 'Iframe-onboarded external documentation site (temporary integration path).',
icon: 'book-open',
tags: ['external'],
type: 'iframe',
url: 'https://example.com/docs',
temporary: true,
},
];

const out = join(ROOT, 'apps.json');
Expand Down
2 changes: 2 additions & 0 deletions src/components/AppCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface Props {
description: string;
icon?: string;
tags?: string[];
type?: string;
};
delay?: number;
base?: string;
Expand All @@ -26,6 +27,7 @@ const delayClass = delay > 0 && delay <= 4 ? ` delay-${delay}` : '';
<h2 class="text-[15px] font-semibold mb-1.5 leading-snug" style="color:var(--text-heading)">{app.name}</h2>
<p class="text-[13px] leading-relaxed flex-1 mb-4" style="color:var(--text-body)">{app.description}</p>
<div class="flex flex-wrap gap-1.5 mt-auto">
{app.type === 'iframe' && <span class="mp-tag mp-tag-external" title="Embedded via iframe (external site)">External</span>}
{(app.tags || []).map(tag => <span class="mp-tag">{tag}</span>)}
</div>
</a>
54 changes: 39 additions & 15 deletions src/pages/[...path].astro
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
---
// src/pages/[...path].astro
//
// Catchall route that serves every sub-app HTML file as a static Astro page.
// getStaticPaths enumerates all HTML files under apps/{slug}/ for every entry
// in apps.json, so `astro build` outputs them to dist/{slug}/...
// In `astro dev` the same function runs on demand — no custom middleware needed.
// Catchall route that serves every sub-app page as a static Astro page.
// getStaticPaths enumerates all HTML files under apps/{slug}/ for every packaged
// entry in apps.json, plus a single route per iframe-type entry (issue #10).

import { readFileSync } from 'node:fs';
import { ClientRouter } from 'astro:transitions';
import { getAppPages } from '../utils/apps.js';
import { transformSubAppHtml } from '../utils/transform.js';
import Base from '../layouts/Base.astro';
import Chrome from '../components/Chrome.astro';

const PREFIX = 'knowledge-base';

Expand All @@ -21,16 +22,39 @@ export function getStaticPaths() {
}));
}

const { file, slug, fileRelDir, appHeadless, apps, title, section } = Astro.props;
const raw = readFileSync(file, 'utf-8');
const html = transformSubAppHtml(raw, apps, slug, fileRelDir, PREFIX, appHeadless);
const props = Astro.props;
const { slug, appHeadless, apps, title } = props;

// Split at </head> so we can inject <ClientRouter /> before it closes.
// This preserves all of transformSubAppHtml's output (URL rewrites, chrome,
// fragment router, etc.) while making every sub-app page participate in
// Astro's client-side routing for cross-page transitions.
const headEnd = html.indexOf('</head>');
const beforeHeadEnd = headEnd >= 0 ? html.slice(0, headEnd) : html;
const afterHeadEnd = headEnd >= 0 ? html.slice(headEnd) : '';
// Packaged apps: read the sub-app HTML file and transform it. Split at </head>
// so <ClientRouter /> is injected for Astro's client-side transitions.
let beforeHeadEnd = '';
let afterHeadEnd = '';
if (!props.iframe) {
const raw = readFileSync(props.file, 'utf-8');
const html = transformSubAppHtml(raw, apps, slug, props.fileRelDir, PREFIX, appHeadless);
const headEnd = html.indexOf('</head>');
beforeHeadEnd = headEnd >= 0 ? html.slice(0, headEnd) : html;
afterHeadEnd = headEnd >= 0 ? html.slice(headEnd) : '';
}

// iFrame entries: fill the viewport (below the 56px chrome when standalone; full
// height when headless/embedded, where the host supplies its own chrome).
const iframeHeight = appHeadless ? '100vh' : 'calc(100vh - var(--mp-chrome-h, 56px))';
---
<Fragment set:html={beforeHeadEnd} /><ClientRouter /><Fragment set:html={afterHeadEnd} />
{props.iframe ? (
<Base headless={appHeadless} title={title}>
{!appHeadless && <Chrome apps={apps} activeSlug={slug} base={`/${PREFIX}`} />}
<main style="display:block;flex:1 1 auto;min-height:0">
<iframe
src={props.url}
title={title}
style={`width:100%;height:${iframeHeight};border:0;display:block`}
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-downloads"
></iframe>
</main>
</Base>
) : (
<Fragment set:html={beforeHeadEnd} /><ClientRouter /><Fragment set:html={afterHeadEnd} />
)}
8 changes: 8 additions & 0 deletions src/styles/marketplace.css
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,11 @@ wf-body.mp-wrapped #sidebar { top: var(--mp-chrome-h) !important; }
color: var(--text-muted);
border: 1px solid var(--border);
}

/* iframe-onboarding entries (issue #10) — flags an externally embedded app */
.mp-tag-external {
background: var(--color-kb-50);
color: var(--color-kb-600);
border-color: var(--color-kb-100);
font-weight: 600;
}
18 changes: 18 additions & 0 deletions src/utils/apps.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ export function getAppPages(cwd, headless) {
const appDir = join(appsDir, app.slug);
const appHeadless = app.headless ?? headless;

if (app.type === 'iframe') {
// iFrame onboarding mode: no artifact on disk — emit a single route that
// renders a full-viewport <iframe> for the external URL. See issue #10.
pages.push({
routePath: app.slug,
file: null,
slug: app.slug,
fileRelDir: '',
appHeadless,
apps,
title: app.name ?? app.slug,
section: null,
iframe: true,
url: app.url,
});
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) {
Expand Down
26 changes: 26 additions & 0 deletions tests/build-integrity.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,29 @@ test.describe('Build integrity', () => {
expect(html).toContain('astro-view-transitions-enabled');
});
});

// ── iframe onboarding mode (issue #10) ──────────────────────────────────────
test.describe('iframe onboarding', () => {
test('renders a single route with a full-viewport iframe to the external URL', () => {
expect(existsSync(join(DIST, 'external-docs/index.html')), 'iframe app page missing').toBe(true);
const html = read('external-docs/index.html');
expect(html).toMatch(/<iframe[^>]*src="https:\/\/example\.com\/docs"/);
expect(html).toContain('height:100vh'); // headless build → full viewport
});

test('iframe entry does not produce packaged sub-app pages', () => {
// No artifact was fetched/built — only the single index route exists.
expect(existsSync(join(DIST, 'external-docs/docs')), 'unexpected packaged pages for iframe entry').toBe(false);
});

test('landing shows the iframe app card with an External badge', () => {
const html = read('index.html');
expect(html).toContain('External Docs');
expect(html).toContain('mp-tag-external');
});

test('packaged apps are unaffected by the iframe entry', () => {
expect(existsSync(join(DIST, 'user-guide/docs/index.html'))).toBe(true);
expect(existsSync(join(DIST, 'guide-mirror/docs/index.html'))).toBe(true);
});
});
Loading