diff --git a/css/style.css b/css/style.css index b0e1183..8f75498 100644 --- a/css/style.css +++ b/css/style.css @@ -189,8 +189,10 @@ button { /* ── side art ─────────────────────────────────────────────────────────── Full-height sakura in both margins: ANSI art (inc/art/sakura.ans, `cat` - it), turned into HTML by inc/art.php and mirrored for the right side so the - branches hang in from the outer corners. Hidden when there's no margin. */ + it), turned by inc/art.php into one text layer per color, served as + sideart.php's ::before content on the empty s and mirrored for the right + side so the branches hang in from the outer corners. Hidden when there's + no margin. */ /* The art is drawn for .6em-wide characters; system monospaces range from .5em up, which squashes it. So it gets its own: JetBrains Mono's printable @@ -217,7 +219,7 @@ button { } /* the art comes as one layer per color, stacked */ .side { display: grid; } -.side > span { grid-area: 1 / 1; direction: ltr; unicode-bidi: isolate; } +.side > b { grid-area: 1 / 1; font-weight: normal; direction: ltr; unicode-bidi: isolate; } .side-left { left: 0; } /* rtl only so the right panel clips on its inner edge; the layers stay ltr (above), or the bidi algorithm reorders runs of punctuation */ diff --git a/inc/art.php b/inc/art.php index 80fd093..b3f8a13 100644 --- a/inc/art.php +++ b/inc/art.php @@ -1,9 +1,15 @@ [] {} so shapes still read. + * ANSI art (a .ans file you can `cat` in a terminal) for the side panels. + * Understands foreground colors: truecolor (38;2;r;g;b), 256-color (38;5;n), + * the 16 basic ones, and resets; everything else is ignored. The right panel + * is the left mirrored, swapping /\ () <> [] {} so shapes still read. + * + * The art ships as a stylesheet (sideart.php), not HTML: one full-size text + * layer per color, each the `content` of a ::before on an empty , stacked + * by style.css. It's render-blocking and cached, so the first paint already + * has it; inline, it made the page too big for one network round trip and + * Firefox painted the page half-arrived (a dark empty flash) on cached loads. */ function ansi_color(array $p, ?string $cur): ?string @@ -38,35 +44,55 @@ function ansi_color(array $p, ?string $cur): ?string return $cur; } -/** ansi_html(), cached on disk until the .ans file changes (parsing costs ~30 ms a side). */ -function ansi_html_cached(string $file, bool $mirror = false): string +/** + * The stylesheet for both panels, cached on disk until the .ans file (or this + * file) changes. Returns [path to the css, layers on the left, on the right]. + */ +function ansi_css_cached(string $file): array { $dir = (defined('DATA_DIR') ? DATA_DIR : sys_get_temp_dir()) . '/art-cache'; - $base = $dir . '/' . md5($file . '|' . (int) $mirror); - $key = $base . '-' . filemtime($file) . '-layers.html'; // bump the suffix when the markup changes - if (is_file($key)) { - return (string) file_get_contents($key); + $base = $dir . '/' . md5($file); + $key = $base . '-' . filemtime($file) . '-' . filemtime(__FILE__); + if (!is_file("$key.css") || !is_file("$key.json")) { + @mkdir($dir, 0755, true); + array_map('unlink', glob($base . '-*') ?: []); // every deploy bumps the mtimes + $css = ''; + $counts = []; + foreach (['left' => false, 'right' => true] as $side => $mirror) { + $layers = ansi_layers($file, $mirror); + $counts[$side] = count($layers); + foreach ($layers as $i => [$color, $text]) { + $css .= ".side-$side > b:nth-child(" . ($i + 1) . ')::before{content:' . ansi_css_string($text) + . ($color !== '' ? ";color:#$color" : '') . "}\n"; + } + } + foreach (["$key.css" => $css, "$key.json" => json_encode($counts)] as $path => $data) { + $tmp = $path . '.' . getmypid(); + if (@file_put_contents($tmp, $data) !== false) { + rename($tmp, $path); + } + } } - $html = ansi_html($file, $mirror); - @mkdir($dir, 0755, true); - array_map('unlink', glob($base . '-*.html') ?: []); // every deploy bumps the mtime - $tmp = $key . '.' . getmypid(); - if (@file_put_contents($tmp, $html) !== false) { - rename($tmp, $key); - } - return $html; + return ["$key.css", ...array_values((array) json_decode((string) @file_get_contents("$key.json"), true))]; } -function ansi_html(string $file, bool $mirror = false): string +/** A CSS string literal; newlines become \A (the space after it ends the escape). */ +function ansi_css_string(string $text): string +{ + return '"' . str_replace(['\\', '"', "\n"], ['\\\\', '\\"', '\\A '], $text) . '"'; +} + +/** The art as [[hex color or '', text], ...]: one full-size layer per color. */ +function ansi_layers(string $file, bool $mirror = false): array { $raw = (string) file_get_contents($file); if (!preg_match('/[\x80-\xff]/', $raw)) { - $html = ansi_html_bytes($raw, $mirror); - if ($html !== null) { - return $html; + $layers = ansi_layers_bytes($raw, $mirror); + if ($layers !== null) { + return $layers; } } - return ansi_html_cells($raw, $mirror); + return ansi_layers_cells($raw, $mirror); } /** @@ -112,7 +138,7 @@ function ansi_parse_bytes(string $raw): ?array * color layer is cut out of the whole art with bitwise string ops, which run * in C. Returns null if there are too many colors to index in a byte. */ -function ansi_html_bytes(string $raw, bool $mirror): ?string +function ansi_layers_bytes(string $raw, bool $mirror): ?array { static $from = null, $to = null; // byte 0 -> 0xff, anything else -> 0 $from ??= implode(array_map('chr', range(0, 255))); @@ -139,21 +165,19 @@ function ansi_html_bytes(string $raw, bool $mirror): ?string $blank = implode("\n", array_fill(0, count($chars), str_repeat(' ', $width))); $n = strlen($all); - $html = ''; + $layers = []; foreach ($index as $color => $k) { $sel = strtr($mask ^ str_repeat(chr($k), $n), $from, $to); // 0xff where this color, else 0 $layer = ($all & $sel) | ($blank & ~$sel); - if (trim($layer) === '') { - continue; // only blanks in this color + if (trim($layer) !== '') { // skip colors that only touch blanks + $layers[] = [(string) $color, $layer]; } - $style = $color !== '' ? " style=\"color:#$color\"" : ''; - $html .= "" . htmlspecialchars($layer) . ''; } - return $html; + return $layers; } /** General path: a [char, color] cell at a time, so multibyte characters work. */ -function ansi_html_cells(string $raw, bool $mirror): string +function ansi_layers_cells(string $raw, bool $mirror): array { static $flip = ['/' => '\\', '\\' => '/', '(' => ')', ')' => '(', '<' => '>', '>' => '<', '[' => ']', ']' => '[', '{' => '}', '}' => '{']; @@ -183,9 +207,6 @@ function ansi_html_cells(string $raw, bool $mirror): string } unset($cells); - // One full-size text layer per color, holding only that color's - // characters; CSS stacks them. A few dozen elements instead of a span per - // color run (thousands), which is what made the art slow to lay out. $colors = []; foreach ($lines as $cells) { foreach ($cells as [$ch, $color]) { @@ -194,7 +215,7 @@ function ansi_html_cells(string $raw, bool $mirror): string } } } - $html = ''; + $layers = []; foreach (array_keys($colors) as $color) { $rows = []; foreach ($lines as $cells) { @@ -204,8 +225,7 @@ function ansi_html_cells(string $raw, bool $mirror): string } $rows[] = $row; } - $style = $color !== '' ? " style=\"color:#$color\"" : ''; - $html .= "" . htmlspecialchars(implode("\n", $rows)) . ''; + $layers[] = [(string) $color, implode("\n", $rows)]; } - return $html; + return $layers; } diff --git a/inc/footer.php b/inc/footer.php index aeaa67d..b33411e 100644 --- a/inc/footer.php +++ b/inc/footer.php @@ -10,8 +10,7 @@ $build = preg_match('/[0-9a-f]{7,40}/i', $build, $m) ? $m[0] : ($build ?: 'dev') © · build: - - - + + diff --git a/inc/header.php b/inc/header.php index 2d9adf8..7c04493 100644 --- a/inc/header.php +++ b/inc/header.php @@ -1,5 +1,7 @@ @@ -10,6 +12,7 @@ require_once __DIR__ . '/../config.php'; <?= htmlspecialchars($page_title ?? SITE_TITLE) ?> + diff --git a/sideart.php b/sideart.php new file mode 100644 index 0000000..4676b5a --- /dev/null +++ b/sideart.php @@ -0,0 +1,10 @@ +