Initial commit
This commit is contained in:
516
scripts/generate-decks-registry.cjs
Normal file
516
scripts/generate-decks-registry.cjs
Normal file
@@ -0,0 +1,516 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user