Files
OpenSpec/website/scripts/sync-docs.mjs
T
65a7233f36 docs: add cloudflare documentation deployment website (#1285)
* docs(website): add Fumadocs documentation site for Cloudflare Pages

Add a self-contained marketing + documentation site under website/, built
with Fumadocs (Next.js) and configured as a static export so it deploys
directly to Cloudflare Pages with no server runtime.

What's included:
- A marketing landing page (hero, the two-folder model, the four core
  ideas, the explore→propose→apply→archive loop, and the "why").
- 13 documentation pages rewritten for clarity and delight: introduction,
  installation, getting started, how commands work, core concepts, the
  workflow, explore first, existing projects, editing a change,
  customization, FAQ, and a reference section (slash commands, CLI,
  supported tools).
- Static client-side search (Orama), per-page Open Graph images, and
  llms.txt / llms-full.txt routes — fitting for an AI-native tool.
- website/README.md with one-table Cloudflare Pages deploy settings
  (root: website, build: npm run build, output: out).

Content is faithful to the docs/ overhaul from #1237, restated in a
simpler, friendlier voice. Verified with a clean `next build` (48 static
pages, no warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(website): sharpen the sell, add a Stores guide

Completes the documentation work begun in #1237 by tightening the
Fumadocs site toward the quality bar of the stores user-guide:

- Intro now opens problem-first ("the requirements lived only in chat"),
  adds an honest "How it compares" table (Spec Kit / Kiro / nothing), and
  frames the tradeoff in a "When the ceremony isn't worth it" callout.
- New Stores guide (beta) distilled from docs/stores-beta/user-guide.md:
  the problem, the annotated shape, a five-minute walkthrough with real
  command output, a role-based story, the root-resolution order, and an
  honest-limitations section. Linked from Existing Projects.

Verified with a clean `next build` (51 static pages, no warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(website): make the value tangible — landing sections + Examples page

Continue the #1237 docs completion with a stronger product story:

- Landing page now reads like a real product site:
  - "Works with the tools you already use" strip (15 named assistants + more)
  - "What a change actually looks like" — three real artifacts
    (proposal.md, a spec delta, tasks.md) so the workflow is concrete
  - "The honest middle" comparison block (Spec Kit / Kiro / no specs)
  - Robust hero gradient via color-mix instead of v3 theme() syntax
- New Examples & Recipes page: seven copy-pasteable, narrated walkthroughs
  (small feature, bug fix, explore-first, parallel changes, no-behavior
  refactor with --skip-specs, step-by-step, onboard). Linked from the intro
  and getting-started.

Verified: clean `next build` (54 static pages, no warnings); Tailwind
opacity/color-mix utilities confirmed in the generated CSS; all internal
links resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(website): add favicon, sitemap, and robots for a complete public site

- Branded SVG favicon (app/icon.svg) in the OpenSpec indigo.
- Static sitemap.xml covering the home page and every doc, built from the
  content source and NEXT_PUBLIC_SITE_URL.
- robots.txt allowing all and pointing at the sitemap.

All three are emitted by the static export. Clean `next build`, 57 pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: lead with stores as "why teams adopt OpenSpec"; complete docs coverage

Final pass completing the #1237 documentation work.

Reposition stores (beta) as the team adoption story, consistently:
- README.md gains a prominent "Why teams adopt OpenSpec" section right
  after the demo (cross-repo features, shared requirements, plan before
  code), leading with stores.
- Landing page gains a matching "Why teams adopt OpenSpec" section.
- Docs intro gains a teams card + callout pointing at stores.
- Stores page expanded with full References and Worksets technical
  examples (the cross-team requirements story, workset create/open).

Incorporate the remaining source-doc knowledge so the site is complete:
- New pages: Glossary, Troubleshooting, Multi-Language, and an
  Agents & Automation reference (the machine-readable --json surfaces and
  workflow primitives that make OpenSpec AI-native).
- The Workflow page now covers ff-vs-continue, a three-dimension verify
  example, and the update-vs-start-fresh decision guide.
- Nav restructured with a Help section; reference section gains Agents.

Build hardening: `build` now runs `fumadocs-mdx && next build` so the
content source is always regenerated. Clean build: 69 static pages, no
warnings; all internal links verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(website): fix docs GitHub source links + address review nits

- page.tsx: prefix ViewOptionsPopover githubUrl with website/ so the
  "view/edit source" links resolve to website/content/docs/... instead
  of 404-ing on every deployed docs page (Alfred blocker).
- installation.mdx: note that `yarn global add` is Classic Yarn only and
  point Yarn Berry users at `yarn dlx` / npm / pnpm.
- index.mdx: label the comparison table's first column ("Option").
- (home)/page.tsx: use the shared docsRoute constant for all /docs links
  instead of hardcoded paths.

Verified with `npm run build` in website/ — 69 static pages, and the
built getting-started page links to blob/main/website/content/docs/...

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(website): mirror docs/*.md into the site + auto-deploy on a cadence

Make the repository's docs/*.md the single source of truth for the docs
site instead of maintaining a parallel set of hand-written MDX pages that
silently drift.

- scripts/sync-docs.mjs mirrors ../docs into content/docs/ on every build:
  derives title/description, injects Fumadocs frontmatter (+ githubSource),
  rewrites internal *.md links to /docs routes, and emits meta.json. Pages
  are written as .md so <placeholders>/{braces} in the docs stay literal
  and never break the MDX build.
- docs.sync.config.mjs is the one manifest deciding which docs publish and
  their slug/section/icon. content/docs/ is now generated + git-ignored;
  the curated .mdx pages are removed. The marketing landing page stays
  hand-authored.
- build/dev/types:check run sync:docs first, so the site is always current.
- .github/workflows/deploy-docs.yml rebuilds and deploys to Cloudflare
  Pages via Wrangler on push to docs/**|website/**, daily on a schedule,
  on demand, and as a build-only check on PRs. Needs CLOUDFLARE_API_TOKEN
  + CLOUDFLARE_ACCOUNT_ID secrets and the DOCS_SITE_URL variable.
- source.config.ts carries githubSource so "edit this page" opens the real
  docs/*.md; website/README.md documents the pipeline.

Verified: clean build, 23 pages generated, 78 static pages, no warnings;
all internal doc links resolve; MDX-hazard docs (cli, customization) build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(website): fall back to default site URL when NEXT_PUBLIC_SITE_URL is empty

The deploy workflow passes NEXT_PUBLIC_SITE_URL from the DOCS_SITE_URL repo
variable, which resolves to an empty string when unset. `?? fallback` does
not catch '' (only null/undefined), so `metadataBase: new URL('')` crashed
`next build` with ERR_INVALID_URL while collecting page data. Use `||` so an
empty value also falls back. Verified: `NEXT_PUBLIC_SITE_URL='' npm run build`
now generates all 78 static pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: add reviewing, writing-specs, and team-workflow guides

Fill the biggest gaps a new user hits, in the plain-language voice of the
stores user guide:

- reviewing-changes.md: the two-minute human review of an AI-drafted plan
  before /opsx:apply — what to open, in what order, and the red flags per
  artifact — plus the /opsx:verify pass after code.
- writing-specs.md: what a strong requirement and scenario are made of,
  choosing ADDED/MODIFIED/REMOVED, and right-sizing a change.
- team-workflow.md: how a change maps onto a branch and a pull request,
  reviewing spec deltas in a PR, when to archive, and parallel changes —
  framed as convention, since OpenSpec never touches git.

Wire them into the docs map (README), the site nav (docs.sync.config.mjs),
and light "next steps" cross-links from getting-started, editing-changes,
and workflows. Verified: site builds clean, 26 pages, all internal links
resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(website): add one-time deploy setup checklist + landing-page note

Spell out the three maintainer steps that activate auto-deploy (create the
openspec-docs Pages project, add CLOUDFLARE_API_TOKEN/ACCOUNT_ID secrets,
merge to main), and note that the pipeline mirrors docs on build regardless.
Also flag that openspec.dev is a separate Astro landing page and whether to
keep/port this Fumadocs landing page is a maintainer decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(website): address review feedback on docs-site PR

Maintainer review (TabishB) + Alfred blocker:

- deploy-docs.yml: guard the Cloudflare deploy on `github.ref ==
  refs/heads/main`. A `workflow_dispatch` on a feature branch previously
  passed the guard and, since wrangler hardcodes `--branch=main` (a
  production deploy), would overwrite the live docs site. Non-main
  dispatches are now build-only. Also resolves Alfred's deploy-path blocker.
- package.json: drop the direct `cnfast` dependency and delete the dead
  `lib/cn.ts` (nothing imports it; a class-merge helper isn't used).
- package.json: declare `zod` (^4.4.3) — it was a phantom dep only
  resolving via fumadocs-mdx's hoisted copy. Refresh the lockfile.
- docs page: omit the on-page <DocsDescription>. The frontmatter
  description is derived from the first body paragraph, so it rendered
  the intro twice on every page. Kept in generateMetadata for SEO/OG.
- team-workflow.md: `openspec store create` does an initial commit, so
  scope "never commits" to the user's project and reframe the store
  clause as "never clones or syncs on its own."
- README.md: bump stale "20+ AI assistants" to "30+" to match the site.

Verified: npm run types:check + npm run build pass, 26 docs synced,
intro paragraph now renders once per page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(website): use pnpm to match the rest of the repo

Per maintainer review (TabishB): the root repo is pnpm (ci.yml runs
`pnpm install --frozen-lockfile` against a v9 `pnpm-lock.yaml`), but
`website/` had introduced npm + a `package-lock.json`. Standardize on
one package manager:

- Replace website/package-lock.json with website/pnpm-lock.yaml
  (lockfileVersion 9.0, generated with pnpm v9 to match root).
- deploy-docs.yml: add pnpm/action-setup@v4 (version 9, before
  setup-node, as in ci.yml), switch setup-node to `cache: pnpm` /
  `cache-dependency-path: website/pnpm-lock.yaml`, and
  `npm ci` → `pnpm install --frozen-lockfile`, `npm run build` →
  `pnpm run build`.
- package.json scripts + README: `npm run ...` → `pnpm run ...`.

website/ stays a standalone package (no pnpm-workspace.yaml), as before.

Verified: `pnpm install --frozen-lockfile`, `pnpm run build`, and
`pnpm run types:check` all pass — 26 docs synced, 87/87 static pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: temporarily disable docs deploy

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
Co-authored-by: TabishB <tabishbidiwale@gmail.com>
2026-07-03 14:21:42 +00:00

186 lines
7.0 KiB
JavaScript

#!/usr/bin/env node
// Generate the Fumadocs content set (`content/docs/**`) from the repository's
// canonical Markdown in `../docs`. This is the mechanical mirror: docs/*.md is
// the single source of truth, and the site is a faithful, always-current view
// of it. Runs as the first step of `build`/`dev`, and on a cadence in CI.
//
// For each published doc (see docs.sync.config.mjs) it:
// - derives the page title from the leading `# H1` (and strips that H1),
// - derives a short description from the first paragraph,
// - injects Fumadocs frontmatter (title / description / icon / githubSource),
// - rewrites internal `*.md` links to their `/docs/...` routes,
// - writes the result as a `.md` file (Fumadocs parses `.md` as plain
// Markdown, so `<placeholders>` and `{braces}` in the docs stay literal),
// - and emits `meta.json` sidebar ordering for the root and the reference folder.
//
// Generated files live under content/docs/ and are git-ignored — never edit
// them by hand; edit ../docs instead.
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, posix, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { docsDir, pages, sections } from '../docs.sync.config.mjs';
const websiteRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const docsRoot = resolve(websiteRoot, docsDir);
const outRoot = join(websiteRoot, 'content', 'docs');
const gitBranch = 'main';
const gitBlobBase = 'https://github.com/Fission-AI/OpenSpec/blob';
// Map every source path (relative to docs/, normalized) -> its /docs route,
// so cross-doc `.md` links resolve to on-site pages.
const routeBySource = new Map();
for (const page of pages) {
const normalized = posix.normalize(page.source);
routeBySource.set(normalized, page.slug === 'index' ? '/docs' : `/docs/${page.slug}`);
}
function yamlQuote(value) {
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
// Pull the first `# Heading` out of the body; return { title, rest }.
function extractTitle(markdown, fallback) {
const lines = markdown.split('\n');
for (let i = 0; i < lines.length; i++) {
const match = /^#\s+(.+?)\s*$/.exec(lines[i]);
if (match) {
lines.splice(0, i + 1);
return { title: match[1].trim(), rest: lines.join('\n').replace(/^\n+/, '') };
}
if (lines[i].trim() !== '') break; // content before any H1 — leave as-is
}
return { title: fallback, rest: markdown };
}
// First real paragraph, flattened to a one-line meta description.
function extractDescription(markdown) {
const lines = markdown.split('\n');
const buffer = [];
for (const line of lines) {
const trimmed = line.trim();
if (buffer.length === 0) {
if (trimmed === '') continue;
// Skip non-paragraph openers (headings, quotes, lists, tables, fences).
if (/^(#|>|[-*+]\s|\d+\.\s|\||```|:::)/.test(trimmed)) return '';
buffer.push(trimmed);
} else {
if (trimmed === '') break;
buffer.push(trimmed);
}
}
let text = buffer.join(' ');
text = text
.replace(/!\[[^\]]*\]\([^)]*\)/g, '') // images
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links -> text
.replace(/[*_`]/g, '') // emphasis / code ticks
.replace(/\s+/g, ' ')
.trim();
if (text.length > 200) {
text = text.slice(0, 200).replace(/\s+\S*$/, '') + '…';
}
return text;
}
// Rewrite internal Markdown links that point at other docs.
// `sourceRel` is the current doc's path relative to docs/ (for resolving ../).
function rewriteLinks(markdown, sourceRel) {
const sourceDir = posix.dirname(sourceRel);
return markdown.replace(/\]\(([^)]+)\)/g, (whole, target) => {
// Leave external, anchor-only, and non-.md links untouched.
if (/^(https?:|mailto:|#|\/)/.test(target)) return whole;
const [rawPath, hash] = target.split('#');
if (!/\.md$/i.test(rawPath)) return whole;
const resolved = posix.normalize(posix.join(sourceDir, rawPath)).replace(/^\.\//, '');
const route = routeBySource.get(resolved);
const suffix = hash ? `#${hash}` : '';
if (route) return `](${route}${suffix})`;
// A link we don't publish (e.g. the repo-root README) — fall back to the
// source on GitHub, normalizing any `../` that escapes the docs/ folder.
const repoPath = posix.normalize(`docs/${resolved}`);
return `](${gitBlobBase}/${gitBranch}/${repoPath}${suffix})`;
});
}
function buildFrontmatter({ title, description, icon, source }) {
const fm = [`title: ${yamlQuote(title)}`];
if (description) fm.push(`description: ${yamlQuote(description)}`);
if (icon) fm.push(`icon: ${icon}`);
fm.push(`githubSource: ${yamlQuote(`docs/${source}`)}`);
return `---\n${fm.join('\n')}\n---\n`;
}
function generatePage(page) {
const srcPath = join(docsRoot, page.source);
if (!existsSync(srcPath)) {
throw new Error(`Missing source doc: docs/${page.source} (referenced by slug "${page.slug}")`);
}
const raw = readFileSync(srcPath, 'utf8');
const fallbackTitle = page.slug.split('/').pop().replace(/-/g, ' ');
const { title, rest } = extractTitle(raw, fallbackTitle);
const description = extractDescription(rest);
const body = rewriteLinks(rest, posix.normalize(page.source));
const frontmatter = buildFrontmatter({
title,
description,
icon: page.icon,
source: posix.normalize(page.source),
});
const outPath = join(outRoot, `${page.slug}.md`);
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, `${frontmatter}\n${body.replace(/\s*$/, '')}\n`, 'utf8');
return outPath;
}
// meta.json for the docs root: labeled section separators + page slugs, with
// the reference folder inserted as a single entry.
function writeRootMeta() {
const items = [];
for (const section of sections) {
items.push(`---${section.label}---`);
if (section.folder) {
items.push(section.folder);
} else {
for (const page of section.pages) items.push(page.slug);
}
}
const meta = { title: 'Documentation', root: true, pages: items };
writeFileSync(join(outRoot, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
}
// meta.json for each folder section (e.g. reference/).
function writeFolderMetas() {
for (const section of sections) {
if (!section.folder) continue;
const meta = {
title: section.label,
...(section.icon ? { icon: section.icon } : {}),
pages: section.pages.map((page) => page.slug.split('/').pop()),
};
const dir = join(outRoot, section.folder);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
}
}
function main() {
// Start clean so removed/renamed docs don't leave stale pages behind.
rmSync(outRoot, { recursive: true, force: true });
mkdirSync(outRoot, { recursive: true });
let count = 0;
for (const page of pages) {
generatePage(page);
count++;
}
writeRootMeta();
writeFolderMetas();
const rel = relative(process.cwd(), outRoot);
console.log(`sync-docs: generated ${count} pages from ${docsDir} into ${rel}/`);
}
main();