diff --git a/.github/workflows/docs-refresh-pr-dry-run.yml b/.github/workflows/docs-refresh-pr-dry-run.yml new file mode 100644 index 0000000..4b8eb56 --- /dev/null +++ b/.github/workflows/docs-refresh-pr-dry-run.yml @@ -0,0 +1,103 @@ +name: External docs refresh dry run (temporary) + +on: + pull_request: + branches: + - main + paths: + - '.github/workflows/docs-refresh*.yml' + - 'docs-sources.yml' + - 'docs/remote-markdown.md' + - 'lib/external-docs.js' + - 'lib/generate-docs-fingerprint.js' + - 'lib/generate-external-docs-manifest.js' + - 'lib/compare-docs-fingerprint.js' + - 'package.json' + - 'package-lock.json' + +permissions: + contents: read + +concurrency: + group: external-docs-refresh-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + dry-run: + name: Compare external docs fingerprints without deploying + runs-on: ubuntu-latest + + steps: + - name: Checkout docs repo + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Validate external docs manifest generation + run: npm run generate:external-docs + + - name: Generate current docs fingerprint + run: npm run docs:fingerprint -- --output /tmp/current-docs-fingerprint.json + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Download deployed docs fingerprint + env: + DOCS_FINGERPRINT_URL: https://docs.hypercerts.org/docs-fingerprint.json + run: | + set +e + http_code=$(curl -sS -L -w "%{http_code}" \ + -o /tmp/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. Dry-run cannot compare against deployed state." + exit 1 + fi + + case "$http_code" in + 200) + ;; + 404) + echo '{}' > /tmp/deployed-docs-fingerprint.json + ;; + *) + echo "::error::Unexpected HTTP $http_code while downloading $DOCS_FINGERPRINT_URL. Dry-run cannot compare against deployed state." + exit 1 + ;; + esac + + - name: Compare fingerprints + id: diff + run: | + node lib/compare-docs-fingerprint.js \ + /tmp/current-docs-fingerprint.json \ + /tmp/deployed-docs-fingerprint.json >> "$GITHUB_OUTPUT" + + - name: Report dry-run result + run: | + if [ "${{ steps.diff.outputs.changed }}" = "true" ]; then + echo "::notice::External docs fingerprint differs from the deployed site. The production workflow would trigger the Vercel deploy hook." + else + echo "::notice::External docs fingerprint matches the deployed site. The production workflow would do nothing." + fi + + { + echo "### External docs refresh dry run" + echo "" + echo "This temporary PR-only workflow never calls the Vercel deploy hook." + echo "" + echo "Changed: ${{ steps.diff.outputs.changed || 'unknown' }}" + echo "Reason: ${{ steps.diff.outputs.reason || 'not computed' }}" + echo "Current: ${{ steps.diff.outputs.current_fingerprint || 'n/a' }}" + echo "Deployed: ${{ steps.diff.outputs.deployed_fingerprint || 'n/a' }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/docs-refresh.yml b/.github/workflows/docs-refresh.yml new file mode 100644 index 0000000..f461533 --- /dev/null +++ b/.github/workflows/docs-refresh.yml @@ -0,0 +1,121 @@ +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@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + 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: https://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() + run: | + { + echo "### External docs refresh" + echo "" + echo "Changed: ${{ steps.diff.outputs.changed || 'unknown' }}" + echo "Reason: ${{ steps.diff.outputs.reason || 'not computed' }}" + echo "Dry run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }}" + echo "Current: ${{ steps.diff.outputs.current_fingerprint || 'n/a' }}" + echo "Deployed: ${{ steps.diff.outputs.deployed_fingerprint || 'n/a' }}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 2c8a0f4..58647af 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/external-docs.json +public/docs-fingerprint.json public/search-index.json public/sitemap.xml +current-docs-fingerprint.json +deployed-docs-fingerprint.json lib/lastUpdated.json diff --git a/components/CopyRawButton.js b/components/CopyRawButton.js index b8ec7b3..aa946c1 100644 --- a/components/CopyRawButton.js +++ b/components/CopyRawButton.js @@ -1,18 +1,59 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; -function getRawUrl(currentPath) { +const EXTERNAL_DOCS_MANIFEST_URL = '/external-docs.json'; + +function getGeneratedRawUrl(currentPath) { if (currentPath === '/') return '/raw/index.md'; return `/raw${currentPath}.md`; } -export function CopyRawButton() { +async function getExternalDocRawUrl(externalDoc, signal) { + const response = await fetch(EXTERNAL_DOCS_MANIFEST_URL, { + cache: 'no-store', + signal, + }); + + if (!response.ok) { + throw new Error(`Could not load ${EXTERNAL_DOCS_MANIFEST_URL}. Run npm run generate:external-docs before starting the docs site.`); + } + + const manifest = await response.json(); + const rawUrl = manifest?.sources?.[externalDoc]?.rawUrl; + if (!rawUrl) { + throw new Error(`External docs source "${externalDoc}" does not define a raw Markdown URL.`); + } + + return rawUrl; +} + +/** + * Render page-level actions for copying or viewing the Markdown source for the current docs page. + * Pages that render canonical Markdown from another repository can set `externalDoc` in frontmatter so these actions use the registry source instead of the generated local fallback. + */ +export function CopyRawButton({ frontmatter }) { const [copied, setCopied] = useState(false); const [copyError, setCopyError] = useState(false); const [isCopying, setIsCopying] = useState(false); + const [externalRawUrl, setExternalRawUrl] = useState(null); const router = useRouter(); const currentPath = router.asPath.split('#')[0].split('?')[0] || '/'; - const rawUrl = getRawUrl(currentPath); + const generatedRawUrl = getGeneratedRawUrl(currentPath); + const rawUrl = frontmatter?.rawUrl || externalRawUrl || generatedRawUrl; + + useEffect(() => { + if (!frontmatter?.externalDoc || frontmatter?.rawUrl) { + setExternalRawUrl(null); + return undefined; + } + + const controller = new AbortController(); + getExternalDocRawUrl(frontmatter.externalDoc, controller.signal) + .then(setExternalRawUrl) + .catch(() => setExternalRawUrl(null)); + + return () => controller.abort(); + }, [frontmatter?.externalDoc, frontmatter?.rawUrl]); const handleCopy = async () => { setIsCopying(true); diff --git a/components/Layout.js b/components/Layout.js index a5e0dc4..39601ec 100644 --- a/components/Layout.js +++ b/components/Layout.js @@ -201,7 +201,7 @@ export default function Layout({ children, frontmatter }) {
- {frontmatter && } + {frontmatter && }
{children}
diff --git a/components/MermaidDiagram.js b/components/MermaidDiagram.js new file mode 100644 index 0000000..0809191 --- /dev/null +++ b/components/MermaidDiagram.js @@ -0,0 +1,116 @@ +import { useEffect, useMemo, useState } from 'react'; +import { CodeBlock } from './CodeBlock'; + +let mermaidModulePromise; +let diagramIdCounter = 0; + +function getMermaid() { + if (!mermaidModulePromise) { + mermaidModulePromise = import('mermaid').then((module) => { + const mermaid = module.default || module; + return mermaid; + }); + } + + return mermaidModulePromise; +} + +function getDiagramId() { + diagramIdCounter += 1; + return `mermaid-diagram-${diagramIdCounter}`; +} + +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(() => { + 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(''); + + 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} +

+ +
+ ); + } + + if (loading) { + return ( +
+ Rendering Mermaid diagram… +
+ ); + } + + return ( +
+ ); +} diff --git a/components/RemoteMarkdown.js b/components/RemoteMarkdown.js new file mode 100644 index 0000000..944cd63 --- /dev/null +++ b/components/RemoteMarkdown.js @@ -0,0 +1,345 @@ +import React, { useEffect, useMemo, useState, useContext } from 'react'; +import { useRouter } from 'next/router'; +import Markdoc from '@markdoc/markdoc'; +import tags from '../markdoc/tags'; +import { fence, heading, link } from '../markdoc/nodes'; +import { Callout } from './Callout'; +import { Columns } from './Columns'; +import { Column } from './Column'; +import { Figure } from './Figure'; +import { Heading } from './Heading'; +import { CardLink } from './CardLink'; +import { CodeBlock } from './CodeBlock'; +import { Link } from './Link'; +import { DotPattern } from './DotPattern'; +import { HeroBanner } from './HeroBanner'; +import { CardGrid } from './CardGrid'; +import { MermaidDiagram } from './MermaidDiagram'; + +const SOURCE_ORG = 'hypercerts-org'; +const EXTERNAL_DOCS_MANIFEST_URL = '/external-docs.json'; +let externalDocsManifestPromise = null; +const RemoteMarkdownContext = React.createContext(null); + +const markdocConfig = { + tags, + nodes: { fence, heading, link }, +}; + +/** + * Convert an allowed GitHub Markdown URL into the raw URL used by the browser fetch. + * Only hypercerts-org GitHub sources are allowed so docs pages cannot become an open proxy. + */ +function resolveGitHubMarkdownSource(source) { + const url = new URL(source); + + if (url.hostname === 'github.com') { + const [, owner, repo, marker, ref, ...fileParts] = url.pathname.split('/'); + if (owner?.toLowerCase() !== SOURCE_ORG || marker !== 'blob' || !repo || !ref || fileParts.length === 0) { + throw new Error('Remote docs must use a hypercerts-org GitHub blob URL, for example https://github.com/hypercerts-org/ePDS/blob/main/docs/tutorial.md.'); + } + + const filePath = fileParts.join('/'); + return { + sourceUrl: url.toString(), + rawUrl: `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`, + owner, + repo, + ref, + filePath, + }; + } + + if (url.hostname === 'raw.githubusercontent.com') { + const [, owner, repo, ref, ...fileParts] = url.pathname.split('/'); + if (owner?.toLowerCase() !== SOURCE_ORG || !repo || !ref || fileParts.length === 0) { + throw new Error('Remote docs must use a raw.githubusercontent.com URL under hypercerts-org.'); + } + + const filePath = fileParts.join('/'); + return { + sourceUrl: `https://github.com/${owner}/${repo}/blob/${ref}/${filePath}`, + rawUrl: url.toString(), + owner, + repo, + ref, + filePath, + }; + } + + throw new Error('Remote docs can only be loaded from github.com or raw.githubusercontent.com.'); +} + +function isHttpUrl(source) { + try { + const url = new URL(source); + return url.protocol === 'https:' || url.protocol === 'http:'; + } catch { + return false; + } +} + +function throwIfAborted(signal) { + if (!signal?.aborted) return; + const error = new Error('Aborted'); + error.name = 'AbortError'; + throw error; +} + +async function loadExternalDocsManifest() { + if (!externalDocsManifestPromise) { + externalDocsManifestPromise = fetch(EXTERNAL_DOCS_MANIFEST_URL, { + cache: 'no-store', + }) + .then((response) => { + if (!response.ok) { + throw new Error(`${EXTERNAL_DOCS_MANIFEST_URL} returned ${response.status} ${response.statusText || 'without an external docs manifest'}. Run npm run generate:external-docs before starting the docs site.`); + } + return response.json(); + }) + .catch((error) => { + externalDocsManifestPromise = null; + throw error; + }); + } + + return externalDocsManifestPromise; +} + +function resolveRegisteredMarkdownSource(source, manifest) { + const id = String(source || '').trim(); + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) { + throw new Error('Remote doc source must be a GitHub URL or a docs-sources.yml id like "epds".'); + } + + const entry = manifest?.sources?.[id]; + if (!entry) { + throw new Error(`No external docs source "${id}" was found in ${EXTERNAL_DOCS_MANIFEST_URL}. Add it to docs-sources.yml and rebuild.`); + } + + const [owner, repo] = String(entry.repo || '').split('/'); + if (owner?.toLowerCase() !== SOURCE_ORG || !repo || !entry.branch || !entry.filePath || !entry.rawUrl || !entry.sourceUrl) { + throw new Error(`External docs source "${id}" is incomplete. Set repo, branch, docsPath, and entrypoint in docs-sources.yml, then rebuild.`); + } + + return { + sourceUrl: entry.sourceUrl, + rawUrl: entry.rawUrl, + owner, + repo, + ref: entry.branch, + filePath: entry.filePath, + }; +} + +async function resolveMarkdownSource(source, signal) { + if (isHttpUrl(source)) return resolveGitHubMarkdownSource(source); + const manifest = await loadExternalDocsManifest(); + throwIfAborted(signal); + return resolveRegisteredMarkdownSource(source, manifest); +} + +/** + * Remove optional YAML frontmatter before parsing remote Markdown with Markdoc. + */ +function stripFrontmatter(markdown) { + if (!markdown.startsWith('---\n')) return markdown; + + const end = markdown.indexOf('\n---\n', 4); + if (end === -1) return markdown; + + return markdown.slice(end + 5).trimStart(); +} + +/** + * Resolve relative links in remotely-rendered docs back to the source GitHub repo. + */ +function resolveRemoteHref(href, source) { + if (!href || href.startsWith('#') || href.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(href)) { + return href; + } + + const sourceDir = source.filePath.split('/').slice(0, -1).join('/'); + const basePath = `/${source.owner}/${source.repo}/blob/${source.ref}/${sourceDir ? `${sourceDir}/` : ''}`; + const resolved = new URL(href, `https://github.com${basePath}`); + const [, owner, repo, , ref, ...repoPathParts] = resolved.pathname.split('/'); + const repoPath = repoPathParts.join('/'); + const lastSegment = repoPathParts[repoPathParts.length - 1] || ''; + const mode = /\.[a-z0-9]+$/i.test(lastSegment) ? 'blob' : 'tree'; + + return `https://github.com/${owner}/${repo}/${mode}/${ref}/${repoPath}${resolved.hash}`; +} + +function RemoteLink({ href, children, ...props }) { + const source = useContext(RemoteMarkdownContext); + const resolvedHref = source ? resolveRemoteHref(href, source) : href; + + return ( + + {children} + + ); +} + +const remoteMarkdocComponents = { + Callout, + Columns, + Column, + Figure, + Heading, + CardLink, + CodeBlock, + Fence: CodeBlock, + Link: RemoteLink, + DotPattern, + HeroBanner, + CardGrid, + MermaidDiagram, +}; + +function getRawCacheUrl(currentPath) { + if (currentPath === '/') return '/raw/index.md'; + return `/raw${currentPath.replace(/\.html$/, '')}.md`; +} + +async function fetchMarkdown(url, signal, errorPrefix) { + const response = await fetch(url, { + cache: 'no-store', + signal, + }); + + if (!response.ok) { + throw new Error(`${errorPrefix} returned ${response.status} ${response.statusText || 'without the Markdown file'}.`); + } + + return response.text(); +} + +function transformMarkdown(markdown) { + const ast = Markdoc.parse(stripFrontmatter(markdown)); + return Markdoc.transform(ast, markdocConfig); +} + +/** + * Runtime Markdoc renderer for Markdown files that live in Hypercerts service repos. + * It tries the live GitHub raw source first, then the build-time `/raw` cache, then the wrapped local fallback. + */ +export function RemoteMarkdown({ source, children }) { + const router = useRouter(); + const currentPath = router.asPath.split('#')[0].split('?')[0] || '/'; + const rawCacheUrl = getRawCacheUrl(currentPath); + const [markdown, setMarkdown] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [sourceInfo, setSourceInfo] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setMarkdown(null); + setError(null); + setSourceInfo(null); + setIsLoading(true); + + async function loadRemoteMarkdown() { + let nextSourceInfo; + + try { + nextSourceInfo = await resolveMarkdownSource(source, controller.signal); + setSourceInfo(nextSourceInfo); + } catch (err) { + if (err.name === 'AbortError') return; + setError(err); + return; + } + + let githubError = null; + + try { + const nextMarkdown = await fetchMarkdown(nextSourceInfo.rawUrl, controller.signal, 'GitHub'); + transformMarkdown(nextMarkdown); + setMarkdown(nextMarkdown); + return; + } catch (err) { + if (err.name === 'AbortError') return; + githubError = err; + } + + try { + const cachedMarkdown = await fetchMarkdown(rawCacheUrl, controller.signal, 'Build-time raw cache'); + transformMarkdown(cachedMarkdown); + setMarkdown(cachedMarkdown); + } catch (err) { + if (err.name === 'AbortError') return; + setError(new Error(`${githubError.message} The build-time raw cache also failed: ${err.message}`)); + } finally { + setIsLoading(false); + } + } + + loadRemoteMarkdown().finally(() => { + if (!controller.signal.aborted) setIsLoading(false); + }); + + return () => controller.abort(); + }, [rawCacheUrl, source]); + + const renderedState = useMemo(() => { + if (!markdown || !sourceInfo) return { renderedContent: null, renderError: null }; + + try { + const content = transformMarkdown(markdown); + return { + renderedContent: Markdoc.renderers.react(content, React, { + components: remoteMarkdocComponents, + }), + renderError: null, + }; + } catch (err) { + return { renderedContent: null, renderError: err }; + } + }, [markdown, sourceInfo]); + const { renderedContent, renderError } = renderedState; + const displayError = error || renderError; + + useEffect(() => { + if (!renderedContent) return undefined; + + const frame = window.requestAnimationFrame(() => { + window.dispatchEvent(new CustomEvent('remote-docs:loaded')); + }); + + return () => window.cancelAnimationFrame(frame); + }, [renderedContent]); + + if (renderedContent) { + return ( + + {renderedContent} + + ); + } + + if (isLoading && !displayError) { + return ( +
+ Loading the canonical docs from GitHub… +
+ ); + } + + return ( + <> +
+ Could not load the canonical docs.{' '} + The browser could not load the registered source, the live GitHub raw file, or the build-time raw cache. Showing the local fallback below. Try refreshing, or edit{' '} + {sourceInfo ? ( + the source file + ) : ( + 'the configured source URL' + )}{' '} + if the URL is wrong. Details: {displayError?.message || 'No renderable Markdown source was available.'} +
+ {children} + + ); +} diff --git a/components/TableOfContents.js b/components/TableOfContents.js index 21f78ba..71ca2ac 100644 --- a/components/TableOfContents.js +++ b/components/TableOfContents.js @@ -8,31 +8,44 @@ export function TableOfContents() { const router = useRouter(); const currentPath = router.asPath.split("#")[0].split("?")[0]; - // Extract H2 and H3 headings from the DOM after render + // Extract H2 and H3 headings from the DOM after render and after runtime docs load. useEffect(() => { - if (typeof window === "undefined") return; + if (typeof window === "undefined") return undefined; const article = document.querySelector(".layout-content article"); if (!article) { setHeadings([]); - return; + return undefined; } - const elements = article.querySelectorAll("h2, h3"); - const items = Array.from(elements).map((el) => { - if (!el.id) { - el.id = el.textContent - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/(^-|-$)/g, ""); - } - return { - id: el.id, - text: el.textContent, - level: el.tagName === "H3" ? 3 : 2, - }; - }); - setHeadings(items); - setActiveId(""); + const collectHeadings = () => { + const elements = article.querySelectorAll("h2, h3, h4"); + const items = Array.from(elements).map((el) => { + if (!el.id) { + el.id = el.textContent + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); + } + return { + id: el.id, + text: el.textContent, + level: Number(el.tagName.slice(1)), + }; + }); + setHeadings(items); + setActiveId(""); + }; + + collectHeadings(); + + const observer = new MutationObserver(collectHeadings); + observer.observe(article, { childList: true, subtree: true }); + window.addEventListener("remote-docs:loaded", collectHeadings); + + return () => { + observer.disconnect(); + window.removeEventListener("remote-docs:loaded", collectHeadings); + }; }, [currentPath]); // Scroll spy using scroll position @@ -94,8 +107,8 @@ export function TableOfContents() { { e.preventDefault(); const target = document.getElementById(id); diff --git a/docs-sources.yml b/docs-sources.yml new file mode 100644 index 0000000..8299b40 --- /dev/null +++ b/docs-sources.yml @@ -0,0 +1,8 @@ +sources: + - id: epds + title: ePDS + repo: hypercerts-org/ePDS + branch: main + docsPath: docs + entrypoint: tutorial.md + routeBase: /architecture/epds diff --git a/docs/remote-markdown.md b/docs/remote-markdown.md new file mode 100644 index 0000000..3c0acfe --- /dev/null +++ b/docs/remote-markdown.md @@ -0,0 +1,74 @@ +# Runtime remote Markdown + +Use the `{% remote-doc %}` Markdoc tag when a page should render canonical Markdown from a Hypercerts service repository without copying that Markdown into this documentation repo. + +Register the source once in `docs-sources.yml`: + +```yaml +sources: + - id: epds + title: ePDS + repo: hypercerts-org/ePDS + branch: main + docsPath: docs + entrypoint: tutorial.md + routeBase: /architecture/epds +``` + +Then reference that registry id from the page: + +```md +--- +title: ePDS (extended PDS) +externalDoc: epds +--- + +{% remote-doc source="epds" %} + +A short unavailable-state fallback goes here. Keep it brief so the documentation repo does not become a second source of truth. + +{% /remote-doc %} +``` + +## How it works + +- The site remains a static Next.js export. +- `npm run generate:external-docs` reads `docs-sources.yml` and writes `/external-docs.json` for browser components. +- The browser resolves `{% remote-doc source="epds" %}` through `/external-docs.json`, then fetches the source file from `raw.githubusercontent.com` at runtime first. +- If the live GitHub fetch fails, the browser falls back to the build-time copy in `/raw`. +- Build-time search and raw-page generation use `externalDoc` frontmatter, so search and `/raw` use the canonical Markdown instead of the local fallback. +- The fetched Markdown is parsed with the same Markdoc tags and nodes used by local pages. +- Fenced `mermaid` diagrams render as SVGs in the browser through the Mermaid npm package. +- Relative links in the remote Markdown point back to the source GitHub repository. +- `Copy raw` and `View raw` use the page's registered `externalDoc` source when it is set. +- The wrapped local Markdown is the last-resort fallback content. Keep it to a short unavailable-state message, not a copy of the canonical docs. + +## Scheduled refresh and deploys + +`npm run docs:fingerprint` reads `docs-sources.yml`, fetches GitHub tree metadata for each registered `docsPath`, and writes `public/docs-fingerprint.json` during the static build. The generated file includes a stable `combinedFingerprint`; timestamps are ignored by the comparison script. + +`.github/workflows/docs-refresh.yml` runs hourly and through `workflow_dispatch`: + +1. Generate the current external-docs fingerprint. +2. Download `https://docs.hypercerts.org/docs-fingerprint.json` from the deployed site. +3. Compare only `combinedFingerprint`. +4. If it changed, `POST` to the configured Vercel Deploy Hook. + +Manual `workflow_dispatch` runs default to `dry_run: true`, which compares fingerprints without calling the Vercel hook. Set `dry_run: false` when you want the manual run to deploy. GitHub only accepts `workflow_dispatch` and scheduled runs once the workflow file exists on the default branch. The workflow only treats a deployed-fingerprint `404` as missing first-run state; transient download failures fail the workflow instead of deploying on an unknown diff. + +`.github/workflows/docs-refresh-pr-dry-run.yml` is a temporary PR-only check for this rollout. It runs the same manifest and fingerprint comparison on pull requests, writes a summary, and never calls the Vercel deploy hook. + +Required GitHub Actions secret: + +- `VERCEL_DEPLOY_HOOK_URL` — the Vercel Deploy Hook URL for the production docs branch. + +Optional GitHub Actions secret: + +- `DOCS_SOURCE_TOKEN` — a GitHub token with read access to source repos. Public repos can use the workflow `GITHUB_TOKEN`, but this avoids API rate limits and is required if a source repo becomes private. + +## Constraints + +- Sources must be in `hypercerts-org` GitHub repositories. +- Registry ids must be lowercase, for example `epds` or `certified-group-service`. +- A source needs `entrypoint` when `docsPath` points at a directory and a page renders it through `{% remote-doc %}`. +- The current page renderer is browser-runtime fetching, not server-side rendering. diff --git a/lib/compare-docs-fingerprint.js b/lib/compare-docs-fingerprint.js new file mode 100644 index 0000000..c4f7104 --- /dev/null +++ b/lib/compare-docs-fingerprint.js @@ -0,0 +1,46 @@ +const { readFileSync } = require('fs'); + +/** + * Read a generated docs-fingerprint.json file and return its combined fingerprint. + */ +function readCombinedFingerprint(path, label) { + let parsed; + try { + parsed = JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + throw new Error(`Unable to read ${label} fingerprint at ${path}: ${error.message}`); + } + + return typeof parsed.combinedFingerprint === 'string' ? parsed.combinedFingerprint : ''; +} + +function main() { + const [currentPath, deployedPath] = process.argv.slice(2); + if (!currentPath || !deployedPath) { + throw new Error('Usage: node lib/compare-docs-fingerprint.js '); + } + + const current = readCombinedFingerprint(currentPath, 'current'); + if (!current) { + throw new Error(`Current fingerprint file ${currentPath} does not contain combinedFingerprint.`); + } + + const deployed = readCombinedFingerprint(deployedPath, 'deployed'); + const changed = current !== deployed; + + console.error(changed + ? `External docs changed: deployed=${deployed || ''} current=${current}` + : `External docs unchanged: ${current}`); + + console.log(`changed=${changed ? 'true' : 'false'}`); + console.log(`current_fingerprint=${current}`); + console.log(`deployed_fingerprint=${deployed}`); + console.log(`reason=${changed ? 'combined fingerprint differs' : 'combined fingerprint matches deployed site'}`); +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exit(1); +} diff --git a/lib/external-docs.js b/lib/external-docs.js new file mode 100644 index 0000000..98809e5 --- /dev/null +++ b/lib/external-docs.js @@ -0,0 +1,233 @@ +const { readFileSync } = require('fs'); +const { join, posix } = require('path'); +const yaml = require('js-yaml'); + +const SOURCE_ORG = 'hypercerts-org'; +const REGISTRY_PATH = join(__dirname, '..', 'docs-sources.yml'); +const MARKDOWN_EXTENSIONS = /\.(md|mdoc|mdx)$/i; + +/** + * Return true when a registry path points at a Markdown file instead of a docs directory. + */ +function isMarkdownFilePath(value) { + return MARKDOWN_EXTENSIONS.test(value); +} + +/** + * Encode a GitHub path without collapsing its slash-separated path segments. + */ +function encodeGitHubPath(value) { + return value.split('/').map(encodeURIComponent).join('/'); +} + +/** + * Normalize a docs-sources.yml path and reject absolute paths or parent traversal. + */ +function normalizeRegistryPath(value, fieldName) { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Invalid docs-sources.yml: ${fieldName} must be a non-empty relative path.`); + } + + const normalized = value.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, ''); + const parts = normalized.split('/'); + + if (normalized.startsWith('/') || parts.includes('..') || parts.includes('')) { + throw new Error(`Invalid docs-sources.yml: ${fieldName} must be a relative path without empty segments or "..".`); + } + + return normalized; +} + +/** + * Parse the YAML frontmatter block from a Markdown file. + */ +function parseMarkdownFrontmatter(markdown, label = 'Markdown file') { + const match = markdown.match(/^---\n([\s\S]*?)\n---\n?/); + if (!match) return {}; + + try { + return yaml.load(match[1]) || {}; + } catch (error) { + throw new Error(`Invalid frontmatter in ${label}: ${error.message}`); + } +} + +/** + * Build the raw.githubusercontent.com URL for a registered external docs file. + */ +function buildRawGitHubUrl(source, filePath) { + return `https://raw.githubusercontent.com/${source.owner}/${source.repoName}/${encodeURIComponent(source.branch)}/${encodeGitHubPath(filePath)}`; +} + +/** + * Build the GitHub browser URL for a registered external docs path. + */ +function buildGitHubSourceUrl(source, filePath, mode = 'blob') { + return `https://github.com/${source.owner}/${source.repoName}/${mode}/${encodeURIComponent(source.branch)}/${encodeGitHubPath(filePath)}`; +} + +/** + * Return the single Markdown file that a remote-doc page should render, when configured. + */ +function getEntrypointPath(source) { + if (source.entrypoint) return posix.join(source.docsPath, source.entrypoint); + if (isMarkdownFilePath(source.docsPath)) return source.docsPath; + return null; +} + +/** + * Validate and normalize one source entry from docs-sources.yml. + */ +function normalizeSource(rawSource, index) { + const label = `sources[${index}]`; + + if (!rawSource || typeof rawSource !== 'object' || Array.isArray(rawSource)) { + throw new Error(`Invalid docs-sources.yml: ${label} must be an object.`); + } + + const id = rawSource.id; + if (typeof id !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(id)) { + throw new Error(`Invalid docs-sources.yml: ${label}.id must be a lowercase id like "epds" or "certified-group-service".`); + } + + const title = rawSource.title; + if (typeof title !== 'string' || title.trim() === '') { + throw new Error(`Invalid docs-sources.yml: source "${id}" must set a human-readable title.`); + } + + const repo = rawSource.repo; + if (typeof repo !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { + throw new Error(`Invalid docs-sources.yml: source "${id}" repo must look like "hypercerts-org/ePDS".`); + } + + const [owner, repoName] = repo.split('/'); + if (owner.toLowerCase() !== SOURCE_ORG) { + throw new Error(`Invalid docs-sources.yml: source "${id}" must use a ${SOURCE_ORG} repository so browser-rendered docs cannot load arbitrary hosts.`); + } + + const branch = rawSource.branch; + if (typeof branch !== 'string' || branch.trim() === '') { + throw new Error(`Invalid docs-sources.yml: source "${id}" must set the branch to read, for example "main".`); + } + + const docsPath = normalizeRegistryPath(rawSource.docsPath, `source "${id}" docsPath`); + const entrypoint = rawSource.entrypoint + ? normalizeRegistryPath(rawSource.entrypoint, `source "${id}" entrypoint`) + : ''; + + const routeBase = rawSource.routeBase || rawSource.route || ''; + if (routeBase && (typeof routeBase !== 'string' || !routeBase.startsWith('/'))) { + throw new Error(`Invalid docs-sources.yml: source "${id}" routeBase must start with "/".`); + } + + const source = { + id, + title: title.trim(), + repo, + owner, + repoName, + branch: branch.trim(), + docsPath, + entrypoint, + routeBase, + }; + + const entrypointPath = getEntrypointPath(source); + return { + ...source, + entrypointPath, + rawUrl: entrypointPath ? buildRawGitHubUrl(source, entrypointPath) : '', + sourceUrl: entrypointPath + ? buildGitHubSourceUrl(source, entrypointPath, 'blob') + : buildGitHubSourceUrl(source, docsPath, 'tree'), + }; +} + +/** + * Load and validate the central external docs registry. + */ +function loadExternalDocSources(registryPath = REGISTRY_PATH) { + let document; + try { + document = yaml.load(readFileSync(registryPath, 'utf8')) || {}; + } catch (error) { + throw new Error(`Unable to read docs source registry at ${registryPath}: ${error.message}`); + } + + if (!Array.isArray(document.sources)) { + throw new Error('Invalid docs-sources.yml: expected a top-level "sources" array.'); + } + + const seen = new Set(); + return document.sources.map((source, index) => { + const normalized = normalizeSource(source, index); + if (seen.has(normalized.id)) { + throw new Error(`Invalid docs-sources.yml: duplicate source id "${normalized.id}".`); + } + seen.add(normalized.id); + return normalized; + }); +} + +/** + * Find one registered external docs source by id. + */ +function findExternalDocSource(id, sources = loadExternalDocSources()) { + return sources.find((source) => source.id === id) || null; +} + +/** + * Convert a registry source into the public manifest consumed by browser components. + */ +function sourceToPublicManifestEntry(source) { + return { + id: source.id, + title: source.title, + repo: source.repo, + branch: source.branch, + docsPath: source.docsPath, + entrypoint: source.entrypoint || undefined, + routeBase: source.routeBase || undefined, + sourceUrl: source.sourceUrl, + rawUrl: source.rawUrl || undefined, + filePath: source.entrypointPath || undefined, + }; +} + +/** + * Resolve a page frontmatter declaration to the raw Markdown URL that build scripts should fetch. + */ +function resolveFrontmatterRawSource(frontmatter, sources = loadExternalDocSources()) { + if (frontmatter.externalDoc) { + const id = String(frontmatter.externalDoc); + const source = findExternalDocSource(id, sources); + if (!source) { + throw new Error(`Unknown externalDoc "${id}". Add it to docs-sources.yml or remove the externalDoc frontmatter.`); + } + if (!source.rawUrl) { + throw new Error(`External doc "${id}" does not define a renderable Markdown file. Set entrypoint in docs-sources.yml or point docsPath at a Markdown file.`); + } + return { rawUrl: source.rawUrl, label: `externalDoc "${id}"` }; + } + + if (frontmatter.rawUrl) { + return { rawUrl: String(frontmatter.rawUrl), label: 'rawUrl frontmatter' }; + } + + return null; +} + +module.exports = { + REGISTRY_PATH, + SOURCE_ORG, + buildGitHubSourceUrl, + buildRawGitHubUrl, + encodeGitHubPath, + findExternalDocSource, + getEntrypointPath, + isMarkdownFilePath, + loadExternalDocSources, + parseMarkdownFrontmatter, + resolveFrontmatterRawSource, + sourceToPublicManifestEntry, +}; diff --git a/lib/generate-docs-fingerprint.js b/lib/generate-docs-fingerprint.js new file mode 100644 index 0000000..2028953 --- /dev/null +++ b/lib/generate-docs-fingerprint.js @@ -0,0 +1,159 @@ +const crypto = require('crypto'); +const { mkdirSync, writeFileSync } = require('fs'); +const { dirname, join } = require('path'); +const { loadExternalDocSources } = require('./external-docs'); + +const DEFAULT_OUTPUT = join(__dirname, '..', 'public', 'docs-fingerprint.json'); +const GITHUB_API_VERSION = '2022-11-28'; + +/** + * Serialize values with stable object-key ordering so hashes do not depend on construction order. + */ +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + + return JSON.stringify(value); +} + +/** + * Compute a sha256 digest with the prefix used in generated fingerprint files. + */ +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +/** + * Read a --output value from the CLI arguments. + */ +function getOutputPath(argv) { + const outputIndex = argv.findIndex((arg) => arg === '--output' || arg === '-o'); + if (outputIndex !== -1) { + const output = argv[outputIndex + 1]; + if (!output) throw new Error('Missing value after --output.'); + return output; + } + + const inlineOutput = argv.find((arg) => arg.startsWith('--output=')); + if (inlineOutput) return inlineOutput.slice('--output='.length); + + return DEFAULT_OUTPUT; +} + +/** + * Fetch JSON from the GitHub REST API with optional authentication for private repos or rate limits. + */ +async function fetchGitHubJson(url, token) { + const headers = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': GITHUB_API_VERSION, + 'User-Agent': 'hypercerts-docs-refresh', + }; + + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetch(url, { headers }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`${url} returned ${response.status} ${response.statusText || ''}. Check repo, branch, docsPath, and DOCS_SOURCE_TOKEN. ${body}`.trim()); + } + + return response.json(); +} + +/** + * Fetch the Git tree for a source branch and return blob entries under docsPath. + */ +async function fetchSourceEntries(source, token) { + const treeUrl = `https://api.github.com/repos/${source.owner}/${source.repoName}/git/trees/${encodeURIComponent(source.branch)}?recursive=1`; + const tree = await fetchGitHubJson(treeUrl, token); + + if (tree.truncated) { + throw new Error(`GitHub returned a truncated tree for ${source.repo}@${source.branch}. Narrow source "${source.id}" docsPath or split it into smaller sources before fingerprinting.`); + } + + const root = source.docsPath.replace(/\/$/, ''); + const prefix = `${root}/`; + const files = (tree.tree || []) + .filter((entry) => entry.type === 'blob' && (entry.path === root || entry.path.startsWith(prefix))) + .map((entry) => ({ + path: entry.path, + sha: entry.sha, + size: entry.size || 0, + })) + .sort((a, b) => a.path.localeCompare(b.path)); + + if (files.length === 0) { + throw new Error(`No files found for source "${source.id}" at ${source.repo}@${source.branch}:${source.docsPath}. Check docs-sources.yml.`); + } + + return files; +} + +/** + * Compute the per-source and combined fingerprints for all registered external docs. + */ +async function generateFingerprint() { + const sources = loadExternalDocSources(); + const token = process.env.DOCS_SOURCE_TOKEN || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ''; + const outputSources = {}; + const stableSources = []; + + for (const source of sources) { + const files = await fetchSourceEntries(source, token); + const stableSource = { + id: source.id, + repo: source.repo, + branch: source.branch, + docsPath: source.docsPath, + entrypoint: source.entrypoint || '', + routeBase: source.routeBase || '', + files, + }; + const fingerprint = sha256(stableStringify(stableSource)); + + outputSources[source.id] = { + title: source.title, + repo: source.repo, + branch: source.branch, + docsPath: source.docsPath, + entrypoint: source.entrypoint || undefined, + routeBase: source.routeBase || undefined, + fileCount: files.length, + fingerprint, + files, + }; + stableSources.push({ ...stableSource, fingerprint }); + } + + const combinedFingerprint = sha256(stableStringify({ schemaVersion: 1, sources: stableSources })); + + return { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + sources: outputSources, + combinedFingerprint, + }; +} + +async function main() { + const output = getOutputPath(process.argv.slice(2)); + const fingerprint = await generateFingerprint(); + + mkdirSync(dirname(output), { recursive: true }); + writeFileSync(output, `${JSON.stringify(fingerprint, null, 2)}\n`); + console.log(`Generated docs fingerprint ${fingerprint.combinedFingerprint} → ${output}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/lib/generate-external-docs-manifest.js b/lib/generate-external-docs-manifest.js new file mode 100644 index 0000000..68e8743 --- /dev/null +++ b/lib/generate-external-docs-manifest.js @@ -0,0 +1,27 @@ +const { mkdirSync, writeFileSync } = require('fs'); +const { dirname, join } = require('path'); +const { + loadExternalDocSources, + sourceToPublicManifestEntry, +} = require('./external-docs'); + +const OUTPUT = join(__dirname, '..', 'public', 'external-docs.json'); + +/** + * Generate the browser-readable manifest used to resolve remote-doc registry ids. + */ +function generateExternalDocsManifest() { + const sources = loadExternalDocSources(); + const manifest = { + schemaVersion: 1, + sources: Object.fromEntries( + sources.map((source) => [source.id, sourceToPublicManifestEntry(source)]) + ), + }; + + mkdirSync(dirname(OUTPUT), { recursive: true }); + writeFileSync(OUTPUT, `${JSON.stringify(manifest, null, 2)}\n`); + console.log(`Generated external docs manifest for ${sources.length} source${sources.length === 1 ? '' : 's'}`); +} + +generateExternalDocsManifest(); diff --git a/lib/generate-raw-pages.js b/lib/generate-raw-pages.js index bda1cbc..8e1d68b 100644 --- a/lib/generate-raw-pages.js +++ b/lib/generate-raw-pages.js @@ -7,6 +7,11 @@ const { mkdirSync, } = require('fs'); const { dirname, join, relative } = require('path'); +const { + loadExternalDocSources, + parseMarkdownFrontmatter, + resolveFrontmatterRawSource, +} = require('./external-docs'); const PAGES_DIR = join(__dirname, '..', 'pages'); const OUTPUT_DIR = join(__dirname, '..', 'public', 'raw'); @@ -33,15 +38,39 @@ function getRawOutputPath(filePath) { return join(OUTPUT_DIR, outputRel); } -rmSync(OUTPUT_DIR, { recursive: true, force: true }); -mkdirSync(OUTPUT_DIR, { recursive: true }); +async function getRawMarkdown(file, sources) { + const localMarkdown = readFileSync(file, 'utf-8'); + const pagePath = relative(PAGES_DIR, file); + const frontmatter = parseMarkdownFrontmatter(localMarkdown, pagePath); + const remoteSource = resolveFrontmatterRawSource(frontmatter, sources); + + if (!remoteSource) return localMarkdown; -const files = walkDir(PAGES_DIR); + const response = await fetch(remoteSource.rawUrl, { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`Failed to fetch ${remoteSource.label} for ${pagePath}: ${remoteSource.rawUrl} returned ${response.status} ${response.statusText || ''}`.trim()); + } + + return response.text(); +} + +async function main() { + rmSync(OUTPUT_DIR, { recursive: true, force: true }); + mkdirSync(OUTPUT_DIR, { recursive: true }); + + const files = walkDir(PAGES_DIR); + const sources = loadExternalDocSources(); + + for (const file of files) { + const outputPath = getRawOutputPath(file); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, await getRawMarkdown(file, sources)); + } -for (const file of files) { - const outputPath = getRawOutputPath(file); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, readFileSync(file, 'utf-8')); + console.log(`Generated raw markdown files for ${files.length} pages`); } -console.log(`Generated raw markdown files for ${files.length} pages`); +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/lib/generate-search-index.js b/lib/generate-search-index.js index e6afca6..45be89d 100644 --- a/lib/generate-search-index.js +++ b/lib/generate-search-index.js @@ -1,5 +1,10 @@ const { readdirSync, statSync, readFileSync, writeFileSync } = require("fs"); const { join, relative } = require("path"); +const { + loadExternalDocSources, + parseMarkdownFrontmatter, + resolveFrontmatterRawSource, +} = require("./external-docs"); const PAGES_DIR = join(__dirname, "..", "pages"); const OUTPUT = join(__dirname, "..", "public", "search-index.json"); @@ -18,18 +23,8 @@ function walkDir(dir) { return results; } -function extractFrontmatter(content) { - const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); - if (!fmMatch) return { title: "", description: "" }; - - const frontmatter = fmMatch[1]; - const titleMatch = frontmatter.match(/^title:\s*(.+)$/m); - const descMatch = frontmatter.match(/^description:\s*(.+)$/m); - - return { - title: titleMatch ? titleMatch[1].trim() : "", - description: descMatch ? descMatch[1].trim() : "", - }; +function getStringFrontmatterValue(frontmatter, key) { + return frontmatter[key] ? String(frontmatter[key]) : ""; } function extractHeadings(content) { @@ -100,40 +95,63 @@ function getSection(path) { return "Other"; } -const files = walkDir(PAGES_DIR); -const index = []; +async function getIndexContent(file, localContent, remoteSource) { + if (!remoteSource) return localContent; -for (const file of files) { - const content = readFileSync(file, "utf-8"); - const rel = "/" + relative(PAGES_DIR, file).replace(/\.md$/, ""); - const path = rel === "/index" ? "/" : rel; + const response = await fetch(remoteSource.rawUrl, { cache: "no-store" }); + if (!response.ok) { + throw new Error(`Failed to fetch ${remoteSource.label} for search index ${relative(PAGES_DIR, file)}: ${remoteSource.rawUrl} returned ${response.status} ${response.statusText || ""}`.trim()); + } - const { title, description } = extractFrontmatter(content); - const headings = extractHeadings(content); - const section = getSection(path); + return response.text(); +} - // For the home page, only include title (body is mostly card markup) - let body = ""; - if (path !== "/") { - body = stripMarkdown(content); - if (body.length > MAX_BODY_LENGTH) { - body = body.substring(0, MAX_BODY_LENGTH); +async function main() { + const files = walkDir(PAGES_DIR); + const sources = loadExternalDocSources(); + const index = []; + + for (const file of files) { + const localContent = readFileSync(file, "utf-8"); + const rel = "/" + relative(PAGES_DIR, file).replace(/\.md$/, ""); + const path = rel === "/index" ? "/" : rel; + + const frontmatter = parseMarkdownFrontmatter(localContent, relative(PAGES_DIR, file)); + const title = getStringFrontmatterValue(frontmatter, "title"); + const description = getStringFrontmatterValue(frontmatter, "description"); + const remoteSource = resolveFrontmatterRawSource(frontmatter, sources); + const content = await getIndexContent(file, localContent, remoteSource); + const headings = extractHeadings(content); + const section = getSection(path); + + // For the home page, only include title (body is mostly card markup) + let body = ""; + if (path !== "/") { + body = stripMarkdown(content); + if (body.length > MAX_BODY_LENGTH) { + body = body.substring(0, MAX_BODY_LENGTH); + } } + + index.push({ + path, + title, + description: description || "", + section, + headings, + body, + }); } - index.push({ - path, - title, - description: description || "", - section, - headings, - body, - }); + writeFileSync(OUTPUT, JSON.stringify(index, null, 2) + "\n"); + console.log( + `Generated search index for ${index.length} pages (${ + Buffer.byteLength(JSON.stringify(index)) / 1024 + } KB)` + ); } -writeFileSync(OUTPUT, JSON.stringify(index, null, 2) + "\n"); -console.log( - `Generated search index for ${index.length} pages (${ - Buffer.byteLength(JSON.stringify(index)) / 1024 - } KB)` -); +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/markdoc/nodes/fence.markdoc.js b/markdoc/nodes/fence.markdoc.js index f154ba3..8595669 100644 --- a/markdoc/nodes/fence.markdoc.js +++ b/markdoc/nodes/fence.markdoc.js @@ -1,3 +1,12 @@ +import { Tag } from "@markdoc/markdoc"; + +function getFenceLanguage(language) { + return String(language || "") + .trim() + .toLowerCase() + .split(/\s+/)[0]; +} + const fence = { render: "CodeBlock", attributes: { @@ -14,6 +23,17 @@ const fence = { default: true, }, }, + transform(node, config) { + const attributes = node.transformAttributes(config); + + if (getFenceLanguage(attributes.language) === "mermaid") { + return new Tag("MermaidDiagram", { + chart: attributes.content, + }); + } + + return new Tag("CodeBlock", attributes); + }, }; export default fence; diff --git a/markdoc/tags/index.js b/markdoc/tags/index.js index c494cae..00e6429 100644 --- a/markdoc/tags/index.js +++ b/markdoc/tags/index.js @@ -5,6 +5,7 @@ import figure from './figure.markdoc'; import cardLink from './card-link.markdoc'; import cardGrid from './card-grid.markdoc'; import heroBanner from './hero-banner.markdoc'; +import remoteDoc from './remote-doc.markdoc'; export default { callout, @@ -14,4 +15,5 @@ export default { 'card-link': cardLink, 'card-grid': cardGrid, 'hero-banner': heroBanner, + 'remote-doc': remoteDoc, }; diff --git a/markdoc/tags/remote-doc.markdoc.js b/markdoc/tags/remote-doc.markdoc.js new file mode 100644 index 0000000..b4403e5 --- /dev/null +++ b/markdoc/tags/remote-doc.markdoc.js @@ -0,0 +1,13 @@ +/** + * Markdoc tag for rendering canonical Markdown from a registered Hypercerts GitHub repo at browser runtime. + * The source can be a docs-sources.yml id such as "epds" or a direct hypercerts-org GitHub Markdown URL. + */ +module.exports = { + render: 'RemoteMarkdown', + attributes: { + source: { + type: String, + required: true, + }, + }, +}; diff --git a/package-lock.json b/package-lock.json index ceb1af4..a739621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,39 @@ "@markdoc/next.js": "^0.5.0", "@vercel/analytics": "^1.6.1", "flexsearch": "^0.8.212", + "js-yaml": "^4.1.1", + "mermaid": "^11.15.0", "next": "^16.1.6", "prism-react-renderer": "^2.4.1", "react": "^19.2.4", "react-dom": "^19.2.4" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -28,6 +55,23 @@ "tslib": "^2.4.0" } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.3.tgz", + "integrity": "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -533,6 +577,15 @@ "react": "*" } }, + "node_modules/@mermaid-js/parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", + "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.1" + } + }, "node_modules/@next/env": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", @@ -676,6 +729,265 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", @@ -707,6 +1019,23 @@ "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vercel/analytics": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", @@ -795,6 +1124,538 @@ "node": ">=6" } }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -805,6 +1666,25 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz", + "integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/es-toolkit": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/flexsearch": { "version": "0.8.212", "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.8.212.tgz", @@ -833,6 +1713,43 @@ ], "license": "Apache-2.0" }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -845,6 +1762,89 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mermaid": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", + "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.1", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.1", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "es-toolkit": "^1.45.1", + "katex": "^0.16.25", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -916,12 +1916,40 @@ } } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "license": "MIT" + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -984,6 +2012,36 @@ "react": "^19.2.4" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1080,11 +2138,48 @@ } } }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" + }, + "node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } } } } diff --git a/package.json b/package.json index da60b12..def3778 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,11 @@ "private": true, "description": "Hypercerts Protocol Documentation", "scripts": { - "dev": "node lib/generate-search-index.js && node lib/generate-last-updated.js && node lib/generate-sitemap.js && node lib/generate-raw-pages.js && next dev --webpack", - "build": "node lib/generate-search-index.js && node lib/generate-last-updated.js && node lib/generate-sitemap.js && node lib/generate-raw-pages.js && next build --webpack", + "generate:external-docs": "node lib/generate-external-docs-manifest.js", + "docs:fingerprint": "node lib/generate-docs-fingerprint.js", + "generate": "npm run generate:external-docs && node lib/generate-search-index.js && node lib/generate-last-updated.js && node lib/generate-sitemap.js && node lib/generate-raw-pages.js && npm run docs:fingerprint", + "dev": "npm run generate && next dev --webpack", + "build": "npm run generate && next build --webpack", "start": "next start" }, "dependencies": { @@ -13,6 +16,8 @@ "@markdoc/next.js": "^0.5.0", "@vercel/analytics": "^1.6.1", "flexsearch": "^0.8.212", + "js-yaml": "^4.1.1", + "mermaid": "^11.15.0", "next": "^16.1.6", "prism-react-renderer": "^2.4.1", "react": "^19.2.4", diff --git a/pages/_app.js b/pages/_app.js index 3422f00..6c6168f 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -11,6 +11,8 @@ import { Link } from '../components/Link'; import { DotPattern } from '../components/DotPattern'; import { HeroBanner } from '../components/HeroBanner'; import { CardGrid } from '../components/CardGrid'; +import { RemoteMarkdown } from '../components/RemoteMarkdown'; +import { MermaidDiagram } from '../components/MermaidDiagram'; import { Analytics } from '@vercel/analytics/next'; const components = { @@ -26,6 +28,9 @@ const components = { DotPattern, HeroBanner, CardGrid, + RemoteMarkdown, + RemoteDoc: RemoteMarkdown, + MermaidDiagram, }; export default function App({ Component, pageProps }) { diff --git a/pages/architecture/epds.md b/pages/architecture/epds.md index a2cfb61..172fa24 100644 --- a/pages/architecture/epds.md +++ b/pages/architecture/epds.md @@ -1,335 +1,15 @@ --- title: ePDS (extended PDS) description: How the ePDS adds email/OTP login on top of AT Protocol without changing the standard OAuth flow for apps. +externalDoc: epds --- -# ePDS (extended PDS) +{% remote-doc source="epds" %} -The ePDS adds email-based, passwordless sign-in on top of a standard AT Protocol PDS. Users enter their email, receive a one-time code, and end up with a normal AT Protocol session tied to a DID. +# ePDS docs unavailable -Certified operates production, staging, and test ePDS instances. See [Certified services](/reference/certified-pdss) for the current hostnames and guidance on which to use in which scenario. +The canonical ePDS docs could not be loaded from GitHub or the build-time raw cache. -For applications, the important part is that ePDS still finishes by issuing a standard AT Protocol authorization code. In practice, this means you can integrate it with [`@atproto/oauth-client-node`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-node). +View the source directly: https://github.com/hypercerts-org/ePDS/blob/main/docs/tutorial.md -## System overview - -```text -Client App - -> starts AT Protocol OAuth against the PDS - -PDS Core - -> remains the OAuth issuer and token endpoint - -> advertises the Auth Service as the authorization endpoint - -Auth Service - -> collects the user's email or OTP - -> verifies the user - -> returns control to PDS Core via signed callback - -PDS Core - -> issues a normal authorization code - -Client App - -> exchanges the code for tokens -``` - -The PDS remains the OAuth issuer and token endpoint. The main difference is that the authorization step happens on the ePDS Auth Service, which handles the email and OTP flow before returning control to the PDS. - -## Integrating with `@atproto/oauth-client-node` - -ePDS works with the standard AT Protocol OAuth client libraries. The main ePDS-specific behavior is how you shape the authorization URL before redirecting the user. - -### Flow 1: your app collects the email - -In Flow 1, your app has its own email field. Start OAuth normally, then add `login_hint=` to the authorization URL before redirecting the user. - -```ts -import { NodeOAuthClient } from '@atproto/oauth-client-node' - -const oauthClient = new NodeOAuthClient({ - clientMetadata: { - client_id: 'https://yourapp.example.com/client-metadata.json', - client_name: 'Your App', - client_uri: 'https://yourapp.example.com', - redirect_uris: ['https://yourapp.example.com/api/oauth/callback'], - scope: 'atproto transition:generic', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - token_endpoint_auth_method: 'none', - dpop_bound_access_tokens: true, - }, - stateStore, - sessionStore, -}) - -const url = await oauthClient.authorize('alice.certified.one', { - scope: 'atproto transition:generic', -}) - -// ePDS-specific customization happens here. -const authUrl = new URL(url) -authUrl.searchParams.set('login_hint', email) -authUrl.searchParams.set('epds_handle_mode', 'picker-with-random') - -return authUrl.toString() -``` - -{% callout type="warning" %} -Do not put an email address into the PAR body as `login_hint`. For ePDS, add `login_hint` to the authorization URL instead. -{% /callout %} - -With `login_hint` set, the user lands directly on the OTP entry step instead of first seeing an email form on ePDS. - -### Flow 2: ePDS collects the email - -In Flow 2, your app just shows a "Sign in" button. Start OAuth normally and redirect the user to the authorization URL without `login_hint`. - -```ts -import { NodeOAuthClient } from '@atproto/oauth-client-node' - -const oauthClient = new NodeOAuthClient({ - clientMetadata: { - client_id: 'https://yourapp.example.com/client-metadata.json', - client_name: 'Your App', - client_uri: 'https://yourapp.example.com', - redirect_uris: ['https://yourapp.example.com/api/oauth/callback'], - scope: 'atproto transition:generic', - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - token_endpoint_auth_method: 'none', - dpop_bound_access_tokens: true, - }, - stateStore, - sessionStore, -}) - -const url = await oauthClient.authorize('alice.certified.one', { - scope: 'atproto transition:generic', -}) - -const authUrl = new URL(url) -authUrl.searchParams.set('epds_handle_mode', 'picker') - -return authUrl.toString() -``` - -Without `login_hint`, ePDS renders its own email form and takes the user through the rest of the OTP flow. - -### Callback handling - -Callback handling stays standard. Once the user finishes on ePDS, your callback handler receives a normal authorization code and hands it back to `oauth-client-node`. - -```ts -const result = await oauthClient.callback(params) - -const session = result.session -const did = session.did -``` - -## Handle modes - -Handle mode controls what happens when a brand new user needs a handle during signup. - -| Mode | Behavior | -|------|----------| -| `picker-with-random` | Show the handle picker with a "Generate random" option. | -| `picker` | Show the handle picker without a random option. | -| `random` | Skip the picker and assign a random handle automatically. | - -Handle mode is resolved in this order: - -1. `epds_handle_mode` query param on the authorization URL -2. `epds_handle_mode` in client metadata -3. The ePDS instance default (`EPDS_DEFAULT_HANDLE_MODE`) - -This only affects new account creation. Existing users keep their current handle and skip this step. - -## Client metadata - -Your client metadata file is a public JSON document served over HTTPS. Its URL is also your `client_id`. - -### Bare-bones example - -```json -{ - "client_id": "https://yourapp.example.com/client-metadata.json", - "client_name": "Your App", - "client_uri": "https://yourapp.example.com", - "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "token_endpoint_auth_method": "none", - "dpop_bound_access_tokens": true -} -``` - -### Full config example - -```json -{ - "client_id": "https://yourapp.example.com/client-metadata.json", - "client_name": "Your App", - "client_uri": "https://yourapp.example.com", - "logo_uri": "https://yourapp.example.com/logo.png", - "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "token_endpoint_auth_method": "none", - "dpop_bound_access_tokens": true, - "brand_color": "#0f172a", - "background_color": "#ffffff", - "email_template_uri": "https://yourapp.example.com/email-template.html", - "email_subject_template": "{{code}} - Your {{app_name}} code", - "branding": { - "css": "body { background: #0f172a; color: #e2e8f0; }" - }, - "epds_handle_mode": "picker-with-random" -} -``` - -The extra branding fields customize the hosted login and email experience. `epds_handle_mode` sets your preferred handle mode for new users unless you override it on the authorization URL. - -## Branding and customization - -### How branding works - -ePDS reads branding settings from your app's `client-metadata.json`, using the OAuth `client_id` to look it up. Standard metadata fields like `logo_uri`, `brand_color`, `background_color`, `email_template_uri`, and `email_subject_template` customize the hosted login and email experience. - -Trusted clients can go further by adding custom CSS in client metadata under `branding.css`: - -```json -{ - "branding": { - "css": "body { background: #0f172a; color: #e2e8f0; }" - } -} -``` - -When the client is trusted, ePDS injects that CSS into its hosted auth pages and the stock consent page. - -{% callout type="warning" %} -Trust is checked against the exact `client_id`. - -The `client_id` you send during OAuth, the `client_id` inside `client-metadata.json`, and the entry in `PDS_OAUTH_TRUSTED_CLIENTS` must all be identical. - -For example, if your client metadata says `"client_id": "https://hypercerts-scaffold.vercel.app/client-metadata.json"`, then `PDS_OAUTH_TRUSTED_CLIENTS` must contain `https://hypercerts-scaffold.vercel.app/client-metadata.json` — not just `https://hypercerts-scaffold.vercel.app`. See the [Scaffold Starter App](/tools/scaffold) for a concrete example of a client serving metadata from `/client-metadata.json`. -{% /callout %} - -### Client metadata branding fields - -These fields are the main branding controls exposed through client metadata: - -| Field | What it affects | -|------|------------------| -| `logo_uri` | App logo shown in hosted auth and email flows | -| `brand_color` | Primary brand color used by hosted screens | -| `background_color` | Background color for hosted screens | -| `email_template_uri` | Custom HTML template for OTP emails | -| `email_subject_template` | Subject line template for OTP emails | -| `branding.css` | Custom CSS for trusted clients | - -### CSS injection for trusted clients - -Custom CSS is only applied for clients whose exact `client_id` appears in `PDS_OAUTH_TRUSTED_CLIENTS`. When present, ePDS injects a `` tag closure, and updates the page's CSP `style-src` directive with a SHA-256 hash for the injected stylesheet. - -This gives operators a safety boundary: untrusted clients never get CSS injection, even if their metadata contains branding CSS. - -### Where branding appears - -The send-OTP and initial-OTP screens are two states of the same auth-service route: `https://auth.epds1.test.certified.app/oauth/authorize`. - -| Surface | URL | Supports branding | -|---|---|---| -| Send OTP | `https://auth.epds1.test.certified.app/oauth/authorize` | Metadata fields + trusted-client CSS | -| Initial OTP | `https://auth.epds1.test.certified.app/oauth/authorize` | Metadata fields + trusted-client CSS | -| Choose handle | `https://auth.epds1.test.certified.app/auth/choose-handle` | Metadata fields + trusted-client CSS | -| Recovery | `https://auth.epds1.test.certified.app/auth/recover` | Metadata fields + trusted-client CSS | -| Consent page | `https://epds1.test.certified.app/oauth/authorize` | Trusted-client CSS | - -### Examples - -#### Send OTP - -{% columns %} -{% column %} -Stock - -![Stock send OTP screen](/images/epds/send-otp-stock.png) -{% /column %} -{% column %} -CSS injected - -![CSS-injected send OTP screen](/images/epds/send-otp-css-injected.png) -{% /column %} -{% /columns %} - -#### Initial OTP - -{% columns %} -{% column %} -Stock - -![Stock initial OTP screen](/images/epds/initial-otp-stock.png) -{% /column %} -{% column %} -CSS injected - -![CSS-injected initial OTP screen](/images/epds/initial-otp-css-injected.png) -{% /column %} -{% /columns %} - -#### Choose handle - -{% columns %} -{% column %} -Stock - -![Stock choose handle screen](/images/epds/choose-handle-stock.png) -{% /column %} -{% column %} -CSS injected - -![CSS-injected choose handle screen](/images/epds/choose-handle-css-injected.png) -{% /column %} -{% /columns %} - -#### Consent page - -{% columns %} -{% column %} -Stock - -![Stock consent page](/images/epds/consent-page-stock.png) -{% /column %} -{% column %} -CSS injected - -![CSS-injected consent page](/images/epds/consent-page-css-injected.png) -{% /column %} -{% /columns %} - -#### Recovery - -{% columns %} -{% column %} -Stock - -![Stock recovery screen](/images/epds/recovery-stock.png) -{% /column %} -{% column %} -CSS injected - -![CSS-injected recovery screen](/images/epds/recovery-css-injected.png) -{% /column %} -{% /columns %} - -## Further reading - -- [Account & Identity Setup](/architecture/account-and-identity) -- [Certified PDSs](/reference/certified-pdss) — the production, staging, and test ePDS instances Certified operates -- [Certified Group Service (CGS)](/architecture/certified-group-service) — a governance layer that sits in front of a PDS to support multi-identity, role-based repo management -- [Scaffold Starter App](/tools/scaffold) -- [ePDS repository](https://github.com/hypercerts-org/ePDS) -- Install the ePDS agent skill with `npx skills add hypercerts-org/ePDS --skill epds-login` +{% /remote-doc %} diff --git a/styles/globals.css b/styles/globals.css index 2ac886c..4c4c3c2 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -1068,6 +1068,11 @@ a.sidebar-link-active:hover { padding-left: 24px; } +.toc-link-h4 { + font-size: 12px; + padding-left: 40px; +} + /* ===== Last Updated ===== */ .page-tools { display: flex; @@ -1134,6 +1139,27 @@ a.sidebar-link-active:hover { font-style: italic; color: var(--color-text-secondary); } + +.remote-doc-status { + margin-bottom: var(--space-6); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-subtle); + color: var(--color-text-secondary); + font-size: 0.875rem; + line-height: 1.5; +} + +.remote-doc-status--error { + border-color: var(--color-warning); + background: var(--color-warning-bg); + color: var(--color-text-primary); +} + +.remote-doc-status--error strong { + color: var(--color-text-heading); +} /* ===== Pagination ===== */ .pagination { display: flex; @@ -1414,6 +1440,36 @@ a.sidebar-link-active:hover { color: #d6deeb; } +.mermaid-diagram { + margin: var(--space-6) 0; + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-bg-subtle); + overflow-x: auto; +} + +.mermaid-diagram svg { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; +} + +.mermaid-diagram--loading { + color: var(--color-text-secondary); + font-size: 0.875rem; +} + +.mermaid-diagram--error { + border-color: var(--color-warning); + background: var(--color-warning-bg); +} + +.mermaid-diagram--error p { + margin-bottom: var(--space-4); +} + .layout-content code { font-family: var(--font-mono); font-size: 0.875em;