249 lines
7.7 KiB
JavaScript
249 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import vm from "node:vm";
|
|
|
|
const DEFAULT_BASE = "docs/site";
|
|
|
|
function usage() {
|
|
console.log(
|
|
[
|
|
"Usage: node scripts/validate-public-site.mjs [docs/site|https://example.github.io/GOD/]",
|
|
"",
|
|
"Validates GOD public-site package preview pages and their key assets."
|
|
].join("\n")
|
|
);
|
|
}
|
|
|
|
function isRemoteBase(base) {
|
|
return /^https?:\/\//i.test(base);
|
|
}
|
|
|
|
function joinUrl(base, target) {
|
|
return new URL(target, base.endsWith("/") ? base : base + "/").toString();
|
|
}
|
|
|
|
async function readText(base, target) {
|
|
if (isRemoteBase(base)) {
|
|
const response = await fetch(joinUrl(base, target), { redirect: "follow" });
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status} ${response.statusText}`);
|
|
}
|
|
return response.text();
|
|
}
|
|
return fs.readFile(path.join(base, target), "utf8");
|
|
}
|
|
|
|
async function checkExists(base, target) {
|
|
if (!target || target === "#") return false;
|
|
if (/^https?:\/\//i.test(target)) {
|
|
const response = await fetch(target, { method: "HEAD", redirect: "follow" });
|
|
return response.ok;
|
|
}
|
|
if (isRemoteBase(base)) {
|
|
const response = await fetch(joinUrl(base, target), { method: "HEAD", redirect: "follow" });
|
|
return response.ok;
|
|
}
|
|
try {
|
|
await fs.access(path.join(base, target));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function parseCatalog(source) {
|
|
const sandbox = { window: {} };
|
|
vm.runInNewContext(source, sandbox, { filename: "catalog.js" });
|
|
if (!sandbox.window.GOD_CATALOG) {
|
|
throw new Error("catalog.js did not define window.GOD_CATALOG");
|
|
}
|
|
return sandbox.window.GOD_CATALOG;
|
|
}
|
|
|
|
function assertContains(errors, html, needle, label, page) {
|
|
if (!html.includes(needle)) {
|
|
errors.push(`${page}: missing ${label}`);
|
|
}
|
|
}
|
|
|
|
function hasHan(value) {
|
|
return /[\u3400-\u9fff]/.test(String(value || ""));
|
|
}
|
|
|
|
function assetPathFromManifest(slug, manifest) {
|
|
if (!manifest || !manifest.preview_url) return "";
|
|
return `public-data/map-packs/${slug}/${manifest.preview_url}`;
|
|
}
|
|
|
|
async function validateMapPack(base, entry) {
|
|
const errors = [];
|
|
const slug = entry.slug;
|
|
const page = `map-packs/${slug}/index.html`;
|
|
const html = await readText(base, page).catch((error) => {
|
|
errors.push(`${page}: ${error.message}`);
|
|
return "";
|
|
});
|
|
|
|
if (html) {
|
|
assertContains(errors, html, `data-map-pack="${slug}"`, "data-map-pack marker", page);
|
|
assertContains(errors, html, 'class="page-hero"', "page hero", page);
|
|
assertContains(errors, html, "data-map-image", "map preview image", page);
|
|
assertContains(errors, html, "data-map-stats", "map stats target", page);
|
|
assertContains(errors, html, "data-map-locations", "map locations target", page);
|
|
assertContains(errors, html, "map-pack-viewer.js", "map viewer script", page);
|
|
assertContains(errors, html, "nav-links", "navigation links", page);
|
|
}
|
|
|
|
const manifestPath = `public-data/map-packs/${slug}/map_pack.json`;
|
|
const manifest = await readText(base, manifestPath)
|
|
.then(JSON.parse)
|
|
.catch((error) => {
|
|
errors.push(`${manifestPath}: ${error.message}`);
|
|
return null;
|
|
});
|
|
|
|
for (const location of manifest?.locations || []) {
|
|
const name = location?.localized?.en?.name || "";
|
|
if (!name || hasHan(name)) {
|
|
errors.push(`${manifestPath}: location ${location.id || "unknown"} needs an English localized name`);
|
|
}
|
|
}
|
|
|
|
const preview = entry.image || assetPathFromManifest(slug, manifest);
|
|
if (preview && !(await checkExists(base, preview))) {
|
|
errors.push(`${page}: missing preview asset ${preview}`);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
async function validateAgentPack(base, entry) {
|
|
const errors = [];
|
|
const slug = entry.slug;
|
|
const page = `agent-packs/${slug}/index.html`;
|
|
const html = await readText(base, page).catch((error) => {
|
|
errors.push(`${page}: ${error.message}`);
|
|
return "";
|
|
});
|
|
|
|
if (html) {
|
|
assertContains(errors, html, `data-agent-pack="${slug}"`, "data-agent-pack marker", page);
|
|
assertContains(errors, html, 'class="page-hero"', "page hero", page);
|
|
assertContains(errors, html, "data-agent-stats", "agent stats target", page);
|
|
assertContains(errors, html, "data-agent-grid", "agent grid target", page);
|
|
assertContains(errors, html, "agent-pack-viewer.js", "agent viewer script", page);
|
|
assertContains(errors, html, "nav-links", "navigation links", page);
|
|
}
|
|
|
|
const manifestPath = `public-data/agent-packs/${slug}/agent_pack.json`;
|
|
const manifest = await readText(base, manifestPath)
|
|
.then(JSON.parse)
|
|
.catch((error) => {
|
|
errors.push(`${manifestPath}: ${error.message}`);
|
|
return null;
|
|
});
|
|
|
|
for (const agent of manifest?.agents || []) {
|
|
const display = agent?.localized?.en || {};
|
|
if (!display.name || hasHan(display.name)) {
|
|
errors.push(`${manifestPath}: agent ${agent.id || "unknown"} needs an English localized name`);
|
|
}
|
|
if (!display.summary || hasHan(display.summary)) {
|
|
errors.push(`${manifestPath}: agent ${agent.id || "unknown"} needs an English localized summary`);
|
|
}
|
|
}
|
|
|
|
const firstSprite = manifest?.agents?.find((agent) => agent.sprite?.path)?.sprite?.path;
|
|
if (firstSprite) {
|
|
const spritePath = `public-data/agent-packs/${slug}/${firstSprite}`;
|
|
if (!(await checkExists(base, spritePath))) {
|
|
errors.push(`${page}: missing sprite asset ${spritePath}`);
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
async function validateReplay(base, entry) {
|
|
const errors = [];
|
|
const slug = entry.slug;
|
|
const page = `replays/${slug}/index.html`;
|
|
const html = await readText(base, page).catch((error) => {
|
|
errors.push(`${page}: ${error.message}`);
|
|
return "";
|
|
});
|
|
|
|
if (html) {
|
|
assertContains(errors, html, `data-replay-slug="${slug}"`, "data-replay-slug marker", page);
|
|
assertContains(errors, html, "data-language-toggle", "language toggle", page);
|
|
assertContains(errors, html, "data-step-range", "timeline range", page);
|
|
assertContains(
|
|
errors,
|
|
html,
|
|
"Local setup gives the best experience. Online replays are static previews with limited live-control features.",
|
|
"local-experience note",
|
|
page
|
|
);
|
|
}
|
|
|
|
const timelinePath = `public-data/replays/${slug}/timeline.json`;
|
|
const timeline = await readText(base, timelinePath)
|
|
.then(JSON.parse)
|
|
.catch((error) => {
|
|
errors.push(`${timelinePath}: ${error.message}`);
|
|
return [];
|
|
});
|
|
|
|
if (!Array.isArray(timeline) || timeline.length === 0) {
|
|
errors.push(`${timelinePath}: expected at least one timeline frame`);
|
|
return errors;
|
|
}
|
|
|
|
const firstFrame = timeline[0]?.frame_url;
|
|
if (firstFrame && !(await checkExists(base, `public-data/replays/${slug}/${firstFrame}`))) {
|
|
errors.push(`${timelinePath}: missing first frame ${firstFrame}`);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
async function main() {
|
|
const arg = process.argv[2] || DEFAULT_BASE;
|
|
if (arg === "-h" || arg === "--help") {
|
|
usage();
|
|
return;
|
|
}
|
|
|
|
const base = arg;
|
|
const catalog = parseCatalog(await readText(base, "data/catalog.js"));
|
|
const allErrors = [];
|
|
|
|
for (const entry of catalog.mapPacks || []) {
|
|
allErrors.push(...(await validateMapPack(base, entry)));
|
|
}
|
|
for (const entry of catalog.agentPacks || []) {
|
|
allErrors.push(...(await validateAgentPack(base, entry)));
|
|
}
|
|
for (const entry of catalog.replays || []) {
|
|
allErrors.push(...(await validateReplay(base, entry)));
|
|
}
|
|
|
|
if (allErrors.length) {
|
|
console.error(`Public site validation failed for ${base}`);
|
|
for (const error of allErrors) {
|
|
console.error(`- ${error}`);
|
|
}
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
console.log(`Public site validation passed for ${base}`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.stack || error.message);
|
|
process.exitCode = 1;
|
|
});
|