mirror of
https://github.com/techr10n/stratus-obsidian-theme.git
synced 2026-07-22 05:00:28 +00:00
327 lines
11 KiB
JavaScript
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}`);
|
|
}
|