import { createHash } from "node:crypto"; const trigramPatterns = Object.freeze({ QIAN: ["YANG", "YANG", "YANG"], DUI: ["YANG", "YANG", "YIN"], LI: ["YANG", "YIN", "YANG"], ZHEN: ["YANG", "YIN", "YIN"], XUN: ["YIN", "YANG", "YANG"], KAN: ["YIN", "YANG", "YIN"], GEN: ["YIN", "YIN", "YANG"], KUN: ["YIN", "YIN", "YIN"], }); const kingWenPairs = Object.freeze([ ["QIAN", "QIAN"], ["KUN", "KUN"], ["KAN", "ZHEN"], ["GEN", "KAN"], ["KAN", "QIAN"], ["QIAN", "KAN"], ["KUN", "KAN"], ["KAN", "KUN"], ["XUN", "QIAN"], ["QIAN", "DUI"], ["KUN", "QIAN"], ["QIAN", "KUN"], ["QIAN", "LI"], ["LI", "QIAN"], ["KUN", "GEN"], ["ZHEN", "KUN"], ["DUI", "ZHEN"], ["GEN", "XUN"], ["KUN", "DUI"], ["XUN", "KUN"], ["LI", "ZHEN"], ["GEN", "LI"], ["GEN", "KUN"], ["KUN", "ZHEN"], ["QIAN", "ZHEN"], ["GEN", "QIAN"], ["GEN", "ZHEN"], ["DUI", "XUN"], ["KAN", "KAN"], ["LI", "LI"], ["DUI", "GEN"], ["ZHEN", "XUN"], ["QIAN", "GEN"], ["ZHEN", "QIAN"], ["LI", "KUN"], ["KUN", "LI"], ["XUN", "LI"], ["LI", "DUI"], ["KAN", "GEN"], ["ZHEN", "KAN"], ["GEN", "DUI"], ["XUN", "ZHEN"], ["DUI", "QIAN"], ["QIAN", "XUN"], ["DUI", "KUN"], ["KUN", "XUN"], ["DUI", "KAN"], ["KAN", "XUN"], ["DUI", "LI"], ["LI", "XUN"], ["ZHEN", "ZHEN"], ["GEN", "GEN"], ["XUN", "GEN"], ["ZHEN", "DUI"], ["ZHEN", "LI"], ["LI", "GEN"], ["XUN", "XUN"], ["DUI", "DUI"], ["XUN", "KAN"], ["KAN", "DUI"], ["XUN", "DUI"], ["ZHEN", "GEN"], ["KAN", "LI"], ["LI", "KAN"], ]); const kingWenByPair = new Map( kingWenPairs.map(([upper, lower], index) => [`${upper}/${lower}`, index + 1]), ); const unsafeText = /(?:<\s*script\b|javascript\s*:|\bon\w+\s*=|[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f])/iu; function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function nonBlank(value) { return typeof value === "string" && value.trim().length > 0; } function validateText(value, path, errors) { if (!nonBlank(value)) { errors.push(`${path} must be non-blank text`); } else if (unsafeText.test(value)) { errors.push(`${path} contains script-like markup or an invisible control character`); } } function validateSixTexts(value, path, errors) { if (!Array.isArray(value) || value.length !== 6) { errors.push(`${path} must contain exactly six bottom-up entries`); return; } value.forEach((text, index) => validateText(text, `${path}[${index}]`, errors)); } function canonicalize(value) { if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; if (isObject(value)) { return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(",")}}`; } return JSON.stringify(value); } export function contentDigest(contentPackage) { return createHash("sha256").update(canonicalize(contentPackage), "utf8").digest("hex"); } export function kingWenEntries() { return kingWenPairs.map(([upperTrigram, lowerTrigram], index) => ({ kingWenNumber: index + 1, upperTrigram, lowerTrigram, patternBottomUp: [...trigramPatterns[lowerTrigram], ...trigramPatterns[upperTrigram]], })); } export function validateContentPackage(contentPackage) { const errors = []; if (!isObject(contentPackage)) return ["content package must be an object"]; if (contentPackage.schemaVersion !== 1) errors.push("schemaVersion must be 1"); validateText(contentPackage.contentVersion, "contentVersion", errors); const usage = contentPackage.specialUsageTexts; if (!isObject(usage) || typeof usage.qian !== "boolean" || typeof usage.kun !== "boolean") { errors.push("specialUsageTexts must declare boolean qian and kun flags"); } const sourceIds = new Set(); if (!Array.isArray(contentPackage.sources) || contentPackage.sources.length === 0) { errors.push("sources must contain at least one licensed source"); } else { contentPackage.sources.forEach((source, index) => { const prefix = `sources[${index}]`; if (!isObject(source)) { errors.push(`${prefix} must be an object`); return; } for (const field of ["id", "title", "edition", "license"]) { validateText(source[field], `${prefix}.${field}`, errors); } if (sourceIds.has(source.id)) errors.push(`${prefix}.id must be unique`); if (nonBlank(source.id)) sourceIds.add(source.id); try { const url = new URL(source.url); if (!["http:", "https:"].includes(url.protocol)) throw new Error("unsupported protocol"); } catch { errors.push(`${prefix}.url must be an absolute HTTP(S) URL`); } }); } if (!Array.isArray(contentPackage.hexagrams) || contentPackage.hexagrams.length !== 64) { errors.push("hexagrams must contain exactly 64 entries"); return errors; } const seenIds = new Set(); const seenPatterns = new Set(); contentPackage.hexagrams.forEach((hexagram, index) => { const prefix = `hexagrams[${index}]`; if (!isObject(hexagram)) { errors.push(`${prefix} must be an object`); return; } if (!Number.isInteger(hexagram.kingWenNumber) || hexagram.kingWenNumber < 1 || hexagram.kingWenNumber > 64) { errors.push(`${prefix}.kingWenNumber must be an integer from 1 through 64`); } else if (seenIds.has(hexagram.kingWenNumber)) { errors.push(`${prefix}.kingWenNumber must be unique`); } else { seenIds.add(hexagram.kingWenNumber); } for (const field of ["name", "symbol", "judgmentOriginal", "judgmentPlain"]) { validateText(hexagram[field], `${prefix}.${field}`, errors); } validateSixTexts(hexagram.lineTextsBottomUp, `${prefix}.lineTextsBottomUp`, errors); validateSixTexts(hexagram.linePlainBottomUp, `${prefix}.linePlainBottomUp`, errors); const lowerPattern = trigramPatterns[hexagram.lowerTrigram]; const upperPattern = trigramPatterns[hexagram.upperTrigram]; if (!lowerPattern) errors.push(`${prefix}.lowerTrigram is unknown`); if (!upperPattern) errors.push(`${prefix}.upperTrigram is unknown`); if (!Array.isArray(hexagram.patternBottomUp) || hexagram.patternBottomUp.length !== 6 || hexagram.patternBottomUp.some((line) => line !== "YIN" && line !== "YANG")) { errors.push(`${prefix}.patternBottomUp must contain exactly six YIN/YANG values`); } else { const encoded = hexagram.patternBottomUp.join("/"); if (seenPatterns.has(encoded)) errors.push(`${prefix}.patternBottomUp must be unique`); seenPatterns.add(encoded); if (lowerPattern && upperPattern) { const expected = [...lowerPattern, ...upperPattern]; if (encoded !== expected.join("/")) { errors.push(`${prefix}.patternBottomUp does not match lower/upper trigrams in bottom-up order`); } } } if (lowerPattern && upperPattern) { const expectedId = kingWenByPair.get(`${hexagram.upperTrigram}/${hexagram.lowerTrigram}`); if (hexagram.kingWenNumber !== expectedId) { errors.push(`${prefix}.kingWenNumber does not match its King Wen trigram pair`); } } if (!Array.isArray(hexagram.sourceRefs) || hexagram.sourceRefs.length === 0) { errors.push(`${prefix}.sourceRefs must not be empty`); } else { for (const sourceRef of hexagram.sourceRefs) { if (!sourceIds.has(sourceRef)) errors.push(`${prefix}.sourceRefs contains unknown source '${sourceRef}'`); } if (new Set(hexagram.sourceRefs).size !== hexagram.sourceRefs.length) { errors.push(`${prefix}.sourceRefs must be unique`); } } const hasSpecialText = nonBlank(hexagram.specialUsageText); if (hexagram.specialUsageText !== null && !hasSpecialText) { errors.push(`${prefix}.specialUsageText must be non-blank text or null`); } if (hasSpecialText) validateText(hexagram.specialUsageText, `${prefix}.specialUsageText`, errors); if (hexagram.kingWenNumber === 1 && isObject(usage) && hasSpecialText !== usage.qian) { errors.push(`${prefix}.specialUsageText must match specialUsageTexts.qian`); } else if (hexagram.kingWenNumber === 2 && isObject(usage) && hasSpecialText !== usage.kun) { errors.push(`${prefix}.specialUsageText must match specialUsageTexts.kun`); } else if (![1, 2].includes(hexagram.kingWenNumber) && hexagram.specialUsageText !== null) { errors.push(`${prefix}.specialUsageText is only valid for Qian or Kun`); } }); if (seenIds.size !== 64) errors.push("King Wen numbers 1 through 64 must each occur exactly once"); if (seenPatterns.size !== 64) errors.push("all 64 polarity patterns must each occur exactly once"); return errors; }