ship the side art as a cached stylesheet so cached loads paint in one frame
Deploy / deploy (push) Successful in 3s

This commit is contained in:
2026-10-13 15:05:21 +02:00
parent ecb899a866
commit ec2ad2c618
5 changed files with 78 additions and 44 deletions
+5 -3
View File
@@ -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 <b>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 */
+58 -38
View File
@@ -1,9 +1,15 @@
<?php
/**
* ANSI art (a .ans file you can `cat` in a terminal) to HTML 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.
* $mirror flips it left-right, swapping /\ () <> [] {} 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 <b>, 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 .= "<span$style>" . htmlspecialchars($layer) . '</span>';
}
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 .= "<span$style>" . htmlspecialchars(implode("\n", $rows)) . '</span>';
$layers[] = [(string) $color, implode("\n", $rows)];
}
return $html;
return $layers;
}
+2 -3
View File
@@ -10,8 +10,7 @@ $build = preg_match('/[0-9a-f]{7,40}/i', $build, $m) ? $m[0] : ($build ?: 'dev')
</div>
<small>&copy; <?= date('Y') ?> · build: <a href="<?= htmlspecialchars(REPO_URL . ($build !== 'dev' ? '/src/commit/' . $build : '')) ?>"><?= htmlspecialchars($build) ?></a></small>
</footer>
<?php require_once __DIR__ . '/art.php'; ?>
<pre class="side side-left" aria-hidden="true"><?= ansi_html_cached(__DIR__ . '/art/sakura.ans') ?></pre>
<pre class="side side-right" aria-hidden="true"><?= ansi_html_cached(__DIR__ . '/art/sakura.ans', mirror: true) ?></pre>
<pre class="side side-left" aria-hidden="true"><?= str_repeat('<b></b>', $artLeft) ?></pre>
<pre class="side side-right" aria-hidden="true"><?= str_repeat('<b></b>', $artRight) ?></pre>
</body>
</html>
+3
View File
@@ -1,5 +1,7 @@
<?php
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/art.php';
[$artCss, $artLeft, $artRight] = ansi_css_cached(__DIR__ . '/art/sakura.ans');
?>
<!doctype html>
<html lang="en">
@@ -10,6 +12,7 @@ require_once __DIR__ . '/../config.php';
<title><?= htmlspecialchars($page_title ?? SITE_TITLE) ?></title>
<link rel="icon" href="/img/favicon.ico" sizes="any">
<link rel="stylesheet" href="/css/style.css?v=<?= filemtime(__DIR__ . '/../css/style.css') ?>">
<link rel="stylesheet" href="/sideart.php?v=<?= filemtime(__DIR__ . '/art/sakura.ans') . '-' . filemtime(__DIR__ . '/art.php') ?>">
<script src="/js/htmx.min.js" defer></script>
<script src="/js/idiomorph-ext.min.js" defer></script>
</head>
+10
View File
@@ -0,0 +1,10 @@
<?php
// The side art as a stylesheet (see inc/art.php). The URL carries ?v=, so it
// can be cached for good.
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/inc/art.php';
[$css] = ansi_css_cached(__DIR__ . '/inc/art/sakura.ans'); // render (or fail) before the headers
header('Content-Type: text/css; charset=utf-8');
header('Cache-Control: public, max-age=31536000, immutable');
readfile($css);