← Files Vocabloot Deck KitARCHIVED FILE
scripts/lib/schema.mjs
9.6 KB · Oct 2, 2026 · 00:34 UTC
// Validators for the deck folder (deck kit spec sections 5 and 6). Each returns
// a list of human-readable problems; [] means valid. Hand-written so the kit
// has no validator dependency and every message names the card key.
import { tokenProblems } from "./tokens.mjs";
export const DECK_ID = /^[a-z]{2}(-[a-z]{2})?-[a-z0-9-]{1,40}$/;
export const LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
export const STICKER_MODES = ["sticker", "symbolic", "contextual", "text-first"];
export const CONFIDENCE = ["high", "medium", "low"];
export const PARTS_OF_SPEECH = [
"noun",
"verb",
"adjective",
"adverb",
"pronoun",
"preposition",
"conjunction",
"numeral",
"interjection",
"phrase",
"particle",
"article",
"determiner",
"other",
];
export const LICENCES = ["CC-BY-SA-4.0", "CC-BY-4.0", "CC-BY-NC-SA-4.0", "CC0-1.0"];
const isStr = (v, min = 1, max = 2000) => typeof v === "string" && v.length >= min && v.length <= max;
const isDate = (v) => typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v);
export function keyOf(card) {
return `${card.text}|${card.article ?? ""}|${card.partOfSpeech ?? ""}`;
}
export function validateDeck(deck) {
const p = [];
if (!deck || typeof deck !== "object") return ["deck.json must be an object"];
if (deck.schemaVersion !== 1) p.push("deck.schemaVersion must be 1");
if (!DECK_ID.test(deck.id ?? ""))
p.push("deck.id must look like de-greetings (learning language code, dash, topic slug)");
if (!isStr(deck.name, 1, 80)) p.push("deck.name is required (up to 80 characters)");
if (!isStr(deck.description, 0, 400)) p.push("deck.description must be a string (up to 400 characters)");
if (!isStr(deck.learningLanguage, 2, 6)) p.push("deck.learningLanguage is required");
if (!isStr(deck.knownLanguage, 2, 6)) p.push("deck.knownLanguage is required");
if (deck.learningLanguage && deck.knownLanguage && deck.learningLanguage === deck.knownLanguage)
p.push("learningLanguage and knownLanguage must differ");
if (deck.learningLanguage && deck.knownLanguage) {
const derived = `${deck.knownLanguage}-to-${deck.learningLanguage}`;
if (deck.direction !== derived) p.push(`deck.direction must be derived from the two languages: ${derived}`);
if (
deck.id &&
!deck.id.startsWith(`${deck.learningLanguage.toLowerCase()}-`) &&
!deck.id.startsWith(`${deck.learningLanguage.slice(0, 2).toLowerCase()}-`)
) {
p.push(
`deck.id should start with the learning language code (${deck.learningLanguage.slice(0, 2).toLowerCase()}-)`,
);
}
}
if (!LEVELS.includes(deck.level)) p.push(`deck.level must be one of ${LEVELS.join(", ")}`);
if (deck.topic != null && !isStr(deck.topic, 1, 60)) p.push("deck.topic must be a short string");
if (!Number.isInteger(deck.wordCount) || deck.wordCount < 1) p.push("deck.wordCount must be a positive integer");
if (!isStr(deck.author, 1, 60)) p.push("deck.author is required (up to 60 characters)");
if (!LICENCES.includes(deck.licence)) p.push(`deck.licence must be one of ${LICENCES.join(", ")}`);
if (!isDate(deck.createdAt)) p.push("deck.createdAt must be YYYY-MM-DD");
if (!isDate(deck.updatedAt)) p.push("deck.updatedAt must be YYYY-MM-DD");
return p;
}
function validateToken(t, where, p) {
if (!isStr(t?.text, 1, 80)) p.push(`${where}: token text is required`);
if (!Number.isInteger(t?.start) || !Number.isInteger(t?.end))
p.push(`${where}: token start and end must be integers`);
if (t?.lemma != null && !isStr(t.lemma, 1, 80)) p.push(`${where}: token lemma must be a string`);
if (t?.partOfSpeech != null && !PARTS_OF_SPEECH.includes(t.partOfSpeech))
p.push(`${where}: token partOfSpeech '${t.partOfSpeech}' is not in the list`);
if (t?.grammarCodes != null && (!Array.isArray(t.grammarCodes) || t.grammarCodes.some((g) => !isStr(g, 1, 40))))
p.push(`${where}: token grammarCodes must be short strings`);
}
/** cards.json, with the language facts deciding which grammar fields are required. */
export function validateCards(cards, { facts = {}, learningLanguage = "", deferred = new Set() } = {}) {
const p = [];
if (!Array.isArray(cards) || cards.length === 0) return ["cards.json must be a non-empty array"];
const f = facts[learningLanguage] ?? {};
const keys = new Set();
cards.forEach((c, i) => {
const where = c?.key ?? `cards[${i}]`;
if (!isStr(c?.key, 3, 200)) p.push(`cards[${i}]: key is required`);
else if (keys.has(c.key)) p.push(`${where}: duplicate key`);
else keys.add(c.key);
if (!isStr(c?.text, 1, 120)) p.push(`${where}: text is required`);
if (c?.key && c?.text && c.key !== keyOf(c))
p.push(`${where}: key must be text|article|partOfSpeech (${keyOf(c)})`);
if (c?.partOfSpeech != null && !PARTS_OF_SPEECH.includes(c.partOfSpeech))
p.push(`${where}: partOfSpeech '${c.partOfSpeech}' is not in the list`);
if (!isStr(c?.phonetic, 0, 120)) p.push(`${where}: phonetic must be a string`);
else if (c.phonetic === "" && c?.review?.flags?.includes("no-phonetic") !== true)
p.push(`${where}: phonetic (IPA) is required; flag no-phonetic to make the omission deliberate`);
if (f.needsRomanization && !isStr(c?.romanization, 1, 120))
p.push(`${where}: romanization is required for ${learningLanguage}`);
if (f.hasGender && c?.partOfSpeech === "noun") {
if (!isStr(c?.article, 1, 20)) p.push(`${where}: article is required for a ${learningLanguage} noun`);
if (!isStr(c?.gender, 1, 20)) p.push(`${where}: gender is required for a ${learningLanguage} noun`);
}
const s = c?.sticker;
if (!s || !STICKER_MODES.includes(s.mode))
p.push(`${where}: sticker.mode must be one of ${STICKER_MODES.join(", ")}`);
else if (s.mode === "text-first" && s.file) p.push(`${where}: a text-first card must not reference a sticker file`);
else if (s.mode !== "text-first" && !isStr(s.file, 1, 200) && !deferred.has(c?.key))
p.push(`${where}: sticker not approved yet; run /deck-stickers and approve, reject or defer it`);
else if (s.mode !== "text-first" && !isStr(s.concept, 3, 200))
p.push(`${where}: sticker.concept (what the picture shows) is required for mode ${s.mode}`);
if (!Array.isArray(c?.examples) || c.examples.length !== 2)
p.push(`${where}: exactly two example sentences are required`);
else {
c.examples.forEach((ex, j) => {
const w = `${where} example ${j + 1}`;
if (!isStr(ex?.source, 2, 300)) p.push(`${w}: source sentence is required`);
if (!Array.isArray(ex?.tokens) || ex.tokens.length === 0) p.push(`${w}: tokens are required`);
else {
ex.tokens.forEach((t, k) => validateToken(t, `${w} token ${k + 1}`, p));
if (isStr(ex?.source)) tokenProblems(ex.source, ex.tokens).forEach((m) => p.push(`${w}: ${m}`));
ex.tokens.forEach((t) => {
if (!isStr(t?.lemma) && !isStr(t?.partOfSpeech))
p.push(`${w} token '${t?.text}': lemma or partOfSpeech is required`);
});
}
});
}
const r = c?.review;
if (!r || !CONFIDENCE.includes(r.confidence) || typeof r.safe !== "boolean" || !Array.isArray(r.flags)) {
p.push(`${where}: review needs confidence (high, medium, low), safe (boolean) and flags (array)`);
}
});
return p;
}
/** localizations/<known>.json against the cards it localizes. */
export function validateLocalization(loc, cards) {
const p = [];
if (!Array.isArray(loc)) return ["localization must be an array"];
const byKey = new Map(loc.map((l) => [l?.key, l]));
if (byKey.size !== loc.length) p.push("localization has duplicate keys");
for (const c of cards ?? []) {
const l = byKey.get(c.key);
if (!l) {
p.push(`${c.key}: no localization entry`);
continue;
}
if (!isStr(l.translation, 1, 200)) p.push(`${c.key}: translation is required`);
if (!Array.isArray(l.examples) || l.examples.length !== (c.examples?.length ?? 0)) {
p.push(`${c.key}: localization must carry one translation per example sentence`);
continue;
}
l.examples.forEach((le, j) => {
const w = `${c.key} example ${j + 1}`;
if (!isStr(le?.target, 2, 300)) p.push(`${w}: translation is required`);
const n = c.examples?.[j]?.tokens?.length ?? 0;
if (!Array.isArray(le?.tokens) || le.tokens.length !== n)
p.push(`${w}: ${le?.tokens?.length ?? 0} token meanings for ${n} tokens`);
else {
le.tokens.forEach((m, k) => {
if (
!Array.isArray(m?.meanings) ||
m.meanings.length < 1 ||
m.meanings.length > 3 ||
m.meanings.some((x) => !isStr(x, 1, 80))
) {
p.push(`${w} token '${c.examples[j].tokens[k]?.text}': one to three meanings are required`);
}
});
}
});
}
for (const l of loc)
if (!(cards ?? []).some((c) => c.key === l?.key)) p.push(`localization entry ${l?.key} has no card`);
return p;
}
/** review/review.json: the cards a creator deferred (ship text-only for now, keep the picture idea). */
export function deferredKeys(review) {
return new Set(
Object.entries(review?.stickers ?? {})
.filter(([, v]) => (v?.status ?? v) === "deferred")
.map(([k]) => k),
);
}
/** review/review.json: sticker approvals; a deferred sticker is a decision too. */
export function validateReview(review, cards) {
const p = [];
const stickers = review?.stickers ?? {};
for (const c of cards ?? []) {
if (c.sticker?.mode === "text-first") continue;
const status = stickers[c.key]?.status ?? stickers[c.key];
if (status === "deferred") continue;
if (status !== "approved")
p.push(`${c.key}: sticker is not approved in review/review.json (status: ${status ?? "none"})`);
}
return p;
}
SHA-256: 22fea78ccfade0bb391b7a39798aa0c74b4d7a592247d5153010b9e98930e16b