Files
remark42/frontend/CLAUDE.md
T
Dmitry VerkhoturovandGitHub 4c9ef37cf1 Move the site from eleventy to hugo (#2179)
* Move the site from eleventy to hugo

The site is built by a single static binary. No node, no package manager,
no lockfile, and the toolchain it needed is gone: eleventy, tailwind,
postcss, markdown-it and its three plugins, date-fns, prism, npm-run-all,
cross-env and html-minifier-terser.

Hugo covers most of that itself. Chroma replaces prism, goldmark replaces
markdown-it, `--minify` replaces html-minifier-terser, and fingerprinted
asset URLs replace the cache-busting `version` shortcode that stamped
`Date.now()` into every stylesheet link.

`assets/styles.css` is hand-written, since tailwind was the only reason
left to keep a package manager. The palette and the light and dark values
are custom properties at the top of the file; the minified stylesheet is
15 kB against tailwind's 46 kB, and the whole build 1.0 MB against 1.2 MB.
It was matched to the old one by comparing computed styles rather than by
eye, which is how the heading weights and line heights, the list marker
colour, and the home page heading and sign-off were caught: the last of
those had been carried by tailwind utilities written into the markup.

The `::: note` container becomes a `note` shortcode taking the emoji to
show. Its closer needs a blank line after it, because a shortcode is not
a block rule the way `markdown-it-container` was, and without one goldmark
keeps the callout inside the open paragraph. The `overflow-x` wrapper
around tables and the heading anchors are goldmark render hooks.

Syntax guessing is off. Chroma detected a systemd unit file as gdscript
and a chat transcript as mysql, and colouring a snippet as the wrong
language is worse than not colouring it. The two chroma themes are scoped
to opposite sides of the theme switch rather than layered, because they do
not declare the same properties on the same tokens: github gives Error a
background github-dark never overrides, and styles Punctuation where
github-dark leaves it alone. Layered, either leaves a light value applying
on a dark page.

`[frontmatter] lastmod` resolves through git, then front matter, then file
modification time. Without that chain `.Lastmod` falls back to `.Date`,
which is zero when a page carries no date, and every page reads
`Jan 01, 0001`. `enableGitInfo` is off because the image build context is
`site/` alone, where hugo fails hard rather than degrading;
`HUGO_ENABLEGITINFO=true` gives real per-page commit dates locally.

Three fixes fall out of the move rather than being sought:

- `/docs/` redirected nowhere. The stub was a markdown file whose
  permalink was a template expression while `markdownTemplateEngine` was
  false, so it never rendered and the URL 404'd. It is an alias now
- `/docs/contributing/` pointed at `/docs/contributing/development/`,
  which has never existed. It points at the backend page
- the 404 page was built to `/404/` and nothing served it. Hugo writes it
  to `/404.html` and reproxy is told to use it

The mobile documentation menu is a checkbox and label. `visibility: hidden`
on the checkbox, which is what the old `invisible` utility set, takes it
out of the tab order, and a label is not focusable on its own, so the menu
could not be opened from the keyboard at all. The checkbox is clipped
rather than hidden, and its label shows a focus ring.

Content is unchanged. Every code block on every page is byte-identical to
the eleventy output; the only prose difference is that two example values,
`mysite.com` and a quoted `https://demo.remark42.com`, are no longer
turned into links, goldmark's linkify being narrower than markdown-it's.

`backend/README.md` and `frontend/apps/remark42/README.md` are symlinks
into the docs tree and follow it to `site/content/`, as does the path
`release.yml` watches. `frontend/CLAUDE.md` described the site as a node
and yarn project in four places.

* Keep the heading anchors markdown-it generated

Goldmark strips punctuation markdown-it kept, so 22 headings holding a
dot, slash, apostrophe, question mark, bracket or em dash would take a new
id and any link into one from outside the repository would stop resolving.

Those headings carry their previous id as well, as an empty target emitted
ahead of the heading by the render hook, from a map of content path to old
anchor in `data/anchor_aliases.json`. The map was built by matching
heading text between the two builds rather than by position, so it
survives a heading being added or moved.

The hook rather than markdown, because goldmark's `{#id}` attribute syntax
cannot express these: it accepts dots, apostrophes and em dashes but
treats a slash, a question mark, a bracket or a percent sign as heading
text, which is 11 of the 22. The ids are stored percent-decoded, since a
browser decodes a fragment before matching, so `#children%E2%80%99s-privacy`
finds `children’s-privacy`. Verified by navigating to the awkward ones
against the built image and measuring where the page settles: each lands
112px down, which is the header offset the target carries.

Three pages carried no title, so the docs template rendered an empty `<h1>`
above the heading their markdown already had. They take their titles from
that heading text, so neither the wording nor its anchor changes, and the
template's `<h1>` carries an id. One in-page link pointed at an anchor
goldmark no longer generates.

The heading render hook emits no permalink anchor. The one it replaced was
an empty `<a href>` with `pointer-events: none`, so it could not be
clicked, and its only job was a `::before` spacer that `scroll-margin-top`
on the heading already does. Being an `<a href>` it stayed in the tab
order, so every heading was an unexplained keyboard stop: eight on the
installation page alone. Fragment navigation still lands 112px down, clear
of the fixed header.

* Harden the site image build and its CI

The architecture guard could not fire. `${TARGETARCH:-amd64}` defaulted
before the `unsupported arch` branch was reachable, so a build without
buildkit put an amd64 hugo inside an aarch64 image and ran only because
Docker Desktop emulates it. Reproduced with `--build-arg TARGETARCH=`:
`/etc/apk/arch` reported aarch64 and `hugo version` linux/amd64. An empty
value is an error now. `Dockerfile.dev` had the same defect and no smoke
step to catch it, so it would have failed at `compose up`.

The hugo tarball is verified against the release's own `checksums.txt`,
and the match is asserted present before it is used: piping grep straight
into `sha256sum -c` left the guarantee resting on what the checker does
with empty input. Busybox exits 1 there, so it did fail closed, but
nothing in the line said so. Verified against a checksums file that does
not list the tarball: the build stops before the install.

Hugo exits 0 on an empty content tree and emits a two-page shell, which
would have been copied, pushed and deployed. The build asserts the home
page and a docs page exist.

`site/**` pull requests were never built. The only building job is gated
on `github.ref == 'refs/heads/master'`, so on a pull request every job
skipped and rendered in the checks list the same way a pass does, and the
image was first built on the run that also deploys it. A `validate` job
builds it with `push: false`, needing no secrets so it works on a fork.

`.github/dependabot.yml` watched `/site` for npm packages that are gone.
That entry is a docker one, which tracks the alpine base. It does not
track the hugo pin and cannot: the docker ecosystem reads `FROM`
references, and `ARG HUGO_VERSION` is a bare string in a download URL, so
that one is a manual bump and `site/README.md` says so.

`Dockerfile.dev` carries a `COPY`, so the dev image works without the
compose bind mount, and compose runs as the invoking user rather than
root, which on linux left root-owned `public/` and `resources/` in the
checkout.

Recorded in the backlog: `master` has `required_status_checks` off with an
empty check list, so the new job surfaces a red X and does not block a
merge. That is a settings decision rather than a code fix.
2026-08-21 18:05:56 -05:00

8.0 KiB

Frontend gotchas

Non-obvious constraints in the frontend toolchain and widget. Read before bumping dependencies or the node/pnpm versions, and before changing how the widget renders or how translations are extracted.

The node/pnpm version is pinned in many places, not one

CI staying green does not mean every pin is consistent — .nvmrc in particular is never read by CI, so it can silently drift. After changing the node or pnpm version, grep the whole repo and update every one of these, not just the ones CI exercises:

  • Dockerfile (production image) — FROM node:X-alpine and npm i -g pnpm@X.Y.Z
  • frontend/.nvmrc — not read by CI at all; only matters to a human running nvm use locally. This is the one that drifted unnoticed: it sat at 16 through the whole node-20 migration because nothing red ever pointed at it.
  • Every package.json's packageManager field (frontend/package.json, frontend/apps/remark42/package.json) and frontend/apps/remark42/package.json's engines block
  • pnpm/action-setup@vN blocks in .github/workflows/ci-frontend.yml (5) and release.yml (2) — pin version: to the exact patch (e.g. 10.10.0), matching packageManager, not just the major. A floating major here is silent in CI (it just resolves to whatever the latest patch is at run time) but breaks the "Dockerfile and CI use the same pnpm" guarantee.
  • node: matrices in .github/workflows/ci-frontend.yml (every entry, not just the first) and the node-version: values in release.yml

When bumping pnpm/node, also re-check frontend/apps/remark42/package.json's engines field — it's separate from packageManager and won't update itself.

engines.node states the major we support, currently >=20, and the docs say the same. Individual dev dependencies can be stricter within that major (undici wants >=20.18.1), which any current Node 20 satisfies; do not chase those patch floors into engines or the docs, or every lockfile refresh becomes a documentation change.

pnpm 10's stricter node-linker layout needs explicit pins

One dep is pinned specifically because of pnpm 10's hoisting changes, not because of the dep itself:

  • @types/minimatch 5.1.2 — 6.x is an empty stub that the hoisted layout picks up instead of the real types

If a dependency bump mysteriously breaks types or module resolution only after a pnpm major bump, suspect the layout change before suspecting the dependency.

node 20's native fetch requires absolute URLs in tests

Unlike the polyfilled fetch in node 16 and 18, it rejects relative request URLs, and the failure is silent: requests simply never match. Any test harness that mocks fetch needs absolute base URLs and a jsdom base URL set.

JSX runs on the automatic runtime, in three places that must agree

preact 10.29 types a component's return as ComponentChildren, which only satisfies a JSX check on TypeScript 5.1+ via JSX.ElementType, and it scopes the JSX namespace to preact/jsx-runtime rather than declaring it globally. So the type layer has to use the automatic runtime:

  • tsconfig.json: jsx: react-jsx with jsxImportSource: preact
  • .babelrc.js: @babel/preset-react with runtime: 'automatic', importSource: 'preact'
  • jest.config.ts: @swc/jest with transform.react.runtime: 'automatic', importSource: 'preact'

ts-loader in webpack.config.js overrides jsx back to preserve. That is deliberate: JSX has to survive as JSX until babel runs, or babel-plugin-jsx-remove-data-test-id has nothing to strip and data-testid attributes ship to production. Verify with grep -c data-testid public/*.mjs after a production build; it must be 0.

Keep all three in step. If babel alone were left on the classic pragma: 'h' transform, a new .tsx without import { h } would type-check and lint clean, then throw at runtime, because eslint-config-preact sets react/react-in-jsx-scope to 0 and the local config turns no-undef off.

Held-back majors

These were deliberately not bumped because each is a config-migration or bundle-changing major, not a drop-in update — don't bump them opportunistically inside an unrelated dependency PR:

  • eslint 8 (9/10 need flat-config migration), stylelint 14 (16 has breaking rule changes), babel 7, jest 28 (30 needs config changes)
  • redux 4

Verifying a build didn't regress

There's no automated build-output diff in CI. Before merging a dependency PR that touches the bundler/build tooling, manually diff the build output against a clean master checkout:

  • apps/remark42: expect webpack module-id numbers and css-module class tokens (e.g. .F_A.L_A) to differ — that's normal churn from a webpack/css-loader bump. HTML, CSS values, and translation content should be byte-identical.
  • site: asset URLs carry a content hash, so a stylesheet or script change moves the filename on every page referencing it. Anything else differing is a real regression.

Where the alerts actually were

When clearing Dependabot/audit alerts, check whether the flagged package is actually reachable from production code or only from the dev/test toolchain — pnpm audit does not distinguish. Several alerts here were in build-time-only tooling (webpack-dev-server, laravel-mix-equivalent dev deps) with no patched release available; those are lower-risk than a runtime dependency with the same severity label.

Don't import preact/compat

It installs hooks on preact's shared options that remap onFocus/onBlur to focusin/focusout on every element and make @testing-library/preact rewrite change to input, so one import changes event behaviour across the whole widget, and it adds about 3.8 kB gzipped.

forwardRef lives only there, so a component that needs a ref takes it as an ordinary prop; TextareaAutosize is the pattern. For a component type annotation use FunctionComponent from preact, not React.FC.

i18n is a hand-written binding whose export names are fixed by the extractor

app/common/intl.tsx provides IntlProvider, useIntl, createIntl, defineMessages, FormattedMessage and IntlShape. formatjs extract (translation:extract) finds messages by recognising the identifiers defineMessages, FormattedMessage and intl.formatMessage in the AST, not by import source, so those three names are fixed. Rename any of them and extraction returns nothing, with no error and a zero exit code.

The damage lands on the next step. translation:check compares locale keys against extracted keys and fails loudly, but tasks/generateDictionary.js calls removeAbandonedKeys, so translation:generate after a rename deletes the now-unextracted keys from all 24 files in app/locales/ and writes them back, after which the check passes. Extract-then-generate is the documented translator workflow, so the destructive order is the normal one.

After any rename or refactor touching those identifiers, verify the count rather than the exit code:

pnpm translation:extract
node -e "console.log(Object.keys(require('./extracted-messages/messages')).length)"

It must match the key count in app/locales/en.json, not drop to 0.

The binding implements {name} interpolation and paired <tag>text</tag> rich text, and nothing else of ICU: no plural, select or selectordinal, no typed arguments such as {n, number}, and no apostrophe quoting. A plural or select form has braces the placeholder rule rejects, so it falls back to English wherever values are passed and shows its raw text where they are not; '' simply stays as two apostrophes.

translation:check validates every translated value's markup and placeholders against the English string it translates, mirroring the binding's rule, and the catalogue sweep in app/common/intl.test.tsx additionally renders the two messages that carry a link. What neither catches is unsupported ICU syntax, since a plural form is well-formed text as far as both are concerned. A message the binding cannot resolve falls back to the English source rather than reaching the page, so without these checks a broken translation is invisible in the interface.