diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..8dd38f2 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,66 @@ +name: Publish docs to GitHub Pages + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - docs/** + - scripts/build-docs-site.mjs + - scripts/docs-site.css + - package.json + - pnpm-lock.yaml + - .github/workflows/pages.yml + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: github-pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.0.0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Configure GitHub Pages + uses: actions/configure-pages@v5 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build docs site + run: pnpm docs:build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: dist/pages + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/package.json b/package.json index 9d61865..ae077a6 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", + "docs:build": "node ./scripts/build-docs-site.mjs", "test": "vitest run", "test:types": "tsc --noEmit -p tsconfig.types.json", "lint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs", @@ -51,6 +52,7 @@ "eslint-plugin-unused-imports": "^4.2.0", "husky": "^9.1.7", "lint-staged": "^16.1.6", + "marked": "^18.0.0", "prettier": "^3.6.2", "tsup": "^8.5.0", "typescript": "^5.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af9f1e2..365dea8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: lint-staged: specifier: ^16.1.6 version: 16.1.6 + marked: + specifier: ^18.0.0 + version: 18.0.0 prettier: specifier: ^3.6.2 version: 3.6.2 @@ -1253,6 +1256,11 @@ packages: magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} + marked@18.0.0: + resolution: {integrity: sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3141,6 +3149,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@18.0.0: {} + math-intrinsics@1.1.0: {} merge2@1.4.1: {} diff --git a/scripts/build-docs-site.mjs b/scripts/build-docs-site.mjs new file mode 100644 index 0000000..31106ce --- /dev/null +++ b/scripts/build-docs-site.mjs @@ -0,0 +1,274 @@ +import { execSync } from 'node:child_process' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { Marked, Renderer } from 'marked' + +const rootDir = process.cwd() +const docsDir = path.join(rootDir, 'docs') +const outputDir = path.join(rootDir, 'dist', 'pages') +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const styleSourcePath = path.join(scriptDir, 'docs-site.css') +const navigationPath = path.join(docsDir, 'navigation.json') + +const readJson = (targetPath) => JSON.parse(readFileSync(targetPath, 'utf8')) +const toPosixPath = (targetPath) => targetPath.split(path.sep).join(path.posix.sep) +const escapeHtml = (value) => + value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + +const slugify = (value) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + +const getGitValue = (command) => { + try { + return execSync(command, { + cwd: rootDir, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }).trim() + } catch { + return '' + } +} + +const parseRepositorySlug = (remote) => { + if (remote.startsWith('git@github.com:')) { + return remote.slice('git@github.com:'.length).replace(/\.git$/, '') + } + + if (remote.startsWith('https://github.com/')) { + return remote.slice('https://github.com/'.length).replace(/\.git$/, '') + } + + return '' +} + +const splitHref = (href) => { + const [pathPart, fragment = ''] = href.split('#', 2) + return { + pathPart, + fragment: fragment === '' ? '' : `#${fragment}`, + } +} + +const toRouteSegments = (docPath) => { + const withoutExtension = docPath.replace(/\.md$/u, '') + const parsed = path.posix.parse(withoutExtension) + + if (parsed.base === 'README') { + return parsed.dir === '' ? [] : parsed.dir.split('/') + } + + return withoutExtension.split('/') +} + +const toRouteDir = (segments) => segments.join('/') + +const relativeDirHref = (fromDir, toDir) => { + const relativePath = path.posix.relative(fromDir || '.', toDir || '.') + + if (relativePath === '' || relativePath === '.') { + return './' + } + + return relativePath.endsWith('/') ? relativePath : `${relativePath}/` +} + +const relativeFileHref = (fromDir, targetPath) => { + const relativePath = path.posix.relative(fromDir || '.', targetPath) + return relativePath === '' ? './' : relativePath +} + +const navigation = readJson(navigationPath) + +if (navigation.formatVersion !== 1) { + throw new Error(`Unsupported docs navigation format: ${String(navigation.formatVersion)}`) +} + +const repoSlug = + process.env.GITHUB_REPOSITORY || + parseRepositorySlug(getGitValue('git config --get remote.origin.url')) +const repoBranch = + process.env.GITHUB_REF_NAME || + process.env.GITHUB_HEAD_REF || + getGitValue('git rev-parse --abbrev-ref HEAD') + +const repoBlobHref = (repoRelativePath) => { + if (repoSlug === '' || repoBranch === '') return null + return `https://github.com/${repoSlug}/blob/${repoBranch}/${repoRelativePath}` +} + +const entries = [ + { + id: 'home', + title: navigation.home.title, + docPath: navigation.home.path, + isHome: true, + }, + ...navigation.pages.map((page) => ({ + id: page.id, + title: page.title, + docPath: page.path, + isHome: false, + })), +].map((entry) => { + const routeSegments = toRouteSegments(entry.docPath) + const routeDir = toRouteDir(routeSegments) + const sourcePath = path.join(docsDir, entry.docPath) + + return { + ...entry, + routeSegments, + routeDir, + sourcePath, + outputPath: path.join(outputDir, ...routeSegments, 'index.html'), + repoPath: toPosixPath(path.relative(rootDir, sourcePath)), + } +}) + +const entryByDocPath = new Map(entries.map((entry) => [entry.docPath, entry])) +const homeEntry = entries.find((entry) => entry.isHome) + +if (homeEntry === undefined) { + throw new Error('Docs navigation is missing a home entry') +} + +const renderNavigation = (currentEntry) => + entries + .map((entry) => { + const href = relativeDirHref(currentEntry.routeDir, entry.routeDir) + const stateClass = entry.id === currentEntry.id ? ' is-active' : '' + + return `
  • ${escapeHtml(entry.title)}
  • ` + }) + .join('') + +const markedForEntry = (entry) => { + const renderer = new Renderer() + + renderer.link = function link(token) { + const text = this.parser.parseInline(token.tokens) + const titleAttribute = token.title ? ` title="${escapeHtml(token.title)}"` : '' + + if (!token.href) { + return text + } + + const rewrittenHref = rewriteHref(entry, token.href) + return `${text}` + } + + renderer.heading = function heading(token) { + const inlineHtml = this.parser.parseInline(token.tokens) + const headingId = slugify(token.text) + const idAttribute = headingId === '' ? '' : ` id="${headingId}"` + return `${inlineHtml}` + } + + return new Marked({ + gfm: true, + renderer, + }) +} + +const rewriteHref = (entry, href) => { + if ( + href.startsWith('#') || + href.startsWith('http://') || + href.startsWith('https://') || + href.startsWith('mailto:') + ) { + return href + } + + const { pathPart, fragment } = splitHref(href) + + if (pathPart === '') { + return fragment === '' ? './' : fragment + } + + const targetSourcePath = path.resolve(path.dirname(entry.sourcePath), pathPart) + const docsRelativePath = toPosixPath(path.relative(docsDir, targetSourcePath)) + const repoRelativePath = toPosixPath(path.relative(rootDir, targetSourcePath)) + const docsEntry = entryByDocPath.get(docsRelativePath) + + if (docsEntry !== undefined) { + return `${relativeDirHref(entry.routeDir, docsEntry.routeDir)}${fragment}` + } + + if (!repoRelativePath.startsWith('..')) { + const blobHref = repoBlobHref(repoRelativePath) + if (blobHref !== null) { + return `${blobHref}${fragment}` + } + } + + return href +} + +const renderPage = (entry) => { + const markdown = readFileSync(entry.sourcePath, 'utf8') + const html = markedForEntry(entry).parse(markdown) + const stylesheetHref = relativeFileHref(entry.routeDir, 'assets/docs-site.css') + const sourceHref = repoBlobHref(entry.repoPath) + const sourceLink = + sourceHref === null + ? '' + : `View source` + + return ` + + + + + ${escapeHtml(entry.title)} | ${escapeHtml(homeEntry.title)} + + + +
    + +
    + +
    +
    + ${html} +
    +
    +
    +
    + + +` +} + +rmSync(outputDir, { recursive: true, force: true }) +mkdirSync(path.join(outputDir, 'assets'), { recursive: true }) + +for (const entry of entries) { + mkdirSync(path.dirname(entry.outputPath), { recursive: true }) + writeFileSync(entry.outputPath, renderPage(entry)) +} + +writeFileSync( + path.join(outputDir, 'assets', 'docs-site.css'), + readFileSync(styleSourcePath, 'utf8'), +) +writeFileSync(path.join(outputDir, '.nojekyll'), '') diff --git a/scripts/docs-site.css b/scripts/docs-site.css new file mode 100644 index 0000000..b3e3de5 --- /dev/null +++ b/scripts/docs-site.css @@ -0,0 +1,236 @@ +:root { + color-scheme: light; + --page-bg: #f4f1e8; + --panel-bg: rgba(255, 255, 255, 0.78); + --panel-border: rgba(40, 44, 52, 0.14); + --text: #1f2430; + --muted: #5d6778; + --accent: #0f766e; + --accent-soft: rgba(15, 118, 110, 0.12); + --code-bg: rgba(31, 36, 48, 0.06); + --shadow: 0 24px 60px rgba(31, 36, 48, 0.12); +} + +* { + box-sizing: border-box; +} + +html { + background: + radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 32%), + linear-gradient(180deg, #faf6ee 0%, var(--page-bg) 100%); +} + +body { + margin: 0; + color: var(--text); + font: + 400 16px/1.6 'Iowan Old Style', + 'Palatino Linotype', + 'Book Antiqua', + Palatino, + 'URW Palladio L', + serif; +} + +a { + color: var(--accent); +} + +code, +pre { + font-family: 'SFMono-Regular', 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; +} + +.site-shell { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 32px 0 48px; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 0 0 20px; +} + +.site-header__brand, +.site-header__source { + color: var(--text); + text-decoration: none; +} + +.site-header__brand { + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.site-header__source { + color: var(--muted); +} + +.site-layout { + display: grid; + grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); + gap: 24px; + align-items: start; +} + +.site-sidebar, +.site-content { + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 24px; + box-shadow: var(--shadow); + backdrop-filter: blur(10px); +} + +.site-sidebar { + position: sticky; + top: 24px; + padding: 20px 16px; +} + +.site-nav { + margin: 0; + padding: 0; + list-style: none; +} + +.site-nav li + li { + margin-top: 8px; +} + +.site-nav__link { + display: block; + padding: 10px 12px; + border-radius: 14px; + color: var(--text); + text-decoration: none; +} + +.site-nav__link:hover, +.site-nav__link:focus-visible, +.site-nav__link.is-active { + background: var(--accent-soft); + color: var(--accent); +} + +.site-content { + padding: 32px; +} + +.prose { + max-width: 72ch; +} + +.prose > :first-child { + margin-top: 0; +} + +.prose h1, +.prose h2, +.prose h3 { + line-height: 1.15; +} + +.prose h1 { + margin-bottom: 20px; + font-size: clamp(2.2rem, 5vw, 3.4rem); +} + +.prose h2 { + margin-top: 40px; + margin-bottom: 14px; + font-size: clamp(1.5rem, 3vw, 2rem); +} + +.prose h3 { + margin-top: 28px; + margin-bottom: 10px; + font-size: 1.2rem; +} + +.prose p, +.prose ul, +.prose ol, +.prose table, +.prose pre { + margin: 0 0 18px; +} + +.prose ul, +.prose ol { + padding-left: 22px; +} + +.prose li + li { + margin-top: 8px; +} + +.prose table { + width: 100%; + border-collapse: collapse; + overflow: hidden; + border-radius: 16px; + border: 1px solid var(--panel-border); +} + +.prose th, +.prose td { + padding: 12px 14px; + border-bottom: 1px solid var(--panel-border); + text-align: left; + vertical-align: top; +} + +.prose th { + background: rgba(31, 36, 48, 0.05); +} + +.prose tr:last-child td { + border-bottom: none; +} + +.prose pre, +.prose code { + background: var(--code-bg); + border-radius: 12px; +} + +.prose code { + padding: 0.18em 0.4em; +} + +.prose pre { + padding: 16px; + overflow-x: auto; +} + +.prose pre code { + padding: 0; + background: transparent; +} + +@media (max-width: 860px) { + .site-shell { + width: min(100% - 24px, 1180px); + padding-top: 24px; + } + + .site-layout { + grid-template-columns: 1fr; + } + + .site-sidebar { + position: static; + } + + .site-content { + padding: 24px; + } +}