mirror of
https://github.com/Since-3/since3-website.git
synced 2026-10-12 19:51:16 +00:00
129 lines
3.9 KiB
TypeScript
129 lines
3.9 KiB
TypeScript
// Pure logic of the live tools. No React here, so it stays easy to test.
|
||
|
||
export type OutputTone = "default" | "muted" | "success" | "error"
|
||
export type OutputLine = { text: string; tone?: OutputTone }
|
||
|
||
/* ── Contrast check (WCAG 2.2) ──────────────────────────────────────── */
|
||
|
||
export function parseHex(input: string): [number, number, number] | null {
|
||
const hex = input.trim().replace(/^#/, "")
|
||
const full =
|
||
hex.length === 3
|
||
? hex
|
||
.split("")
|
||
.map((c) => c + c)
|
||
.join("")
|
||
: hex
|
||
if (!/^[0-9a-f]{6}$/i.test(full)) return null
|
||
return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16)) as [
|
||
number,
|
||
number,
|
||
number,
|
||
]
|
||
}
|
||
|
||
function relativeLuminance([r, g, b]: [number, number, number]) {
|
||
const channel = (value: number) => {
|
||
const c = value / 255
|
||
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||
}
|
||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
||
}
|
||
|
||
export function contrastRatio(
|
||
a: [number, number, number],
|
||
b: [number, number, number]
|
||
) {
|
||
const [light, dark] = [relativeLuminance(a), relativeLuminance(b)].sort(
|
||
(x, y) => y - x
|
||
)
|
||
return (light + 0.05) / (dark + 0.05)
|
||
}
|
||
|
||
export function runContrastCheck(fg: string, bg: string): OutputLine[] {
|
||
const command: OutputLine = {
|
||
text: `$ kontrast --text ${fg} --hintergrund ${bg}`,
|
||
tone: "muted",
|
||
}
|
||
const fgRgb = parseHex(fg)
|
||
const bgRgb = parseHex(bg)
|
||
|
||
if (!fgRgb || !bgRgb) {
|
||
return [
|
||
command,
|
||
{ text: "fehler: bitte Farben als Hex angeben, z. B. #1a1a1a", tone: "error" },
|
||
]
|
||
}
|
||
|
||
// Floor instead of round, otherwise 4.499 would be reported as passing 4.5
|
||
const ratio = Math.floor(contrastRatio(fgRgb, bgRgb) * 100) / 100
|
||
const check = (label: string, min: number): OutputLine => {
|
||
const pass = ratio >= min
|
||
return {
|
||
text: `${label.padEnd(22)}${pass ? "bestanden" : "nicht bestanden"} (min. ${min}:1)`,
|
||
tone: pass ? "success" : "error",
|
||
}
|
||
}
|
||
|
||
return [
|
||
command,
|
||
{ text: `verhältnis ${ratio.toFixed(2)} : 1` },
|
||
check("AA fließtext", 4.5),
|
||
check("AA große schrift", 3),
|
||
check("AA bedienelemente", 3),
|
||
check("AAA fließtext", 7),
|
||
]
|
||
}
|
||
|
||
/* ── Search snippet check ───────────────────────────────────────────── */
|
||
|
||
const TITLE_RANGE = [30, 60] as const
|
||
const DESCRIPTION_RANGE = [70, 160] as const
|
||
|
||
function rangeLine(
|
||
label: string,
|
||
length: number,
|
||
[min, max]: readonly [number, number]
|
||
): OutputLine {
|
||
const ok = length >= min && length <= max
|
||
const hint = length < min ? "zu kurz" : length > max ? "wird abgeschnitten" : "passt"
|
||
return {
|
||
text: `${label.padEnd(16)}${String(length).padStart(3)} zeichen ${hint} (${min}–${max})`,
|
||
tone: ok ? "success" : "error",
|
||
}
|
||
}
|
||
|
||
export function runSnippetCheck(title: string, description: string): OutputLine[] {
|
||
const lines: OutputLine[] = [
|
||
{ text: "$ snippet --pruefen", tone: "muted" },
|
||
rangeLine("titel", title.trim().length, TITLE_RANGE),
|
||
rangeLine("beschreibung", description.trim().length, DESCRIPTION_RANGE),
|
||
]
|
||
|
||
const titleWords = new Set(
|
||
title
|
||
.toLowerCase()
|
||
.split(/[^\p{L}\p{N}]+/u)
|
||
.filter((word) => word.length > 3)
|
||
)
|
||
const shared = description
|
||
.toLowerCase()
|
||
.split(/[^\p{L}\p{N}]+/u)
|
||
.filter((word) => titleWords.has(word))
|
||
|
||
lines.push(
|
||
shared.length > 0
|
||
? { text: `suchbegriff in beiden: ${[...new Set(shared)].join(", ")}`, tone: "success" }
|
||
: {
|
||
text: "suchbegriff titel und beschreibung teilen kein wort",
|
||
tone: "error",
|
||
}
|
||
)
|
||
|
||
return lines
|
||
}
|
||
|
||
export function truncate(text: string, max: number) {
|
||
const trimmed = text.trim()
|
||
return trimmed.length > max ? `${trimmed.slice(0, max - 1).trimEnd()} …` : trimmed
|
||
}
|