const fs = require("fs"); const path = require("path"); const projectRoot = path.resolve(__dirname, ".."); const decksRoot = path.join(projectRoot, "asset", "tarot deck"); const registryPath = path.join(decksRoot, "decks.json"); const ignoredFolderNames = new Set(["template", "templates", "example", "examples"]); const tarotSuits = ["wands", "cups", "swords", "disks"]; const majorTrumpNumbers = Array.from({ length: 22 }, (_, index) => index); const expectedMinorCardCount = 56; function isPlainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function slugifyId(input) { return String(input || "") .trim() .toLowerCase() .replace(/&/g, " and ") .replace(/['\"`]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .replace(/-{2,}/g, "-"); } function asNonEmptyString(value) { const normalized = String(value || "").trim(); return normalized || null; } function toTitleCase(value) { const normalized = String(value || "").trim().toLowerCase(); if (!normalized) { return ""; } return normalized.charAt(0).toUpperCase() + normalized.slice(1); } function applyTemplate(template, variables) { return String(template || "") .replace(/\{([a-zA-Z0-9_]+)\}/g, (_, token) => { const value = variables[token]; return value == null ? "" : String(value); }); } function readManifestJson(manifestPath) { try { const text = fs.readFileSync(manifestPath, "utf8"); const parsed = JSON.parse(text); return parsed && typeof parsed === "object" ? parsed : null; } catch { return null; } } function hasNonEmptyStringMapEntries(value) { if (!isPlainObject(value)) { return false; } return Object.values(value).some((entryValue) => String(entryValue || "").trim()); } function hasRankResolver(minorRule) { if (!isPlainObject(minorRule)) { return false; } const rankOrder = Array.isArray(minorRule.rankOrder) ? minorRule.rankOrder.filter((entry) => String(entry || "").trim()) : []; if (rankOrder.length) { return true; } return hasNonEmptyStringMapEntries(minorRule.rankIndexByKey); } function hasSuitNumberMap(value) { if (!isPlainObject(value)) { return false; } return tarotSuits.every((suitId) => Number.isFinite(Number(value[suitId]))); } function hasSuitStringMap(value) { if (!isPlainObject(value)) { return false; } return tarotSuits.every((suitId) => String(value[suitId] || "").trim()); } function isValidMinorOverrideKey(value) { return /^(ace|two|three|four|five|six|seven|eight|nine|ten|knight|queen|prince|princess|king|page|[2-9]|10)\s+of\s+(cups|wands|swords|pentacles|disks)$/i.test(String(value || "").trim()); } function getDefinedMajorCount(majors) { const mode = asNonEmptyString(majors?.mode); if (mode === "trump-template") { return majorTrumpNumbers.length; } if (mode === "canonical-map" || mode === "trump-map") { return Object.values(majors?.cards || {}) .map((value) => String(value || "").trim()) .filter(Boolean) .length; } return 0; } function validateMajorsRule(majors) { if (!isPlainObject(majors)) { return "majors must be an object"; } const mode = asNonEmptyString(majors.mode); if (!mode) { return "majors.mode is required"; } if (mode === "canonical-map" || mode === "trump-map") { if (!hasNonEmptyStringMapEntries(majors.cards)) { return `majors.cards must be a non-empty object for mode '${mode}'`; } if (getDefinedMajorCount(majors) < majorTrumpNumbers.length) { return `majors.cards must define all ${majorTrumpNumbers.length} major trumps for mode '${mode}'`; } return null; } if (mode === "trump-template") { const template = asNonEmptyString(majors.template); if (!template) { return "majors.template is required for mode 'trump-template'"; } if (majors.numberPad != null && !Number.isInteger(Number(majors.numberPad))) { return "majors.numberPad must be an integer when provided"; } return null; } return `unsupported majors.mode '${mode}'`; } function validateMinorsRule(minors) { if (!isPlainObject(minors)) { return "minors must be an object"; } const mode = asNonEmptyString(minors.mode); if (!mode) { return "minors.mode is required"; } if (!hasRankResolver(minors)) { return "minors must define rankOrder or rankIndexByKey"; } if (getRankEntries(minors).length !== 14) { return `minors must define exactly 14 ranks to cover ${expectedMinorCardCount} cards`; } if (mode === "suit-prefix-and-rank-order") { if (!hasSuitStringMap(minors.suitPrefix)) { return "minors.suitPrefix must define wands, cups, swords, and disks"; } if (minors.indexStart != null && !Number.isInteger(Number(minors.indexStart))) { return "minors.indexStart must be an integer when provided"; } if (minors.indexPad != null && !Number.isInteger(Number(minors.indexPad))) { return "minors.indexPad must be an integer when provided"; } return null; } if (mode === "suit-base-and-rank-order" || mode === "suit-base-number-template") { if (!hasSuitNumberMap(minors.suitBase)) { return "minors.suitBase must define numeric bases for wands, cups, swords, and disks"; } if (minors.numberPad != null && !Number.isInteger(Number(minors.numberPad))) { return "minors.numberPad must be an integer when provided"; } return null; } return `unsupported minors.mode '${mode}'`; } function validateDeckManifest(manifest) { const errors = []; if (!isPlainObject(manifest)) { return ["deck.json must contain an object"]; } const majorsError = validateMajorsRule(manifest.majors); if (majorsError) { errors.push(majorsError); } const minorsError = validateMinorsRule(manifest.minors); if (minorsError) { errors.push(minorsError); } if (manifest.nameOverrides != null && !isPlainObject(manifest.nameOverrides)) { errors.push("nameOverrides must be an object when provided"); } if (manifest.minorNameOverrides != null && !isPlainObject(manifest.minorNameOverrides)) { errors.push("minorNameOverrides must be an object when provided"); } if (isPlainObject(manifest.minorNameOverrides)) { Object.entries(manifest.minorNameOverrides).forEach(([rawKey, rawValue]) => { if (!isValidMinorOverrideKey(rawKey)) { errors.push(`minorNameOverrides contains unsupported card key '${rawKey}'`); } if (!asNonEmptyString(rawValue)) { errors.push(`minorNameOverrides '${rawKey}' must map to a non-empty string`); } }); } if (manifest.majorNameOverridesByTrump != null && !isPlainObject(manifest.majorNameOverridesByTrump)) { errors.push("majorNameOverridesByTrump must be an object when provided"); } return errors; } function getRankEntries(minors) { const rankOrder = Array.isArray(minors?.rankOrder) ? minors.rankOrder .map((entry) => String(entry || "").trim()) .filter(Boolean) : []; if (rankOrder.length) { return rankOrder.map((rankWord, rankIndex) => ({ rankWord, rankKey: rankWord.toLowerCase(), rankIndex })); } const rankIndexByKey = isPlainObject(minors?.rankIndexByKey) ? minors.rankIndexByKey : {}; return Object.entries(rankIndexByKey) .map(([rankKey, rankIndex]) => ({ rankWord: toTitleCase(rankKey), rankKey: String(rankKey || "").trim().toLowerCase(), rankIndex: Number(rankIndex) })) .filter((entry) => entry.rankKey && Number.isInteger(entry.rankIndex) && entry.rankIndex >= 0) .sort((left, right) => left.rankIndex - right.rankIndex); } function getReferencedMajorFiles(manifest) { const majors = manifest?.majors; const mode = asNonEmptyString(majors?.mode); if (!mode) { return []; } if (mode === "canonical-map" || mode === "trump-map") { return Object.values(majors.cards || {}) .map((value) => String(value || "").trim()) .filter(Boolean); } if (mode === "trump-template") { const template = String(majors.template || "{number}.png"); const numberPad = Number.isInteger(Number(majors.numberPad)) ? Number(majors.numberPad) : 2; return majorTrumpNumbers.map((trump) => applyTemplate(template, { trump, number: String(trump).padStart(numberPad, "0") })); } return []; } function getReferencedMinorFiles(manifest) { const minors = manifest?.minors; const mode = asNonEmptyString(minors?.mode); if (!mode) { return []; } const rankEntries = getRankEntries(minors); if (!rankEntries.length) { return []; } if (mode === "suit-prefix-and-rank-order") { const template = String(minors.template || "{suit}{index}.png"); const indexStart = Number.isInteger(Number(minors.indexStart)) ? Number(minors.indexStart) : 1; const indexPad = Number.isInteger(Number(minors.indexPad)) ? Number(minors.indexPad) : 2; return tarotSuits.flatMap((suitId) => { const suitPrefix = String(minors?.suitPrefix?.[suitId] || "").trim(); return rankEntries.map((entry) => applyTemplate(template, { suit: suitPrefix, suitId, index: String(indexStart + entry.rankIndex).padStart(indexPad, "0"), rank: entry.rankWord, rankKey: entry.rankKey })); }); } if (mode === "suit-base-and-rank-order") { const template = String(minors.template || "{number}_{rank} {suit}.webp"); const numberPad = Number.isInteger(Number(minors.numberPad)) ? Number(minors.numberPad) : 2; return tarotSuits.flatMap((suitId) => { const suitBase = Number(minors?.suitBase?.[suitId]); const suitWord = String(minors?.suitLabel?.[suitId] || toTitleCase(suitId)); return rankEntries.map((entry) => applyTemplate(template, { number: String(suitBase + entry.rankIndex).padStart(numberPad, "0"), rank: entry.rankWord, rankKey: entry.rankKey, suit: suitWord, suitId, index: entry.rankIndex + 1 })); }); } if (mode === "suit-base-number-template") { const template = String(minors.template || "{number}.png"); const numberPad = Number.isInteger(Number(minors.numberPad)) ? Number(minors.numberPad) : 2; return tarotSuits.flatMap((suitId) => { const suitBase = Number(minors?.suitBase?.[suitId]); return rankEntries.map((entry) => applyTemplate(template, { number: String(suitBase + entry.rankIndex).padStart(numberPad, "0"), rank: entry.rankWord, rankKey: entry.rankKey, suitId, index: entry.rankIndex })); }); } return []; } function summarizeMissingFiles(fileList) { const maxPreview = 8; const preview = fileList.slice(0, maxPreview).join(", "); if (fileList.length <= maxPreview) { return preview; } return `${preview}, ... (+${fileList.length - maxPreview} more)`; } function auditDeckFiles(folderName, manifest) { const deckFolderPath = path.join(decksRoot, folderName); const referencedFiles = [ ...getReferencedMajorFiles(manifest), ...getReferencedMinorFiles(manifest) ] .map((relativePath) => String(relativePath || "").trim()) .filter(Boolean); const uniqueReferencedFiles = Array.from(new Set(referencedFiles)); const missingFiles = uniqueReferencedFiles.filter((relativePath) => !fs.existsSync(path.join(deckFolderPath, relativePath))); if (!missingFiles.length) { return null; } return `missing ${missingFiles.length} referenced image file${missingFiles.length === 1 ? "" : "s"}: ${summarizeMissingFiles(missingFiles)}`; } function shouldIgnoreDeckFolder(folderName) { const normalized = String(folderName || "").trim().toLowerCase(); if (!normalized) { return true; } return normalized.startsWith("_") || normalized.startsWith(".") || ignoredFolderNames.has(normalized); } function compileDeckRegistry() { if (!fs.existsSync(decksRoot)) { throw new Error(`Deck root not found: ${decksRoot}`); } const entries = fs.readdirSync(decksRoot, { withFileTypes: true }); const deckRows = []; const warnings = []; const seenIds = new Set(); entries.forEach((entry) => { if (!entry.isDirectory()) { return; } const folderName = entry.name; if (shouldIgnoreDeckFolder(folderName)) { return; } const manifestFsPath = path.join(decksRoot, folderName, "deck.json"); if (!fs.existsSync(manifestFsPath)) { warnings.push(`Skipped '${folderName}': missing deck.json`); return; } const manifest = readManifestJson(manifestFsPath); if (!manifest) { warnings.push(`Skipped '${folderName}': deck.json is invalid JSON`); return; } const validationErrors = validateDeckManifest(manifest); if (validationErrors.length) { warnings.push(`Skipped '${folderName}': ${validationErrors.join("; ")}`); return; } const auditError = auditDeckFiles(folderName, manifest); if (auditError) { warnings.push(`Skipped '${folderName}': ${auditError}`); return; } const idFromManifest = asNonEmptyString(manifest.id); const labelFromManifest = asNonEmptyString(manifest.label); const fallbackId = slugifyId(folderName); if (!fallbackId && !idFromManifest) { warnings.push(`Skipped '${folderName}': unable to infer deck id`); return; } const id = (idFromManifest || fallbackId).toLowerCase(); const label = labelFromManifest || folderName; const basePath = `asset/tarot deck/${folderName}`; if (seenIds.has(id)) { warnings.push(`Skipped '${folderName}': duplicate deck id '${id}'`); return; } seenIds.add(id); deckRows.push({ id, label, basePath, manifestPath: `${basePath}/deck.json` }); }); // Keep output deterministic for stable diffs. deckRows.sort((a, b) => a.label.localeCompare(b.label, "en", { sensitivity: "base" })); return { registry: { decks: deckRows }, warnings }; } function writeDeckRegistry(registry) { fs.writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`, "utf8"); } function parseCliOptions(argv) { const args = new Set(Array.isArray(argv) ? argv : []); return { validateOnly: args.has("--validate-only"), strict: args.has("--strict") }; } function main() { const options = parseCliOptions(process.argv.slice(2)); const { registry, warnings } = compileDeckRegistry(); const count = Array.isArray(registry.decks) ? registry.decks.length : 0; if (options.validateOnly) { console.log(`[decks] Validated ${count} deck manifest${count === 1 ? "" : "s"}`); } else { writeDeckRegistry(registry); console.log(`[decks] Wrote ${count} deck entr${count === 1 ? "y" : "ies"} to ${path.relative(projectRoot, registryPath)}`); } warnings.forEach((warning) => { console.warn(`[decks] ${warning}`); }); if (options.strict && warnings.length) { process.exitCode = 1; console.error(`[decks] Validation failed with ${warnings.length} warning${warnings.length === 1 ? "" : "s"}`); } } main();