Skip to content
Open
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
126 changes: 126 additions & 0 deletions .github/scripts/validate_extensions.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const vm = require('vm');
const { pathToFileURL } = require('url');
const { execFileSync } = require('child_process');

const FILE = 'src/lib/extensions.js';
const CODE_DIR = 'static/extensions';
const BANNER_DIR = 'static/images';

let errors = 0;

const esc = (s) => String(s).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
function fail(msg, file = FILE, line) {
errors++;
console.log(`::error file=${file}${line ? `,line=${line}` : ''}::${esc(msg)}`);
}
const warn = (msg, file = FILE) => console.log(`::warning file=${file}::${esc(msg)}`);

function walk(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((d) => {
const p = path.join(dir, d.name);
return d.isDirectory() ? walk(p) : [p];
});
}

function safeResolve(baseDir, rel) {
const base = path.resolve(baseDir);
const full = path.resolve(base, rel);
return full.startsWith(base + path.sep) ? full : null;
}

function changedFiles() {
const base =
process.env.BASE_SHA ||
(process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : null);
if (!base) return null;
try {
const out = execFileSync(
'git',
['diff', '--name-only', '--diff-filter=AMR', `${base}...HEAD`, '--', CODE_DIR],
{ encoding: 'utf8' }
);
return out.split('\n').filter(Boolean).map((f) => path.resolve(f));
} catch (err) {
console.log(`Could not diff against ${base}: ${err.message}`);
return null;
}
}

async function loadExtensions() {
const tmp = path.join(os.tmpdir(), `extensions-validate-${Date.now()}.mjs`);
fs.writeFileSync(tmp, fs.readFileSync(FILE, 'utf8'));
const mod = await import(pathToFileURL(tmp).href);
return mod.default;
}

(async () => {
let list;
try {
list = await loadExtensions();
} catch (err) {
fail(`Could not load the file: ${err.message}`);
process.exit(1);
}

const seen = new Set();
const referenced = new Set();

list.forEach((ext, i) => {
const label = ext && ext.code ? ext.code : `#${i}`;

if (!ext || typeof ext !== 'object') return fail(`Entry ${label} is not an object.`);

if (typeof ext.code !== 'string' || !ext.code.trim()) {
return fail(`Entry ${label} has no valid "code".`);
}
if (seen.has(ext.code)) fail(`Duplicate "code": ${ext.code}`);
seen.add(ext.code);

if (ext.tags !== undefined && !Array.isArray(ext.tags)) {
fail(`${label}: "tags" must be an array.`);
}

const codePath = safeResolve(CODE_DIR, ext.code);
if (!codePath) return fail(`${label}: invalid path.`);
if (!fs.existsSync(codePath)) {
return fail(`${label}: file not found at ${CODE_DIR}/${ext.code}`);
}
referenced.add(codePath);

const rel = path.relative(process.cwd(), codePath);
const src = fs.readFileSync(codePath, 'utf8');
if (!src.trim()) {
fail(`${label}: file is empty.`, rel);
} else {
try {
new vm.Script(src, { filename: rel });
} catch (err) {
const m = /:(\d+)$/.exec(String(err.stack).split('\n')[0]);
fail(`${label}: syntax error: ${err.message}`, rel, m ? m[1] : undefined);
}
}

if (typeof ext.banner === 'string') {
const bannerPath = safeResolve(BANNER_DIR, ext.banner);
if (!bannerPath || !fs.existsSync(bannerPath)) {
fail(`${label}: banner not found at ${BANNER_DIR}/${ext.banner}`);
}
}
});

const changed = changedFiles();
if (changed) {
for (const f of changed) {
if (f.endsWith('.js') && !referenced.has(f)) {
warn(`Extension not registered in ${FILE}`, path.relative(process.cwd(), f));
}
}
}

console.log(`${list.length} extensions checked, ${errors} error(s).`);
if (errors) process.exit(1);
})();
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: ci

on:
push:
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
validate-extensions:
name: validate extensions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: install node
uses: actions/setup-node@v4
with:
node-version: 22.x

- name: validate extensions.js and extension code
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: node .github/scripts/validate_extensions.cjs

build:
name: build site
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: install node
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: npm

- name: install dependencies
run: npm ci

- name: build
run: npm run build
Loading