From 1b94e2fffb20bb469dc7d809ba3a9469c3910c95 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 12:55:58 +0000 Subject: [PATCH] Statically render every page before deploying to GitHub Pages The app renders pages server-side (Express + EJS) purely as a function of (route, language), with no per-request dynamic data, so each page can be pre-rendered once at build time. static-export.js boots the real server, crawls every route the controllers declare (in all their supported languages), and writes the responses alongside a full copy of the built os-app asset tree, producing a self-contained static site for GitHub Pages. Also: - fix static.yml's push trigger, which targeted "master" while the repo's default branch is "main", so it never actually ran - build the app and run the export before uploading the Pages artifact, instead of uploading raw repo source - pin magic-string to a CommonJS-compatible version via "overrides"; the unpinned latest is ESM-only and breaks OLSKRollupPluginLocalize, which "require()"s it directly --- .github/workflows/static.yml | 19 +++- package.json | 7 +- static-export.js | 175 +++++++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 static-export.js diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index e34c500c..7a090d59 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -1,10 +1,11 @@ -# Simple workflow for deploying static content to GitHub Pages +# Builds the app, statically renders every page it serves, and deploys +# the result to GitHub Pages. name: Deploy static content to Pages on: # Runs on pushes targeting the default branch push: - branches: ["master"] + branches: ["main"] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: @@ -22,7 +23,6 @@ concurrency: cancel-in-progress: false jobs: - # Single deploy job since we're just deploying deploy: environment: name: github-pages @@ -31,13 +31,22 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + - name: Install dependencies + run: npm run setup + - name: Build + run: npm run build + - name: Render static pages + run: npm run static-export - name: Setup Pages uses: actions/configure-pages@v5 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - # Upload entire repository - path: '.' + path: '.static' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v5 diff --git a/package.json b/package.json index 53702a09..b38dc4e1 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "test": "olsk-spec", "build": "olsk-rollup", - "watch": "olsk-rollup-watch" + "watch": "olsk-rollup-watch", + + "static-export": "node ./static-export.js" }, "dependencies": { "OLSKPithVitrine": "olsk/OLSKPithVitrine", @@ -33,5 +35,8 @@ "devDependencies": { "OLSKPithToolchain": "olsk/OLSKPithToolchain", "OLSKRollupScaffold": "olsk/OLSKRollupScaffold" + }, + "overrides": { + "magic-string": "^0.25.0" } } diff --git a/static-export.js b/static-export.js new file mode 100644 index 00000000..391f6958 --- /dev/null +++ b/static-export.js @@ -0,0 +1,175 @@ +#!/usr/bin/env node + +// Crawls every public route this app's Express server renders and writes +// the responses to `.static/`, alongside a full copy of the built `os-app` +// asset tree (which Express serves as-is), producing a self-contained +// static site suitable for GitHub Pages. + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const { spawn } = require('child_process'); + +const kRootDirectory = __dirname; +const kOutputDirectory = path.join(kRootDirectory, '.static'); +const kPort = process.env.PORT || '3000'; +const kBaseURL = `http://localhost:${ kPort }`; + +const OLSKStaticExportDiscoverRoutes = function () { + const controllerFiles = []; + + (function walk(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.name.startsWith('__') || entry.name === 'node_modules') { + continue; + } + + const fullPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + walk(fullPath); + continue; + } + + if (entry.name === 'controller.js') { + controllerFiles.push(fullPath); + } + } + })(path.join(kRootDirectory, 'os-app')); + + return controllerFiles.reduce(function (coll, filePath) { + const controllerModule = require(filePath); + + if (typeof controllerModule.OLSKControllerRoutes !== 'function') { + return coll; + } + + const routesResult = controllerModule.OLSKControllerRoutes(); + const routesArray = Array.isArray(routesResult) ? routesResult : Object.values(routesResult); + + return coll.concat(routesArray.filter(function (route) { + return (route.OLSKRouteMethod || 'get') === 'get' + && !route.OLSKRouteRedirect + && !route.OLSKRouteIsHidden + && !route.OLSKRoutePath.startsWith('/stub/'); + })); + }, []); +}; + +const OLSKStaticExportURLsForRoute = function (route) { + const basePath = route.OLSKRoutePath; + const languageCodes = Array.isArray(route.OLSKRouteLanguageCodes) ? route.OLSKRouteLanguageCodes : []; + + // Pages link to explicit `/en/...` alternates (hreflang) even though the + // unprefixed path is the canonical default, so both must be crawled. + return [basePath].concat(languageCodes.map(function (languageCode) { + return basePath === '/' ? `/${ languageCode }` : `/${ languageCode }${ basePath }`; + })); +}; + +const OLSKStaticExportDestinationForURL = function (urlPath) { + if (urlPath === '/') { + return path.join(kOutputDirectory, 'index.html'); + } + + if (/\.[a-zA-Z0-9]+$/.test(urlPath)) { + return path.join(kOutputDirectory, urlPath); + } + + return path.join(kOutputDirectory, urlPath, 'index.html'); +}; + +const OLSKStaticExportFetch = function (urlPath) { + return new Promise(function (resolve, reject) { + http.get(`${ kBaseURL }${ urlPath }`, function (response) { + if (response.statusCode >= 300 && response.statusCode < 400) { + response.resume(); + return resolve(null); + } + + if (response.statusCode !== 200) { + response.resume(); + return reject(new Error(`GET ${ urlPath } -> ${ response.statusCode }`)); + } + + const chunks = []; + response.on('data', function (chunk) { + chunks.push(chunk); + }); + response.on('end', function () { + resolve(Buffer.concat(chunks)); + }); + }).on('error', reject); + }); +}; + +const OLSKStaticExportWaitForServer = async function () { + for (let attempt = 0; attempt < 120; attempt++) { + try { + await OLSKStaticExportFetch('/robots.txt'); + return; + } catch (error) { + await new Promise(function (resolve) { + setTimeout(resolve, 500); + }); + } + } + + throw new Error('Server did not become ready in time'); +}; + +const OLSKStaticExportCopyBuiltAssets = function () { + fs.cpSync(path.join(kRootDirectory, 'os-app'), kOutputDirectory, { + recursive: true, + }); +}; + +const OLSKStaticExportMain = async function () { + fs.rmSync(kOutputDirectory, { recursive: true, force: true }); + fs.mkdirSync(kOutputDirectory, { recursive: true }); + + OLSKStaticExportCopyBuiltAssets(); + + const urls = Array.from(OLSKStaticExportDiscoverRoutes().reduce(function (coll, route) { + OLSKStaticExportURLsForRoute(route).forEach(function (url) { + coll.add(url); + }); + + return coll; + }, new Set(['/robots.txt', '/manifest.json', '/sw.js']))); + + const server = spawn('npm', ['start'], { + cwd: kRootDirectory, + env: Object.assign({}, process.env, { PORT: kPort }), + stdio: 'inherit', + detached: true, + }); + + try { + await OLSKStaticExportWaitForServer(); + + for (const urlPath of urls) { + const body = await OLSKStaticExportFetch(urlPath); + + if (body === null) { + console.warn(`skipped (redirect) ${ urlPath }`); + continue; + } + + const destinationPath = OLSKStaticExportDestinationForURL(urlPath); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.writeFileSync(destinationPath, body); + console.log(`saved ${ urlPath } -> ${ path.relative(kRootDirectory, destinationPath) }`); + } + } finally { + // `npm start` forks its own child process, so kill the whole group. + process.kill(-server.pid, 'SIGTERM'); + } +}; + +OLSKStaticExportMain().then(function () { + process.exit(0); +}, function (error) { + console.error(error); + process.exit(1); +});