From 4713bbd54b48e1bb821d1db81881bd132e7f4067 Mon Sep 17 00:00:00 2001 From: Nose Date: Sun, 8 Mar 2026 05:40:53 -0700 Subject: [PATCH] added thumbs generation for performation and also added a new deck format for registration --- app/card-images.js | 161 +++++++- app/styles.css | 7 + app/ui-now-helpers.js | 21 +- app/ui-tarot-detail.js | 8 +- app/ui-tarot-house.js | 95 ++++- app/ui-tarot-spread.js | 13 +- app/ui-tarot.js | 15 +- package-lock.json | 583 ++++++++++++++++++++++++++- package.json | 8 +- scripts/generate-deck-thumbnails.cjs | 206 ++++++++++ scripts/generate-decks-registry.cjs | 182 ++++++++- 11 files changed, 1255 insertions(+), 44 deletions(-) create mode 100644 scripts/generate-deck-thumbnails.cjs diff --git a/app/card-images.js b/app/card-images.js index 73d54a7..3247add 100644 --- a/app/card-images.js +++ b/app/card-images.js @@ -98,6 +98,14 @@ swords: ["swords"], disks: ["disks", "pentacles", "coins"] }; + const defaultPipRankOrder = ["Ace", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"]; + const defaultThumbnailConfig = { + root: "thumbs", + width: 240, + height: 360, + fit: "inside", + quality: 82 + }; const DECK_REGISTRY_PATH = "asset/tarot deck/decks.json"; @@ -105,6 +113,7 @@ const manifestCache = new Map(); const cardBackCache = new Map(); + const cardBackThumbnailCache = new Map(); let activeDeckId = DEFAULT_DECK_ID; function canonicalMajorName(cardName) { @@ -278,6 +287,56 @@ return null; } + function normalizeThumbnailConfig(rawConfig, fallbackRoot = "") { + if (rawConfig === false) { + return false; + } + + if (!rawConfig || typeof rawConfig !== "object") { + const root = String(fallbackRoot || "").trim(); + if (!root) { + return null; + } + + return { + ...defaultThumbnailConfig, + root + }; + } + + const root = String(rawConfig.root || fallbackRoot || defaultThumbnailConfig.root).trim(); + if (!root) { + return null; + } + + return { + root, + width: Number.isInteger(Number(rawConfig.width)) && Number(rawConfig.width) > 0 + ? Number(rawConfig.width) + : defaultThumbnailConfig.width, + height: Number.isInteger(Number(rawConfig.height)) && Number(rawConfig.height) > 0 + ? Number(rawConfig.height) + : defaultThumbnailConfig.height, + fit: String(rawConfig.fit || defaultThumbnailConfig.fit).trim() || defaultThumbnailConfig.fit, + quality: Number.isInteger(Number(rawConfig.quality)) && Number(rawConfig.quality) >= 1 && Number(rawConfig.quality) <= 100 + ? Number(rawConfig.quality) + : defaultThumbnailConfig.quality + }; + } + + function resolveDeckThumbnailPath(manifest, relativePath) { + if (!manifest || !manifest.thumbnails || manifest.thumbnails === false) { + return null; + } + + const normalizedPath = String(relativePath || "").trim().replace(/^\.\//, ""); + if (!normalizedPath || isRemoteAssetPath(normalizedPath) || normalizedPath.startsWith("/")) { + return null; + } + + return toDeckAssetPath(manifest, `${manifest.thumbnails.root}/${normalizedPath}`) || null; + } + function readManifestJsonSync(path) { try { const request = new XMLHttpRequest(); @@ -314,7 +373,8 @@ label: String(entry?.label || id), basePath, manifestPath, - cardBackPath: String(entry?.cardBackPath || "").trim() + cardBackPath: String(entry?.cardBackPath || "").trim(), + thumbnailRoot: String(entry?.thumbnailRoot || "").trim() }; }); @@ -385,6 +445,7 @@ basePath: String(source.basePath || "").replace(/\/$/, ""), cardBack: String(rawManifest.cardBack || "").trim(), cardBackPath: String(source.cardBackPath || "").trim(), + thumbnails: normalizeThumbnailConfig(rawManifest.thumbnails, source.thumbnailRoot), majors: rawManifest.majors || {}, minors: rawManifest.minors || {}, nameOverrides, @@ -418,7 +479,13 @@ return normalizedManifest; } - function getRankIndex(minorRule, parsedMinor) { + function getRankOrder(minorRule, fallbackRankOrder = []) { + const explicitRankOrder = Array.isArray(minorRule?.rankOrder) ? minorRule.rankOrder : []; + const rankOrderSource = explicitRankOrder.length ? explicitRankOrder : fallbackRankOrder; + return rankOrderSource.map((entry) => String(entry || "").trim()).filter(Boolean); + } + + function getRankIndex(minorRule, parsedMinor, fallbackRankOrder = []) { if (!minorRule || !parsedMinor) { return null; } @@ -434,7 +501,7 @@ } } - const rankOrder = Array.isArray(minorRule.rankOrder) ? minorRule.rankOrder : []; + const rankOrder = getRankOrder(minorRule, fallbackRankOrder); for (let i = 0; i < rankOrder.length; i += 1) { const candidate = String(rankOrder[i] || "").toLowerCase(); if (candidate && (candidate === lowerRankWord || candidate === lowerRankKey)) { @@ -445,6 +512,34 @@ return null; } + function resolveMinorNumberTemplateGroup(groupRule, parsedMinor, fallbackRankOrder = []) { + if (!groupRule || typeof groupRule !== "object") { + return null; + } + + const rankIndex = getRankIndex(groupRule, parsedMinor, fallbackRankOrder); + if (!Number.isInteger(rankIndex) || rankIndex < 0) { + return null; + } + + const suitBaseRaw = Number(groupRule?.suitBase?.[parsedMinor.suitId]); + if (!Number.isFinite(suitBaseRaw)) { + return null; + } + + const numberPad = Number.isInteger(groupRule.numberPad) ? groupRule.numberPad : 2; + const cardNumber = String(suitBaseRaw + rankIndex).padStart(numberPad, "0"); + const template = String(groupRule.template || "{number}.png"); + + return applyTemplate(template, { + number: cardNumber, + suitId: parsedMinor.suitId, + rank: parsedMinor.rankWord, + rankKey: parsedMinor.rankKey, + index: rankIndex + }); + } + function resolveMajorFile(manifest, canonicalName) { const majorRule = manifest?.majors; if (!majorRule || typeof majorRule !== "object") { @@ -487,6 +582,14 @@ return null; } + if (minorRule.mode === "split-number-template") { + if (Number.isFinite(parsedMinor.pipValue)) { + return resolveMinorNumberTemplateGroup(minorRule.smalls, parsedMinor, defaultPipRankOrder); + } + + return resolveMinorNumberTemplateGroup(minorRule.courts, parsedMinor); + } + const rankIndex = getRankIndex(minorRule, parsedMinor); if (!Number.isInteger(rankIndex) || rankIndex < 0) { return null; @@ -555,8 +658,7 @@ return null; } - function resolveWithDeck(deckId, cardName) { - const manifest = getDeckManifest(deckId); + function resolveCardRelativePath(manifest, cardName) { if (!manifest) { return null; } @@ -564,7 +666,7 @@ const canonical = canonicalMajorName(cardName); const majorFile = resolveMajorFile(manifest, canonical); if (majorFile) { - return `${manifest.basePath}/${majorFile}`; + return majorFile; } const parsedMinor = parseMinorCard(cardName); @@ -572,12 +674,25 @@ return null; } - const minorFile = resolveMinorFile(manifest, parsedMinor); - if (!minorFile) { + return resolveMinorFile(manifest, parsedMinor); + } + + function resolveWithDeck(deckId, cardName, variant = "full") { + const manifest = getDeckManifest(deckId); + if (!manifest) { return null; } - return `${manifest.basePath}/${minorFile}`; + const relativePath = resolveCardRelativePath(manifest, cardName); + if (!relativePath) { + return null; + } + + if (variant === "thumbnail") { + return resolveDeckThumbnailPath(manifest, relativePath) || `${manifest.basePath}/${relativePath}`; + } + + return `${manifest.basePath}/${relativePath}`; } function resolveTarotCardImage(cardName) { @@ -589,6 +704,16 @@ return null; } + function resolveTarotCardThumbnail(cardName, optionsOrDeckId) { + const { resolvedDeckId } = resolveDeckOptions(optionsOrDeckId); + const thumbnailPath = resolveWithDeck(resolvedDeckId, cardName, "thumbnail"); + if (thumbnailPath) { + return encodeURI(thumbnailPath); + } + + return null; + } + function resolveTarotCardBackImage(optionsOrDeckId) { const { resolvedDeckId } = resolveDeckOptions(optionsOrDeckId); @@ -608,6 +733,22 @@ return null; } + function resolveTarotCardBackThumbnail(optionsOrDeckId) { + const { resolvedDeckId } = resolveDeckOptions(optionsOrDeckId); + + if (cardBackThumbnailCache.has(resolvedDeckId)) { + const cachedPath = cardBackThumbnailCache.get(resolvedDeckId); + return cachedPath ? encodeURI(cachedPath) : null; + } + + const manifest = getDeckManifest(resolvedDeckId); + const relativeBackPath = String(manifest?.cardBack || manifest?.cardBackPath || "").trim(); + const thumbnailPath = resolveDeckThumbnailPath(manifest, relativeBackPath) || resolveDeckCardBackPath(manifest); + cardBackThumbnailCache.set(resolvedDeckId, thumbnailPath || null); + + return thumbnailPath ? encodeURI(thumbnailPath) : null; + } + function resolveDisplayNameWithDeck(deckId, cardName, trumpNumber) { const manifest = getDeckManifest(deckId); const fallbackName = String(cardName || "").trim(); @@ -712,7 +853,9 @@ window.TarotCardImages = { resolveTarotCardImage, + resolveTarotCardThumbnail, resolveTarotCardBackImage, + resolveTarotCardBackThumbnail, getTarotCardDisplayName, getTarotCardSearchAliases, setActiveDeck, diff --git a/app/styles.css b/app/styles.css index 9c92ec5..7473f45 100644 --- a/app/styles.css +++ b/app/styles.css @@ -767,6 +767,7 @@ overflow: hidden; line-height: 0; position: relative; + contain: layout paint; transform-origin: center; transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease; } @@ -803,6 +804,12 @@ height: var(--tarot-house-card-height); object-fit: cover; background: #09090b; + opacity: 0; + transition: opacity 140ms ease; + } + + .tarot-house-card-image.is-loaded { + opacity: 1; } .tarot-house-card-text-face { diff --git a/app/ui-now-helpers.js b/app/ui-now-helpers.js index 2facf1c..4afbb31 100644 --- a/app/ui-now-helpers.js +++ b/app/ui-now-helpers.js @@ -3,7 +3,7 @@ "use strict"; const { DAY_IN_MS, getMoonPhaseName, getDecanForDate } = window.TarotCalc || {}; - const { resolveTarotCardImage, getTarotCardDisplayName } = window.TarotCardImages || {}; + const { resolveTarotCardImage, resolveTarotCardThumbnail, getTarotCardDisplayName } = window.TarotCardImages || {}; let nowLightboxOverlayEl = null; let nowLightboxImageEl = null; @@ -207,7 +207,7 @@ imageEl.style.cursor = "zoom-in"; imageEl.title = "Click to enlarge"; imageEl.addEventListener("click", () => { - const src = imageEl.getAttribute("src"); + const src = imageEl.dataset.fullSrc || imageEl.getAttribute("src"); if (!src || imageEl.style.display === "none") { return; } @@ -489,23 +489,32 @@ bindNowCardLightbox(imageEl); - if (!cardName || typeof resolveTarotCardImage !== "function") { + if (!cardName || (typeof resolveTarotCardThumbnail !== "function" && typeof resolveTarotCardImage !== "function")) { imageEl.style.display = "none"; imageEl.removeAttribute("src"); + delete imageEl.dataset.fullSrc; return; } - const src = resolveTarotCardImage(cardName); - if (!src) { + const fullSrc = typeof resolveTarotCardImage === "function" + ? resolveTarotCardImage(cardName) + : null; + const thumbnailSrc = typeof resolveTarotCardThumbnail === "function" + ? (resolveTarotCardThumbnail(cardName) || fullSrc) + : fullSrc; + if (!thumbnailSrc) { imageEl.style.display = "none"; imageEl.removeAttribute("src"); + delete imageEl.dataset.fullSrc; return; } - imageEl.src = src; + imageEl.src = thumbnailSrc; + imageEl.dataset.fullSrc = fullSrc || thumbnailSrc; const displayName = getDisplayTarotName(cardName, trumpNumber); imageEl.alt = `${fallbackLabel}: ${displayName}`; imageEl.style.display = "block"; + imageEl.decoding = "async"; } window.NowUiHelpers = { diff --git a/app/ui-tarot-detail.js b/app/ui-tarot-detail.js index 874a218..fe1b61f 100644 --- a/app/ui-tarot-detail.js +++ b/app/ui-tarot-detail.js @@ -4,6 +4,7 @@ getMonthRefsByCardId, getMagickDataset, resolveTarotCardImage, + resolveTarotCardThumbnail, getDisplayCardName, buildTypeLabel, clearChildren, @@ -203,9 +204,9 @@ } const cardDisplayName = getDisplayCardName(card); - const imageUrl = typeof resolveTarotCardImage === "function" - ? resolveTarotCardImage(card.name) - : null; + const imageUrl = typeof resolveTarotCardThumbnail === "function" + ? resolveTarotCardThumbnail(card.name) + : (typeof resolveTarotCardImage === "function" ? resolveTarotCardImage(card.name) : null); if (elements.tarotDetailImageEl) { if (imageUrl) { @@ -214,6 +215,7 @@ elements.tarotDetailImageEl.style.display = "block"; elements.tarotDetailImageEl.style.cursor = "zoom-in"; elements.tarotDetailImageEl.title = "Click to enlarge"; + elements.tarotDetailImageEl.decoding = "async"; } else { elements.tarotDetailImageEl.removeAttribute("src"); elements.tarotDetailImageEl.alt = ""; diff --git a/app/ui-tarot-house.js b/app/ui-tarot-house.js index 3de41b8..92ad743 100644 --- a/app/ui-tarot-house.js +++ b/app/ui-tarot-house.js @@ -45,6 +45,7 @@ const config = { resolveTarotCardImage: null, + resolveTarotCardThumbnail: null, getDisplayCardName: (card) => card?.name || "", clearChildren: () => {}, normalizeTarotCardLookupName: (value) => String(value || "").trim().toLowerCase(), @@ -59,6 +60,8 @@ getHouseBottomInfoModes: () => ({}) }; + let houseImageObserver = null; + function init(nextConfig = {}) { Object.assign(config, nextConfig || {}); } @@ -597,6 +600,73 @@ return faceEl; } + function disconnectHouseImageObserver() { + if (!houseImageObserver) { + return; + } + + houseImageObserver.disconnect(); + houseImageObserver = null; + } + + function hydrateHouseCardImage(image) { + if (!(image instanceof HTMLImageElement)) { + return; + } + + const nextSrc = String(image.dataset.src || "").trim(); + if (!nextSrc || image.dataset.imageHydrated === "true") { + return; + } + + image.dataset.imageHydrated = "true"; + image.classList.add("is-loading"); + image.src = nextSrc; + } + + function getHouseImageObserver(elements) { + const root = elements?.tarotHouseOfCardsEl?.closest(".tarot-section-house-top") || null; + if (!root || typeof IntersectionObserver !== "function") { + return null; + } + + if (houseImageObserver) { + return houseImageObserver; + } + + houseImageObserver = new IntersectionObserver((entries, observer) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) { + return; + } + + const target = entry.target; + observer.unobserve(target); + hydrateHouseCardImage(target); + }); + }, { + root, + rootMargin: "160px 0px", + threshold: 0.01 + }); + + return houseImageObserver; + } + + function registerHouseCardImage(image, elements) { + if (!(image instanceof HTMLImageElement)) { + return; + } + + const observer = getHouseImageObserver(elements); + if (!observer) { + hydrateHouseCardImage(image); + return; + } + + observer.observe(image); + } + function createHouseCardButton(card, elements) { const button = document.createElement("button"); button.type = "button"; @@ -620,16 +690,30 @@ button.title = labelText ? `${cardDisplayName || card.name} - ${labelText}` : (cardDisplayName || card.name); button.setAttribute("aria-label", labelText ? `${cardDisplayName || card.name}, ${labelText}` : (cardDisplayName || card.name)); button.dataset.houseCardId = card.id; - const imageUrl = typeof config.resolveTarotCardImage === "function" - ? config.resolveTarotCardImage(card.name) - : null; + const imageUrl = typeof config.resolveTarotCardThumbnail === "function" + ? config.resolveTarotCardThumbnail(card.name) + : (typeof config.resolveTarotCardImage === "function" ? config.resolveTarotCardImage(card.name) : null); if (showImage && imageUrl) { const image = document.createElement("img"); image.className = "tarot-house-card-image"; - image.src = imageUrl; - image.alt = cardDisplayName || card.name; + image.alt = ""; + image.setAttribute("aria-hidden", "true"); + image.loading = "lazy"; + image.decoding = "async"; + image.fetchPriority = config.isHouseFocusMode?.() === true ? "auto" : "low"; + image.dataset.src = imageUrl; + image.addEventListener("load", () => { + image.classList.remove("is-loading"); + image.classList.add("is-loaded"); + }, { once: true }); + image.addEventListener("error", () => { + image.classList.remove("is-loading"); + image.classList.remove("is-loaded"); + image.dataset.imageHydrated = "false"; + }); button.appendChild(image); + registerHouseCardImage(image, elements); } else if (showImage) { const fallback = document.createElement("span"); fallback.className = "tarot-house-card-fallback"; @@ -1095,6 +1179,7 @@ } const cards = config.getCards(); + disconnectHouseImageObserver(); config.clearChildren(elements.tarotHouseOfCardsEl); const cardLookupMap = getCardLookupMap(cards); diff --git a/app/ui-tarot-spread.js b/app/ui-tarot-spread.js index 88a8585..e507744 100644 --- a/app/ui-tarot-spread.js +++ b/app/ui-tarot-spread.js @@ -152,7 +152,11 @@ const normalizedSpread = normalizeTarotSpread(activeTarotSpread); const isCeltic = normalizedSpread === "celtic-cross"; - const cardBackImageSrc = String(window.TarotCardImages?.resolveTarotCardBackImage?.() || "").trim(); + const cardBackImageSrc = String( + window.TarotCardImages?.resolveTarotCardBackThumbnail?.() + || window.TarotCardImages?.resolveTarotCardBackImage?.() + || "" + ).trim(); if (!activeTarotSpreadDraw.length) { regenerateTarotSpreadDraw(); @@ -188,7 +192,8 @@ tarotSpreadBoardEl.innerHTML = activeTarotSpreadDraw.map((entry, index) => { const position = entry.position; const card = entry.card; - const imgSrc = window.TarotCardImages?.resolveTarotCardImage?.(card.name); + const imgSrc = window.TarotCardImages?.resolveTarotCardThumbnail?.(card.name) + || window.TarotCardImages?.resolveTarotCardImage?.(card.name); const isRevealed = Boolean(entry.revealed); const cardBackAttr = cardBackImageSrc ? ` data-card-back-src="${escapeHtml(cardBackImageSrc)}"` @@ -203,10 +208,10 @@ let faceMarkup = ""; if (isRevealed) { faceMarkup = imgSrc - ? `${escapeHtml(card.name)}` + ? `${escapeHtml(card.name)}` : `
${escapeHtml(card.name)}
`; } else if (cardBackImageSrc) { - faceMarkup = 'Face-down tarot card'; + faceMarkup = 'Face-down tarot card'; } else { faceMarkup = '
CARD BACK
'; } diff --git a/app/ui-tarot.js b/app/ui-tarot.js index c924f8c..8e34c3b 100644 --- a/app/ui-tarot.js +++ b/app/ui-tarot.js @@ -1,5 +1,5 @@ (function () { - const { resolveTarotCardImage, getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {}; + const { resolveTarotCardImage, resolveTarotCardThumbnail, getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {}; const tarotHouseUi = window.TarotHouseUi || {}; const tarotRelationsUi = window.TarotRelationsUi || {}; const tarotCardDerivations = window.TarotCardDerivations || {}; @@ -339,6 +339,7 @@ getMonthRefsByCardId: () => state.monthRefsByCardId, getMagickDataset: () => state.magickDataset, resolveTarotCardImage, + resolveTarotCardThumbnail, getDisplayCardName, buildTypeLabel, clearChildren, @@ -751,6 +752,7 @@ tarotHouseUi.init?.({ resolveTarotCardImage, + resolveTarotCardThumbnail, getDisplayCardName, clearChildren, normalizeTarotCardLookupName, @@ -934,11 +936,16 @@ if (elements.tarotDetailImageEl) { elements.tarotDetailImageEl.addEventListener("click", () => { - const src = elements.tarotDetailImageEl.getAttribute("src") || ""; - if (!src || elements.tarotDetailImageEl.style.display === "none") { + if (elements.tarotDetailImageEl.style.display === "none" || !state.selectedCardId) { return; } - window.TarotUiLightbox?.open?.(src, elements.tarotDetailImageEl.alt || "Tarot card enlarged image"); + + const request = buildLightboxCardRequestById(state.selectedCardId); + if (!request?.src) { + return; + } + + window.TarotUiLightbox?.open?.(request); }); } diff --git a/package-lock.json b/package-lock.json index 2ba5934..45bab2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,10 @@ { - "name": "tarot-clean", + "name": "TaroTime", "lockfileVersion": 3, "requires": true, "packages": { "": { + "hasInstallScript": true, "dependencies": { "@fontsource/amiri": "^5.2.8", "@fontsource/noto-naskh-arabic": "^5.2.11", @@ -15,7 +16,19 @@ "suncalc": "^1.9.0" }, "devDependencies": { - "http-server": "^14.1.1" + "http-server": "^14.1.1", + "sharp": "^0.34.5" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@fontsource/amiri": { @@ -63,6 +76,496 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@toast-ui/calendar": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@toast-ui/calendar/-/calendar-2.1.3.tgz", @@ -361,6 +864,16 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -1053,6 +1566,64 @@ "dev": true, "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -1190,6 +1761,14 @@ "node": ">=12" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tui-date-picker": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tui-date-picker/-/tui-date-picker-4.3.3.tgz", diff --git a/package.json b/package.json index 878e933..f4c678c 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,15 @@ "suncalc": "^1.9.0" }, "devDependencies": { - "http-server": "^14.1.1" + "http-server": "^14.1.1", + "sharp": "^0.34.5" }, "scripts": { "generate:decks": "node scripts/generate-decks-registry.cjs", + "generate:deck-thumbs": "node scripts/generate-deck-thumbnails.cjs", "validate:decks": "node scripts/generate-decks-registry.cjs --validate-only --strict", - "postinstall": "npm run generate:decks", - "prestart": "npm run generate:decks", + "postinstall": "npm run generate:decks && npm run generate:deck-thumbs", + "prestart": "npm run generate:decks && npm run generate:deck-thumbs", "start": "npx http-server . -o index.html", "dev": "npm run start" } diff --git a/scripts/generate-deck-thumbnails.cjs b/scripts/generate-deck-thumbnails.cjs new file mode 100644 index 0000000..4fcc642 --- /dev/null +++ b/scripts/generate-deck-thumbnails.cjs @@ -0,0 +1,206 @@ +const fs = require("fs"); +const path = require("path"); +const sharp = require("sharp"); + +const projectRoot = path.resolve(__dirname, ".."); +const decksRoot = path.join(projectRoot, "asset", "tarot deck"); +const ignoredFolderNames = new Set(["template", "templates", "example", "examples"]); +const supportedImageExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".avif", ".gif"]); +const defaultThumbnailConfig = { + root: "thumbs", + width: 240, + height: 360, + fit: "inside", + quality: 82 +}; + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function asNonEmptyString(value) { + const normalized = String(value || "").trim(); + return normalized || null; +} + +function shouldIgnoreDeckFolder(folderName) { + const normalized = String(folderName || "").trim().toLowerCase(); + if (!normalized) { + return true; + } + + return normalized.startsWith("_") || normalized.startsWith(".") || ignoredFolderNames.has(normalized); +} + +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 normalizeThumbnailConfig(rawConfig) { + if (rawConfig === false) { + return false; + } + + if (!isPlainObject(rawConfig)) { + return null; + } + + return { + root: asNonEmptyString(rawConfig.root) || defaultThumbnailConfig.root, + width: Number.isInteger(Number(rawConfig.width)) && Number(rawConfig.width) > 0 + ? Number(rawConfig.width) + : defaultThumbnailConfig.width, + height: Number.isInteger(Number(rawConfig.height)) && Number(rawConfig.height) > 0 + ? Number(rawConfig.height) + : defaultThumbnailConfig.height, + fit: asNonEmptyString(rawConfig.fit) || defaultThumbnailConfig.fit, + quality: Number.isInteger(Number(rawConfig.quality)) && Number(rawConfig.quality) >= 1 && Number(rawConfig.quality) <= 100 + ? Number(rawConfig.quality) + : defaultThumbnailConfig.quality + }; +} + +function ensureDirectory(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function listSourceImages(deckFolderPath, thumbnailRoot) { + const results = []; + + function walk(currentPath) { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + entries.forEach((entry) => { + const entryPath = path.join(currentPath, entry.name); + const relativePath = path.relative(deckFolderPath, entryPath).replace(/\\/g, "/"); + if (!relativePath) { + return; + } + + if (entry.isDirectory()) { + if (relativePath === thumbnailRoot || relativePath.startsWith(`${thumbnailRoot}/`)) { + return; + } + + walk(entryPath); + return; + } + + if (!entry.isFile()) { + return; + } + + if (!supportedImageExtensions.has(path.extname(entry.name).toLowerCase())) { + return; + } + + results.push(relativePath); + }); + } + + walk(deckFolderPath); + return results; +} + +function shouldRegenerateThumbnail(sourcePath, outputPath) { + if (!fs.existsSync(outputPath)) { + return true; + } + + const sourceStat = fs.statSync(sourcePath); + const outputStat = fs.statSync(outputPath); + return sourceStat.mtimeMs > outputStat.mtimeMs; +} + +function buildSharpPipeline(sourcePath, extension, config) { + let pipeline = sharp(sourcePath, { animated: extension === ".gif" }).rotate().resize({ + width: config.width, + height: config.height, + fit: config.fit, + withoutEnlargement: true + }); + + if (extension === ".jpg" || extension === ".jpeg") { + pipeline = pipeline.jpeg({ quality: config.quality, mozjpeg: true }); + } else if (extension === ".png") { + pipeline = pipeline.png({ quality: config.quality, compressionLevel: 9 }); + } else if (extension === ".webp") { + pipeline = pipeline.webp({ quality: config.quality }); + } else if (extension === ".avif") { + pipeline = pipeline.avif({ quality: config.quality, effort: 4 }); + } + + return pipeline; +} + +async function generateDeckThumbnails() { + const entries = fs.readdirSync(decksRoot, { withFileTypes: true }); + let generatedCount = 0; + const warnings = []; + + for (const entry of entries) { + if (!entry.isDirectory() || shouldIgnoreDeckFolder(entry.name)) { + continue; + } + + const deckFolderPath = path.join(decksRoot, entry.name); + const manifestPath = path.join(deckFolderPath, "deck.json"); + if (!fs.existsSync(manifestPath)) { + warnings.push(`Skipped '${entry.name}': missing deck.json`); + continue; + } + + const manifest = readManifestJson(manifestPath); + if (!manifest) { + warnings.push(`Skipped '${entry.name}': deck.json is invalid JSON`); + continue; + } + + const thumbnailConfig = normalizeThumbnailConfig(manifest.thumbnails); + if (!thumbnailConfig) { + continue; + } + + const thumbnailRoot = thumbnailConfig.root.replace(/^\.\//, "").replace(/\/$/, ""); + ensureDirectory(path.join(deckFolderPath, thumbnailRoot)); + const sourceImages = listSourceImages(deckFolderPath, thumbnailRoot); + + for (const relativePath of sourceImages) { + const sourcePath = path.join(deckFolderPath, relativePath); + const outputPath = path.join(deckFolderPath, thumbnailRoot, relativePath); + ensureDirectory(path.dirname(outputPath)); + + if (!shouldRegenerateThumbnail(sourcePath, outputPath)) { + continue; + } + + const extension = path.extname(sourcePath).toLowerCase(); + try { + await buildSharpPipeline(sourcePath, extension, thumbnailConfig).toFile(outputPath); + generatedCount += 1; + } catch (error) { + warnings.push(`Failed '${entry.name}/${relativePath}': ${error instanceof Error ? error.message : "unknown error"}`); + } + } + } + + return { generatedCount, warnings }; +} + +async function main() { + const { generatedCount, warnings } = await generateDeckThumbnails(); + console.log(`[deck-thumbs] Generated ${generatedCount} thumbnail${generatedCount === 1 ? "" : "s"}`); + warnings.forEach((warning) => { + console.warn(`[deck-thumbs] ${warning}`); + }); +} + +main().catch((error) => { + console.error(`[deck-thumbs] ${error instanceof Error ? error.message : "Unknown error"}`); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/scripts/generate-decks-registry.cjs b/scripts/generate-decks-registry.cjs index 42f6dd0..e287413 100644 --- a/scripts/generate-decks-registry.cjs +++ b/scripts/generate-decks-registry.cjs @@ -8,6 +8,15 @@ const ignoredFolderNames = new Set(["template", "templates", "example", "example const tarotSuits = ["wands", "cups", "swords", "disks"]; const majorTrumpNumbers = Array.from({ length: 22 }, (_, index) => index); const expectedMinorCardCount = 56; +const defaultPipRankOrder = ["Ace", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"]; +const defaultThumbnailConfig = { + root: "thumbs", + width: 240, + height: 360, + fit: "inside", + quality: 82 +}; +const supportedThumbnailFits = new Set(["contain", "cover", "fill", "inside", "outside"]); const cardBackCandidateExtensions = ["webp", "png", "jpg", "jpeg", "avif", "gif"]; function isPlainObject(value) { @@ -61,6 +70,71 @@ function readManifestJson(manifestPath) { } } +function normalizeThumbnailConfig(thumbnails) { + if (thumbnails == null) { + return null; + } + + if (thumbnails === false) { + return false; + } + + if (!isPlainObject(thumbnails)) { + return null; + } + + return { + root: asNonEmptyString(thumbnails.root) || defaultThumbnailConfig.root, + width: Number.isInteger(Number(thumbnails.width)) && Number(thumbnails.width) > 0 + ? Number(thumbnails.width) + : defaultThumbnailConfig.width, + height: Number.isInteger(Number(thumbnails.height)) && Number(thumbnails.height) > 0 + ? Number(thumbnails.height) + : defaultThumbnailConfig.height, + fit: asNonEmptyString(thumbnails.fit) || defaultThumbnailConfig.fit, + quality: Number.isInteger(Number(thumbnails.quality)) && Number(thumbnails.quality) >= 1 && Number(thumbnails.quality) <= 100 + ? Number(thumbnails.quality) + : defaultThumbnailConfig.quality + }; +} + +function validateThumbnailConfig(thumbnails) { + if (thumbnails == null || thumbnails === false) { + return null; + } + + if (!isPlainObject(thumbnails)) { + return "thumbnails must be an object or false when provided"; + } + + const root = asNonEmptyString(thumbnails.root) || defaultThumbnailConfig.root; + if (!root || root.startsWith("/") || isRemoteAssetPath(root)) { + return "thumbnails.root must be a relative folder name"; + } + + if (thumbnails.width != null && (!Number.isInteger(Number(thumbnails.width)) || Number(thumbnails.width) <= 0)) { + return "thumbnails.width must be a positive integer when provided"; + } + + if (thumbnails.height != null && (!Number.isInteger(Number(thumbnails.height)) || Number(thumbnails.height) <= 0)) { + return "thumbnails.height must be a positive integer when provided"; + } + + if (thumbnails.quality != null) { + const quality = Number(thumbnails.quality); + if (!Number.isInteger(quality) || quality < 1 || quality > 100) { + return "thumbnails.quality must be an integer between 1 and 100 when provided"; + } + } + + const fit = asNonEmptyString(thumbnails.fit) || defaultThumbnailConfig.fit; + if (!supportedThumbnailFits.has(fit)) { + return `thumbnails.fit must be one of ${Array.from(supportedThumbnailFits).join(", ")}`; + } + + return null; +} + function hasNonEmptyStringMapEntries(value) { if (!isPlainObject(value)) { return false; @@ -69,12 +143,20 @@ function hasNonEmptyStringMapEntries(value) { return Object.values(value).some((entryValue) => String(entryValue || "").trim()); } -function hasRankResolver(minorRule) { +function getNormalizedRankOrder(rankSource, fallbackRankOrder = []) { + const explicitRankOrder = Array.isArray(rankSource?.rankOrder) ? rankSource.rankOrder : []; + const rankOrderSource = explicitRankOrder.length ? explicitRankOrder : fallbackRankOrder; + return rankOrderSource + .map((entry) => String(entry || "").trim()) + .filter(Boolean); +} + +function hasRankResolver(minorRule, fallbackRankOrder = []) { if (!isPlainObject(minorRule)) { return false; } - const rankOrder = Array.isArray(minorRule.rankOrder) ? minorRule.rankOrder.filter((entry) => String(entry || "").trim()) : []; + const rankOrder = getNormalizedRankOrder(minorRule, fallbackRankOrder); if (rankOrder.length) { return true; } @@ -166,6 +248,27 @@ function validateMinorsRule(minors) { return "minors.mode is required"; } + if (mode === "split-number-template") { + const courtsError = validateMinorNumberTemplateGroup(minors.courts, { + label: "minors.courts", + expectedRankCount: 4 + }); + if (courtsError) { + return courtsError; + } + + const smallsError = validateMinorNumberTemplateGroup(minors.smalls, { + label: "minors.smalls", + expectedRankCount: defaultPipRankOrder.length, + fallbackRankOrder: defaultPipRankOrder + }); + if (smallsError) { + return smallsError; + } + + return null; + } + if (!hasRankResolver(minors)) { return "minors must define rankOrder or rankIndexByKey"; } @@ -205,6 +308,38 @@ function validateMinorsRule(minors) { return `unsupported minors.mode '${mode}'`; } +function validateMinorNumberTemplateGroup(groupRule, options = {}) { + const label = String(options.label || "minor group"); + const expectedRankCount = Number(options.expectedRankCount); + const fallbackRankOrder = Array.isArray(options.fallbackRankOrder) ? options.fallbackRankOrder : []; + + if (!isPlainObject(groupRule)) { + return `${label} must be an object`; + } + + if (!hasRankResolver(groupRule, fallbackRankOrder)) { + return `${label} must define rankOrder or rankIndexByKey`; + } + + if (getRankEntries(groupRule, fallbackRankOrder).length !== expectedRankCount) { + return `${label} must define exactly ${expectedRankCount} ranks`; + } + + if (!hasSuitNumberMap(groupRule.suitBase)) { + return `${label}.suitBase must define numeric bases for wands, cups, swords, and disks`; + } + + if (!asNonEmptyString(groupRule.template)) { + return `${label}.template is required`; + } + + if (groupRule.numberPad != null && !Number.isInteger(Number(groupRule.numberPad))) { + return `${label}.numberPad must be an integer when provided`; + } + + return null; +} + function validateDeckManifest(manifest) { const errors = []; @@ -250,15 +385,16 @@ function validateDeckManifest(manifest) { errors.push("cardBack must be a non-empty string when provided"); } + const thumbnailsError = validateThumbnailConfig(manifest.thumbnails); + if (thumbnailsError) { + errors.push(thumbnailsError); + } + return errors; } -function getRankEntries(minors) { - const rankOrder = Array.isArray(minors?.rankOrder) - ? minors.rankOrder - .map((entry) => String(entry || "").trim()) - .filter(Boolean) - : []; +function getRankEntries(minors, fallbackRankOrder = []) { + const rankOrder = getNormalizedRankOrder(minors, fallbackRankOrder); if (rankOrder.length) { return rankOrder.map((rankWord, rankIndex) => ({ @@ -311,6 +447,13 @@ function getReferencedMinorFiles(manifest) { return []; } + if (mode === "split-number-template") { + return [ + ...getReferencedSplitMinorGroupFiles(minors.courts), + ...getReferencedSplitMinorGroupFiles(minors.smalls, defaultPipRankOrder) + ]; + } + const rankEntries = getRankEntries(minors); if (!rankEntries.length) { return []; @@ -367,6 +510,27 @@ function getReferencedMinorFiles(manifest) { return []; } +function getReferencedSplitMinorGroupFiles(groupRule, fallbackRankOrder = []) { + const rankEntries = getRankEntries(groupRule, fallbackRankOrder); + if (!rankEntries.length) { + return []; + } + + const template = String(groupRule?.template || "{number}.png"); + const numberPad = Number.isInteger(Number(groupRule?.numberPad)) ? Number(groupRule.numberPad) : 2; + + return tarotSuits.flatMap((suitId) => { + const suitBase = Number(groupRule?.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 + })); + }); +} + function getReferencedCardBackFiles(manifest) { const cardBack = asNonEmptyString(manifest?.cardBack); if (!cardBack || isRemoteAssetPath(cardBack)) { @@ -491,6 +655,7 @@ function compileDeckRegistry() { const label = labelFromManifest || folderName; const basePath = `asset/tarot deck/${folderName}`; const cardBackPath = detectDeckCardBackRelativePath(folderName, manifest); + const thumbnailConfig = normalizeThumbnailConfig(manifest.thumbnails); if (seenIds.has(id)) { warnings.push(`Skipped '${folderName}': duplicate deck id '${id}'`); @@ -504,6 +669,7 @@ function compileDeckRegistry() { label, basePath, manifestPath: `${basePath}/deck.json`, + ...(thumbnailConfig && thumbnailConfig !== false ? { thumbnailRoot: thumbnailConfig.root } : {}), ...(cardBackPath ? { cardBackPath } : {}) }); });