127 lines
3.9 KiB
TypeScript
127 lines
3.9 KiB
TypeScript
import {
|
|
DOMParser,
|
|
} from "https://deno.land/x/deno_dom@v0.1.46/deno-dom-wasm.ts";
|
|
import * as css from "https://deno.land/x/css@0.3.0/mod.ts";
|
|
|
|
// so, so brittle
|
|
function is_valid_color(color: string): boolean {
|
|
try {
|
|
const { stylesheet } = css.parse(`*{color:${color};}`);
|
|
return color.toLowerCase() != "example"
|
|
&& stylesheet.rules.length == 1
|
|
&& stylesheet.rules[0].declarations.length == 1;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// maybe a bit high, maybe a bit low
|
|
const MAX_AGE_MS = 10 * 60 * 1000;
|
|
|
|
// > "light-dark(oklab(000, 000, 000), oklab(255, 255, 255))".length
|
|
// 54
|
|
const MAX_COLOR_LEN = 512;
|
|
|
|
const colors: Record<
|
|
string,
|
|
{ color: string | undefined; last_checked: Date }
|
|
> = {};
|
|
const dom_parser = new DOMParser();
|
|
|
|
function parse_color(str: string): string | undefined {
|
|
const text = str.replace(/^#/, "");
|
|
if (text.startsWith("name-color:")) {
|
|
const line = text.split("\n", 1)[0];
|
|
const color = line.split("name-color:")[1]
|
|
.trim()
|
|
.slice(0, MAX_COLOR_LEN)
|
|
.replaceAll(/[;{}"']|\/\*|\*\//g, "");
|
|
if (is_valid_color(color)) {
|
|
return color;
|
|
} else {
|
|
console.log(`invalid color!!!! ${color}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function get_name_color(name: string): Promise<string | undefined> {
|
|
console.log(`fetching ${name}...`);
|
|
const resp = await fetch(`https://cohost.org/${name}`);
|
|
if (!resp.ok) {
|
|
console.log(`fetching https://cohost.org/${name} failed!`);
|
|
return;
|
|
}
|
|
const body = await resp.text();
|
|
const dom = dom_parser.parseFromString(body, "text/html");
|
|
if (!dom) {
|
|
console.log(`parsing https://cohost.org/${name} to the dom failed!`);
|
|
return;
|
|
}
|
|
|
|
const a = dom.querySelector("a[href*='name-color:']");
|
|
if (a) {
|
|
const href = a.getAttribute("href");
|
|
if (href) {
|
|
const color = parse_color(href);
|
|
if (color) return color;
|
|
}
|
|
}
|
|
|
|
for (const p of dom.querySelectorAll("p")) {
|
|
if (!p.textContent.includes("name-color:")) continue;
|
|
|
|
for (const node of p.childNodes) {
|
|
const color = parse_color(node.textContent);
|
|
if (color) return color;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function update_name_color(name: string) {
|
|
const now = new Date();
|
|
if (
|
|
colors[name] &&
|
|
now.getTime() - colors[name].last_checked.getTime() <= MAX_AGE_MS
|
|
) {
|
|
return;
|
|
}
|
|
const color = await get_name_color(name);
|
|
colors[name] = { color, last_checked: now };
|
|
}
|
|
|
|
function cssify({ requester }: { requester?: string }): string {
|
|
const preamble = `/* customized: ${
|
|
Object.keys(colors)
|
|
.filter((name) => colors[name].color)
|
|
.sort()
|
|
.join(", ")
|
|
} */\n/* look, i don't like !important either, but i think it's a better choice than some lame .spec.ific.ity.hack.ing */\n\n`;
|
|
|
|
let vars = `:root {\n`;
|
|
if (requester && colors[requester]) {
|
|
vars += `--name-color: var(--name-color-${requester.toLowerCase()});\n`;
|
|
}
|
|
let classes = "";
|
|
for (const name of Object.keys(colors).sort()) {
|
|
const { color } = colors[name];
|
|
if (!color) continue;
|
|
vars += `--name-color-${name.toLowerCase()}: ${color};\n`;
|
|
classes +=
|
|
`a[href$='/${name}' i]{color: var(--name-color-${name.toLowerCase()}) !important;}\n`;
|
|
}
|
|
vars += "}\n\n";
|
|
return preamble + vars + classes;
|
|
}
|
|
|
|
Deno.serve({ port: 61266 }, async (req) => {
|
|
const url = new URL(req.url);
|
|
const names = url.searchParams.getAll("name").filter((name) =>
|
|
!name.match(/[\/\\?"';{}]/)
|
|
).map((name) => name.toLowerCase());
|
|
await Promise.all(names.map(update_name_color));
|
|
const css = cssify({
|
|
requester: url.searchParams.get("requester") ?? undefined,
|
|
});
|
|
return new Response(css, { headers: { "content-type": "text/css" } });
|
|
});
|