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 `