feat: add romhack archive site
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
param(
|
||||
[string]$Server = "ginnoir@valhalla",
|
||||
[string]$RemotePath = "/config/caddy/site/romhacks-wiki"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$RepoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
$Dist = Join-Path $RepoRoot "dist"
|
||||
|
||||
if (-not (Test-Path $Dist)) {
|
||||
throw "dist/ does not exist. Run npm run build first."
|
||||
}
|
||||
|
||||
Write-Host "Preparing remote site directory $RemotePath ..."
|
||||
ssh $Server "mkdir -p '$RemotePath'"
|
||||
|
||||
Write-Host "Syncing built site to $Server:$RemotePath ..."
|
||||
scp -r (Join-Path $Dist "*") "${Server}:$RemotePath/"
|
||||
|
||||
Write-Host "Reloading Caddy ..."
|
||||
ssh $Server "docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
|
||||
|
||||
Write-Host "Deployed ROM Hack Archive to https://romhacks.ginnoir.com"
|
||||
@@ -0,0 +1,135 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const CATALOG_PATH = process.env.ROMHACK_CATALOG_PATH ?? path.join(ROOT, 'data', 'catalog.json');
|
||||
const BANNER_DIR = path.join(ROOT, 'public', 'assets', 'banners');
|
||||
const GENERATED_DIR = path.join(ROOT, 'src', 'generated');
|
||||
const SCRAPE_LIB_DIR = process.env.ROMHACK_SCRAPE_LIB_DIR ?? path.join(ROOT, 'data', 'scrape_lib');
|
||||
const FILE_BASE = 'https://romhacks-files.ginnoir.com';
|
||||
const GUIDE_EXTS = new Set(['.html', '.htm', '.pdf', '.xlsx', '.xls', '.ods', '.csv', '.doc', '.docx', '.rtf', '.txt', '.md', '.zip', '.rar', '.7z']);
|
||||
const DOC_EXTS = new Set(['.html', '.htm', '.pdf', '.xlsx', '.xls', '.ods', '.csv', '.doc', '.docx', '.rtf']);
|
||||
const GAME_DATA_DIRS = new Set(['pbs', 'graphics', 'plugins', 'audio', 'data', 'fonts', 'scripts', 'optional', 'se', 'bgm', 'tilesets', 'sound', 'movies', 'backup']);
|
||||
|
||||
await mkdir(BANNER_DIR, { recursive: true });
|
||||
await mkdir(GENERATED_DIR, { recursive: true });
|
||||
|
||||
const catalog = JSON.parse(await readFile(CATALOG_PATH, 'utf8'));
|
||||
const bannerManifest = {};
|
||||
const fileManifest = {};
|
||||
|
||||
for (const hack of catalog.hacks) {
|
||||
const slug = slugifyStem(hack.stem);
|
||||
if (hack.banner) {
|
||||
const localPath = await downloadBanner(slug, hack.banner);
|
||||
if (localPath) bannerManifest[slug] = localPath;
|
||||
}
|
||||
const generatedLinks = await generatedFileLinks(slug, hack.scrape_dir);
|
||||
if (generatedLinks.length > 0) fileManifest[slug] = generatedLinks;
|
||||
}
|
||||
|
||||
await writeFile(path.join(GENERATED_DIR, 'banner-manifest.json'), `${JSON.stringify(bannerManifest, null, 2)}\n`);
|
||||
await writeFile(path.join(GENERATED_DIR, 'file-manifest.json'), `${JSON.stringify(fileManifest, null, 2)}\n`);
|
||||
|
||||
console.log(`prepared banners=${Object.keys(bannerManifest).length}/${catalog.hacks.filter((hack) => hack.banner).length}`);
|
||||
console.log(`prepared file manifests=${Object.keys(fileManifest).length}`);
|
||||
|
||||
async function downloadBanner(slug, url) {
|
||||
const ext = extensionFromUrl(url);
|
||||
const filename = `${slug}${ext}`;
|
||||
const outPath = path.join(BANNER_DIR, filename);
|
||||
if (existsSync(outPath)) return `/assets/banners/${filename}`;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
if (!response.ok) {
|
||||
console.warn(`banner skipped ${slug}: HTTP ${response.status}`);
|
||||
return undefined;
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length < 128) {
|
||||
console.warn(`banner skipped ${slug}: response too small`);
|
||||
return undefined;
|
||||
}
|
||||
await writeFile(outPath, buffer);
|
||||
return `/assets/banners/${filename}`;
|
||||
} catch (error) {
|
||||
console.warn(`banner skipped ${slug}: ${error.message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function generatedFileLinks(slug, scrapeDir) {
|
||||
if (!scrapeDir) return [];
|
||||
const handoffPath = path.join(SCRAPE_LIB_DIR, scrapeDir, 'handoff.json');
|
||||
if (!existsSync(handoffPath)) return [];
|
||||
try {
|
||||
const handoff = JSON.parse(await readFile(handoffPath, 'utf8'));
|
||||
const guides = pickGuides(handoff.guides ?? [], handoff.artifact);
|
||||
return guides.slice(0, 30).map((rel) => ({
|
||||
label: rel,
|
||||
url: `${FILE_BASE}/${encodePath(scrapeDir)}/${encodePath(rel)}`,
|
||||
kind: 'guide',
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn(`file manifest skipped ${slug}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pickGuides(guides, artifact) {
|
||||
const artifactName = String(artifact ?? '').toLowerCase();
|
||||
return guides
|
||||
.map((rel) => String(rel).replaceAll('\\', '/'))
|
||||
.filter((rel) => {
|
||||
const parts = rel.toLowerCase().split('/');
|
||||
const basename = parts.at(-1) ?? '';
|
||||
const ext = path.extname(basename);
|
||||
const inGameData = parts.slice(0, -1).some((part) => GAME_DATA_DIRS.has(part));
|
||||
if (!GUIDE_EXTS.has(ext)) return false;
|
||||
if (basename === 'note.md' || basename === 'handoff.json' || basename === artifactName) return false;
|
||||
if (DOC_EXTS.has(ext)) return !inGameData;
|
||||
if (ext === '.txt' || ext === '.md') return !inGameData && parts.length <= 2;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => scoreGuide(a) - scoreGuide(b) || a.localeCompare(b));
|
||||
}
|
||||
|
||||
function scoreGuide(rel) {
|
||||
const ext = path.extname(rel).toLowerCase();
|
||||
if (DOC_EXTS.has(ext)) return 0;
|
||||
if (ext === '.txt' || ext === '.md') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function slugifyStem(stem) {
|
||||
return String(stem)
|
||||
.replaceAll('ΓÇÖ', "'")
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function extensionFromUrl(url) {
|
||||
try {
|
||||
const ext = path.extname(new URL(url).pathname).toLowerCase();
|
||||
if (['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext)) return ext;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return '.img';
|
||||
}
|
||||
|
||||
function encodePath(rel) {
|
||||
return String(rel)
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => encodeURIComponent(segment).replaceAll('(', '%28').replaceAll(')', '%29'))
|
||||
.join('/');
|
||||
}
|
||||
Reference in New Issue
Block a user