diff --git a/.github/workflows/faq.yml b/.github/workflows/faq.yml
new file mode 100644
index 00000000..7f300d32
--- /dev/null
+++ b/.github/workflows/faq.yml
@@ -0,0 +1,56 @@
+name: FAQ website
+on:
+ pull_request:
+ paths:
+ - 'apps/website/**'
+ - 'docs/**'
+ - 'design/**'
+ - 'tools/website-*.mjs'
+ - 'package*.json'
+ - '.github/workflows/faq.yml'
+ push:
+ branches: [main]
+ paths:
+ - 'apps/website/**'
+ - 'docs/**'
+ - 'design/**'
+ - 'tools/website-*.mjs'
+ - 'package*.json'
+ - '.github/workflows/faq.yml'
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: faq-${{ github.ref }}
+ cancel-in-progress: true
+jobs:
+ browser:
+ name: Built FAQ / Chromium
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ persist-credentials: false
+ - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
+ with:
+ node-version: '22'
+ package-manager-cache: false
+ - name: Restore locked project packages
+ run: npm ci --ignore-scripts --no-audit --no-fund
+ - name: Build the real website and search index
+ env:
+ DIFFDEVIL_SITE_ORIGIN: https://diffdevil.dev
+ run: npm run website:build
+ - name: Install the repository-selected Chromium
+ run: npx --no-install playwright install --with-deps chromium
+ - name: Exercise FAQ navigation, links, search and responsive layout
+ run: node apps/website/qa/faq.mjs
+ - name: Preserve browser evidence
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: faq-browser-evidence
+ path: artifacts/website/faq-qa/
+ if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index 1865c871..9662487f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@ coverage/
# Generated website docs collection (tools/website-docs.mjs) and Astro type output
apps/website/src/content/docs/
apps/website/src/content/sidebar.json
+apps/website/src/generated/faq.md
apps/website/.astro/
# Derived website icons/rasters (tools/website-assets.mjs): the identity SVGs are the source; no raster is committed
apps/website/public/favicon.ico
diff --git a/README.md b/README.md
index f75ac590..e66c7cdb 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ node dist/lib/cli/main.js query --report docs/examples/reports/exact.json \
# 178
```
-**[Try the playground](https://diffdevil-playground.wolfsblvt.workers.dev)** · **[Label your PRs](docs/guides/auto-label-pull-requests.md)** · [Local scripts](docs/guides/local-automation.md) · [Policy recipes](docs/guides/policy-recipes.md) · [User manual](docs/automation.md) · [CLI reference](docs/integration/cli.md) · [Actions](docs/integration/github-actions.md) · [TypeScript API](docs/integration/typescript-api.md)
+**[Try the playground](https://diffdevil-playground.wolfsblvt.workers.dev)** · **[Label your PRs](docs/guides/auto-label-pull-requests.md)** · [Local scripts](docs/guides/local-automation.md) · [Policy recipes](docs/guides/policy-recipes.md) · [FAQ](docs/manual/faq.md) · [User manual](docs/automation.md) · [CLI reference](docs/integration/cli.md) · [Actions](docs/integration/github-actions.md) · [TypeScript API](docs/integration/typescript-api.md)
## Label your PRs with one file
diff --git a/apps/website/astro.config.mjs b/apps/website/astro.config.mjs
index cb168850..2e11579f 100644
--- a/apps/website/astro.config.mjs
+++ b/apps/website/astro.config.mjs
@@ -6,6 +6,7 @@ import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { diffdevilSyntax } from './src/lib/shiki-theme.ts';
import { themeScript } from './src/lib/theme-script.mjs';
+import searchIndex from './search-index.mjs';
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
const engine = path => fileURLToPath(new URL(`../../dist/lib/${path}`, import.meta.url));
@@ -23,6 +24,7 @@ export default defineConfig({
title: 'diffdevil',
description: 'The diffdevil manual: CLI, GitHub Actions, policies and the detail language, TypeScript API, playground and GitHub App.',
sidebar,
+ pagefind: false,
customCss: ['./src/styles/fonts.css', './src/styles/tokens.css', './src/styles/global.css', './src/styles/starlight.css'],
components: {
Header: './src/components/docs/Header.astro',
@@ -49,6 +51,7 @@ export default defineConfig({
disable404Route: true,
}),
react(),
+ searchIndex(),
],
markdown: { shikiConfig: { theme: diffdevilSyntax } },
vite: {
diff --git a/apps/website/docs-manifest.mjs b/apps/website/docs-manifest.mjs
index e6552359..d5da06ab 100644
--- a/apps/website/docs-manifest.mjs
+++ b/apps/website/docs-manifest.mjs
@@ -9,13 +9,18 @@
* `tested: true` marks pages whose command/policy/workflow specimens are executed by
* `src/diffdevil/tests/examples.test.mjs`; the docs footer shows "Example tested ✓" there.
*/
+import { FAQ_ROUTE, FAQ_SOURCE } from './faq-content.mjs';
+
+/** Product prose shares source routing, but never creates a duplicate manual page. */
+export const productPages = [{ source: FAQ_SOURCE, route: FAQ_ROUTE }];
+
export const groups = [
{ label: 'Overview', entries: [
- { source: 'docs/README.md', slug: '', title: 'Docs' },
+ { source: 'docs/README.md', faq: ["beyond-size-labels", "why-managed"], slug: '', title: 'Docs' },
] },
{ label: 'Get started', entries: [
- { source: 'docs/guides/auto-label-pull-requests.md', slug: 'get-started/auto-label-pull-requests', tested: true },
- { source: 'docs/guides/local-automation.md', slug: 'get-started/local-automation', tested: true },
+ { source: 'docs/guides/auto-label-pull-requests.md', faq: ["beyond-size-labels", "automation-overlap", "trusted-policy"], slug: 'get-started/auto-label-pull-requests', tested: true },
+ { source: 'docs/guides/local-automation.md', faq: ["non-javascript-projects", "no-language-required"], slug: 'get-started/local-automation', tested: true },
{ source: 'docs/automation.md', slug: 'get-started/automation' },
{ source: 'docs/examples/README.md', slug: 'get-started/examples', title: 'Example map' },
] },
@@ -23,22 +28,22 @@ export const groups = [
{ source: 'docs/guides/policy-recipes.md', slug: 'recipes/policy-recipes', tested: true },
] },
{ label: 'CLI', entries: [
- { source: 'docs/integration/cli.md', slug: 'cli', title: 'CLI' },
+ { source: 'docs/integration/cli.md', faq: ["different-results", "no-language-required"], slug: 'cli', title: 'CLI' },
{ source: 'docs/PRESENTATION.md', slug: 'cli/presentation', title: 'Human and agent presentation' },
] },
{ label: 'Actions', entries: [
- { source: 'docs/integration/github-actions.md', slug: 'actions/github-actions', tested: true },
+ { source: 'docs/integration/github-actions.md', faq: ["automation-overlap", "trusted-policy"], slug: 'actions/github-actions', tested: true },
{ source: 'docs/integration/action-distribution.md', slug: 'actions/action-distribution' },
- { source: 'docs/integration/github-api.md', slug: 'actions/github-api' },
+ { source: 'docs/integration/github-api.md', faq: ["code-data"], slug: 'actions/github-api' },
] },
{ label: 'Policies and detail', entries: [
{ source: 'docs/language/policies-and-bands.md', slug: 'policies-and-detail/policies-and-bands' },
- { source: 'docs/integration/presets-and-shortcuts.md', slug: 'policies-and-detail/presets-and-shortcuts' },
- { source: 'docs/integration/templates.md', slug: 'policies-and-detail/templates' },
+ { source: 'docs/integration/presets-and-shortcuts.md', faq: ["no-language-required", "semantic-versioning"], slug: 'policies-and-detail/presets-and-shortcuts' },
+ { source: 'docs/integration/templates.md', faq: ["automation-overlap"], slug: 'policies-and-detail/templates' },
{ source: 'docs/language.md', slug: 'policies-and-detail/language' },
{ source: 'docs/language/syntax.md', slug: 'policies-and-detail/syntax' },
- { source: 'docs/language/types-and-measurements.md', slug: 'policies-and-detail/types-and-measurements' },
- { source: 'docs/language/collections-and-scopes.md', slug: 'policies-and-detail/collections-and-scopes' },
+ { source: 'docs/language/types-and-measurements.md', faq: ["changed-vs-churn", "changed-makes-sense", "measurement-evidence"], slug: 'policies-and-detail/types-and-measurements' },
+ { source: 'docs/language/collections-and-scopes.md', faq: ["files-and-exclusions"], slug: 'policies-and-detail/collections-and-scopes' },
{ source: 'docs/language/standard-library.md', slug: 'policies-and-detail/standard-library' },
{ source: 'docs/language/diagnostics-and-limits.md', slug: 'policies-and-detail/diagnostics-and-limits' },
{ source: 'docs/language/versioning-and-interchange.md', slug: 'policies-and-detail/versioning-and-interchange' },
@@ -51,6 +56,7 @@ export const groups = [
{ source: 'docs/agent-skill/README.md', slug: 'agents/agent-integration', optional: true },
{ source: 'skills/diffdevil/SKILL.md', slug: 'agents/skill', optional: true, title: 'Agent Skill source' },
{ source: 'docs/setup/README.md', slug: 'agents/setup', optional: true },
+ { source: 'docs/setup/skill.md', faq: ["coding-agents", "non-javascript-projects"], slug: 'setup/skill', title: 'Coding-agent setup' },
{ source: 'docs/setup/cli.md', slug: 'setup/cli', optional: true },
{ source: 'docs/setup/actions.md', slug: 'setup/actions', optional: true },
{ source: 'docs/setup/app.md', slug: 'setup/app', optional: true },
@@ -58,20 +64,23 @@ export const groups = [
{ link: '/docs/cli/presentation/#agent-report-projection', label: 'Agent output contract' },
] },
{ label: 'Playground', entries: [
- { source: 'docs/integration/playground.md', slug: 'playground', title: 'Playground' },
+ { source: 'docs/integration/playground.md', faq: ["playground-or-extension", "code-data"], slug: 'playground', title: 'Playground' },
{ source: 'apps/playground/README.md', slug: 'playground/application', title: 'Playground application and local route' },
] },
{ label: 'GitHub App', entries: [
- { source: 'docs/integration/github-app.md', slug: 'github-app/architecture', title: 'App architecture' },
- { source: 'docs/PRIVACY-AND-DATA.md', slug: 'github-app/privacy-and-data', title: 'Privacy and data' },
- { source: 'apps/github-app/README.md', slug: 'github-app/self-hosting', title: 'Self-hosting the runtime' },
+ { source: 'docs/integration/github-app.md', faq: ["why-managed", "leaving-managed-service"], slug: 'github-app/architecture', title: 'App architecture' },
+ { source: 'docs/PRIVACY-AND-DATA.md', faq: ["code-data", "app-history"], slug: 'github-app/privacy-and-data', title: 'Privacy and data' },
+ { source: 'apps/github-app/README.md', faq: ["leaving-managed-service"], slug: 'github-app/self-hosting', title: 'Self-hosting the runtime' },
] },
// The browser extension's manual pages are authored with the extension application.
// They render as soon as that source is in the repository; until then the group is empty.
{ label: 'Browser extension', entries: [
- { source: 'apps/browser-extension/README.md', slug: 'browser-extension', title: 'diffdevil for GitHub', optional: true },
- { source: 'apps/browser-extension/PRIVACY.md', slug: 'browser-extension/privacy', title: 'Extension privacy', optional: true },
- { source: 'docs/integration/browser-extension.md', slug: 'browser-extension/integration', title: 'Browser integration', optional: true },
+ { source: 'apps/browser-extension/README.md', faq: ["playground-or-extension", "extension-without-repo-setup"], slug: 'browser-extension', title: 'diffdevil for GitHub', optional: true },
+ { source: 'apps/browser-extension/PRIVACY.md', faq: ["code-data"], slug: 'browser-extension/privacy', title: 'Extension privacy', optional: true },
+ { source: 'docs/integration/browser-extension.md', faq: ["different-results"], slug: 'browser-extension/integration', title: 'Browser integration', optional: true },
+ ] },
+ { label: 'Help', entries: [
+ { link: FAQ_ROUTE, label: 'FAQ' },
] },
{ label: 'Troubleshooting', entries: [
{ link: '/docs/get-started/auto-label-pull-requests/#troubleshooting', label: 'Labels did not appear' },
diff --git a/apps/website/faq-content.mjs b/apps/website/faq-content.mjs
new file mode 100644
index 00000000..038d7bec
--- /dev/null
+++ b/apps/website/faq-content.mjs
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+/** Public coordinates and extraction from the real rendered FAQ, not another answer registry. */
+export const FAQ_SOURCE = 'docs/manual/faq.md';
+export const FAQ_ROUTE = '/faq/';
+export const FAQ_CANONICAL = 'https://diffdevil.dev/faq/';
+
+export function escapeHtml(text) {
+ return String(text).replace(/[&<>"']/gu, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]);
+}
+function attribute(attributes, name) {
+ return new RegExp(`(?:^|\\s)${name}=["']([^"']*)["']`, 'u').exec(attributes)?.[1];
+}
+/** Decode the HTML entities that can occur in authored titles and metadata. */
+export function textOf(html) {
+ const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
+ return html.replace(/<[^>]*>/gu, ' ').replace(/&(#x[0-9a-f]+|#[0-9]+|amp|lt|gt|quot|apos|nbsp);/giu, (whole, entity) => {
+ if (entity[0] !== '#') return named[entity.toLowerCase()] ?? whole;
+ const point = entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1));
+ return point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : whole;
+ }).replace(/\s+/gu, ' ').trim();
+}
+
+/**
+ * Read only the FAQ's authored h2/article structure from built HTML. Pagefind itself
+ * parses each resulting HTML record, so Markdown rendering and search cannot disagree.
+ */
+export function faqRecords(html) {
+ const records = [], seen = new Set();
+ let category = '';
+ const pieces = /
]*>([\s\S]*?)<\/h2>|]*)>([\s\S]*?)<\/article>/gu;
+ for (const match of html.matchAll(pieces)) {
+ if (match[1] !== undefined) { category = textOf(match[1]); continue; }
+ const attributes = match[2], body = match[3];
+ if (!attribute(attributes, 'class')?.split(/\s+/u).includes('faq-question')) continue;
+ const id = attribute(attributes, 'data-faq-id');
+ const title = textOf(/]*>([\s\S]*?)<\/summary>/u.exec(body)?.[1] ?? '');
+ if (!id || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u.test(id) || seen.has(id) || !title || !category) {
+ throw new Error('FAQ questions need a unique semantic identifier, category and title.');
+ }
+ if (!new RegExp(`\\bid=["']${id}["']`, 'u').test(body)) throw new Error(`FAQ fragment is missing: ${id}`);
+ seen.add(id);
+ const noteId = attribute(attributes, 'data-faq-note');
+ const note = noteId ? new RegExp(`