diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml new file mode 100644 index 0000000..4260c4a --- /dev/null +++ b/.github/workflows/docs-ci.yml @@ -0,0 +1,58 @@ +name: Docs CI + +on: + pull_request: + branches: + - main + paths: + - '.github/workflows/docs*.yml' + - 'docs-sources.yml' + - 'docs/remote-markdown.md' + - 'components/**' + - 'lib/external-docs*.js' + - 'lib/generate-docs-fingerprint.js' + - 'lib/generate-external-docs-manifest.js' + - 'lib/generate-raw-pages.js' + - 'lib/generate-search-index.js' + - 'lib/compare-docs-fingerprint.js' + - 'markdoc/**' + - 'next.config.js' + - 'pages/**' + - 'test/**' + - 'package.json' + - 'package-lock.json' + +permissions: + contents: read + +concurrency: + group: docs-ci-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + checks: + name: Test and build documentation + runs-on: ubuntu-latest + + steps: + - name: Checkout docs repo + uses: actions/checkout@v6.0.2 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v6.4.0 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build static documentation + run: npm run build + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/docs-refresh.yml b/.github/workflows/docs-refresh.yml new file mode 100644 index 0000000..66deb82 --- /dev/null +++ b/.github/workflows/docs-refresh.yml @@ -0,0 +1,129 @@ +name: Refresh external docs + +on: + schedule: + - cron: '17 * * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Compare fingerprints without posting to the Vercel deploy hook.' + required: false + default: true + type: boolean + force_deploy: + description: 'Mark the run as changed even when the deployed fingerprint matches.' + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: docs-refresh + cancel-in-progress: false + +jobs: + check-docs: + name: Check external docs fingerprint + runs-on: ubuntu-latest + + steps: + - name: Checkout docs repo + uses: actions/checkout@v6.0.2 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v6.4.0 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate current docs fingerprint + run: npm run docs:fingerprint -- --output current-docs-fingerprint.json + env: + DOCS_SOURCE_TOKEN: ${{ secrets.DOCS_SOURCE_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} + + - name: Download deployed docs fingerprint + env: + DOCS_FINGERPRINT_URL: ${{ vars.DOCS_FINGERPRINT_URL || 'https://staging.docs.hypercerts.org/docs-fingerprint.json' }} + run: | + set +e + http_code=$(curl -sS -L -w "%{http_code}" \ + -o deployed-docs-fingerprint.json \ + "$DOCS_FINGERPRINT_URL") + curl_status=$? + set -e + + if [ "$curl_status" -ne 0 ]; then + echo "::error::Failed to download deployed docs fingerprint from $DOCS_FINGERPRINT_URL. Refusing to deploy on an unknown diff." + exit 1 + fi + + case "$http_code" in + 200) + ;; + 404) + echo '{}' > deployed-docs-fingerprint.json + ;; + *) + echo "::error::Unexpected HTTP $http_code while downloading $DOCS_FINGERPRINT_URL. Refusing to deploy on an unknown diff." + exit 1 + ;; + esac + + - name: Check if external docs changed + id: diff + env: + FORCE_DEPLOY: ${{ github.event_name == 'workflow_dispatch' && inputs.force_deploy == true }} + run: | + node lib/compare-docs-fingerprint.js \ + current-docs-fingerprint.json \ + deployed-docs-fingerprint.json >> "$GITHUB_OUTPUT" + + if [ "$FORCE_DEPLOY" = "true" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "reason=forced by workflow_dispatch input" >> "$GITHUB_OUTPUT" + fi + + - name: Trigger Vercel deployment + if: steps.diff.outputs.changed == 'true' + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} + VERCEL_DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_URL }} + run: | + if [ "$DRY_RUN" = "true" ]; then + echo "workflow_dispatch dry_run=true; skipping Vercel deploy hook." + exit 0 + fi + + if [ -z "$VERCEL_DEPLOY_HOOK_URL" ]; then + echo "::error::VERCEL_DEPLOY_HOOK_URL is required when external docs have changed. Create a Vercel Deploy Hook for the production branch and store its URL as a GitHub Actions secret." + exit 1 + fi + + curl -fsS -X POST "$VERCEL_DEPLOY_HOOK_URL" + + - name: Write summary + if: always() + env: + DIFF_CHANGED: ${{ steps.diff.outputs.changed || 'unknown' }} + DIFF_REASON: ${{ steps.diff.outputs.reason || 'not computed' }} + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} + CURRENT_FINGERPRINT: ${{ steps.diff.outputs.current_fingerprint || 'n/a' }} + DEPLOYED_FINGERPRINT: ${{ steps.diff.outputs.deployed_fingerprint || 'n/a' }} + run: | + { + echo "### External docs refresh" + echo "" + echo "Changed: $DIFF_CHANGED" + echo "Reason: $DIFF_REASON" + echo "Dry run: $DRY_RUN" + echo "Current: $CURRENT_FINGERPRINT" + echo "Deployed: $DEPLOYED_FINGERPRINT" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 2c8a0f4..06937ba 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ out/ # Generated at build time by lib/generate-*.js — no need to track public/raw/ +public/docs-fingerprint.json public/search-index.json public/sitemap.xml +current-docs-fingerprint.json +deployed-docs-fingerprint.json +lib/external-docs-content.json lib/lastUpdated.json diff --git a/components/CopyRawButton.js b/components/CopyRawButton.js index b8ec7b3..801c362 100644 --- a/components/CopyRawButton.js +++ b/components/CopyRawButton.js @@ -1,18 +1,25 @@ import { useState } from 'react'; import { useRouter } from 'next/router'; -function getRawUrl(currentPath) { +/** + * Map a documentation route to the generated local Markdown artifact used by page actions. + */ +function getGeneratedRawUrl(currentPath) { if (currentPath === '/') return '/raw/index.md'; return `/raw${currentPath}.md`; } +/** + * Render page-level actions for copying or viewing the generated Markdown for the current docs page. + * External docs are included in the same local /raw output as ordinary pages. + */ export function CopyRawButton() { const [copied, setCopied] = useState(false); const [copyError, setCopyError] = useState(false); const [isCopying, setIsCopying] = useState(false); const router = useRouter(); const currentPath = router.asPath.split('#')[0].split('?')[0] || '/'; - const rawUrl = getRawUrl(currentPath); + const rawUrl = getGeneratedRawUrl(currentPath); const handleCopy = async () => { setIsCopying(true); diff --git a/components/MermaidDiagram.js b/components/MermaidDiagram.js new file mode 100644 index 0000000..9c3ceec --- /dev/null +++ b/components/MermaidDiagram.js @@ -0,0 +1,133 @@ +import { useEffect, useMemo, useState } from 'react'; +import { CodeBlock } from './CodeBlock'; + +/** Cached Mermaid module load shared by every diagram on the current page. */ +let mermaidModulePromise; +/** Monotonic counter used to give Mermaid render targets unique DOM identifiers. */ +let diagramIdCounter = 0; + +/** + * Load Mermaid once per browser session while allowing a failed chunk load to be retried. + */ +function getMermaid() { + if (!mermaidModulePromise) { + mermaidModulePromise = import('mermaid') + .then((module) => module.default || module) + .catch((error) => { + mermaidModulePromise = undefined; + throw error; + }); + } + + return mermaidModulePromise; +} + +/** + * Allocate a unique identifier for one Mermaid render target. + */ +function getDiagramId() { + diagramIdCounter += 1; + return `mermaid-diagram-${diagramIdCounter}`; +} + +/** + * Select the Mermaid theme that matches the current documentation color scheme. + */ +function getPreferredMermaidTheme() { + if (typeof document !== 'undefined' && document.documentElement.classList.contains('dark')) { + return 'dark'; + } + + return 'neutral'; +} + +/** + * Render a Mermaid fenced code block as an SVG diagram in the browser. + * Use this for Markdown fences with `mermaid` as the language; invalid diagrams fall back to copyable code with an actionable syntax error. + */ +export function MermaidDiagram({ chart, children }) { + const source = (chart || children || '').trim(); + const diagramId = useMemo(getDiagramId, []); + const [svg, setSvg] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(Boolean(source)); + const [theme, setTheme] = useState('neutral'); + + useEffect(() => { + /** Synchronize the diagram theme when the page color-scheme class changes. */ + const updateTheme = () => setTheme(getPreferredMermaidTheme()); + updateTheme(); + + const observer = new MutationObserver(updateTheme); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); + + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!source) { + setLoading(false); + setError(new Error('No Mermaid source was provided. Add diagram text inside the mermaid code fence.')); + return undefined; + } + + let cancelled = false; + setLoading(true); + setError(null); + setSvg(''); + + /** + * Render the current source with Mermaid unless this React effect has been cancelled. + */ + async function renderDiagram() { + try { + const mermaid = await getMermaid(); + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'strict', + theme, + }); + const result = await mermaid.render(diagramId, source); + if (cancelled) return; + setSvg(result.svg); + } catch (err) { + if (cancelled) return; + setError(err); + } finally { + if (!cancelled) setLoading(false); + } + } + + renderDiagram(); + + return () => { + cancelled = true; + }; + }, [diagramId, source, theme]); + + if (error) { + return ( +
+ Could not render Mermaid diagram. Check the diagram syntax in the source Markdown and reload the page. Details: {error.message} +
+