diff --git a/.github/workflows/docs-ci.yaml b/.github/workflows/docs-ci.yaml new file mode 100644 index 00000000000..90f15bde428 --- /dev/null +++ b/.github/workflows/docs-ci.yaml @@ -0,0 +1,55 @@ +name: Docs CI + +on: + pull_request: + paths: + - "docs/**" + - "api/v4/source/**" + - "api/playbooks/**" + push: + branches: + - master + paths: + - "docs/**" + - "api/v4/source/**" + - "api/playbooks/**" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version-file: docs/site/.nvmrc + cache: "npm" + cache-dependency-path: docs/site/package-lock.json + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version-file: api/server/go.mod + + - name: Install docs dependencies + working-directory: docs/site + run: npm ci + + - name: Typecheck + working-directory: docs/site + run: npm run typecheck + + - name: Build docs site (includes OpenAPI prebuild) + working-directory: docs/site + run: npm run build + env: + ALGOLIA_APP_ID: ${{ vars.ALGOLIA_APP_ID }} + ALGOLIA_SEARCH_API_KEY: ${{ vars.ALGOLIA_SEARCH_API_KEY }} diff --git a/.gitignore b/.gitignore index 8ca39077996..0a83ff20332 100644 --- a/.gitignore +++ b/.gitignore @@ -187,3 +187,9 @@ docs/site/node_modules/ docs/api/reference/ docs/pdf/build/ docs/pdf/node_modules/ +docs/site/openapi/ + +# OpenAPI make build artifacts +api/v4/html/static/mattermost-openapi-v4.yaml +api/v4/html/index.html +api/node_modules/ diff --git a/docs/site/README.md b/docs/site/README.md index 30f70658369..defd37e1300 100644 --- a/docs/site/README.md +++ b/docs/site/README.md @@ -1,38 +1,111 @@ -# Mattermost Docs Site +# Mattermost Documentation Site -Built with [Docusaurus](https://docusaurus.io/). +Docusaurus workspace for [docs.mattermost.com](https://docs.mattermost.com), +living in the [mattermost/mattermost](https://github.com/mattermost/mattermost) +monorepo at `docs/site/`. + +## Content layout + +| Directory | Route | Description | +|---|---|---| +| `docs/main/` | `/` | User and admin documentation | +| `docs/develop/` | `/developers` | Developer documentation | +| `docs/api/` | `/api` | API reference intro + generated OpenAPI pages | + +Paths are relative to the repository root. The Docusaurus site reads them +via the relative paths `../main`, `../develop`, `../api` (from `docs/site/`). + +## Prerequisites + +- Node.js ≥ 20 — use `nvm use` inside `docs/site/` to pick up `.nvmrc` +- Go and `make` (required only for the OpenAPI prebuild step — see below) +- Vale ≥ 3 (for content linting) ## Local development -```bash -# 1. Install dependencies -cd docs/site && npm ci - -# 2. Generate the OpenAPI YAML bundle (reads api/v4/source/*.yaml) -node scripts/build-openapi.mjs - -# 3. Generate the documentation and developer sidebars -# These produce sidebars/documentation.generated.json and -# sidebars/developers.generated.json, which are .gitignored and must be -# regenerated locally after adding or removing doc pages. -# TODO: move these steps into the CI/CD build pipeline so they run automatically. -node scripts/gen-documentation-sidebar.mjs -node scripts/gen-developer-sidebar.mjs - -# 4. Generate the API reference MDX + sidebar (~2 min) -npx docusaurus gen-api-docs all - -# 5. Start dev server -npm start +```shell +cd docs/site +npm ci +npm start # dev server at http://localhost:3000 ``` -## Build +### Sidebar generation -```bash -npm run build +The `documentation` and `developers` sidebars are generated from the content +directories (`sidebars/documentation.generated.json` / +`developers.generated.json`, both gitignored) by `npm run build:sidebars`. +Docusaurus imports these files directly, so **they must exist before +`docusaurus start` or `docusaurus build` runs** — on a fresh checkout there's +no other source for them. This is wired automatically via the `prestart` and +`prebuild` npm lifecycle hooks, so plain `npm start` / `npm run build` just +work. + +(`sidebars/active-redirects.json`, by contrast, *is* committed — it's +regenerated and checked in manually via `node scripts/gen-active-redirects.mjs` +when the legacy redirect map changes, not on every build.) + +The API reference section (`docs/api/reference/`, also gitignored) has the +same requirement: `docusaurus-plugin-openapi-docs` needs `docusaurus +gen-api-docs mattermost` run before it has any pages to render. `prestart` +handles this too — using the existing OpenAPI spec if present, only falling +back to the slow `make -C api build` spec rebuild if it's missing. + +### Full production build + +The production build also includes an OpenAPI prebuild step (`npm run +build:openapi`, wired to run automatically before `npm run build` via the +same `prebuild` hook) that invokes `make -C api build`. This requires Go and +takes ~2 minutes. + +```shell +cd docs/site +npm ci +npm run build # runs build:sidebars + build:openapi (via prebuild), then Docusaurus build ``` -## Notes +To skip the OpenAPI rebuild during iterative content work: -- `sidebars/documentation.generated.json` and `sidebars/developers.generated.json` are generated by the sidebar scripts and are not committed. Re-run step 3 whenever you add or remove pages under `docs/` or `develop/`. -- `scripts/migrate-*.mjs` are one-time migration scripts kept locally only; they are not committed. +```shell +cd docs/site +npm run build -- --no-minify # still runs build:sidebars + build:openapi first +``` + +If you need to bypass the prebuild step entirely (e.g., all generated +artifacts already exist and are current), run: + +```shell +cd docs/site +npm run docusaurus build # calls docusaurus directly, skips prebuild +``` + +### Algolia search + +There's a single Algolia DocSearch app for `docs.mattermost.com` — credentials +aren't distributed to individual developers. They're set as repository +variables (`vars.ALGOLIA_APP_ID`, `vars.ALGOLIA_SEARCH_API_KEY`) and injected +only in CI/CD. Local builds simply run without them: the site builds cleanly +and the search bar is omitted (see the conditional in +`docusaurus.config.ts`). + +If you need to test search locally, export the same two variables in your +shell before running `npm start`/`npm run build`. + +## Scripts + +| Command | Description | +|---|---| +| `npm start` | Dev server with hot reload (runs `build:sidebars` + `build:openapi:docs` first via `prestart`) | +| `npm run build` | Production build to `build/` (runs `build:sidebars` + `build:openapi` first via `prebuild`) | +| `npm run build:sidebars` | Regenerate the documentation + developer sidebar JSON | +| `npm run build:openapi:spec` | Regenerate the OpenAPI spec only (slow — invokes `make -C api build`) | +| `npm run build:openapi:docs` | Regenerate the API reference MDX pages from the existing spec (fast) | +| `npm run build:openapi` | Full OpenAPI pipeline: spec then docs | +| `npm run serve` | Serve the `build/` output locally | +| `npm run typecheck` | TypeScript type check | +| `node scripts/gen-active-redirects.mjs` | Regenerate legacy redirect map (committed to git; run manually when it changes) | + +## Content linting + +```shell +vale main develop api +``` diff --git a/docs/site/docusaurus.config.ts b/docs/site/docusaurus.config.ts index fb99e30ae11..c9051b72549 100644 --- a/docs/site/docusaurus.config.ts +++ b/docs/site/docusaurus.config.ts @@ -12,6 +12,25 @@ import activeRedirects from './sidebars/active-redirects.json'; // /api → API Reference (OpenAPI-generated) sources: ../api // See PLAN.md §3.1 for the IA, §3.2 for design tokens. +// Docusaurus's Algolia schema rejects an empty `appId`/`apiKey`, so the +// block is only included when both are set. Without credentials the search +// bar is simply omitted (rather than rendered as an inert element), which +// keeps local and CI builds green. +const algoliaAppId = process.env.ALGOLIA_APP_ID; +const algoliaApiKey = process.env.ALGOLIA_SEARCH_API_KEY; +const algoliaThemeConfig = + algoliaAppId && algoliaApiKey + ? { + algolia: { + appId: algoliaAppId, + apiKey: algoliaApiKey, + indexName: 'mattermost-docs', + contextualSearch: true, + searchPagePath: 'search', + }, + } + : {}; + const config: Config = { title: 'Mattermost Documentation', tagline: 'Mission in Motion', @@ -23,6 +42,7 @@ const config: Config = { url: 'https://docs.mattermost.com', baseUrl: '/', + trailingSlash: false, organizationName: 'mattermost', projectName: 'mattermost', @@ -113,6 +133,8 @@ const config: Config = { redirects: activeRedirects.redirects, }, ], + // Generates API reference pages from the OpenAPI bundle produced by + // build-openapi.mjs [ 'docusaurus-plugin-openapi-docs', { @@ -213,6 +235,7 @@ const config: Config = { ], copyright: `Copyright © ${new Date().getFullYear()} Mattermost, Inc. All rights reserved.`, }, + ...algoliaThemeConfig, prism: { theme: prismThemes.github, darkTheme: prismThemes.dracula, diff --git a/docs/site/package.json b/docs/site/package.json index 67aa4c572f0..3e810a6f11a 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -6,6 +6,12 @@ "docusaurus": "docusaurus", "start": "docusaurus start", "build": "docusaurus build", + "build:sidebars": "node scripts/gen-documentation-sidebar.mjs && node scripts/gen-developer-sidebar.mjs", + "build:openapi:spec": "node scripts/build-openapi.mjs", + "build:openapi:docs": "docusaurus gen-api-docs mattermost", + "build:openapi": "npm run build:openapi:spec && npm run build:openapi:docs", + "prestart": "npm run build:sidebars && ( [ -f openapi/mattermost-openapi-v4.yaml ] || npm run build:openapi:spec ) && npm run build:openapi:docs", + "prebuild": "npm run build:sidebars && npm run build:openapi", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", diff --git a/docs/site/scripts/build-openapi.mjs b/docs/site/scripts/build-openapi.mjs index 5ebe1608b35..72dbd9348a6 100644 --- a/docs/site/scripts/build-openapi.mjs +++ b/docs/site/scripts/build-openapi.mjs @@ -1,75 +1,30 @@ #!/usr/bin/env node -/* - * Bundles the Mattermost OpenAPI fragments into a single YAML document. +/** + * Builds the merged OpenAPI spec by delegating to the canonical api/Makefile, + * then sanitizes the output for MDX compatibility. * - * Canonical source: mattermost/mattermost/api/v4/source/. The upstream - * Makefile uses naive `cat` concatenation which produces *invalid* YAML - * when fragments declare the same path key (currently - * /api/v4/sharedchannels/{channel_id}/remotes lives in both - * channels.yaml and sharedchannels.yaml). This script does a real - * YAML parse + merge with last-wins semantics so the bundle validates. + * Sanitization (description / summary / title strings): + * 1) Embedded "..." breaks docusaurus-plugin-openapi-docs frontmatter quoting. + * Replace with curly quotes (“ / ”). + * 2) Markdown autolinks like are parsed as JSX closing tags by MDX. + * Escape bare < not followed by a letter or ! to <. * - * Skips: - * - The Go-based x-codeSamples extractor (we use the plugin's - * auto-generated curl/Go/Node/Python samples for now). - * - The Playbooks merge (separate spec; integrate later if needed). - * - * Usage: node docs-site/scripts/build-openapi.mjs - * Output: docs-site/openapi/mattermost-openapi-v4.yaml + * Usage: node scripts/build-openapi.mjs (from docs/site/) + * Output: docs/site/openapi/mattermost-openapi-v4.yaml */ -import {readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync} from 'node:fs'; -import {dirname, join, resolve} from 'node:path'; +import {execSync} from 'node:child_process'; +import {readFileSync, writeFileSync, mkdirSync, existsSync} from 'node:fs'; +import {dirname, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; import {parse, stringify} from 'yaml'; -const HERE = dirname(fileURLToPath(import.meta.url)); -const SITE_ROOT = resolve(HERE, '..'); -// docs/site/ → docs/ → mattermost/ (monorepo root where api/v4/source lives) -const REPO_ROOT = resolve(SITE_ROOT, '../..'); - -const PRE_MERGE_PATH = join(REPO_ROOT, 'sources/mattermost-api-source/api/v4/source'); -const POST_MERGE_PATH = join(REPO_ROOT, 'api/v4/source'); -const SOURCE_DIR = (existsSync(POST_MERGE_PATH) && readdirSync(POST_MERGE_PATH).some((f) => f.endsWith('.yaml'))) - ? POST_MERGE_PATH - : PRE_MERGE_PATH; - -const OUT = join(SITE_ROOT, 'openapi/mattermost-openapi-v4.yaml'); - -// Order matches the upstream Makefile build-v4 target. Order is significant -// because the openapi-docs plugin renders sidebar groups in tag order, and -// tags are first-seen in the file order. -const PATH_FRAGMENTS = [ - 'users', 'status', 'teams', 'channels', 'posts', 'preferences', 'files', - 'recaps', 'ai', 'uploads', 'jobs', 'system', 'emoji', 'webhooks', 'saml', - 'compliance', 'ldap', 'groups', 'cluster', 'brand', 'commands', 'oauth', - 'elasticsearch', 'bleve', 'dataretention', 'plugins', 'roles', 'schemes', - 'service_terms', 'remoteclusters', 'sharedchannels', 'reactions', 'actions', - 'bots', 'cloud', 'usage', 'permissions', 'imports', 'exports', 'ip_filters', - 'bookmarks', 'views', 'reports', 'limits', 'logs', - 'outgoing_oauth_connections', 'metrics', 'scheduled_post', - 'custom_profile_attributes', 'audit_logging', 'access_control', - 'content_flagging', 'agents', 'properties', -]; - -function loadYaml(name) { - const file = join(SOURCE_DIR, `${name}.yaml`); - if (!existsSync(file)) { - console.warn(`[openapi] skip (missing): ${name}.yaml`); - return null; - } - return parse(readFileSync(file, 'utf8')); -} - -function mergePaths(base, fragment, fragmentName) { - if (!fragment || typeof fragment !== 'object') return; - for (const [pathKey, pathItem] of Object.entries(fragment)) { - if (base[pathKey]) { - console.warn(`[openapi] duplicate path "${pathKey}" — overwritten by ${fragmentName}.yaml`); - } - base[pathKey] = pathItem; - } -} +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); // docs/site/scripts +const DOCS_SITE_DIR = resolve(SCRIPT_DIR, '..'); // docs/site +const MONOREPO_ROOT = resolve(SCRIPT_DIR, '../../..'); // mattermost/ +const API_ROOT = resolve(MONOREPO_ROOT, 'api'); // mattermost/api +const RAW_SPEC_PATH = resolve(API_ROOT, 'v4/html/static/mattermost-openapi-v4.yaml'); // make's own output +const SANITIZED_SPEC_PATH = resolve(DOCS_SITE_DIR, 'openapi/mattermost-openapi-v4.yaml'); // what Docusaurus reads function walkStrings(node, fn, key) { if (typeof node === 'string') return fn(node, key); @@ -84,64 +39,27 @@ function walkStrings(node, fn, key) { return node; } -function deepMerge(target, source) { - for (const [k, v] of Object.entries(source ?? {})) { - if (v && typeof v === 'object' && !Array.isArray(v) && target[k] && typeof target[k] === 'object' && !Array.isArray(target[k])) { - deepMerge(target[k], v); - } else { - target[k] = v; - } - } -} - function main() { - if (!existsSync(SOURCE_DIR)) { - console.error(`OpenAPI source not found at ${SOURCE_DIR}`); + if (!existsSync(API_ROOT)) { + console.error(`[openapi] api/ directory not found at ${API_ROOT}`); + console.error('[openapi] Ensure you are running from within the mattermost monorepo.'); process.exit(1); } - console.log(`[openapi] source: ${SOURCE_DIR}`); + console.log(`[openapi] Building OpenAPI spec via make -C ${API_ROOT} build`); + execSync(`make -C ${API_ROOT} build`, {stdio: 'inherit'}); - const intro = loadYaml('introduction'); - if (!intro) { - console.error('introduction.yaml is required as the bundle base'); + if (!existsSync(RAW_SPEC_PATH)) { + console.error(`[openapi] Expected make output not found: ${RAW_SPEC_PATH}`); process.exit(1); } - // introduction.yaml ends with `paths:` (open). After parsing it yields - // a doc whose paths key is null — initialize as empty object so we can - // merge fragments into it. - intro.paths = intro.paths ?? {}; - console.log(`[openapi] base: introduction.yaml (info, tags=${(intro.tags ?? []).length}, servers=${(intro.servers ?? []).length})`); - let endpointCount = 0; - for (const name of PATH_FRAGMENTS) { - const frag = loadYaml(name); - if (!frag) continue; - const before = Object.keys(intro.paths).length; - mergePaths(intro.paths, frag, name); - const added = Object.keys(intro.paths).length - before; - endpointCount += Object.keys(frag).length; - console.log(`[openapi] ${name.padEnd(28)} +${added.toString().padStart(3)} paths (${Object.keys(frag).length} declared, ${Object.keys(frag).length - added} duplicates)`); - } + console.log('[openapi] Sanitizing for MDX compatibility ...'); + const doc = parse(readFileSync(RAW_SPEC_PATH, 'utf8')); - const defs = loadYaml('definitions'); - if (defs) { - deepMerge(intro, defs); - console.log(`[openapi] merged definitions.yaml (components.schemas: ${Object.keys(intro.components?.schemas ?? {}).length})`); - } - - // Sanitize description text so docusaurus-plugin-openapi-docs writes - // valid MDX/YAML. - // 1) Embedded double-quotes break the plugin's `description: "..."` - // frontmatter (it discards the closing quote of `("")`). - // → replace inner `"..."` with curly quotes. - // 2) Markdown autolinks like `` break MDX (` { + let quoteFixes = 0; + let autolinkFixes = 0; + walkStrings(doc, (value, key) => { if (key !== 'description' && key !== 'summary' && key !== 'title') return value; let v = value; if (v.includes('"')) { @@ -154,21 +72,14 @@ function main() { } return v; }); - console.log(`[openapi] sanitized: quotes=${quoteFixes}, autolinks=${autolinkFixes}`); - const totalPaths = Object.keys(intro.paths).length; - const totalOperations = Object.values(intro.paths) - .flatMap((p) => Object.keys(p ?? {})) - .filter((k) => ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'].includes(k)) - .length; + console.log(`[openapi] Sanitized: quotes=${quoteFixes}, autolinks=${autolinkFixes}`); - const out = stringify(intro, {lineWidth: 0}); - mkdirSync(dirname(OUT), {recursive: true}); - writeFileSync(OUT, out); + mkdirSync(dirname(SANITIZED_SPEC_PATH), {recursive: true}); + writeFileSync(SANITIZED_SPEC_PATH, stringify(doc, {lineWidth: 0})); - const sizeMb = (out.length / 1024 / 1024).toFixed(2); - console.log(`[openapi] wrote ${OUT}`); - console.log(`[openapi] ${sizeMb} MB · ${totalPaths} unique paths · ${totalOperations} operations`); + const sizeMb = (readFileSync(SANITIZED_SPEC_PATH).length / 1024 / 1024).toFixed(2); + console.log(`[openapi] Wrote ${sizeMb} MB → ${SANITIZED_SPEC_PATH}`); } main(); diff --git a/docs/site/scripts/gen-active-redirects.mjs b/docs/site/scripts/gen-active-redirects.mjs index d08ab197b92..ad051ae536c 100644 --- a/docs/site/scripts/gen-active-redirects.mjs +++ b/docs/site/scripts/gen-active-redirects.mjs @@ -21,7 +21,7 @@ import {fileURLToPath} from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const SITE_ROOT = resolve(HERE, '..'); const REPO_ROOT = resolve(SITE_ROOT, '..'); -const DOCS = join(REPO_ROOT, 'docs'); +const DOCS = join(REPO_ROOT, 'main'); const SRC = join(SITE_ROOT, 'scripts', 'migrate-main-docs', 'redirects.json'); const OUT = join(SITE_ROOT, 'sidebars', 'active-redirects.json'); diff --git a/docs/site/scripts/gen-documentation-sidebar.mjs b/docs/site/scripts/gen-documentation-sidebar.mjs index 69f3d222046..8a474264374 100644 --- a/docs/site/scripts/gen-documentation-sidebar.mjs +++ b/docs/site/scripts/gen-documentation-sidebar.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node // Generate the Documentation sidebar from the migrated content tree under -// docs/. Output: docs-site/sidebars/documentation.generated.json +// main/. Output: docs-site/sidebars/documentation.generated.json // // Mirrors gen-developer-sidebar.mjs in structure. Only differences are // the source directory and the top-level section list (per PLAN.md 3.1). @@ -14,7 +14,7 @@ import {fileURLToPath} from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const SITE_ROOT = resolve(HERE, '..'); const REPO_ROOT = resolve(SITE_ROOT, '..'); -const SRC = join(REPO_ROOT, 'docs'); +const SRC = join(REPO_ROOT, 'main'); const OUT = join(SITE_ROOT, 'sidebars', 'documentation.generated.json'); const TOP_LEVEL = [ @@ -528,7 +528,7 @@ function buildOverviewSidebar(autoCat) { } function main() { - if (!existsSync(SRC)) { console.error(`docs/ not found at ${SRC}`); process.exit(1); } + if (!existsSync(SRC)) { console.error(`SRC not found at ${SRC}`); process.exit(1); } const sidebar = []; for (const {dir, label} of TOP_LEVEL) {