204 lines
8.5 KiB
JavaScript
204 lines
8.5 KiB
JavaScript
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;
|
|
}
|