Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions .github/workflows/static.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -22,7 +23,6 @@ concurrency:
cancel-in-progress: false

jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
Expand All @@ -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
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -33,5 +35,8 @@
"devDependencies": {
"OLSKPithToolchain": "olsk/OLSKPithToolchain",
"OLSKRollupScaffold": "olsk/OLSKRollupScaffold"
},
"overrides": {
"magic-string": "^0.25.0"
}
}
175 changes: 175 additions & 0 deletions static-export.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading