{"owner":"umputun","repo":"remark42","hasSkills":true,"totalSkillsCount":2,"totalTokensCount":6415,"categories":["claude-rule"],"hasMcp":false,"mcpConfig":null,"found":["frontend/CLAUDE.md","CLAUDE.md"],"skills":{"frontend/CLAUDE.md":"# Frontend gotchas\n\nNon-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.\n\n## The node/pnpm version is pinned in many places, not one\n\nCI 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:\n\n- `Dockerfile` (production image) — `FROM node:X-alpine` and `npm i -g pnpm@X.Y.Z`\n- `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.\n- `frontend/apps/remark42/package.json`, both its `packageManager` field and its `engines` block\n- `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.\n- `node:` matrices in `.github/workflows/ci-frontend.yml` (every entry, not just the first) and the `node-version:` values in `release.yml`\n- the two dependency-free test commands live in `package.json` as `test:unit` and `test:unit:node`, are called by name from `release.yml`, and are **repeated inline** in the `unit` job of `ci-frontend.yml`, which carries no pnpm by design. Change either script and that job keeps running the old one, green\n\nWhen bumping pnpm/node, also re-check `frontend/apps/remark42/package.json`'s `engines` field — it's separate from `packageManager` and won't update itself.\n\n`engines.node` states the major we support, currently `>=24`, which is the active LTS; 22 has dropped to maintenance. `@babel/core` 8 wants `^22.18 || >=24.11` and `size-limit` 13 wants `^22.18 || ^24 || >=26`, so 22 was the floor rather than the target. Individual dev dependencies can be stricter within that major (`undici` wants `>=20.18.1`); do not chase those patch floors into `engines` or the docs, or every lockfile refresh becomes a documentation change.\n\n## One manifest, at `frontend/apps/remark42`\n\n`frontend/` holds no `package.json`, no lockfile and no `pnpm-workspace.yaml`. Everything pnpm reads\nlives in `frontend/apps/remark42`: the dependencies, `packageManager`, `engines` and the\n`pnpm.overrides` block. Install and run from there, not from `frontend/`.\n\nThe directory nesting is kept because every path in the repository points at it, from the Dockerfile\nand the workflows to the published contributing docs. `frontend/` still carries `.nvmrc`, `.husky`\nand this file, none of which pnpm reads.\n\n## pnpm 10's stricter `node-linker` layout needs explicit pins\n\nOne dep is pinned specifically because of pnpm 10's hoisting changes, not because of the dep itself:\n- `@types/minimatch` 5.1.2 — 6.x is an empty stub that the hoisted layout picks up instead of the real types\n\nIf a dependency bump mysteriously breaks types or module resolution only after a pnpm major bump, suspect the layout change before suspecting the dependency.\n\n## node's native fetch requires absolute URLs in tests\n\nIt rejects relative request URLs, and the failure is\nsilent: requests simply never match. Any test harness that mocks fetch needs absolute base URLs and\na jsdom base URL set.\n\n## Babel is the only thing that compiles\n\n`.babelrc.js` drives both the bundle, through `babel-loader`, and the tests, which get it as\n`babel-jest`'s `configFile` in `jest.config.mjs`. That indirection is not decoration: `.babelrc.js`\nis a file-relative config and would not reach `node_modules`, so the ESM-only packages in\n`transformIgnorePatterns` would arrive at jest untransformed.\n\nBabel merges an `env` block over the root rather than replacing it, so anything the test run must\nnot see has to be decided before the object is built rather than put in an `env`. `jest.config.mjs`\nsets `BABEL_ENV` itself and `.babelrc.js` reads it for two things: the compile targets, and the\n`data-testid` stripper, which the suites query by and the bundle must not carry. Verify with\n`grep -c data-testid public/*.mjs` after a production build; it must be 0. The plugin matches a\nliteral `JSXAttribute`, which is every use in the tree; a spread such as\n`{...{'data-testid': x}}` would reach production, and that grep is what would catch it.\n\n`tsconfig.json` types but never emits, so its `target` has no effect on output; browserslist in\n`.babelrc.js` decides that. Type checking runs out of band through\n`fork-ts-checker-webpack-plugin`.\n\n## Type-only imports have to say so\n\nBabel compiles one file at a time with no type information, so it cannot tell that an import is\ntypes-only. Left unmarked, the module and everything it imports stay in the bundle, and a single\ntype import of a store-connected component is enough to pull the whole redux graph into an entry\nthat never uses it.\n\n`verbatimModuleSyntax` in `tsconfig.json` and `@typescript-eslint/consistent-type-imports` are what\nenforce this, and the fix has to be a separate `import type { ... }` statement. Inline\n`import { type X }` does **not** work here: verbatim semantics keep the statement, so the module is\nstill loaded. That is also why `no-duplicate-imports` runs with `allowSeparateTypeImports`, since a\nmodule legitimately appears twice.\n\n## JSX runs on the automatic runtime, in two places that must agree\n\n`preact` 10.29 types a component's return as `ComponentChildren`, which only satisfies a JSX check on\nTypeScript 5.1+ via `JSX.ElementType`, and it scopes the `JSX` namespace to `preact/jsx-runtime` rather\nthan declaring it globally. So the type layer has to use the automatic runtime:\n\n- `tsconfig.json`: `jsx: react-jsx` with `jsxImportSource: preact`\n- `.babelrc.js`: `@babel/preset-react` with `runtime: 'automatic'`, `importSource: 'preact'`\n\nKeep both in step. If babel were left on the classic `pragma: 'h'` transform, a new `.tsx`\nwithout `import { h }` would type-check and lint clean, then throw at runtime, because\n`eslint-config-preact` sets `react/react-in-jsx-scope` to 0 and the local config turns `no-undef` off.\n\n## `@babel/core` is pinned to 8 for the whole dependency tree\n\n`@jest/transform` and `istanbul-lib-instrument` depend on `@babel/core` 7 outright, and a babel 8\npreset loaded into a babel 7 core fails on the first `enum` it meets. The `pnpm.overrides` entry in\n`frontend/apps/remark42/package.json` is what stops that. `eslint-config-preact` is the one consumer that cannot\ntake it: its `@babel/eslint-parser` loads babel 7 syntax plugins that babel 8 rejects, so a second\nscoped override, `eslint-config-preact>@babel/core`, holds that subtree on 7.\n\n## Held-back majors\n\nThese 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:\n\n- `eslint` 10 (`eslint-config-preact` peers on ^9), `typescript` 7, `redux` 5 with `redux-thunk` 3, `node-emoji` 2 (renames the field `search()` returns), `@formatjs/cli` 6 (the translation extraction pipeline), and `postcss-preset-env` 11 with `cssnano` 8 and `postcss-html` 2, which change generated CSS\n- `redux` 4\n\n## `/web` has a second source\n\n`privacy.html`, `markdown-help.html` and `400x400.jpeg` live in `backend/app/webassets/assets` and\nare embedded in the Go binary. The frontend build wins for any name present in both, which is what\nlets an operator override one by dropping a file next to the frontend files in `--web-root`. That\ndirectory replaces the embedded frontend outright when it exists, so an override belongs in a\npopulated one. These three sit outside this toolchain: prettier, stylelint and `pnpm lint` do not\nsee them, and they are served unminified. `devServer.static` lists the build output first and that\ndirectory second, matching the backend's order, so links to them resolve on the dev port too.\n\n## eslint config resolves from the process cwd\n\n`eslint.config.mjs` lives in `apps/remark42`, and eslint loads the config next to the directory it\nis *run from* rather than the one nearest the file being linted. Anything that invokes eslint has\nto have `apps/remark42` as its working directory: it is where every script runs, `.husky/pre-commit`\ncd's into it, and an IDE integration needs\n`\"eslint.workingDirectories\": [\"frontend/apps/remark42\"]`.\n\nRules that only exist under a plugin's flat-config export are spread in explicitly; the block that\nre-enables project rules is the last entry in the array, because `eslint-config-prettier` (pulled in\nby `prettierRecommended`) switches several of them off and later entries win.\n\n## Verifying a build didn't regress\n\nThere'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:\n- `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.\n- `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.\n\n## Where the alerts actually were\n\nWhen 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.\n\n## Don't import `preact/compat`\n\nIt installs hooks on preact's shared `options` that remap `onFocus`/`onBlur` to\n`focusin`/`focusout` on every element and make `@testing-library/preact` rewrite `change` to\n`input`, so one import changes event behaviour across the whole widget, and it adds about\n3.8 kB gzipped.\n\n`forwardRef` lives only there, so a component that needs a ref takes it as an ordinary prop;\n`TextareaAutosize` is the pattern. For a component type annotation use `FunctionComponent`\nfrom `preact`, not `React.FC`.\n\n## i18n is a hand-written binding whose export names are fixed by the extractor\n\n`app/common/intl.tsx` provides `IntlProvider`, `useIntl`, `createIntl`, `defineMessages`,\n`FormattedMessage` and `IntlShape`. The formatting itself lives in `app/common/intl-message.ts`,\nwhose only preact reference is a type-only import that erases at build time, so it pulls in nothing at runtime, and which is where every rule below is implemented; `intl.tsx` is the\nbinding over it and re-exports `createIntl` and `defineMessages` unchanged, so no call site can tell\nthe two apart. `formatjs extract` (`translation:extract`) finds messages\nby recognising the identifiers `defineMessages`, `FormattedMessage` and `intl.formatMessage`\nin the AST, not by import source, so those three names are fixed. Rename any of them and\nextraction returns nothing, with no error and a zero exit code.\n\nThe damage lands on the next step. `translation:check` compares locale keys against extracted\nkeys and fails loudly, but `tasks/generateDictionary.js` calls `removeAbandonedKeys`, so\n`translation:generate` after a rename deletes the now-unextracted keys from all 24 files in\n`app/locales/` and writes them back, after which the check passes. Extract-then-generate is\nthe documented translator workflow, so the destructive order is the normal one.\n\nAfter any rename or refactor touching those identifiers, verify the count rather than the exit\ncode:\n\n```sh\npnpm translation:extract\nnode -e \"console.log(Object.keys(require('./extracted-messages/messages')).length)\"\n```\n\nIt must match the key count in `app/locales/en.json`, not drop to 0.\n\nThe binding implements `{name}` interpolation and paired `<tag>text</tag>` rich text, and\nnothing else of ICU: no plural, select or selectordinal, no typed arguments such as\n`{n, number}`, and no apostrophe quoting. A plural or select form has braces the placeholder\nrule rejects, so it falls back to English wherever values are passed and shows its raw text\nwhere they are not; `''` simply stays as two apostrophes.\n\n`translation:check` validates every translated value's markup and placeholders against the\nEnglish string it translates, mirroring the binding's rule, and the catalogue sweep in\n`app/common/intl.test.tsx` additionally renders the two messages that carry a link.\n`app/common/intl-message.unit.test.ts` covers the formatter's own edge cases -- broken markup, an\nunclosed tag, a regex metacharacter in a tag name -- which are the ones no shipped catalogue can\nproduce and so no sweep can reach. What\nneither catches is unsupported ICU syntax, since a plural form is well-formed text as far as\nboth are concerned. A message the binding cannot resolve falls back to the English source\nrather than reaching the page, so without these checks a broken translation is invisible in\nthe interface.\n\n## `*.unit.test.ts` runs with no toolchain, and that is what it is for\n\nTwo test layers, split by what they need to run. Jest owns anything with a DOM in it; `*.unit.test.ts`\nfiles import nothing but the module under test, its fixtures and `node:test`, and run under plain\nnode straight from source:\n\n```sh\npnpm test:unit\n```\n\nThe `unit` job in `ci-frontend.yml` runs them with **no `pnpm install` step**, deliberately.\n`release.yml` runs the same script after an install, as a second gate. The layer's value is that a\nfunction over plain values can be tested without a bundler, a jsdom or a `node_modules`, and the\nabsent install is the only thing that keeps that true: add an import that needs resolving and the\njob goes red instead of quietly acquiring the dependency. `jest.config.mjs` excludes the suffix, so\na file is in exactly one layer.\n\n`node --test` exits 0 on a glob that matches nothing, so it cannot tell a renamed suffix from a\npassing run. The CI step counts the files first and fails when none match. Two import rules follow\nfrom node's ESM resolver, and they contradict the repository's usual style, so they are local to\nthis layer:\n\n- **write the extension in full**, `./types.ts`. This is why `app/store/thread/reducers.ts` and\n  `app/store/user/reducers.ts` import `./types.ts` while their neighbours do not, and why\n  `app/common/json-store.ts` and `app/utils/errorUtils.ts` carry it too. `allowImportingTsExtensions` in `tsconfig.json` is what\n  stops `tsc` rejecting it, and webpack and jest resolve the explicit path unchanged\n- **import by relative path**, never through the `common/…` alias, which only webpack, jest and\n  `tsc` know about. A type-only import of an alias is fine, since it erases before either runtime\n  sees the file\n\nA module that mixes computation with browser state gets split instead of skipped:\n`app/common/intl-message.ts` and `app/hooks/session-storage.ts` are the pure halves of `intl.tsx`\nand `useSessionState.ts`, and `session-storage.ts` takes the store as an argument instead of reading\nthe `sessionStorage` global, which is what lets a test hand it a plain object.\n","CLAUDE.md":"# Remark42 Development Guidelines\n\n## Build/Test/Lint Commands\n- **Backend**:\n  - Run server: `make rundev`\n  - Build: `make backend`\n  - Race test: `make race_test`\n- **Backend Testing**:\n  - Run all tests: `cd backend/app && go test -timeout=300s -count 1 ./...`\n  - Run single test: `cd backend/app && go test -run TestName ./path/to/package`\n  - **IMPORTANT**: Run example tests: `cd backend/_example/memory_store && go test -race ./... && go build -race ./...`\n- **Frontend**:\n  - Development: `cd frontend/apps/remark42 && pnpm dev`\n  - Tests: `cd frontend/apps/remark42 && pnpm test`\n  - Dependency-free tests: `cd frontend/apps/remark42 && pnpm test:unit`.\n    `*.unit.test.ts` files run straight from source with no `node_modules`, and the `unit` CI job\n    installs nothing so a test that needs `node_modules` goes red. Their **value** imports write the\n    extension in full (`./types.ts`) and use a relative path, never the `common/…` alias, because\n    node resolves neither; a type-only import erases and keeps the ordinary style. See\n    `frontend/CLAUDE.md`.\n- **End-to-end**: `make e2e` drives the widget in a real browser; see `e2e/README.md`. Build-tagged, so `go test ./...` never runs it.\n- **Lint**:\n  - Backend: `cd backend && golangci-lint run`\n  - **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml`\n  - Frontend: `cd frontend/apps/remark42 && pnpm lint`\n  - **Before committing**: Always run tests and linter on both main backend AND examples\n- **Go module changes**:\n  - **Any** change to `backend/go.mod` or `backend/go.sum` requires `go mod tidy` in `backend/_example/memory_store` in the same commit. That covers dependency bumps, adding or removing a dependency, and changing the `go` directive, not only version updates.\n  - Only `go mod tidy` there, not `go mod vendor`: the example's vendor directory is gitignored (`.gitignore:26`), so its output is never committed, while a stale local copy silently becomes what the example resolves against.\n  - The example module replaces `github.com/umputun/remark42/backend` with `../../`, so it carries the backend's dependencies as indirect entries. Leaving them stale fails the `test examples` CI step with `go: updates to go.mod needed; to update it: go mod tidy`.\n  - This applies to Dependabot pull requests too: the bot updates `backend/` only, so its Go module PRs need the example tidied before they can go green.\n\n\n## Backend Test Determinism\n\nBackend tests must never depend on how fast the machine is. CI runs them under `-race` with coverage on a shared runner, so any test that assumes an operation finishes within some duration eventually fails on a rerun-and-it-passes basis.\n\n- **Wait on a condition, never on a duration.** Use `require.Eventually` / `require.EventuallyWithT` to poll for the state the assertion needs, and `require.Never` when the point is that something did *not* happen. A bare `time.Sleep` before an assertion is a defect; sleeping until a deadline you computed, as `waitPastMillisecond` does, is not.\n- **Polling closures must not touch `*testing.T`.** testify runs them on a separate goroutine, where `t.FailNow` is undefined behaviour. Assert on the `*assert.CollectT` that `EventuallyWithT` hands the closure, so the real error also lands in the failure message.\n- **Mind the rate limiter when polling over HTTP.** Route groups are capped independently and most of the caps are hard-coded in `rest.go`, out of reach of a test: `/auth/` at 2 req/s and the admin, protected and image routes at 10 req/s. Only the open-route group is settable, via `openRouteLimiter` (100 in `startupT`). Poll with the existing constants rather than a new number, `httpPoll` for anything issuing an HTTP request and `pollInterval` only for in-process or filesystem checks, or the poll manufactures the 429s it then has to interpret.\n- **When a test needs time to have passed, pin the clock input rather than waiting for it:** `os.Chtimes` for file ages, an explicit `store.Comment.Timestamp` for anything that formats a timestamp.\n- **Prefer a `testing/synctest` bubble** where the code under test has no real I/O. Inside one the clock is fake, so `time.Sleep` is instant and deterministic. `app/notify`, `app/store/service`, `app/store/image`, `app/store/engine`, `app/providers`, `app/migrator` and `_example/memory_store/accessor` already use it, and most surviving `time.Sleep` calls live in them.\n- **Helpers fail loudly.** A wait that gives up must call `t.Fatal`/`require` naming what it was waiting for, never return silently and leave the next assertion to fail with something unrelated. Because these packages run `goleak.VerifyTestMain`, a failing helper also exits the test goroutine, so anything that started a server in a goroutine must `defer cancel()` or `defer srv.Shutdown()` right after launching it; otherwise a failed readiness wait is reported as a goroutine leak rather than the failure that caused it.\n- **Take ports and paths from outside the test.** Ports come from the kernel with `net.Listen(\"tcp\", \":0\")`, files from `t.TempDir()`. `go test ./...` runs package binaries concurrently, so a number out of a fixed range or a fixed name under `/tmp` lets two of them collide.\n- **Close idle connections before shutting a test server down.** Clients built as `http.Client{Timeout: x}` share `http.DefaultTransport`, and `Shutdown` waits on their keep-alive connections until its own deadline expires.\n- **Keep the test timeout budgets aligned.** `Makefile`, `ci-backend.yml`, `release.yml` and the command above all use `-timeout=300s`; the wait helpers allow 30s per condition, so a shorter per-package budget turns a slow runner into a timeout panic instead of a readable failure.\n\n`chooseUnusedPort` and the server-start wait helpers are duplicated in `app`, `app/cmd`, `app/rest/api` and `_example/memory_store/server`. Nothing shares them today; keep the copies in step when changing one.\n\n## Release Procedure\n\nRemark42 uses two tags for each release:\n- `vX.Y.Z` - product release tag used by GitHub releases, GoReleaser binary artifacts, and Docker image publishing.\n- `backend/vX.Y.Z` - nested Go module tag for `github.com/umputun/remark42/backend`.\n\nRelease flow:\n1. Create the GitHub release for `vX.Y.Z` with title `Version X.Y.Z`. The GitHub release must exist before the `vX.Y.Z` tag reaches the remote; `gh release create vX.Y.Z` satisfies this because it creates and pushes the tag.\n2. The `vX.Y.Z` tag triggers GoReleaser, which builds and uploads binary artifacts to the existing release.\n3. Create and push the matching backend module tag pointing at the same commit:\n\n```bash\ngit fetch origin --tags\ngit tag backend/vX.Y.Z vX.Y.Z\ngit push origin backend/vX.Y.Z\n```\n\nGoReleaser must ignore `backend/*` tags in `.goreleaser.yml` so release notes and current-tag detection use only product tags. Docker image publishing stays separate and is handled by the existing Docker workflow.\n\nFor local artifact runs, install GoReleaser, Go 1.27, Node 24+ and PNPM 10, then use `make release`. The target runs a snapshot/no-publish GoReleaser build, leaves local artifacts and metadata in `dist/`, and cleans generated frontend embed files after GoReleaser exits. Do not run raw `goreleaser release` for local artifacts unless you also run `./scripts/cleanup-release-assets.sh` afterward.\n\n## Milestones and Issue Labels\n\n**Milestones** — one `vX.Y.Z` milestone per release. Assign every merged PR, and every issue closed by a code change, to the milestone of the release it shipped in.\n- Decide which release a PR belongs to by whether its merge commit is **contained in a release tag** — not by comparing dates (a tag can be cut from an earlier commit, or moved). `git fetch --tags`, then `git tag --contains <merge_sha> | grep '^v' | sort -V | head -1` is its release. If no release tag contains it yet, it belongs to the next (unreleased) version's milestone — create it if missing (`gh api repos/umputun/remark42/milestones -f title=\"vX.Y.Z\"`).\n- An **issue gets a milestone only when it was closed by a code change** (a linked closing PR/commit); take the milestone from that PR/commit (via the commit-in-tag rule). Issues closed as `duplicate`/`invalid`/`wontfix`/answered get no milestone.\n- Find unassigned: `gh pr list --state merged --search \"no:milestone\"`, `gh issue list --state closed --search \"no:milestone\"`. Assign with `gh pr edit N --milestone \"vX.Y.Z\"` / `gh issue edit N --milestone \"vX.Y.Z\"`.\n\n**Issue labels** — classify each issue with a type and an area (add priority when relevant):\n- Type: `bug`, `enhancement`, `question`, `documentation`, `discussion`\n- Area: `backend`, `frontend`, `site`, `CI`, `design`, `localization`\n- Priority: `important`, `minor`, `some day`\n- Contribution: `help wanted`, `good-first-issue`\n- Resolution (on close, when applicable): `duplicate`, `invalid`, `wontfix`, `no-action-needed`\n- PR auto-labels (applied by Dependabot/Actions, not manual PRs): `dependencies`, `go`, `javascript`, `github_actions`\n\n## Code Style\n- **Backend**: Formatting with golangci-lint, strict error handling\n- **Frontend**: TypeScript with ESLint, Stylelint and Prettier\n- **Imports**: Group stdlib, external packages, then internal packages\n- **CSS**: All components use CSS Modules (`component.module.css`). Class naming: BEM block = `.root`, elements = camelCase, modifiers = camelCase. Use `clsx` for conditional class composition. `raw-content.css` is the only global CSS file (syntax highlighting utility). Root wrapper keeps bare `.dark`/`.light` theme class — 8+ module CSS files depend on `:global(.dark)` ancestor. `comment_highlighting` uses `:global()` for imperative `classList` usage in root.tsx\n\n## Key Backend Packages\n- **Web/API**: `github.com/go-pkgz/routegroup`, `github.com/go-pkgz/rest`\n- **Auth**: `github.com/go-pkgz/auth/v2`\n- **Logging**: `github.com/go-pkgz/lgr`\n- **Testing**: `github.com/stretchr/testify`\n- **Notifications**: `github.com/go-pkgz/notify`\n\n## Repository Structure\n- Backend: Go server using BoltDB for storage\n- Frontend: Preact/Redux-based UI with iframe embedding\n- `/web` is served from two sources, in lookup order: the frontend build output\n  (`frontend/apps/remark42/public`, embedded at `backend/app/cmd/web` or read from `--web-root`),\n  then `backend/app/webassets/assets`, embedded in the binary. A plain page or image the bundler\n  does not process belongs in `webassets`; anything needing templating or the widget's CSS/JS goes\n  through webpack. A name present in both is served from the frontend build.\n"},"files":{"frontend/CLAUDE.md":"# Frontend gotchas\n\nNon-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.\n\n## The node/pnpm version is pinned in many places, not one\n\nCI 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:\n\n- `Dockerfile` (production image) — `FROM node:X-alpine` and `npm i -g pnpm@X.Y.Z`\n- `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.\n- `frontend/apps/remark42/package.json`, both its `packageManager` field and its `engines` block\n- `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.\n- `node:` matrices in `.github/workflows/ci-frontend.yml` (every entry, not just the first) and the `node-version:` values in `release.yml`\n- the two dependency-free test commands live in `package.json` as `test:unit` and `test:unit:node`, are called by name from `release.yml`, and are **repeated inline** in the `unit` job of `ci-frontend.yml`, which carries no pnpm by design. Change either script and that job keeps running the old one, green\n\nWhen bumping pnpm/node, also re-check `frontend/apps/remark42/package.json`'s `engines` field — it's separate from `packageManager` and won't update itself.\n\n`engines.node` states the major we support, currently `>=24`, which is the active LTS; 22 has dropped to maintenance. `@babel/core` 8 wants `^22.18 || >=24.11` and `size-limit` 13 wants `^22.18 || ^24 || >=26`, so 22 was the floor rather than the target. Individual dev dependencies can be stricter within that major (`undici` wants `>=20.18.1`); do not chase those patch floors into `engines` or the docs, or every lockfile refresh becomes a documentation change.\n\n## One manifest, at `frontend/apps/remark42`\n\n`frontend/` holds no `package.json`, no lockfile and no `pnpm-workspace.yaml`. Everything pnpm reads\nlives in `frontend/apps/remark42`: the dependencies, `packageManager`, `engines` and the\n`pnpm.overrides` block. Install and run from there, not from `frontend/`.\n\nThe directory nesting is kept because every path in the repository points at it, from the Dockerfile\nand the workflows to the published contributing docs. `frontend/` still carries `.nvmrc`, `.husky`\nand this file, none of which pnpm reads.\n\n## pnpm 10's stricter `node-linker` layout needs explicit pins\n\nOne dep is pinned specifically because of pnpm 10's hoisting changes, not because of the dep itself:\n- `@types/minimatch` 5.1.2 — 6.x is an empty stub that the hoisted layout picks up instead of the real types\n\nIf a dependency bump mysteriously breaks types or module resolution only after a pnpm major bump, suspect the layout change before suspecting the dependency.\n\n## node's native fetch requires absolute URLs in tests\n\nIt rejects relative request URLs, and the failure is\nsilent: requests simply never match. Any test harness that mocks fetch needs absolute base URLs and\na jsdom base URL set.\n\n## Babel is the only thing that compiles\n\n`.babelrc.js` drives both the bundle, through `babel-loader`, and the tests, which get it as\n`babel-jest`'s `configFile` in `jest.config.mjs`. That indirection is not decoration: `.babelrc.js`\nis a file-relative config and would not reach `node_modules`, so the ESM-only packages in\n`transformIgnorePatterns` would arrive at jest untransformed.\n\nBabel merges an `env` block over the root rather than replacing it, so anything the test run must\nnot see has to be decided before the object is built rather than put in an `env`. `jest.config.mjs`\nsets `BABEL_ENV` itself and `.babelrc.js` reads it for two things: the compile targets, and the\n`data-testid` stripper, which the suites query by and the bundle must not carry. Verify with\n`grep -c data-testid public/*.mjs` after a production build; it must be 0. The plugin matches a\nliteral `JSXAttribute`, which is every use in the tree; a spread such as\n`{...{'data-testid': x}}` would reach production, and that grep is what would catch it.\n\n`tsconfig.json` types but never emits, so its `target` has no effect on output; browserslist in\n`.babelrc.js` decides that. Type checking runs out of band through\n`fork-ts-checker-webpack-plugin`.\n\n## Type-only imports have to say so\n\nBabel compiles one file at a time with no type information, so it cannot tell that an import is\ntypes-only. Left unmarked, the module and everything it imports stay in the bundle, and a single\ntype import of a store-connected component is enough to pull the whole redux graph into an entry\nthat never uses it.\n\n`verbatimModuleSyntax` in `tsconfig.json` and `@typescript-eslint/consistent-type-imports` are what\nenforce this, and the fix has to be a separate `import type { ... }` statement. Inline\n`import { type X }` does **not** work here: verbatim semantics keep the statement, so the module is\nstill loaded. That is also why `no-duplicate-imports` runs with `allowSeparateTypeImports`, since a\nmodule legitimately appears twice.\n\n## JSX runs on the automatic runtime, in two places that must agree\n\n`preact` 10.29 types a component's return as `ComponentChildren`, which only satisfies a JSX check on\nTypeScript 5.1+ via `JSX.ElementType`, and it scopes the `JSX` namespace to `preact/jsx-runtime` rather\nthan declaring it globally. So the type layer has to use the automatic runtime:\n\n- `tsconfig.json`: `jsx: react-jsx` with `jsxImportSource: preact`\n- `.babelrc.js`: `@babel/preset-react` with `runtime: 'automatic'`, `importSource: 'preact'`\n\nKeep both in step. If babel were left on the classic `pragma: 'h'` transform, a new `.tsx`\nwithout `import { h }` would type-check and lint clean, then throw at runtime, because\n`eslint-config-preact` sets `react/react-in-jsx-scope` to 0 and the local config turns `no-undef` off.\n\n## `@babel/core` is pinned to 8 for the whole dependency tree\n\n`@jest/transform` and `istanbul-lib-instrument` depend on `@babel/core` 7 outright, and a babel 8\npreset loaded into a babel 7 core fails on the first `enum` it meets. The `pnpm.overrides` entry in\n`frontend/apps/remark42/package.json` is what stops that. `eslint-config-preact` is the one consumer that cannot\ntake it: its `@babel/eslint-parser` loads babel 7 syntax plugins that babel 8 rejects, so a second\nscoped override, `eslint-config-preact>@babel/core`, holds that subtree on 7.\n\n## Held-back majors\n\nThese 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:\n\n- `eslint` 10 (`eslint-config-preact` peers on ^9), `typescript` 7, `redux` 5 with `redux-thunk` 3, `node-emoji` 2 (renames the field `search()` returns), `@formatjs/cli` 6 (the translation extraction pipeline), and `postcss-preset-env` 11 with `cssnano` 8 and `postcss-html` 2, which change generated CSS\n- `redux` 4\n\n## `/web` has a second source\n\n`privacy.html`, `markdown-help.html` and `400x400.jpeg` live in `backend/app/webassets/assets` and\nare embedded in the Go binary. The frontend build wins for any name present in both, which is what\nlets an operator override one by dropping a file next to the frontend files in `--web-root`. That\ndirectory replaces the embedded frontend outright when it exists, so an override belongs in a\npopulated one. These three sit outside this toolchain: prettier, stylelint and `pnpm lint` do not\nsee them, and they are served unminified. `devServer.static` lists the build output first and that\ndirectory second, matching the backend's order, so links to them resolve on the dev port too.\n\n## eslint config resolves from the process cwd\n\n`eslint.config.mjs` lives in `apps/remark42`, and eslint loads the config next to the directory it\nis *run from* rather than the one nearest the file being linted. Anything that invokes eslint has\nto have `apps/remark42` as its working directory: it is where every script runs, `.husky/pre-commit`\ncd's into it, and an IDE integration needs\n`\"eslint.workingDirectories\": [\"frontend/apps/remark42\"]`.\n\nRules that only exist under a plugin's flat-config export are spread in explicitly; the block that\nre-enables project rules is the last entry in the array, because `eslint-config-prettier` (pulled in\nby `prettierRecommended`) switches several of them off and later entries win.\n\n## Verifying a build didn't regress\n\nThere'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:\n- `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.\n- `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.\n\n## Where the alerts actually were\n\nWhen 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.\n\n## Don't import `preact/compat`\n\nIt installs hooks on preact's shared `options` that remap `onFocus`/`onBlur` to\n`focusin`/`focusout` on every element and make `@testing-library/preact` rewrite `change` to\n`input`, so one import changes event behaviour across the whole widget, and it adds about\n3.8 kB gzipped.\n\n`forwardRef` lives only there, so a component that needs a ref takes it as an ordinary prop;\n`TextareaAutosize` is the pattern. For a component type annotation use `FunctionComponent`\nfrom `preact`, not `React.FC`.\n\n## i18n is a hand-written binding whose export names are fixed by the extractor\n\n`app/common/intl.tsx` provides `IntlProvider`, `useIntl`, `createIntl`, `defineMessages`,\n`FormattedMessage` and `IntlShape`. The formatting itself lives in `app/common/intl-message.ts`,\nwhose only preact reference is a type-only import that erases at build time, so it pulls in nothing at runtime, and which is where every rule below is implemented; `intl.tsx` is the\nbinding over it and re-exports `createIntl` and `defineMessages` unchanged, so no call site can tell\nthe two apart. `formatjs extract` (`translation:extract`) finds messages\nby recognising the identifiers `defineMessages`, `FormattedMessage` and `intl.formatMessage`\nin the AST, not by import source, so those three names are fixed. Rename any of them and\nextraction returns nothing, with no error and a zero exit code.\n\nThe damage lands on the next step. `translation:check` compares locale keys against extracted\nkeys and fails loudly, but `tasks/generateDictionary.js` calls `removeAbandonedKeys`, so\n`translation:generate` after a rename deletes the now-unextracted keys from all 24 files in\n`app/locales/` and writes them back, after which the check passes. Extract-then-generate is\nthe documented translator workflow, so the destructive order is the normal one.\n\nAfter any rename or refactor touching those identifiers, verify the count rather than the exit\ncode:\n\n```sh\npnpm translation:extract\nnode -e \"console.log(Object.keys(require('./extracted-messages/messages')).length)\"\n```\n\nIt must match the key count in `app/locales/en.json`, not drop to 0.\n\nThe binding implements `{name}` interpolation and paired `<tag>text</tag>` rich text, and\nnothing else of ICU: no plural, select or selectordinal, no typed arguments such as\n`{n, number}`, and no apostrophe quoting. A plural or select form has braces the placeholder\nrule rejects, so it falls back to English wherever values are passed and shows its raw text\nwhere they are not; `''` simply stays as two apostrophes.\n\n`translation:check` validates every translated value's markup and placeholders against the\nEnglish string it translates, mirroring the binding's rule, and the catalogue sweep in\n`app/common/intl.test.tsx` additionally renders the two messages that carry a link.\n`app/common/intl-message.unit.test.ts` covers the formatter's own edge cases -- broken markup, an\nunclosed tag, a regex metacharacter in a tag name -- which are the ones no shipped catalogue can\nproduce and so no sweep can reach. What\nneither catches is unsupported ICU syntax, since a plural form is well-formed text as far as\nboth are concerned. A message the binding cannot resolve falls back to the English source\nrather than reaching the page, so without these checks a broken translation is invisible in\nthe interface.\n\n## `*.unit.test.ts` runs with no toolchain, and that is what it is for\n\nTwo test layers, split by what they need to run. Jest owns anything with a DOM in it; `*.unit.test.ts`\nfiles import nothing but the module under test, its fixtures and `node:test`, and run under plain\nnode straight from source:\n\n```sh\npnpm test:unit\n```\n\nThe `unit` job in `ci-frontend.yml` runs them with **no `pnpm install` step**, deliberately.\n`release.yml` runs the same script after an install, as a second gate. The layer's value is that a\nfunction over plain values can be tested without a bundler, a jsdom or a `node_modules`, and the\nabsent install is the only thing that keeps that true: add an import that needs resolving and the\njob goes red instead of quietly acquiring the dependency. `jest.config.mjs` excludes the suffix, so\na file is in exactly one layer.\n\n`node --test` exits 0 on a glob that matches nothing, so it cannot tell a renamed suffix from a\npassing run. The CI step counts the files first and fails when none match. Two import rules follow\nfrom node's ESM resolver, and they contradict the repository's usual style, so they are local to\nthis layer:\n\n- **write the extension in full**, `./types.ts`. This is why `app/store/thread/reducers.ts` and\n  `app/store/user/reducers.ts` import `./types.ts` while their neighbours do not, and why\n  `app/common/json-store.ts` and `app/utils/errorUtils.ts` carry it too. `allowImportingTsExtensions` in `tsconfig.json` is what\n  stops `tsc` rejecting it, and webpack and jest resolve the explicit path unchanged\n- **import by relative path**, never through the `common/…` alias, which only webpack, jest and\n  `tsc` know about. A type-only import of an alias is fine, since it erases before either runtime\n  sees the file\n\nA module that mixes computation with browser state gets split instead of skipped:\n`app/common/intl-message.ts` and `app/hooks/session-storage.ts` are the pure halves of `intl.tsx`\nand `useSessionState.ts`, and `session-storage.ts` takes the store as an argument instead of reading\nthe `sessionStorage` global, which is what lets a test hand it a plain object.\n","CLAUDE.md":"# Remark42 Development Guidelines\n\n## Build/Test/Lint Commands\n- **Backend**:\n  - Run server: `make rundev`\n  - Build: `make backend`\n  - Race test: `make race_test`\n- **Backend Testing**:\n  - Run all tests: `cd backend/app && go test -timeout=300s -count 1 ./...`\n  - Run single test: `cd backend/app && go test -run TestName ./path/to/package`\n  - **IMPORTANT**: Run example tests: `cd backend/_example/memory_store && go test -race ./... && go build -race ./...`\n- **Frontend**:\n  - Development: `cd frontend/apps/remark42 && pnpm dev`\n  - Tests: `cd frontend/apps/remark42 && pnpm test`\n  - Dependency-free tests: `cd frontend/apps/remark42 && pnpm test:unit`.\n    `*.unit.test.ts` files run straight from source with no `node_modules`, and the `unit` CI job\n    installs nothing so a test that needs `node_modules` goes red. Their **value** imports write the\n    extension in full (`./types.ts`) and use a relative path, never the `common/…` alias, because\n    node resolves neither; a type-only import erases and keeps the ordinary style. See\n    `frontend/CLAUDE.md`.\n- **End-to-end**: `make e2e` drives the widget in a real browser; see `e2e/README.md`. Build-tagged, so `go test ./...` never runs it.\n- **Lint**:\n  - Backend: `cd backend && golangci-lint run`\n  - **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml`\n  - Frontend: `cd frontend/apps/remark42 && pnpm lint`\n  - **Before committing**: Always run tests and linter on both main backend AND examples\n- **Go module changes**:\n  - **Any** change to `backend/go.mod` or `backend/go.sum` requires `go mod tidy` in `backend/_example/memory_store` in the same commit. That covers dependency bumps, adding or removing a dependency, and changing the `go` directive, not only version updates.\n  - Only `go mod tidy` there, not `go mod vendor`: the example's vendor directory is gitignored (`.gitignore:26`), so its output is never committed, while a stale local copy silently becomes what the example resolves against.\n  - The example module replaces `github.com/umputun/remark42/backend` with `../../`, so it carries the backend's dependencies as indirect entries. Leaving them stale fails the `test examples` CI step with `go: updates to go.mod needed; to update it: go mod tidy`.\n  - This applies to Dependabot pull requests too: the bot updates `backend/` only, so its Go module PRs need the example tidied before they can go green.\n\n\n## Backend Test Determinism\n\nBackend tests must never depend on how fast the machine is. CI runs them under `-race` with coverage on a shared runner, so any test that assumes an operation finishes within some duration eventually fails on a rerun-and-it-passes basis.\n\n- **Wait on a condition, never on a duration.** Use `require.Eventually` / `require.EventuallyWithT` to poll for the state the assertion needs, and `require.Never` when the point is that something did *not* happen. A bare `time.Sleep` before an assertion is a defect; sleeping until a deadline you computed, as `waitPastMillisecond` does, is not.\n- **Polling closures must not touch `*testing.T`.** testify runs them on a separate goroutine, where `t.FailNow` is undefined behaviour. Assert on the `*assert.CollectT` that `EventuallyWithT` hands the closure, so the real error also lands in the failure message.\n- **Mind the rate limiter when polling over HTTP.** Route groups are capped independently and most of the caps are hard-coded in `rest.go`, out of reach of a test: `/auth/` at 2 req/s and the admin, protected and image routes at 10 req/s. Only the open-route group is settable, via `openRouteLimiter` (100 in `startupT`). Poll with the existing constants rather than a new number, `httpPoll` for anything issuing an HTTP request and `pollInterval` only for in-process or filesystem checks, or the poll manufactures the 429s it then has to interpret.\n- **When a test needs time to have passed, pin the clock input rather than waiting for it:** `os.Chtimes` for file ages, an explicit `store.Comment.Timestamp` for anything that formats a timestamp.\n- **Prefer a `testing/synctest` bubble** where the code under test has no real I/O. Inside one the clock is fake, so `time.Sleep` is instant and deterministic. `app/notify`, `app/store/service`, `app/store/image`, `app/store/engine`, `app/providers`, `app/migrator` and `_example/memory_store/accessor` already use it, and most surviving `time.Sleep` calls live in them.\n- **Helpers fail loudly.** A wait that gives up must call `t.Fatal`/`require` naming what it was waiting for, never return silently and leave the next assertion to fail with something unrelated. Because these packages run `goleak.VerifyTestMain`, a failing helper also exits the test goroutine, so anything that started a server in a goroutine must `defer cancel()` or `defer srv.Shutdown()` right after launching it; otherwise a failed readiness wait is reported as a goroutine leak rather than the failure that caused it.\n- **Take ports and paths from outside the test.** Ports come from the kernel with `net.Listen(\"tcp\", \":0\")`, files from `t.TempDir()`. `go test ./...` runs package binaries concurrently, so a number out of a fixed range or a fixed name under `/tmp` lets two of them collide.\n- **Close idle connections before shutting a test server down.** Clients built as `http.Client{Timeout: x}` share `http.DefaultTransport`, and `Shutdown` waits on their keep-alive connections until its own deadline expires.\n- **Keep the test timeout budgets aligned.** `Makefile`, `ci-backend.yml`, `release.yml` and the command above all use `-timeout=300s`; the wait helpers allow 30s per condition, so a shorter per-package budget turns a slow runner into a timeout panic instead of a readable failure.\n\n`chooseUnusedPort` and the server-start wait helpers are duplicated in `app`, `app/cmd`, `app/rest/api` and `_example/memory_store/server`. Nothing shares them today; keep the copies in step when changing one.\n\n## Release Procedure\n\nRemark42 uses two tags for each release:\n- `vX.Y.Z` - product release tag used by GitHub releases, GoReleaser binary artifacts, and Docker image publishing.\n- `backend/vX.Y.Z` - nested Go module tag for `github.com/umputun/remark42/backend`.\n\nRelease flow:\n1. Create the GitHub release for `vX.Y.Z` with title `Version X.Y.Z`. The GitHub release must exist before the `vX.Y.Z` tag reaches the remote; `gh release create vX.Y.Z` satisfies this because it creates and pushes the tag.\n2. The `vX.Y.Z` tag triggers GoReleaser, which builds and uploads binary artifacts to the existing release.\n3. Create and push the matching backend module tag pointing at the same commit:\n\n```bash\ngit fetch origin --tags\ngit tag backend/vX.Y.Z vX.Y.Z\ngit push origin backend/vX.Y.Z\n```\n\nGoReleaser must ignore `backend/*` tags in `.goreleaser.yml` so release notes and current-tag detection use only product tags. Docker image publishing stays separate and is handled by the existing Docker workflow.\n\nFor local artifact runs, install GoReleaser, Go 1.27, Node 24+ and PNPM 10, then use `make release`. The target runs a snapshot/no-publish GoReleaser build, leaves local artifacts and metadata in `dist/`, and cleans generated frontend embed files after GoReleaser exits. Do not run raw `goreleaser release` for local artifacts unless you also run `./scripts/cleanup-release-assets.sh` afterward.\n\n## Milestones and Issue Labels\n\n**Milestones** — one `vX.Y.Z` milestone per release. Assign every merged PR, and every issue closed by a code change, to the milestone of the release it shipped in.\n- Decide which release a PR belongs to by whether its merge commit is **contained in a release tag** — not by comparing dates (a tag can be cut from an earlier commit, or moved). `git fetch --tags`, then `git tag --contains <merge_sha> | grep '^v' | sort -V | head -1` is its release. If no release tag contains it yet, it belongs to the next (unreleased) version's milestone — create it if missing (`gh api repos/umputun/remark42/milestones -f title=\"vX.Y.Z\"`).\n- An **issue gets a milestone only when it was closed by a code change** (a linked closing PR/commit); take the milestone from that PR/commit (via the commit-in-tag rule). Issues closed as `duplicate`/`invalid`/`wontfix`/answered get no milestone.\n- Find unassigned: `gh pr list --state merged --search \"no:milestone\"`, `gh issue list --state closed --search \"no:milestone\"`. Assign with `gh pr edit N --milestone \"vX.Y.Z\"` / `gh issue edit N --milestone \"vX.Y.Z\"`.\n\n**Issue labels** — classify each issue with a type and an area (add priority when relevant):\n- Type: `bug`, `enhancement`, `question`, `documentation`, `discussion`\n- Area: `backend`, `frontend`, `site`, `CI`, `design`, `localization`\n- Priority: `important`, `minor`, `some day`\n- Contribution: `help wanted`, `good-first-issue`\n- Resolution (on close, when applicable): `duplicate`, `invalid`, `wontfix`, `no-action-needed`\n- PR auto-labels (applied by Dependabot/Actions, not manual PRs): `dependencies`, `go`, `javascript`, `github_actions`\n\n## Code Style\n- **Backend**: Formatting with golangci-lint, strict error handling\n- **Frontend**: TypeScript with ESLint, Stylelint and Prettier\n- **Imports**: Group stdlib, external packages, then internal packages\n- **CSS**: All components use CSS Modules (`component.module.css`). Class naming: BEM block = `.root`, elements = camelCase, modifiers = camelCase. Use `clsx` for conditional class composition. `raw-content.css` is the only global CSS file (syntax highlighting utility). Root wrapper keeps bare `.dark`/`.light` theme class — 8+ module CSS files depend on `:global(.dark)` ancestor. `comment_highlighting` uses `:global()` for imperative `classList` usage in root.tsx\n\n## Key Backend Packages\n- **Web/API**: `github.com/go-pkgz/routegroup`, `github.com/go-pkgz/rest`\n- **Auth**: `github.com/go-pkgz/auth/v2`\n- **Logging**: `github.com/go-pkgz/lgr`\n- **Testing**: `github.com/stretchr/testify`\n- **Notifications**: `github.com/go-pkgz/notify`\n\n## Repository Structure\n- Backend: Go server using BoltDB for storage\n- Frontend: Preact/Redux-based UI with iframe embedding\n- `/web` is served from two sources, in lookup order: the frontend build output\n  (`frontend/apps/remark42/public`, embedded at `backend/app/cmd/web` or read from `--web-root`),\n  then `backend/app/webassets/assets`, embedded in the binary. A plain page or image the bundler\n  does not process belongs in `webassets`; anything needing templating or the widget's CSS/JS goes\n  through webpack. A name present in both is served from the frontend build.\n"},"items":[{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/umputun/remark42/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# Remark42 Development Guidelines\n\n## Build/Test/Lint Commands\n- **Backend**:\n  - Run server: `make rundev`\n  - Build: `make backend`\n  - Race test: `make race_test`\n- **Backend Testing**:\n  - Run all tests: `cd backend/app && go test -timeout=300s -count 1 ./...`\n  - Run single test: `cd backend/app && go test -run TestName ./path/to/package`\n  - **IMPORTANT**: Run example tests: `cd backend/_example/memory_store && go test -race ./... && go build -race ./...`\n- **Frontend**:\n  - Development: `cd frontend/apps/remark42 && pnpm dev`\n  - Tests: `cd frontend/apps/remark42 && pnpm test`\n  - Dependency-free tests: `cd frontend/apps/remark42 && pnpm test:unit`.\n    `*.unit.test.ts` files run straight from source with no `node_modules`, and the `unit` CI job\n    installs nothing so a test that needs `node_modules` goes red. Their **value** imports write the\n    extension in full (`./types.ts`) and use a relative path, never the `common/…` alias, because\n    node resolves neither; a type-only import erases and keeps the ordinary style. See\n    `frontend/CLAUDE.md`.\n- **End-to-end**: `make e2e` drives the widget in a real browser; see `e2e/README.md`. Build-tagged, so `go test ./...` never runs it.\n- **Lint**:\n  - Backend: `cd backend && golangci-lint run`\n  - **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml`\n  - Frontend: `cd frontend/apps/remark42 && pnpm lint`\n  - **Before committing**: Always run tests and linter on both main backend AND examples\n- **Go module changes**:\n  - **Any** change to `backend/go.mod` or `backend/go.sum` requires `go mod tidy` in `backend/_example/memory_store` in the same commit. That covers dependency bumps, adding or removing a dependency, and changing the `go` directive, not only version updates.\n  - Only `go mod tidy` there, not `go mod vendor`: the example's vendor directory is gitignored (`.gitignore:26`), so its output is never committed, while a stale local copy silently becomes what the example resolves against.\n  - The example module replaces `github.com/umputun/remark42/backend` with `../../`, so it carries the backend's dependencies as indirect entries. Leaving them stale fails the `test examples` CI step with `go: updates to go.mod needed; to update it: go mod tidy`.\n  - This applies to Dependabot pull requests too: the bot updates `backend/` only, so its Go module PRs need the example tidied before they can go green.\n\n\n## Backend Test Determinism\n\nBackend tests must never depend on how fast the machine is. CI runs them under `-race` with coverage on a shared runner, so any test that assumes an operation finishes within some duration eventually fails on a rerun-and-it-passes basis.\n\n- **Wait on a condition, never on a duration.** Use `require.Eventually` / `require.EventuallyWithT` to poll for the state the assertion needs, and `require.Never` when the point is that something did *not* happen. A bare `time.Sleep` before an assertion is a defect; sleeping until a deadline you computed, as `waitPastMillisecond` does, is not.\n- **Polling closures must not touch `*testing.T`.** testify runs them on a separate goroutine, where `t.FailNow` is undefined behaviour. Assert on the `*assert.CollectT` that `EventuallyWithT` hands the closure, so the real error also lands in the failure message.\n- **Mind the rate limiter when polling over HTTP.** Route groups are capped independently and most of the caps are hard-coded in `rest.go`, out of reach of a test: `/auth/` at 2 req/s and the admin, protected and image routes at 10 req/s. Only the open-route group is settable, via `openRouteLimiter` (100 in `startupT`). Poll with the existing constants rather than a new number, `httpPoll` for anything issuing an HTTP request and `pollInterval` only for in-process or filesystem checks, or the poll manufactures the 429s it then has to interpret.\n- **When a test needs time to have passed, pin the clock input rather than waiting for it:** `os.Chtimes` for file ages, an explicit `store.Comment.Timestamp` for anything that formats a timestamp.\n- **Prefer a `testing/synctest` bubble** where the code under test has no real I/O. Inside one the clock is fake, so `time.Sleep` is instant and deterministic. `app/notify`, `app/store/service`, `app/store/image`, `app/store/engine`, `app/providers`, `app/migrator` and `_example/memory_store/accessor` already use it, and most surviving `time.Sleep` calls live in them.\n- **Helpers fail loudly.** A wait that gives up must call `t.Fatal`/`require` naming what it was waiting for, never return silently and leave the next assertion to fail with something unrelated. Because these packages run `goleak.VerifyTestMain`, a failing helper also exits the test goroutine, so anything that started a server in a goroutine must `defer cancel()` or `defer srv.Shutdown()` right after launching it; otherwise a failed readiness wait is reported as a goroutine leak rather than the failure that caused it.\n- **Take ports and paths from outside the test.** Ports come from the kernel with `net.Listen(\"tcp\", \":0\")`, files from `t.TempDir()`. `go test ./...` runs package binaries concurrently, so a number out of a fixed range or a fixed name under `/tmp` lets two of them collide.\n- **Close idle connections before shutting a test server down.** Clients built as `http.Client{Timeout: x}` share `http.DefaultTransport`, and `Shutdown` waits on their keep-alive connections until its own deadline expires.\n- **Keep the test timeout budgets aligned.** `Makefile`, `ci-backend.yml`, `release.yml` and the command above all use `-timeout=300s`; the wait helpers allow 30s per condition, so a shorter per-package budget turns a slow runner into a timeout panic instead of a readable failure.\n\n`chooseUnusedPort` and the server-start wait helpers are duplicated in `app`, `app/cmd`, `app/rest/api` and `_example/memory_store/server`. Nothing shares them today; keep the copies in step when changing one.\n\n## Release Procedure\n\nRemark42 uses two tags for each release:\n- `vX.Y.Z` - product release tag used by GitHub releases, GoReleaser binary artifacts, and Docker image publishing.\n- `backend/vX.Y.Z` - nested Go module tag for `github.com/umputun/remark42/backend`.\n\nRelease flow:\n1. Create the GitHub release for `vX.Y.Z` with title `Version X.Y.Z`. The GitHub release must exist before the `vX.Y.Z` tag reaches the remote; `gh release create vX.Y.Z` satisfies this because it creates and pushes the tag.\n2. The `vX.Y.Z` tag triggers GoReleaser, which builds and uploads binary artifacts to the existing release.\n3. Create and push the matching backend module tag pointing at the same commit:\n\n```bash\ngit fetch origin --tags\ngit tag backend/vX.Y.Z vX.Y.Z\ngit push origin backend/vX.Y.Z\n```\n\nGoReleaser must ignore `backend/*` tags in `.goreleaser.yml` so release notes and current-tag detection use only product tags. Docker image publishing stays separate and is handled by the existing Docker workflow.\n\nFor local artifact runs, install GoReleaser, Go 1.27, Node 24+ and PNPM 10, then use `make release`. The target runs a snapshot/no-publish GoReleaser build, leaves local artifacts and metadata in `dist/`, and cleans generated frontend embed files after GoReleaser exits. Do not run raw `goreleaser release` for local artifacts unless you also run `./scripts/cleanup-release-assets.sh` afterward.\n\n## Milestones and Issue Labels\n\n**Milestones** — one `vX.Y.Z` milestone per release. Assign every merged PR, and every issue closed by a code change, to the milestone of the release it shipped in.\n- Decide which release a PR belongs to by whether its merge commit is **contained in a release tag** — not by comparing dates (a tag can be cut from an earlier commit, or moved). `git fetch --tags`, then `git tag --contains <merge_sha> | grep '^v' | sort -V | head -1` is its release. If no release tag contains it yet, it belongs to the next (unreleased) version's milestone — create it if missing (`gh api repos/umputun/remark42/milestones -f title=\"vX.Y.Z\"`).\n- An **issue gets a milestone only when it was closed by a code change** (a linked closing PR/commit); take the milestone from that PR/commit (via the commit-in-tag rule). Issues closed as `duplicate`/`invalid`/`wontfix`/answered get no milestone.\n- Find unassigned: `gh pr list --state merged --search \"no:milestone\"`, `gh issue list --state closed --search \"no:milestone\"`. Assign with `gh pr edit N --milestone \"vX.Y.Z\"` / `gh issue edit N --milestone \"vX.Y.Z\"`.\n\n**Issue labels** — classify each issue with a type and an area (add priority when relevant):\n- Type: `bug`, `enhancement`, `question`, `documentation`, `discussion`\n- Area: `backend`, `frontend`, `site`, `CI`, `design`, `localization`\n- Priority: `important`, `minor`, `some day`\n- Contribution: `help wanted`, `good-first-issue`\n- Resolution (on close, when applicable): `duplicate`, `invalid`, `wontfix`, `no-action-needed`\n- PR auto-labels (applied by Dependabot/Actions, not manual PRs): `dependencies`, `go`, `javascript`, `github_actions`\n\n## Code Style\n- **Backend**: Formatting with golangci-lint, strict error handling\n- **Frontend**: TypeScript with ESLint, Stylelint and Prettier\n- **Imports**: Group stdlib, external packages, then internal packages\n- **CSS**: All components use CSS Modules (`component.module.css`). Class naming: BEM block = `.root`, elements = camelCase, modifiers = camelCase. Use `clsx` for conditional class composition. `raw-content.css` is the only global CSS file (syntax highlighting utility). Root wrapper keeps bare `.dark`/`.light` theme class — 8+ module CSS files depend on `:global(.dark)` ancestor. `comment_highlighting` uses `:global()` for imperative `classList` usage in root.tsx\n\n## Key Backend Packages\n- **Web/API**: `github.com/go-pkgz/routegroup`, `github.com/go-pkgz/rest`\n- **Auth**: `github.com/go-pkgz/auth/v2`\n- **Logging**: `github.com/go-pkgz/lgr`\n- **Testing**: `github.com/stretchr/testify`\n- **Notifications**: `github.com/go-pkgz/notify`\n\n## Repository Structure\n- Backend: Go server using BoltDB for storage\n- Frontend: Preact/Redux-based UI with iframe embedding\n- `/web` is served from two sources, in lookup order: the frontend build output\n  (`frontend/apps/remark42/public`, embedded at `backend/app/cmd/web` or read from `--web-root`),\n  then `backend/app/webassets/assets`, embedded in the binary. A plain page or image the bundler\n  does not process belongs in `webassets`; anything needing templating or the widget's CSS/JS goes\n  through webpack. A name present in both is served from the frontend build.\n","isInternal":false,"tokens":2629,"sizeBytes":10601},{"name":"CLAUDE.md","path":"frontend/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/umputun/remark42/HEAD/frontend/CLAUDE.md","title":"frontend - Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# Frontend gotchas\n\nNon-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.\n\n## The node/pnpm version is pinned in many places, not one\n\nCI 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:\n\n- `Dockerfile` (production image) — `FROM node:X-alpine` and `npm i -g pnpm@X.Y.Z`\n- `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.\n- `frontend/apps/remark42/package.json`, both its `packageManager` field and its `engines` block\n- `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.\n- `node:` matrices in `.github/workflows/ci-frontend.yml` (every entry, not just the first) and the `node-version:` values in `release.yml`\n- the two dependency-free test commands live in `package.json` as `test:unit` and `test:unit:node`, are called by name from `release.yml`, and are **repeated inline** in the `unit` job of `ci-frontend.yml`, which carries no pnpm by design. Change either script and that job keeps running the old one, green\n\nWhen bumping pnpm/node, also re-check `frontend/apps/remark42/package.json`'s `engines` field — it's separate from `packageManager` and won't update itself.\n\n`engines.node` states the major we support, currently `>=24`, which is the active LTS; 22 has dropped to maintenance. `@babel/core` 8 wants `^22.18 || >=24.11` and `size-limit` 13 wants `^22.18 || ^24 || >=26`, so 22 was the floor rather than the target. Individual dev dependencies can be stricter within that major (`undici` wants `>=20.18.1`); do not chase those patch floors into `engines` or the docs, or every lockfile refresh becomes a documentation change.\n\n## One manifest, at `frontend/apps/remark42`\n\n`frontend/` holds no `package.json`, no lockfile and no `pnpm-workspace.yaml`. Everything pnpm reads\nlives in `frontend/apps/remark42`: the dependencies, `packageManager`, `engines` and the\n`pnpm.overrides` block. Install and run from there, not from `frontend/`.\n\nThe directory nesting is kept because every path in the repository points at it, from the Dockerfile\nand the workflows to the published contributing docs. `frontend/` still carries `.nvmrc`, `.husky`\nand this file, none of which pnpm reads.\n\n## pnpm 10's stricter `node-linker` layout needs explicit pins\n\nOne dep is pinned specifically because of pnpm 10's hoisting changes, not because of the dep itself:\n- `@types/minimatch` 5.1.2 — 6.x is an empty stub that the hoisted layout picks up instead of the real types\n\nIf a dependency bump mysteriously breaks types or module resolution only after a pnpm major bump, suspect the layout change before suspecting the dependency.\n\n## node's native fetch requires absolute URLs in tests\n\nIt rejects relative request URLs, and the failure is\nsilent: requests simply never match. Any test harness that mocks fetch needs absolute base URLs and\na jsdom base URL set.\n\n## Babel is the only thing that compiles\n\n`.babelrc.js` drives both the bundle, through `babel-loader`, and the tests, which get it as\n`babel-jest`'s `configFile` in `jest.config.mjs`. That indirection is not decoration: `.babelrc.js`\nis a file-relative config and would not reach `node_modules`, so the ESM-only packages in\n`transformIgnorePatterns` would arrive at jest untransformed.\n\nBabel merges an `env` block over the root rather than replacing it, so anything the test run must\nnot see has to be decided before the object is built rather than put in an `env`. `jest.config.mjs`\nsets `BABEL_ENV` itself and `.babelrc.js` reads it for two things: the compile targets, and the\n`data-testid` stripper, which the suites query by and the bundle must not carry. Verify with\n`grep -c data-testid public/*.mjs` after a production build; it must be 0. The plugin matches a\nliteral `JSXAttribute`, which is every use in the tree; a spread such as\n`{...{'data-testid': x}}` would reach production, and that grep is what would catch it.\n\n`tsconfig.json` types but never emits, so its `target` has no effect on output; browserslist in\n`.babelrc.js` decides that. Type checking runs out of band through\n`fork-ts-checker-webpack-plugin`.\n\n## Type-only imports have to say so\n\nBabel compiles one file at a time with no type information, so it cannot tell that an import is\ntypes-only. Left unmarked, the module and everything it imports stay in the bundle, and a single\ntype import of a store-connected component is enough to pull the whole redux graph into an entry\nthat never uses it.\n\n`verbatimModuleSyntax` in `tsconfig.json` and `@typescript-eslint/consistent-type-imports` are what\nenforce this, and the fix has to be a separate `import type { ... }` statement. Inline\n`import { type X }` does **not** work here: verbatim semantics keep the statement, so the module is\nstill loaded. That is also why `no-duplicate-imports` runs with `allowSeparateTypeImports`, since a\nmodule legitimately appears twice.\n\n## JSX runs on the automatic runtime, in two places that must agree\n\n`preact` 10.29 types a component's return as `ComponentChildren`, which only satisfies a JSX check on\nTypeScript 5.1+ via `JSX.ElementType`, and it scopes the `JSX` namespace to `preact/jsx-runtime` rather\nthan declaring it globally. So the type layer has to use the automatic runtime:\n\n- `tsconfig.json`: `jsx: react-jsx` with `jsxImportSource: preact`\n- `.babelrc.js`: `@babel/preset-react` with `runtime: 'automatic'`, `importSource: 'preact'`\n\nKeep both in step. If babel were left on the classic `pragma: 'h'` transform, a new `.tsx`\nwithout `import { h }` would type-check and lint clean, then throw at runtime, because\n`eslint-config-preact` sets `react/react-in-jsx-scope` to 0 and the local config turns `no-undef` off.\n\n## `@babel/core` is pinned to 8 for the whole dependency tree\n\n`@jest/transform` and `istanbul-lib-instrument` depend on `@babel/core` 7 outright, and a babel 8\npreset loaded into a babel 7 core fails on the first `enum` it meets. The `pnpm.overrides` entry in\n`frontend/apps/remark42/package.json` is what stops that. `eslint-config-preact` is the one consumer that cannot\ntake it: its `@babel/eslint-parser` loads babel 7 syntax plugins that babel 8 rejects, so a second\nscoped override, `eslint-config-preact>@babel/core`, holds that subtree on 7.\n\n## Held-back majors\n\nThese 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:\n\n- `eslint` 10 (`eslint-config-preact` peers on ^9), `typescript` 7, `redux` 5 with `redux-thunk` 3, `node-emoji` 2 (renames the field `search()` returns), `@formatjs/cli` 6 (the translation extraction pipeline), and `postcss-preset-env` 11 with `cssnano` 8 and `postcss-html` 2, which change generated CSS\n- `redux` 4\n\n## `/web` has a second source\n\n`privacy.html`, `markdown-help.html` and `400x400.jpeg` live in `backend/app/webassets/assets` and\nare embedded in the Go binary. The frontend build wins for any name present in both, which is what\nlets an operator override one by dropping a file next to the frontend files in `--web-root`. That\ndirectory replaces the embedded frontend outright when it exists, so an override belongs in a\npopulated one. These three sit outside this toolchain: prettier, stylelint and `pnpm lint` do not\nsee them, and they are served unminified. `devServer.static` lists the build output first and that\ndirectory second, matching the backend's order, so links to them resolve on the dev port too.\n\n## eslint config resolves from the process cwd\n\n`eslint.config.mjs` lives in `apps/remark42`, and eslint loads the config next to the directory it\nis *run from* rather than the one nearest the file being linted. Anything that invokes eslint has\nto have `apps/remark42` as its working directory: it is where every script runs, `.husky/pre-commit`\ncd's into it, and an IDE integration needs\n`\"eslint.workingDirectories\": [\"frontend/apps/remark42\"]`.\n\nRules that only exist under a plugin's flat-config export are spread in explicitly; the block that\nre-enables project rules is the last entry in the array, because `eslint-config-prettier` (pulled in\nby `prettierRecommended`) switches several of them off and later entries win.\n\n## Verifying a build didn't regress\n\nThere'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:\n- `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.\n- `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.\n\n## Where the alerts actually were\n\nWhen 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.\n\n## Don't import `preact/compat`\n\nIt installs hooks on preact's shared `options` that remap `onFocus`/`onBlur` to\n`focusin`/`focusout` on every element and make `@testing-library/preact` rewrite `change` to\n`input`, so one import changes event behaviour across the whole widget, and it adds about\n3.8 kB gzipped.\n\n`forwardRef` lives only there, so a component that needs a ref takes it as an ordinary prop;\n`TextareaAutosize` is the pattern. For a component type annotation use `FunctionComponent`\nfrom `preact`, not `React.FC`.\n\n## i18n is a hand-written binding whose export names are fixed by the extractor\n\n`app/common/intl.tsx` provides `IntlProvider`, `useIntl`, `createIntl`, `defineMessages`,\n`FormattedMessage` and `IntlShape`. The formatting itself lives in `app/common/intl-message.ts`,\nwhose only preact reference is a type-only import that erases at build time, so it pulls in nothing at runtime, and which is where every rule below is implemented; `intl.tsx` is the\nbinding over it and re-exports `createIntl` and `defineMessages` unchanged, so no call site can tell\nthe two apart. `formatjs extract` (`translation:extract`) finds messages\nby recognising the identifiers `defineMessages`, `FormattedMessage` and `intl.formatMessage`\nin the AST, not by import source, so those three names are fixed. Rename any of them and\nextraction returns nothing, with no error and a zero exit code.\n\nThe damage lands on the next step. `translation:check` compares locale keys against extracted\nkeys and fails loudly, but `tasks/generateDictionary.js` calls `removeAbandonedKeys`, so\n`translation:generate` after a rename deletes the now-unextracted keys from all 24 files in\n`app/locales/` and writes them back, after which the check passes. Extract-then-generate is\nthe documented translator workflow, so the destructive order is the normal one.\n\nAfter any rename or refactor touching those identifiers, verify the count rather than the exit\ncode:\n\n```sh\npnpm translation:extract\nnode -e \"console.log(Object.keys(require('./extracted-messages/messages')).length)\"\n```\n\nIt must match the key count in `app/locales/en.json`, not drop to 0.\n\nThe binding implements `{name}` interpolation and paired `<tag>text</tag>` rich text, and\nnothing else of ICU: no plural, select or selectordinal, no typed arguments such as\n`{n, number}`, and no apostrophe quoting. A plural or select form has braces the placeholder\nrule rejects, so it falls back to English wherever values are passed and shows its raw text\nwhere they are not; `''` simply stays as two apostrophes.\n\n`translation:check` validates every translated value's markup and placeholders against the\nEnglish string it translates, mirroring the binding's rule, and the catalogue sweep in\n`app/common/intl.test.tsx` additionally renders the two messages that carry a link.\n`app/common/intl-message.unit.test.ts` covers the formatter's own edge cases -- broken markup, an\nunclosed tag, a regex metacharacter in a tag name -- which are the ones no shipped catalogue can\nproduce and so no sweep can reach. What\nneither catches is unsupported ICU syntax, since a plural form is well-formed text as far as\nboth are concerned. A message the binding cannot resolve falls back to the English source\nrather than reaching the page, so without these checks a broken translation is invisible in\nthe interface.\n\n## `*.unit.test.ts` runs with no toolchain, and that is what it is for\n\nTwo test layers, split by what they need to run. Jest owns anything with a DOM in it; `*.unit.test.ts`\nfiles import nothing but the module under test, its fixtures and `node:test`, and run under plain\nnode straight from source:\n\n```sh\npnpm test:unit\n```\n\nThe `unit` job in `ci-frontend.yml` runs them with **no `pnpm install` step**, deliberately.\n`release.yml` runs the same script after an install, as a second gate. The layer's value is that a\nfunction over plain values can be tested without a bundler, a jsdom or a `node_modules`, and the\nabsent install is the only thing that keeps that true: add an import that needs resolving and the\njob goes red instead of quietly acquiring the dependency. `jest.config.mjs` excludes the suffix, so\na file is in exactly one layer.\n\n`node --test` exits 0 on a glob that matches nothing, so it cannot tell a renamed suffix from a\npassing run. The CI step counts the files first and fails when none match. Two import rules follow\nfrom node's ESM resolver, and they contradict the repository's usual style, so they are local to\nthis layer:\n\n- **write the extension in full**, `./types.ts`. This is why `app/store/thread/reducers.ts` and\n  `app/store/user/reducers.ts` import `./types.ts` while their neighbours do not, and why\n  `app/common/json-store.ts` and `app/utils/errorUtils.ts` carry it too. `allowImportingTsExtensions` in `tsconfig.json` is what\n  stops `tsc` rejecting it, and webpack and jest resolve the explicit path unchanged\n- **import by relative path**, never through the `common/…` alias, which only webpack, jest and\n  `tsc` know about. A type-only import of an alias is fine, since it erases before either runtime\n  sees the file\n\nA module that mixes computation with browser state gets split instead of skipped:\n`app/common/intl-message.ts` and `app/hooks/session-storage.ts` are the pure halves of `intl.tsx`\nand `useSessionState.ts`, and `session-storage.ts` takes the store as an argument instead of reading\nthe `sessionStorage` global, which is what lets a test hand it a plain object.\n","isInternal":false,"tokens":3786,"sizeBytes":15486}],"systemPromptSnippet":"<yakaai_skills repo=\"umputun/remark42\">\n<!-- File: CLAUDE.md (Tokens: ~2629 | Category: claude-rule) -->\n# Remark42 Development Guidelines\n\n## Build/Test/Lint Commands\n- **Backend**:\n  - Run server: `make rundev`\n  - Build: `make backend`\n  - Race test: `make race_test`\n- **Backend Testing**:\n  - Run all tests: `cd backend/app && go test -timeout=300s -count 1 ./...`\n  - Run single test: `cd backend/app && go test -run TestName ./path/to/package`\n  - **IMPORTANT**: Run example tests: `cd backend/_example/memory_store && go test -race ./... && go build -race ./...`\n- **Frontend**:\n  - Development: `cd frontend/apps/remark42 && pnpm dev`\n  - Tests: `cd frontend/apps/remark42 && pnpm test`\n  - Dependency-free tests: `cd frontend/apps/remark42 && pnpm test:unit`.\n    `*.unit.test.ts` files run straight from source with no `node_modules`, and the `unit` CI job\n    installs nothing so a test that needs `node_modules` goes red. Their **value** imports write the\n    extension in full (`./types.ts`) and use a relative path, never the `common/…` alias, because\n    node resolves neither; a type-only import erases and keeps the ordinary style. See\n    `frontend/CLAUDE.md`.\n- **End-to-end**: `make e2e` drives the widget in a real browser; see `e2e/README.md`. Build-tagged, so `go test ./...` never runs it.\n- **Lint**:\n  - Backend: `cd backend && golangci-lint run`\n  - **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml`\n  - Frontend: `cd frontend/apps/remark42 && pnpm lint`\n  - **Before committing**: Always run tests and linter on both main backend AND examples\n- **Go module changes**:\n  - **Any** change to `backend/go.mod` or `backend/go.sum` requires `go mod tidy` in `backend/_example/memory_store` in the same commit. That covers dependency bumps, adding or removing a dependency, and changing the `go` directive, not only version updates.\n  - Only `go mod tidy` there, not `go mod vendor`: the example's vendor directory is gitignored (`.gitignore:26`), so its output is never committed, while a stale local copy silently becomes what the example resolves against.\n  - The example module replaces `github.com/umputun/remark42/backend` with `../../`, so it carries the backend's dependencies as indirect entries. Leaving them stale fails the `test examples` CI step with `go: updates to go.mod needed; to update it: go mod tidy`.\n  - This applies to Dependabot pull requests too: the bot updates `backend/` only, so its Go module PRs need the example tidied before they can go green.\n\n\n## Backend Test Determinism\n\nBackend tests must never depend on how fast the machine is. CI runs them under `-race` with coverage on a shared runner, so any test that assumes an operation finishes within some duration eventually fails on a rerun-and-it-passes basis.\n\n- **Wait on a condition, never on a duration.** Use `require.Eventually` / `require.EventuallyWithT` to poll for the state the assertion needs, and `require.Never` when the point is that something did *not* happen. A bare `time.Sleep` before an assertion is a defect; sleeping until a deadline you computed, as `waitPastMillisecond` does, is not.\n- **Polling closures must not touch `*testing.T`.** testify runs them on a separate goroutine, where `t.FailNow` is undefined behaviour. Assert on the `*assert.CollectT` that `EventuallyWithT` hands the closure, so the real error also lands in the failure message.\n- **Mind the rate limiter when polling over HTTP.** Route groups are capped independently and most of the caps are hard-coded in `rest.go`, out of reach of a test: `/auth/` at 2 req/s and the admin, protected and image routes at 10 req/s. Only the open-route group is settable, via `openRouteLimiter` (100 in `startupT`). Poll with the existing constants rather than a new number, `httpPoll` for anything issuing an HTTP request and `pollInterval` only for in-process or filesystem checks, or the poll manufactures the 429s it then has to interpret.\n- **When a test needs time to have passed, pin the clock input rather than waiting for it:** `os.Chtimes` for file ages, an explicit `store.Comment.Timestamp` for anything that formats a timestamp.\n- **Prefer a `testing/synctest` bubble** where the code under test has no real I/O. Inside one the clock is fake, so `time.Sleep` is instant and deterministic. `app/notify`, `app/store/service`, `app/store/image`, `app/store/engine`, `app/providers`, `app/migrator` and `_example/memory_store/accessor` already use it, and most surviving `time.Sleep` calls live in them.\n- **Helpers fail loudly.** A wait that gives up must call `t.Fatal`/`require` naming what it was waiting for, never return silently and leave the next assertion to fail with something unrelated. Because these packages run `goleak.VerifyTestMain`, a failing helper also exits the test goroutine, so anything that started a server in a goroutine must `defer cancel()` or `defer srv.Shutdown()` right after launching it; otherwise a failed readiness wait is reported as a goroutine leak rather than the failure that caused it.\n- **Take ports and paths from outside the test.** Ports come from the kernel with `net.Listen(\"tcp\", \":0\")`, files from `t.TempDir()`. `go test ./...` runs package binaries concurrently, so a number out of a fixed range or a fixed name under `/tmp` lets two of them collide.\n- **Close idle connections before shutting a test server down.** Clients built as `http.Client{Timeout: x}` share `http.DefaultTransport`, and `Shutdown` waits on their keep-alive connections until its own deadline expires.\n- **Keep the test timeout budgets aligned.** `Makefile`, `ci-backend.yml`, `release.yml` and the command above all use `-timeout=300s`; the wait helpers allow 30s per condition, so a shorter per-package budget turns a slow runner into a timeout panic instead of a readable failure.\n\n`chooseUnusedPort` and the server-start wait helpers are duplicated in `app`, `app/cmd`, `app/rest/api` and `_example/memory_store/server`. Nothing shares them today; keep the copies in step when changing one.\n\n## Release Procedure\n\nRemark42 uses two tags for each release:\n- `vX.Y.Z` - product release tag used by GitHub releases, GoReleaser binary artifacts, and Docker image publishing.\n- `backend/vX.Y.Z` - nested Go module tag for `github.com/umputun/remark42/backend`.\n\nRelease flow:\n1. Create the GitHub release for `vX.Y.Z` with title `Version X.Y.Z`. The GitHub release must exist before the `vX.Y.Z` tag reaches the remote; `gh release create vX.Y.Z` satisfies this because it creates and pushes the tag.\n2. The `vX.Y.Z` tag triggers GoReleaser, which builds and uploads binary artifacts to the existing release.\n3. Create and push the matching backend module tag pointing at the same commit:\n\n```bash\ngit fetch origin --tags\ngit tag backend/vX.Y.Z vX.Y.Z\ngit push origin backend/vX.Y.Z\n```\n\nGoReleaser must ignore `backend/*` tags in `.goreleaser.yml` so release notes and current-tag detection use only product tags. Docker image publishing stays separate and is handled by the existing Docker workflow.\n\nFor local artifact runs, install GoReleaser, Go 1.27, Node 24+ and PNPM 10, then use `make release`. The target runs a snapshot/no-publish GoReleaser build, leaves local artifacts and metadata in `dist/`, and cleans generated frontend embed files after GoReleaser exits. Do not run raw `goreleaser release` for local artifacts unless you also run `./scripts/cleanup-release-assets.sh` afterward.\n\n## Milestones and Issue Labels\n\n**Milestones** — one `vX.Y.Z` milestone per release. Assign every merged PR, and every issue closed by a code change, to the milestone of the release it shipped in.\n- Decide which release a PR belongs to by whether its merge commit is **contained in a release tag** — not by comparing dates (a tag can be cut from an earlier commit, or moved). `git fetch --tags`, then `git tag --contains <merge_sha> | grep '^v' | sort -V | head -1` is its release. If no release tag contains it yet, it belongs to the next (unreleased) version's milestone — create it if missing (`gh api repos/umputun/remark42/milestones -f title=\"vX.Y.Z\"`).\n- An **issue gets a milestone only when it was closed by a code change** (a linked closing PR/commit); take the milestone from that PR/commit (via the commit-in-tag rule). Issues closed as `duplicate`/`invalid`/`wontfix`/answered get no milestone.\n- Find unassigned: `gh pr list --state merged --search \"no:milestone\"`, `gh issue list --state closed --search \"no:milestone\"`. Assign with `gh pr edit N --milestone \"vX.Y.Z\"` / `gh issue edit N --milestone \"vX.Y.Z\"`.\n\n**Issue labels** — classify each issue with a type and an area (add priority when relevant):\n- Type: `bug`, `enhancement`, `question`, `documentation`, `discussion`\n- Area: `backend`, `frontend`, `site`, `CI`, `design`, `localization`\n- Priority: `important`, `minor`, `some day`\n- Contribution: `help wanted`, `good-first-issue`\n- Resolution (on close, when applicable): `duplicate`, `invalid`, `wontfix`, `no-action-needed`\n- PR auto-labels (applied by Dependabot/Actions, not manual PRs): `dependencies`, `go`, `javascript`, `github_actions`\n\n## Code Style\n- **Backend**: Formatting with golangci-lint, strict error handling\n- **Frontend**: TypeScript with ESLint, Stylelint and Prettier\n- **Imports**: Group stdlib, external packages, then internal packages\n- **CSS**: All components use CSS Modules (`component.module.css`). Class naming: BEM block = `.root`, elements = camelCase, modifiers = camelCase. Use `clsx` for conditional class composition. `raw-content.css` is the only global CSS file (syntax highlighting utility). Root wrapper keeps bare `.dark`/`.light` theme class — 8+ module CSS files depend on `:global(.dark)` ancestor. `comment_highlighting` uses `:global()` for imperative `classList` usage in root.tsx\n\n## Key Backend Packages\n- **Web/API**: `github.com/go-pkgz/routegroup`, `github.com/go-pkgz/rest`\n- **Auth**: `github.com/go-pkgz/auth/v2`\n- **Logging**: `github.com/go-pkgz/lgr`\n- **Testing**: `github.com/stretchr/testify`\n- **Notifications**: `github.com/go-pkgz/notify`\n\n## Repository Structure\n- Backend: Go server using BoltDB for storage\n- Frontend: Preact/Redux-based UI with iframe embedding\n- `/web` is served from two sources, in lookup order: the frontend build output\n  (`frontend/apps/remark42/public`, embedded at `backend/app/cmd/web` or read from `--web-root`),\n  then `backend/app/webassets/assets`, embedded in the binary. A plain page or image the bundler\n  does not process belongs in `webassets`; anything needing templating or the widget's CSS/JS goes\n  through webpack. A name present in both is served from the frontend build.\n\n<!-- File: frontend/CLAUDE.md (Tokens: ~3786 | Category: claude-rule) -->\n# Frontend gotchas\n\nNon-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.\n\n## The node/pnpm version is pinned in many places, not one\n\nCI 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:\n\n- `Dockerfile` (production image) — `FROM node:X-alpine` and `npm i -g pnpm@X.Y.Z`\n- `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.\n- `frontend/apps/remark42/package.json`, both its `packageManager` field and its `engines` block\n- `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.\n- `node:` matrices in `.github/workflows/ci-frontend.yml` (every entry, not just the first) and the `node-version:` values in `release.yml`\n- the two dependency-free test commands live in `package.json` as `test:unit` and `test:unit:node`, are called by name from `release.yml`, and are **repeated inline** in the `unit` job of `ci-frontend.yml`, which carries no pnpm by design. Change either script and that job keeps running the old one, green\n\nWhen bumping pnpm/node, also re-check `frontend/apps/remark42/package.json`'s `engines` field — it's separate from `packageManager` and won't update itself.\n\n`engines.node` states the major we support, currently `>=24`, which is the active LTS; 22 has dropped to maintenance. `@babel/core` 8 wants `^22.18 || >=24.11` and `size-limit` 13 wants `^22.18 || ^24 || >=26`, so 22 was the floor rather than the target. Individual dev dependencies can be stricter within that major (`undici` wants `>=20.18.1`); do not chase those patch floors into `engines` or the docs, or every lockfile refresh becomes a documentation change.\n\n## One manifest, at `frontend/apps/remark42`\n\n`frontend/` holds no `package.json`, no lockfile and no `pnpm-workspace.yaml`. Everything pnpm reads\nlives in `frontend/apps/remark42`: the dependencies, `packageManager`, `engines` and the\n`pnpm.overrides` block. Install and run from there, not from `frontend/`.\n\nThe directory nesting is kept because every path in the repository points at it, from the Dockerfile\nand the workflows to the published contributing docs. `frontend/` still carries `.nvmrc`, `.husky`\nand this file, none of which pnpm reads.\n\n## pnpm 10's stricter `node-linker` layout needs explicit pins\n\nOne dep is pinned specifically because of pnpm 10's hoisting changes, not because of the dep itself:\n- `@types/minimatch` 5.1.2 — 6.x is an empty stub that the hoisted layout picks up instead of the real types\n\nIf a dependency bump mysteriously breaks types or module resolution only after a pnpm major bump, suspect the layout change before suspecting the dependency.\n\n## node's native fetch requires absolute URLs in tests\n\nIt rejects relative request URLs, and the failure is\nsilent: requests simply never match. Any test harness that mocks fetch needs absolute base URLs and\na jsdom base URL set.\n\n## Babel is the only thing that compiles\n\n`.babelrc.js` drives both the bundle, through `babel-loader`, and the tests, which get it as\n`babel-jest`'s `configFile` in `jest.config.mjs`. That indirection is not decoration: `.babelrc.js`\nis a file-relative config and would not reach `node_modules`, so the ESM-only packages in\n`transformIgnorePatterns` would arrive at jest untransformed.\n\nBabel merges an `env` block over the root rather than replacing it, so anything the test run must\nnot see has to be decided before the object is built rather than put in an `env`. `jest.config.mjs`\nsets `BABEL_ENV` itself and `.babelrc.js` reads it for two things: the compile targets, and the\n`data-testid` stripper, which the suites query by and the bundle must not carry. Verify with\n`grep -c data-testid public/*.mjs` after a production build; it must be 0. The plugin matches a\nliteral `JSXAttribute`, which is every use in the tree; a spread such as\n`{...{'data-testid': x}}` would reach production, and that grep is what would catch it.\n\n`tsconfig.json` types but never emits, so its `target` has no effect on output; browserslist in\n`.babelrc.js` decides that. Type checking runs out of band through\n`fork-ts-checker-webpack-plugin`.\n\n## Type-only imports have to say so\n\nBabel compiles one file at a time with no type information, so it cannot tell that an import is\ntypes-only. Left unmarked, the module and everything it imports stay in the bundle, and a single\ntype import of a store-connected component is enough to pull the whole redux graph into an entry\nthat never uses it.\n\n`verbatimModuleSyntax` in `tsconfig.json` and `@typescript-eslint/consistent-type-imports` are what\nenforce this, and the fix has to be a separate `import type { ... }` statement. Inline\n`import { type X }` does **not** work here: verbatim semantics keep the statement, so the module is\nstill loaded. That is also why `no-duplicate-imports` runs with `allowSeparateTypeImports`, since a\nmodule legitimately appears twice.\n\n## JSX runs on the automatic runtime, in two places that must agree\n\n`preact` 10.29 types a component's return as `ComponentChildren`, which only satisfies a JSX check on\nTypeScript 5.1+ via `JSX.ElementType`, and it scopes the `JSX` namespace to `preact/jsx-runtime` rather\nthan declaring it globally. So the type layer has to use the automatic runtime:\n\n- `tsconfig.json`: `jsx: react-jsx` with `jsxImportSource: preact`\n- `.babelrc.js`: `@babel/preset-react` with `runtime: 'automatic'`, `importSource: 'preact'`\n\nKeep both in step. If babel were left on the classic `pragma: 'h'` transform, a new `.tsx`\nwithout `import { h }` would type-check and lint clean, then throw at runtime, because\n`eslint-config-preact` sets `react/react-in-jsx-scope` to 0 and the local config turns `no-undef` off.\n\n## `@babel/core` is pinned to 8 for the whole dependency tree\n\n`@jest/transform` and `istanbul-lib-instrument` depend on `@babel/core` 7 outright, and a babel 8\npreset loaded into a babel 7 core fails on the first `enum` it meets. The `pnpm.overrides` entry in\n`frontend/apps/remark42/package.json` is what stops that. `eslint-config-preact` is the one consumer that cannot\ntake it: its `@babel/eslint-parser` loads babel 7 syntax plugins that babel 8 rejects, so a second\nscoped override, `eslint-config-preact>@babel/core`, holds that subtree on 7.\n\n## Held-back majors\n\nThese 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:\n\n- `eslint` 10 (`eslint-config-preact` peers on ^9), `typescript` 7, `redux` 5 with `redux-thunk` 3, `node-emoji` 2 (renames the field `search()` returns), `@formatjs/cli` 6 (the translation extraction pipeline), and `postcss-preset-env` 11 with `cssnano` 8 and `postcss-html` 2, which change generated CSS\n- `redux` 4\n\n## `/web` has a second source\n\n`privacy.html`, `markdown-help.html` and `400x400.jpeg` live in `backend/app/webassets/assets` and\nare embedded in the Go binary. The frontend build wins for any name present in both, which is what\nlets an operator override one by dropping a file next to the frontend files in `--web-root`. That\ndirectory replaces the embedded frontend outright when it exists, so an override belongs in a\npopulated one. These three sit outside this toolchain: prettier, stylelint and `pnpm lint` do not\nsee them, and they are served unminified. `devServer.static` lists the build output first and that\ndirectory second, matching the backend's order, so links to them resolve on the dev port too.\n\n## eslint config resolves from the process cwd\n\n`eslint.config.mjs` lives in `apps/remark42`, and eslint loads the config next to the directory it\nis *run from* rather than the one nearest the file being linted. Anything that invokes eslint has\nto have `apps/remark42` as its working directory: it is where every script runs, `.husky/pre-commit`\ncd's into it, and an IDE integration needs\n`\"eslint.workingDirectories\": [\"frontend/apps/remark42\"]`.\n\nRules that only exist under a plugin's flat-config export are spread in explicitly; the block that\nre-enables project rules is the last entry in the array, because `eslint-config-prettier` (pulled in\nby `prettierRecommended`) switches several of them off and later entries win.\n\n## Verifying a build didn't regress\n\nThere'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:\n- `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.\n- `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.\n\n## Where the alerts actually were\n\nWhen 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.\n\n## Don't import `preact/compat`\n\nIt installs hooks on preact's shared `options` that remap `onFocus`/`onBlur` to\n`focusin`/`focusout` on every element and make `@testing-library/preact` rewrite `change` to\n`input`, so one import changes event behaviour across the whole widget, and it adds about\n3.8 kB gzipped.\n\n`forwardRef` lives only there, so a component that needs a ref takes it as an ordinary prop;\n`TextareaAutosize` is the pattern. For a component type annotation use `FunctionComponent`\nfrom `preact`, not `React.FC`.\n\n## i18n is a hand-written binding whose export names are fixed by the extractor\n\n`app/common/intl.tsx` provides `IntlProvider`, `useIntl`, `createIntl`, `defineMessages`,\n`FormattedMessage` and `IntlShape`. The formatting itself lives in `app/common/intl-message.ts`,\nwhose only preact reference is a type-only import that erases at build time, so it pulls in nothing at runtime, and which is where every rule below is implemented; `intl.tsx` is the\nbinding over it and re-exports `createIntl` and `defineMessages` unchanged, so no call site can tell\nthe two apart. `formatjs extract` (`translation:extract`) finds messages\nby recognising the identifiers `defineMessages`, `FormattedMessage` and `intl.formatMessage`\nin the AST, not by import source, so those three names are fixed. Rename any of them and\nextraction returns nothing, with no error and a zero exit code.\n\nThe damage lands on the next step. `translation:check` compares locale keys against extracted\nkeys and fails loudly, but `tasks/generateDictionary.js` calls `removeAbandonedKeys`, so\n`translation:generate` after a rename deletes the now-unextracted keys from all 24 files in\n`app/locales/` and writes them back, after which the check passes. Extract-then-generate is\nthe documented translator workflow, so the destructive order is the normal one.\n\nAfter any rename or refactor touching those identifiers, verify the count rather than the exit\ncode:\n\n```sh\npnpm translation:extract\nnode -e \"console.log(Object.keys(require('./extracted-messages/messages')).length)\"\n```\n\nIt must match the key count in `app/locales/en.json`, not drop to 0.\n\nThe binding implements `{name}` interpolation and paired `<tag>text</tag>` rich text, and\nnothing else of ICU: no plural, select or selectordinal, no typed arguments such as\n`{n, number}`, and no apostrophe quoting. A plural or select form has braces the placeholder\nrule rejects, so it falls back to English wherever values are passed and shows its raw text\nwhere they are not; `''` simply stays as two apostrophes.\n\n`translation:check` validates every translated value's markup and placeholders against the\nEnglish string it translates, mirroring the binding's rule, and the catalogue sweep in\n`app/common/intl.test.tsx` additionally renders the two messages that carry a link.\n`app/common/intl-message.unit.test.ts` covers the formatter's own edge cases -- broken markup, an\nunclosed tag, a regex metacharacter in a tag name -- which are the ones no shipped catalogue can\nproduce and so no sweep can reach. What\nneither catches is unsupported ICU syntax, since a plural form is well-formed text as far as\nboth are concerned. A message the binding cannot resolve falls back to the English source\nrather than reaching the page, so without these checks a broken translation is invisible in\nthe interface.\n\n## `*.unit.test.ts` runs with no toolchain, and that is what it is for\n\nTwo test layers, split by what they need to run. Jest owns anything with a DOM in it; `*.unit.test.ts`\nfiles import nothing but the module under test, its fixtures and `node:test`, and run under plain\nnode straight from source:\n\n```sh\npnpm test:unit\n```\n\nThe `unit` job in `ci-frontend.yml` runs them with **no `pnpm install` step**, deliberately.\n`release.yml` runs the same script after an install, as a second gate. The layer's value is that a\nfunction over plain values can be tested without a bundler, a jsdom or a `node_modules`, and the\nabsent install is the only thing that keeps that true: add an import that needs resolving and the\njob goes red instead of quietly acquiring the dependency. `jest.config.mjs` excludes the suffix, so\na file is in exactly one layer.\n\n`node --test` exits 0 on a glob that matches nothing, so it cannot tell a renamed suffix from a\npassing run. The CI step counts the files first and fails when none match. Two import rules follow\nfrom node's ESM resolver, and they contradict the repository's usual style, so they are local to\nthis layer:\n\n- **write the extension in full**, `./types.ts`. This is why `app/store/thread/reducers.ts` and\n  `app/store/user/reducers.ts` import `./types.ts` while their neighbours do not, and why\n  `app/common/json-store.ts` and `app/utils/errorUtils.ts` carry it too. `allowImportingTsExtensions` in `tsconfig.json` is what\n  stops `tsc` rejecting it, and webpack and jest resolve the explicit path unchanged\n- **import by relative path**, never through the `common/…` alias, which only webpack, jest and\n  `tsc` know about. A type-only import of an alias is fine, since it erases before either runtime\n  sees the file\n\nA module that mixes computation with browser state gets split instead of skipped:\n`app/common/intl-message.ts` and `app/hooks/session-storage.ts` are the pure halves of `intl.tsx`\nand `useSessionState.ts`, and `session-storage.ts` takes the store as an argument instead of reading\nthe `sessionStorage` global, which is what lets a test hand it a plain object.\n</yakaai_skills>"}