since3-website/modules/landingPage/lib/tools.ts
2026-09-25 00:45:33 +02:00

129 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}