try_files listed $uri/ before any index, so nginx answered /gallery/aldi/65 with
a 301 appending the slash. Every internal link on this site is the bare form —
the thumbnails, the lightbox hrefs, the chevrons — so every navigation paid a
redirect before anything on the page could start. Lighthouse measured it at 78 ms
on the critical path of the first request.
Naming index.html first serves the file directly. Both forms still work, neither
redirects, and $uri/ stays after it for a directory with no index.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The config is a JavaScript template literal, and the comment added with the /crops/ rename quoted the
paths in backticks. They closed the string, `crops` became a bare identifier, and the plugin failed to
build: "crops is not defined".
What that looked like from the outside is the part worth recording. The plugin did not mount, so every
/fofos-admin call 404'd, so useReady's catch — which deliberately reports "cannot tell" as not-ready —
locked all nine sections behind "Set a root location below to begin." A build error wearing the
costume of a setup problem, and the honest suspicion was that the gate had gone wrong rather than the
thing behind it.
The gate is right. It is one line of a log away from being diagnosable, and the log said so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The site's crop store was renamed — see its own commit for why the name was wrong. This is the fourth
place that had to move with it: the constant, the development route, the build's copy target, and this
cache rule.
The rule itself is unchanged. A year and immutable is still EARNED here rather than assumed: the
filename is a hash of the original, its stamp, the framing, the slot and the width, so the bytes at a
given URL can never legitimately change.
A site published before the rename carries /images/ in its HTML and no longer has the files behind it.
The fix is a publish, which rewrites every one of those URLs anyway.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cropper needs an encoder, and only the build was told where one is. That made development the one
place the old behaviour survived: the framing approved in the admin, the picture dev showed, and the
file that shipped would have been three different things — which is the class of bug this whole change
exists to remove.
The port is ephemeral by design, so it is read at start rather than written to a file. av-club moving
leaves dev pointing at a port that has gone; restarting dev from the panel fixes it, and startServer
already deletes and re-creates precisely so a changed port is picked up.
/images/ gets a year and immutable in the nginx config. Earned rather than assumed: the filename is a
hash of the original, the framing, the slot and the width, so the bytes at a URL cannot legitimately
change. That is the difference from /derived/, which is rewritten in place and therefore capped at
thirty days.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A photograph is rarely the shape of the slot it goes in, so something decides which part survives. It
was a centred crop and no way to change it.
Dragging the picture slides the window; the wheel zooms, anchored to the pointer so the thing under the
cursor stays under it. Both write the site's own format — `x,y,w,h` in percentages of the ORIGINAL —
which is resolution-independent and survives the file being re-encoded.
Zooming in is the window getting smaller: w and h shrink together, keeping the slot's shape. Fit is the
floor, because you cannot show more of a photograph than there is, and at exactly fit the crop is
cleared rather than written as the centred value — a null crop and a crop that happens to be centred
should not be two states.
── The preview is not object-cover ──
It draws the rectangle: the picture scaled to `100/w` of the box and offset so the chosen corner lands
at the origin. object-cover shows the largest window of the slot's shape that fits and nothing else —
it can be panned and it cannot be zoomed, so a preview built on it would silently ignore half of what
this control produces.
── Also here ──
buildSite now hands the site an av-club URL and user. That is the first piece of publishing images
cropped rather than cropping them in the browser with CSS: the plugin knows the sidecar registry, the
site knows a URL. Inert until the site uses it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It sits behind the proxy that terminates TLS, so it believes it is serving plain HTTP. Asked for
/home/gallery/16 it correctly redirects to the directory and wrote the Location as
http://host/home/gallery/16/ — downgrading an HTTPS visitor to HTTP for a round trip before the proxy
sent them back where they came from.
absolute_redirect off emits just the path, and the browser keeps the scheme it already had.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It existed to make a bisect bearable — rendering all 48 routes to measure one of them was most of the
wait. The bisect is over and the switch is not something to leave lying in a build script, because it
also disables the prune at the swap, and a build that quietly does not clean up after itself is worse
than a slow one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment I added used backticks around ?v= and no-cache, inside a template literal. That closed the
string early and made the file unparseable — committed and deployed before the typecheck was read.
Caught by tsgo immediately afterwards; officer stayed online because pm2 kept the previous module
loaded, but the next restart would not have come back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven days was correct while the URLs were bare: a stable name with a long life means somebody running
last week's script against this week's markup.
The site's layout now stamps every asset URL with the build's id, so a build that changes a file
changes its URL, and the HTML carrying those URLs is no-cache and revalidated every visit. The two
cannot disagree, which is what earns `immutable` and a year.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TEMPORARY, and inert unless set. Bisecting a Performance score means a build between every
measurement, and rendering all 48 routes to change one of them is most of the wait.
FOFOS_BUILD_ONLY is passed through to the site's build script, which renders only the listed paths and
— the part that is not optional — skips the prune at the swap. The swap deletes anything in dist/ that
the run did not stage, so a one-page build would otherwise take every other page of the live site with
it, 404 until the next full build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything sat at seven days, which Lighthouse counts as short for a file that never changes.
/fonts/ now gets a year and `immutable`. A typeface is not edited — it is replaced, and a replacement
arrives under a new name. It is the one directory here where a long life cannot pin something stale.
Everything else stays at a week deliberately. Those names are stable across builds and the HTML that
references them revalidates every time, so a longer life means somebody running last week's stylesheet
against this week's markup. Fingerprinted filenames are what would earn a year, and the build does not
write them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`nginx:alpine` ships a config that serves the directory and does nothing else: no compression, and no
cache headers at all. Every asset on the live site carried a TTL of zero, so a returning visitor
re-downloaded all of it — 1.1 MB by Lighthouse's count.
Written out on every start rather than kept as a file somebody edits, so what nginx does is decided in
this file and cannot drift from it.
Three lifetimes, because there are three kinds of file. `derived/` renditions are rebuilt IN PLACE —
av-club replaces the contents of `1280.webp` rather than writing a new name — so they cannot be
`immutable` however much they look like they could be, or a re-crop stays invisible for a year to
everyone who saw the old one. Bundled assets change only when a build runs, and a build changes the
HTML that names them: a week. HTML revalidates every time, because anything longer makes a publish
invisible to whoever already visited, which is the one failure that makes the whole panel look broken.
`^~` on the prefix locations. A regex location beats a prefix location in nginx, so the extension rule
would otherwise capture /derived/…/1280.webp and give it the wrong lifetime.
Validated with `nginx -t` in the real image rather than by reading it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bun run build:site` resolves the script from the package.json in the current
directory. The helper had no cwd parameter, so it ran in the platform's own
directory, which has no such script — and the error, "Script not found
build:site", reads as the site missing a script rather than as it being asked in
the wrong place.
Second time today a spawn helper quietly lacked something the caller needed: the
first was the environment for pm2. Both had the same shape — assembled correctly
and then not passed.
Verified by running the real build with the same environment the plugin builds:
29 entries in dist and an index.html with the site in it.
The site's own build script says it outright: "The public site is not served by
Bun in production — it is a directory of files behind nginx. Output: dist/."
So production has no port, nothing to restart, and nothing that can crash at
three in the morning. It is built or stale, and served or not served.
Showing it as a pm2 process was showing something that does not exist, and its
Start button could only ever have failed. It now has Build, Serve and Stop, and
the two states are reported separately — a build can succeed while nothing
serves it, and a container can serve a directory built weeks ago. They fail
independently and are fixed differently.
The container exists because nginx could not see the files. NPM mounts only its
own /data and /etc/letsencrypt, and there is no other nginx on the box. The
alternatives were putting site files inside NPM's own volume, or editing NPM's
compose and restarting it — which takes every site on this machine down for a
moment. So: one nginx:alpine, dist mounted READ-ONLY, on NPM's own network with
NO published port. NPM reaches it by name, and nothing about the site is exposed
except through the proxy.
Recreated rather than restarted, for the same reason pm2 is: a container keeps
the mounts it was created with, so a root location changed here would be saved,
displayed, and still serving the old directory.
Domains follows. Production's forward target is a container NAME, not the docker
gateway that reaches a host process — and matching what already points at it
uses the full target rather than the port, since every other site behind this
NPM on port 80 would otherwise be listed as fofos's.
The compiler found the dead branches this left in startServer once production
could no longer reach it. Removed rather than left to read as though the case
were still possible.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>