techr10n_stratus-obsidian-t.../scripts/validate.mjs

327 lines
11 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import process from "node:process";
const root = process.cwd();
const errors = [];
const notes = [];
function fail(message) {
errors.push(message);
}
function pass(message) {
notes.push(message);
}
function read(relativePath) {
const absolutePath = path.join(root, relativePath);
if (!fs.existsSync(absolutePath)) {
fail(`Missing required file: ${relativePath}`);
return "";
}
return fs.readFileSync(absolutePath, "utf8");
}
function parseJson(relativePath, source) {
if (!source) return null;
try {
return JSON.parse(source);
} catch (error) {
fail(`${relativePath} is not valid JSON: ${error.message}`);
return null;
}
}
function findBlock(source, selector) {
const start = source.indexOf(selector);
if (start === -1) return "";
const open = source.indexOf("{", start + selector.length);
if (open === -1) return "";
let depth = 1;
let quote = "";
let inComment = false;
for (let index = open + 1; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (inComment) {
if (char === "*" && next === "/") {
inComment = false;
index += 1;
}
continue;
}
if (quote) {
if (char === "\\") {
index += 1;
} else if (char === quote) {
quote = "";
}
continue;
}
if (char === "/" && next === "*") {
inComment = true;
index += 1;
} else if (char === '"' || char === "'") {
quote = char;
} else if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) return source.slice(open + 1, index);
}
}
return "";
}
function validateCssStructure(source) {
let depth = 0;
let quote = "";
let inComment = false;
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (inComment) {
if (char === "*" && next === "/") {
inComment = false;
index += 1;
}
continue;
}
if (quote) {
if (char === "\\") index += 1;
else if (char === quote) quote = "";
continue;
}
if (char === "/" && next === "*") {
inComment = true;
index += 1;
} else if (char === '"' || char === "'") {
quote = char;
} else if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth < 0) {
fail(`theme.css has an unexpected closing brace near byte ${index}`);
return;
}
}
}
if (inComment) fail("theme.css contains an unclosed comment");
if (quote) fail("theme.css contains an unclosed quoted string");
if (depth !== 0) fail(`theme.css has ${depth} unclosed block(s)`);
if (!inComment && !quote && depth === 0) pass("CSS syntax structure is balanced");
}
function collectVariables(source) {
const variables = new Map();
const pattern = /(--[a-zA-Z0-9_-]+)\s*:\s*([^;{}]+);/g;
for (const match of source.matchAll(pattern)) {
variables.set(match[1], match[2].trim());
}
return variables;
}
function resolveColor(name, variables, seen = new Set()) {
if (seen.has(name)) return null;
seen.add(name);
const value = variables.get(name);
if (!value) return null;
const variableReference = value.match(/^var\(\s*(--[a-zA-Z0-9_-]+)(?:\s*,[^)]*)?\s*\)$/);
if (variableReference) return resolveColor(variableReference[1], variables, seen);
const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!hex) return null;
const digits = hex[1].length === 3
? [...hex[1]].map((digit) => digit + digit).join("")
: hex[1];
return [0, 2, 4].map((offset) => Number.parseInt(digits.slice(offset, offset + 2), 16));
}
function luminance([red, green, blue]) {
const channels = [red, green, blue].map((channel) => {
const normalized = channel / 255;
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
});
return (0.2126 * channels[0]) + (0.7152 * channels[1]) + (0.0722 * channels[2]);
}
function contrast(first, second) {
const lighter = Math.max(luminance(first), luminance(second));
const darker = Math.min(luminance(first), luminance(second));
return (lighter + 0.05) / (darker + 0.05);
}
function checkContrast(label, foregroundName, backgroundName, minimum, variables) {
const foreground = resolveColor(foregroundName, variables);
const background = resolveColor(backgroundName, variables);
if (!foreground || !background) {
fail(`Could not resolve ${label} colors (${foregroundName} on ${backgroundName})`);
return;
}
const ratio = contrast(foreground, background);
if (ratio < minimum) {
fail(`${label} contrast is ${ratio.toFixed(2)}:1; expected at least ${minimum}:1`);
} else {
pass(`${label} contrast is ${ratio.toFixed(2)}:1`);
}
}
function validatePng(relativePath) {
const absolutePath = path.join(root, relativePath);
if (!fs.existsSync(absolutePath)) {
fail(`Missing required file: ${relativePath}`);
return;
}
const image = fs.readFileSync(absolutePath);
const signature = "89504e470d0a1a0a";
if (image.length < 24 || image.subarray(0, 8).toString("hex") !== signature) {
fail(`${relativePath} is not a valid PNG`);
return;
}
const width = image.readUInt32BE(16);
const height = image.readUInt32BE(20);
if (width !== 512 || height !== 288) {
fail(`${relativePath} is ${width}x${height}; expected 512x288`);
} else {
pass(`${relativePath} is the recommended 512x288`);
}
if (image.length > 1_000_000) fail(`${relativePath} should remain below 1 MB`);
}
const manifestSource = read("manifest.json");
const themeCss = read("theme.css");
const readme = read("README.md");
const license = read("LICENSE");
const thirdPartyNotices = read("THIRD_PARTY_NOTICES.md");
const packageSource = read("package.json");
const showcase = read(path.join("tests", "Theme showcase.md"));
const manifest = parseJson("manifest.json", manifestSource);
const packageJson = parseJson("package.json", packageSource);
const semver = /^\d+\.\d+\.\d+$/;
if (manifest) {
for (const field of ["name", "version", "minAppVersion", "author"]) {
if (typeof manifest[field] !== "string" || !manifest[field].trim()) {
fail(`manifest.json requires a non-empty string field: ${field}`);
}
}
if (manifest.name !== "Stratus") fail('manifest name must be exactly "Stratus"');
if (/obsidian|theme/i.test(manifest.name ?? "")) {
fail('manifest name may not contain "Obsidian" or "Theme"');
}
if (!semver.test(manifest.version ?? "")) fail("manifest version must use x.y.z");
if (!semver.test(manifest.minAppVersion ?? "")) fail("minAppVersion must use x.y.z");
if (packageJson?.version !== manifest.version) {
fail("package.json and manifest.json versions must match");
}
if (!themeCss.includes(`@version ${manifest.version}`)) {
fail("theme.css metadata version must match manifest.json");
}
pass(`Manifest identifies ${manifest.name} ${manifest.version}`);
}
if (themeCss) {
validateCssStructure(themeCss);
const forbidden = [
[/@import\b/i, "remote or imported styles"],
[/url\(\s*["']?(?:https?:)?\/\//i, "remote assets"],
[/!important\b/i, "!important declarations"],
[/:has\s*\(/i, ":has() selectors"],
[/letter-spacing\s*:/i, "custom letter spacing"],
[/^\s*text-decoration-(?:color|thickness)\s*:/im, "partially supported text-decoration declarations"],
];
for (const [pattern, label] of forbidden) {
if (pattern.test(themeCss)) fail(`theme.css contains forbidden ${label}`);
}
const coverage = [
".theme-dark",
".markdown-rendered",
".markdown-source-view",
".callout",
".metadata-container",
".workspace-tab-header",
".nav-file-title",
".menu",
".modal",
".setting-item",
".graph-controls",
".canvas-node-container",
".bases-view",
".is-mobile",
"prefers-contrast",
"prefers-reduced-motion",
];
for (const selector of coverage) {
if (!themeCss.includes(selector)) fail(`theme.css is missing expected coverage: ${selector}`);
}
pass("Core editor, application, mobile, and accessibility surfaces are covered");
const darkBlock = findBlock(themeCss, ".theme-dark");
if (!darkBlock) {
fail("Could not locate a complete .theme-dark block");
} else {
const variables = collectVariables(darkBlock);
checkContrast("Primary text", "--text-normal", "--background-primary", 7, variables);
checkContrast("Muted text", "--text-muted", "--background-primary", 4.5, variables);
checkContrast("Default link text", "--stratus-sky", "--background-primary", 4.5, variables);
checkContrast("CTA text", "--text-on-accent", "--stratus-orange", 4.5, variables);
checkContrast("Unresolved link text", "--link-unresolved-color", "--background-primary", 4.5, variables);
checkContrast("Unchecked control border", "--checkbox-border-color", "--background-primary", 3, variables);
checkContrast("Error state text", "--text-on-accent", "--background-modifier-error", 4.5, variables);
checkContrast("Success state text", "--text-on-accent", "--background-modifier-success", 4.5, variables);
}
const lightBlock = findBlock(themeCss, ".theme-light");
if (lightBlock) {
const variables = collectVariables(lightBlock);
checkContrast("Light primary text", "--text-normal", "--background-primary", 7, variables);
checkContrast("Light muted text", "--text-muted", "--background-primary", 4.5, variables);
checkContrast("Light default link text", "--stratus-sky", "--background-primary", 4.5, variables);
checkContrast("Light CTA text", "--text-on-accent", "--stratus-orange", 4.5, variables);
checkContrast("Light unresolved link text", "--link-unresolved-color", "--background-primary", 4.5, variables);
checkContrast("Light unchecked control border", "--checkbox-border-color", "--background-primary", 3, variables);
checkContrast("Light error state text", "--text-on-accent", "--background-modifier-error", 4.5, variables);
checkContrast("Light success state text", "--text-on-accent", "--background-modifier-success", 4.5, variables);
}
}
if (readme) {
for (const phrase of ["manual installation", "not affiliated", "dark", "Kumo"]) {
if (!readme.toLowerCase().includes(phrase.toLowerCase())) {
fail(`README.md should mention: ${phrase}`);
}
}
}
if (license && !/GNU GENERAL PUBLIC LICENSE[\s\S]*Version 3, 29 June 2007/i.test(license)) {
fail("LICENSE should contain the canonical GNU GPL version 3 text");
}
if (packageJson?.license !== "GPL-3.0-only") {
fail("package.json should identify GPL-3.0-only");
}
if (thirdPartyNotices && !/MIT License[\s\S]*Copyright \(c\) 2026 Cloudflare, Inc\./i.test(thirdPartyNotices)) {
fail("THIRD_PARTY_NOTICES.md should retain Kumo's MIT notice");
}
if (showcase) {
for (const sample of ["[!note]", "|", "```", "- [ ]", "---"]) {
if (!showcase.includes(sample)) fail(`Theme showcase is missing sample: ${sample}`);
}
}
validatePng("screenshot.png");
if (errors.length) {
console.error(`\nStratus validation failed with ${errors.length} issue(s):`);
for (const error of errors) console.error(` - ${error}`);
process.exitCode = 1;
} else {
console.log(`\nStratus validation passed (${notes.length} checks).`);
for (const note of notes) console.log(` - ${note}`);
}