Commit Graph
3573 Commits
Author SHA1 Message Date
Dmitry Verkhoturov d534247c5a Pin the public path #2203 fixed, which shipped without a test
The bundler used to bake a fixed public path into every entry, so an
instance mounted under a prefix, which manuals/subdomain documents,
asked the domain root for its provider icons and got nothing. #2197
derived the path from the URL the bundle was loaded from instead, and
covered it with nothing: publicPath appears only in webpack.config.js
and neither suite touched asset paths.

The check is on the assignment webpack emits for its runtime public
path, a string literal when the path is fixed and an expression when it
is derived. Asset filenames are bare in both builds, so the obvious
assertion, looking for a rooted "/web/name.svg" string, finds nothing
either way and proves nothing. I wrote that one first and it passed
against a deliberately broken build.

Every emitted bundle is checked rather than the entry the bug was
reported against: it put fifteen icons in remark.mjs and one in
last-comments.mjs, so a case reading a single entry would have gone
green with half of it still live.

Verified by rebuilding the frontend with the public path baked back in:
the assertion fails on that build and passes on master's.
2026-08-23 21:22:42 +01:00
Dmitry Verkhoturov 75e1b69342 Judge the iframe reveal by the mark, not by when the read lands
Three cases separate a reveal that came from the inited message from one that
came from the five second fallback, and two of them did it against a clock this
process holds. That is a bet on how fast an engine is, and both bets lose on a
loaded machine: TestIframe_StaysHiddenUntilTheDocumentReportsInited polled the
DOM after a loop bounded 500ms below the fallback, and an evaluate round trip
outlasts that margin, so a fallback firing exactly on time reads as an early
reveal; TestIframe_IsRevealedByTheInitedMessage capped the message path at 3s,
and webkit has reported inited 2.6s after creation here.

All three now judge the recorded reveal against one cutoff, half a second under
the fallback. A timer cannot fire early, so below it the reveal can only be the
message and above it only the fallback, and the mark carries the moment itself,
so a slow read cannot move it. The hidden case drops its polled visibility
assertion for the same mark, which is what the observer records in the first
place.
2026-08-23 21:22:42 +01:00
Dmitry VerkhoturovandGitHub 389189afcf Give each import request in TestMigrator_ImportDouble its own reader (#2220)
The test passed one strings.Reader as the body of both POSTs. client.Do
returns once the response headers arrive, and the import answers 202 before
the transport has finished copying the body, so the second http.NewRequest
reads the reader's Len to set ContentLength while the first request's
writeLoop is still advancing it. The race detector caught it on CI as a write
in strings.(*Reader).WriteTo against a read in NewRequestWithContext, failing
a test nothing had touched.

Reproduced in isolation to confirm the mechanism rather than infer it from the
trace: a handler that answers 202 without draining an 8 MiB body, two requests
sharing one reader, and -race reports strings.(*Reader).Len in
NewRequestWithContext against strings.(*Reader).Read on every run. It does not
reproduce in this package locally, which is why it reads as a flake.

Both requests now build their own reader over the same content. The second one
carries a full body where before it inherited a consumed one, which is closer
to what the case is about: a second import arriving while the first is running
still has to be refused.
2026-08-23 14:58:31 -05:00
Dmitry VerkhoturovandGitHub 6f40926241 Drop the origin-anchored public path from the delete-me bundle (#2219)
deleteme.ts set __webpack_public_path__ to window.location.origin plus /web/,
which discards any path prefix the instance is served under. It is inert today
because that bundle references no asset and loads no chunk, so the value is
assigned and never read, but it is wrong by construction and would resolve at
the domain root the moment anyone adds an image to that page. Removing it
leaves webpack's own publicPath: 'auto', which derives the base from the
script's URL and is right in both arrangements.
2026-08-23 14:58:27 -05:00
Dmitry VerkhoturovandGitHub 2640aaee9e Reach what http cannot: the widget over TLS, embedded cross-origin (#2214)
Every service in the suite spoke http, and the browser gates a whole class
of behaviour on the page protocol: Secure cookies, SameSite=None,
Partitioned, and any code reading location.protocol. None of it was
executed, which is how setAuthCookie came to decorate its cookies with
__Host- on https pages and survive for years.

A TLS pair joins the stack: remark42 with SSL_TYPE=static on 8443, and an
nginx serving a host page on its own name on 8444, both on a self-signed
certificate that e2e/tls/generate.sh makes and .gitignore keeps out. Every
context accepts it, and so does the readiness client, since those are the
only servers either talks to. The instance also runs with
AUTH_SEND_JWT_HEADER, which is what makes the widget write cookies of its
own: without it the client-side writer never runs on any https page here
and every assertion about the attributes it chooses is vacuous.

Three cases. Signing in across origins and then reloading, which is the one
the http cross-origin case cannot make: the widget holds its token in
memory for the life of a page, so signing in and posting says nothing about
persistence and only the reload asks whether the cookie was delivered,
stored under a name the backend reads and sent back from a third-party
frame. The cookies themselves, read out of the browser store while the
widget is embedded elsewhere, since a cookie the browser refused is absent
from that list entirely and one it kept but will not send is worse than
useless: every copy of both names has to be Secure and SameSite=None, at
least one has to be partitioned, and none may carry a __Host- prefix
nothing on either side reads. And the same reload under a browser that
blocks third-party cookies, which the widget's own partitioned pair is the
only reason to survive.

That last one needs a browser playwright does not offer: its default
arguments disable ThirdPartyStoragePartitioning outright, so a run
configured wrongly keeps every third-party cookie and the case would pass
while asserting nothing. IgnoreDefaultArgs drops that list and re-supplies
it without the one feature, and a control cookie set from inside the frame
has to be refused before anything else is read, so a playwright release
that changes the list fails as itself instead of going quietly vacuous.

All three pass against master. What TLS still cannot reach, the OAuth popup
above all, is written down in the README.
2026-08-23 14:58:23 -05:00
Dmitry VerkhoturovandGitHub 250e8ad925 Report the widget height when the sign-in panel closes (#2213)
The sign-in panel is positioned absolutely, so it grows the iframe without
growing the document: `useDropdown` measures the panel itself and posts the
sum, and a ResizeObserver on the panel keeps that number current while it is
open. Closing it resizes no box anything watches. The panel observer goes with
the element, and the document observer in `Root` sees nothing, because the
document height never changed in the first place. Nothing then tells the parent
to come back down, so the iframe keeps the open panel's height and the
embedding page carries a hole under the widget for as long as the reader stays
on it.

The effect's cleanup now reports the height, with no element, so the number is
the document's own. That is the one place both close paths reach: the click
inside the widget, and the clickOutside message the host page posts when the
reader clicks anywhere else.

TestGeometry_HeightFollowsTheAuthPanelAndTheTextarea covers this and has been
intermittently green: whether the frame comes back down without the fix depends
on timing, and it fails on every run here while CI has been passing. The unit
test fails with the cleanup reverted.
2026-08-23 00:26:31 -05:00
Dmitry VerkhoturovandGitHub 4793c1cd2c Fill the instance URL into the embedded frontend at serve time, and stop pinning compressor output in tests (#2198)
* Assert what the image endpoints promise rather than the compressor's output

Three tests pinned the exact bytes or the exact length of an encoded
image, so they fail on any toolchain whose deflate or png encoder emits
something different. CI pins go 1.25 and passes; go 1.27 fails all three,
while the images themselves are perfectly valid.

TestRest_QR now decodes both the golden file and the response and
compares the pixels, which is the same assertion about the qr code and
none about the encoder. The two resize cases assert the decoded image
fits the box resize was given and touches one of its sides, which is what
fitting to a box means and what the function actually promises.

Resolves #2200.

* Fill the instance URL into the embedded frontend at serve time

The widget falls back to a compiled-in URL whenever a page omits
`remark_config.host`. The bundler cannot know that URL, so it emits
`{% REMARK_URL %}` and each distribution substitutes it: the docker image
rewrites the files under its web root at container start, and the release
binary, which serves the build embedded in itself, had nothing doing it.
`prepare-release-assets.sh` filled the marker with `http://127.0.0.1:8080`
before the embed instead, so every copy of the binary shipped pointing at
the visitor's own loopback address, and on an https site the request is
blocked as mixed content besides.

It has been that way since v1.11.0, the first release to embed the
frontend, and the earlier binaries embedded none, so the tarball has never
served a correctly addressed widget.

The placeholder now survives into the embedded copy and the file server
fills it with the configured `REMARK_URL` as it serves, which is what the
docker image already does to its own copy. The image no longer bakes the
loopback address into its embedded copy either, so the fallback it keeps
for a missing web root is correct rather than misleading.

Substituted in html, js and mjs, the same set `docker-init.sh` rewrites,
and the served size is the substituted one so a response is neither
truncated nor left hanging.

Nothing exercised the marker the frontend build emits wherever the instance
url belongs. Every page in the suite sets `remark_config.host` from its own
origin, so the compiled-in fallback is never read, and a distribution that
stopped substituting would keep the suite green.

Two tests. The first reads the served bundles and pages back and asserts the
marker is gone from each and that what replaced it is this instance. The
second covers what the substitution is for: the widget document carries no
host of its own, since `iframe.html` builds its config from a query string the
parent never puts one in, so everything it requests is addressed with the
compiled-in url. It asserts the widget renders and that the config request
went to this instance.

The demo pages cannot show the second. Their loader builds the bundle's own
script url from `remark_config.host`, so a page without one never gets as far
as loading the widget.

Verified by disabling both substitution paths, the serve-time one and the
docker image's, and rebuilding: both tests fail. Editing the files on disk is
not enough, since the file server substitutes as it serves.

The served body now depends on remarkURL, but cacheControl builds its
etag from version and path only. An operator who notices the widget is
addressed to the wrong host, corrects REMARK_URL and restarts the same
binary gets 304 on revalidation, so the client keeps a bundle pointing
at the old host. Cache-Control is no-cache, so it revalidates every time
and never ages out of that state either.

That is the exact situation this substitution exists to fix, so the
validator has to carry the url.
2026-08-22 13:15:27 -05:00
Dmitry VerkhoturovandGitHub 23be25d84a Fix seven widget defects, including the cookies the separate-domain setup needs (#2197)
* Drop the frontend workspace root and re-resolve the lockfile

`frontend/` carried a `package.json`, a `pnpm-workspace.yaml` and the lockfile
for a workspace of exactly one package. Two manifests meant two places to
declare a version, and the app pin was the one that did not win: `preact` and
`@babel/core` were each written twice, and a bump to the app manifest alone
would have been a silent no-op, since `pnpm.overrides` decides and it lived at
the root.

Everything pnpm reads now lives in `frontend/apps/remark42`: dependencies,
`packageManager`, `engines` and the overrides. `frontend/` keeps `.nvmrc`,
`.husky` and `CLAUDE.md`, none of which pnpm reads. The directory nesting
stays: every path in the repository points at `frontend/apps/remark42`,
including the published contributing docs, so moving the package up would have
rewritten 14 files to no benefit.

Moving the manifest kept the old resolutions verbatim, which left optional peer
subtrees the tree no longer reaches: `ts-node` under jest, `@swc/core` under
webpack, `vitest` under `@testing-library/jest-dom`, `tslib` under
`webpack-dev-server`. None is referenced by any config or source file here.
Re-resolving drops 137 packages and moves 59 to versions already permitted by
the ranges in the manifest, 1446 to 1308, with no direct dependency changing
version: the five that look changed differ only in their peer suffix. Every
file `pnpm build` produces is identical in size before and after.

The frontend-deps stage of the Dockerfile sets `CI=true` so the `prepare`
script skips husky, which has no git repository to install hooks into there.

* Stop markdown-only changes triggering heavy workflows, and check the documented versions

`ci-backend.yml`, `ci-build.yml` and `ci-frontend.yml` all end their path
filters with `!**.md`. The e2e workflow did not, so a change to any markdown
file under `frontend/` or `backend/` matched its `frontend/**` and `backend/**`
entries and started a docker build and the whole browser suite. The release
filter had the same hole and two of its own: it names `README.md` and `LICENSE`
on purpose, since `.goreleaser.yml` packages both, so it now excludes markdown
under `backend/` and `frontend/` only. `CLAUDE.md` and the installation page
were listed as well, and neither is packaged.

`ci-site.yml` goes on matching markdown, which is right, since the site is
built from it. It excludes `CLAUDE.md`, so a future `site/CLAUDE.md` cannot
start a site build, and `site/README.md`, which documents how to build the site
rather than being part of it.

The installation page tells a reader that a source build needs Go 1.25, Node
24+ and PNPM 10. Nothing kept those in step with `backend/go.mod`,
`engines.node`, `packageManager` and `.nvmrc`, and the drift is silent: a wrong
version in the docs builds and tests exactly as well as a right one. `.nvmrc`
is the pin with form here, having sat at 16 through the whole node 20 migration
because nothing red ever pointed at it. The check compares each stated version
against its source and holds `.nvmrc` to `engines.node`, and it fails when the
page states no version at all, so removing the claims cannot turn it into a
check that passes by comparing nothing.

Its own workflow rather than a step in an existing one, since the inputs span
the backend module, the frontend manifest and the site.

* Fix the cookie fallback page, asset path, message senders, auth teardown and cookies

Two defects with the same origin: 5825a55b, the January 2021 frontend
rewrite, first released in v1.7.0.

It removed the build entry for comments.html while leaving both the
template and the link to it in place, so the page the auth panel offers
when third-party cookies are blocked has been a 404 ever since, for
exactly the reader who has no other way in. The template needed no
changes; it is built again, and an e2e case now opens it on a thread
carrying a comment and waits for that comment, so the page being served,
its inline script running and it asking for the thread named in its own
query string are all covered. Against an image built without the plugin
entry that case fails on the 404, which is the regression it exists for.

It also fixed the public path to the domain root, so an instance mounted
under a prefix, which manuals/separate-domain documents, asked for
/web/google.svg when its own icons live under that prefix. Fifteen
provider icons in remark.mjs and one in last-comments.mjs. The path is
now derived from the url the bundle was loaded from, which is correct for
both arrangements, and the file loader no longer overrides it.

The host page also accepted postMessage from any window: every frame on a
page can reach window.parent, and the handler resizes the widget, scrolls
the page and opens the profile overlay. It now ignores anything that did
not come from a frame this module created.

A fourth, in the same family: the OAuth flow never tore its polling down.
`subscribed` was declared, checked and cleared but never set, so the guard
against a second subscription was dead code and every provider click
attached another listener pair. The five minute deadline then rejected
without unsubscribing, leaving those listeners and a retry that
reschedules itself for as long as getUser returns null. Cross-domain is
where getUser never stops returning null, so a reader on the arrangement
manuals/separate-domain documents was left polling /auth/user once a
minute for the life of the page, against a route capped at 2 req/s. It
also rejected with no argument, and the caller stores that as the error
state, so the interface had undefined to render. The deadline now tears
the subscription down and rejects with an error.

The message check had a second half. Hardening the parent left the widget
document trusting any sender, and it acts on signout and theme, so
anything holding a reference to the frame could sign a reader out.
`auth.hooks` already checked `event.source !== window.parent`; that check
is now a shared `isFromParent` and the three listeners that lacked it use
it too. The origin cannot stand in for it, since the host page is
whatever site embeds the widget and `ALLOWED_HOSTS` is enforced server
side through `frame-ancestors`.

And createInstance stacked its listeners. It reuses the marked iframe
instead of building one, but installed three listeners plus a title
observer on every call, while destroy could only reach the newest
closure, so a second call without a destroy stranded a set for good. The
listeners of the current instance are now detached before the next set
goes on. Reuse and the ignored config are unchanged: that contract is
open in the backlog note and not settled here.

The auth cookies the embedded case needs were not being delivered, in
both halves of the client's own writer. The name was decorated:
setAuthCookie prefixed with __Host- whenever the page was https, so a
real deployment wrote __Host-JWT and __Host-XSRF-TOKEN while the backend
looks for JWT and the fetcher reads XSRF-TOKEN, and nothing anywhere
reads a prefixed name. Nothing caught it because the prefix is applied
from the page protocol and every test and the dev server run on http;
there is now a second suite pinned to an https page, which is the only
condition that shows it. And the attributes could not be delivered: both
were SameSite=Strict, judged against the top-level site and not the
request's own origin, so a Strict cookie is never sent from a
third-party frame, which is the entire configuration this code exists
for. They now follow the embedding, Strict while the widget shares its
page origin and None with Secure and Partitioned once it does not, since
that is the only third-party form browsers still accept. Over http in a
third-party frame no combination works, and the strict form is written
instead of one the browser would reject outright.

That leaves the client half of #1877 working, whose reporter wanted
AUTH_SEND_JWT_HEADER for exactly this arrangement, and whose first half
merged as #1929. The server's own cookies still carry no Partitioned;
that is upstream work in go-pkgz/auth.

Two plan changes. A review pass corrected its central Path B premise,
which said the first document render is anonymous permanently, in every
configuration: it is anonymous in the configuration remark42 ships,
go-pkgz/auth exposing XSRFIgnoreMethods and remark42 leaving it unset.
The door is not shut, it is closed by a setting, and opening it is
scoped security work and not a flag flip, because GET /deleteme
deletes every comment a user has written and is a GET so the emailed
link works. And the separate-domain arrangement is promoted from a
constraint bullet to a named requirement with acceptance criteria, since
a test that signs in and posts without reloading passes while
persistence is entirely broken.

Review found a seventh, and it was reachable only because of the first:
comments.ejs built its title with innerHTML from the url query
parameter, so restoring the build entry made a reflected XSS live on the
instance origin, where the page is a top-level document, frame-ancestors
does not apply and the /web CSP allows unsafe-inline. The anchor is now
built through the DOM with textContent, and only http and https reach
href, since escaping alone leaves a javascript: url working. Two e2e
subtests pin both halves, and mutation testing separates them: restoring
innerHTML fails four assertions, while keeping the escaping and dropping
only the scheme guard fails the href one alone.

Review also found the poll teardown test did not exercise the poll.
handleWindowVisibilityChange is reachable only from the two listeners
and from the retry it schedules itself, and the test dispatched neither,
so no request was ever made and the assertion compared zero to zero; it
passed with the teardown reverted. It now dispatches focus, asserts
requests are being made and keep coming, and only then that they stop.
And the teardown could not cancel an in-flight getUser: a null resolving
after the deadline ran the code past the await and scheduled a fresh
retry with nothing left to clear it. A closure-local flag checked after
the await stops that, chosen over a second guard at the top of the
handler because only one of the two is detectable by mutation and this
is the one that prevents the stray timer rather than neutering it.

The inline handler in the iframe template accepted messages from any
window while acting on them through location.replace and document.title.
It now takes only the parent, the same check the host page side makes.
2026-08-22 12:34:24 -05:00
Dmitry VerkhoturovandGitHub 4d5dae20e2 Broaden the e2e suite from 21 cases to 63, and harden its harness (#2196)
* Pin the published /web surface in the e2e suite

#2178 renamed the widget bundles from .js to .mjs and the URLs earlier
releases served under those names stopped resolving. Three were noticed
from the demo site; the rest, including every locale chunk, were found
only by requesting the whole surface of both images over HTTP. #2192
restored them with a server-side alias, and nothing in the suite would
have caught the break or would notice it returning.

Two cases with deliberately different criteria. The documented names are
written out, because the documentation decides that list and not the
build: an operator pastes privacy.html into an OAuth application, the
nginx manual proxies index.html by name, and the integration guides start
from the embed script. Everything else is taken from the build itself, so
whatever the bundler emitted has to serve identical bytes under its
legacy .js name and parse as a classic script, which is the premise
serving one under the other rests on. A third case requests a name that
does not exist, without which a fallback serving one page for everything
would keep the whole table green.

All of them check the content type as well as the bytes: nosniff is set
on every response, so a bundle served as text/plain is as broken as one
that 404s while comparing equal.

On e3d1d0e2, the commit before the alias, this fails with 32 red subtests.

* Cover the widget behavior the e2e suite never drove

Removing npm from the widget takes the jest tests with it, and everything
here was protected by jest or by nothing at all.

Signing out was untested at every level, and the panel repainting is the
half that always works: the assertion after the reload is the one that
catches a session the server never ended. The edit form has to hand back
the source that was posted and not the rendered comment, which #2040
shipped the other way round, taking every entity and tag the author had
written with it. A draft has to survive a reload and be gone once the
comment is posted. A refused comment has to stay in the form with
something said about it, so that case drives the backend's own
restricted-words code and then retries with the route removed.

Uploads had no browser coverage in either direction. The posted case
asserts naturalWidth instead of visibility, since a broken src still
renders as an empty box, and the failing case holds the intercepted
request long enough for the in-flight state to be observed: without that,
"the text is unchanged afterwards" would hold for an upload that never
started.

Moderation and reader-side hiding are asserted from a page other than the
one that made the change. Hiding seeds a second author, so it proves one
person is hidden and the thread not emptied, and both the blocked
author and the moderated one carry the run id in their names: a block and
a verification are properties of the user and outlive the run in the
stack's database, so a fixed name works exactly once.

Locales were the largest hole. Each catalog is a chunk fetched at
runtime, and loadLocale falls back to english on any failure instead of
throwing, exactly as an unrecognized name does, so every case compares
the rendered string against the file on disk. One case per catalog
covers that they are all served and render; another fetches one through
the widget document and asserts it parsed, which is the half a chunk that
serves but fails to parse would slip through. The delete page and the
last-comments stylesheet had no coverage of any kind.

Two things the suite itself needed. The widget's own aria-label is
translated, so commentFormSel only ever finds an english widget and a
localized case cannot use widget(). And the auth probe is capped at two
requests a second for the whole suite, hard-coded in rest.go: the added
cases pushed past it and the suite began manufacturing its own 429s,
which render as a signed-out widget and fail whichever test happens to be
signing in, so the pacing gap is wider and the locale cases stub the
probe they never needed.

* Harden the e2e harness against silent failures and stale stacks

Three things the suite could not tell you about itself.

A browser failure nothing asserts on now fails the test that caused it.
Uncaught exceptions are the ones worth the machinery: a widget throwing
while it renders leaves most of these cases green, since they assert on
elements the browser lays out either way. Rate-limit responses are
recorded as well as logged for the same reason. A test driving an error
path declares what it expects by substring, so a case that means to
break something says which thing.

The stack the suite adopts is now checked against the sources under
test. Every checkout builds the image tag the compose file names, so a
stack from another worktree, or from this one before an edit, answers on
these ports and passes every readiness probe while serving code nobody
is looking at. stamp.sh digests what goes into the image, compose passes
it as the revision label, and a mismatch is refused with what to do
about it. Checked after our own build too, or a stamp that never reaches
the image would be a guard that silently passes everything.

assertSignedIn no longer waits out the whole timeout on a refused status
read. /auth/ is capped at two requests a second for the entire suite, a
bare literal at rest.go:242, and a case signing in on two pages spends
that twice; when the read that repaints the panel is the one the limiter
turns down, the widget shows signed out over a session that exists and
no later request will ask again. The short first wait now ends in a
focus handoff, which the widget answers by re-probing, and only then
does the real wait run. A sign-in that genuinely failed still fails,
since the second read finds no state either. That is what
TestComment_AdminPinsAndVerifies and TestComment_BlockedAuthorCannotPost
were failing on in CI while passing locally.

signInAnon takes the page for that reason, and its callers pass it.

* Cover iframe geometry, the embed contract and five deployment modes

Seventeen cases for the parts of the widget that broke repeatedly and
that nothing here could see, plus the areas jest was the only check on.

Geometry is the biggest of them. The widget measures its own document
and posts the number for the parent to apply, and every way that has
gone wrong is invisible to assertions about elements, which read the
same whether the frame is right, twice too tall or a strip. So: the
first height the parent is given describes rendered content and not the
preloader, the frame matches the document it holds and does not stand
24px taller, no_footer leaves the last comment inside the frame, and the
frame follows the sign-in dropdown and the growing textarea and comes
back down again. Each was verified by reintroducing the defect it covers
and watching it fail: a 63px report before the real one, six pixels of
body padding, and a frame sized under the content.

The embed surface is the other half the widget cannot see. An element
placeholder gives way to exactly one iframe carrying the embed's own
marker, a second createInstance reuses it, destroy takes it away with
its handles, and a theme change after load reaches both the element and
the document. A page that posts a message of its own, which is all
embed.ts's own title observer does, no longer empties an open login
form.

Five configurations that cannot share an instance get one each, since
each changes the widget for every reader: an admin's unlimited edit
window, a session carried in a header and not a cookie, an instance with
no auth provider to offer, anonymous voting, and the notify module,
without which email_notifications is false and the subscribe control
never renders at all. The subscription round trip is the one place a
token from a real message is exchanged for state the server keeps.

Two things surfaced there and are left alone, both said so in place. The
panel confirming an unsubscribe cannot be observed, because the click
changes the step and the dropdown closes on an element no longer in the
rerendered view, which the component notes as its own awkwardness; the
case asserts the request and the answer, which is what decides whether
the reader still gets mail. And the widget takes the subscribed state
from user.email_subscription, absent from what it hydrates the user with
on the next load, so after a reload it offers to subscribe somebody who
already is.

simple_view needs no instance, being a query parameter, so both branches
run against the main one. A transient failure of the status probe has a
case too: the session belongs to the server, and one refused answer must
not end it.

The suite runs about four and a half minutes now, so the workflow's own
budget goes to 20m to match the Makefile, well inside the job timeout,
and the workflow stamps the stack it starts the way the Makefile does.
The locale case that loads the widget document directly is renamed for
what it protects, the origin and CSP its chunks are fetched under.

Telegram gets no test: the base URL is formatted inline inside
go-pkgz/auth, so nothing here can point it elsewhere, and the fix
belongs upstream in v1 and v2 both. Reported as #2208.

Three things CI found that a laptop cannot. The anonymous sign-in form
validates its input against pattern="[\p{L}\d\s_]+", and E2E_RUN_ID is
"<run id>-<attempt>" on a runner, so every username built from it
carried a hyphen the browser refused to submit: no request was made and
the case waited out its timeout on a panel that was never going to
change. Names are built by anonName now, which drops what the pattern
does not allow and adds the pid, so a second run against a surviving
stack does not meet its own blocked and verified users. signInAnon waits
for the request the submit makes, so the next such refusal fails as
itself.

The admin instance takes its admin from an email address, not a name.
remark42 hashes an anonymous id from the name and the client address
together, to tell apart two people picking the same name, so the id
written into ADMIN_SHARED_ID belonged to nobody on a runner and the
instance had no admin at all: the countdown stayed, and the backend
refused the edit. An email id is sha1 of the address, which is the same
everywhere.

The subscription case clears its own precondition and confirms through
the page's session. The dev user is shared and a subscription outlives
the run, so the panel opened on the subscribed step; and the panel moves
to that step while its token textarea is still on screen, leaving no
moment at which the control to submit it exists.

Three settings of remark_config get cases of their own, none having had
any: __colors__, which is the one setting that travels through
window.name and not the query string, so nothing else in the suite would
notice the path going; the url override, which is how a canonical
address keeps one conversation across pages that differ; and the
subscription controls an integrator turns off, with the both-shown case
as the control.

Writing the url case turned up a backend defect, reported as #2204 and
not fixed here: a thread url containing "&" cannot be commented on at
all. Sanitize runs the locator
through SanitizeAsURL, which round-trips it through bluemonday, so the
url is stored html-escaped; the bucket is created under the escaped key,
the read-back uses the real one and answers 500, and every later find,
count and feed asks for the real url and is told the thread is empty.
Any page addressed with two query parameters is affected. The case uses
a single-parameter url for that reason.

Five more from the same audit, none needing a service. Collapsing a
thread shrinks the frame, which every other geometry case would miss:
they all assert growth, and a widget that only grew would satisfy them
while leaving a hole under each collapsed thread. Voting gains the
direction nothing covered, downvoting and its survival of a reload, and
the rule the other vote cases work around, that your own comment offers
no buttons and the backend refuses the vote anyway.

A vote with the X-XSRF-TOKEN header stripped has to be refused. That
check is why a document navigation, an iframe src among them, is always
anonymous and why the widget hydrates its user over XHR, so anything
designed around that wants it pinned.

An unrecognized locale has to render English, which is loadLocale's only
observable guarantee: it falls back the same way for a name it does not
know and for a chunk it cannot fetch. And a comment's timestamp has to
be the reader's own, which is the one part of rendering that cannot move
to the server, asserted from a context in Kiritimati against the same
Intl the widget uses.

A host page on an origin the widget is not served from, which is the
separate-domain setup the manuals describe and the configuration readers
actually hit problems with. Every other host page here is served by
remark42 itself, so the cross-site path was never taken: an nginx on its
own name and port serves e2e/hostsite, and the case asserts the frame is
revealed, which means its document loaded and reported itself inited
across the origin boundary, and that the thread it renders is the one
the page's address names. Signing in is left out on purpose, an embedded
cookie needing SameSite=None, which browsers take only as Secure, and
this stack speaks http; that is #1139 and not something a case here can
settle.

The other half is ALLOWED_HOSTS. The no-provider instance names only
itself, so a page elsewhere embedding it is refused by the browser, the
document never runs, and the reveal comes from the widget's own fallback
five seconds later. Both directions are worth holding: without the
fallback a mistyped host leaves a permanently invisible widget with
nothing to say why, and without the refusal the setting does nothing.

The host page's title reaching the stored comment gets a case, the path
running the other way from everything else here: the page posts its
title into the widget, the widget sends it with the comment, and it is
what a feed and the admin listing show. Set after the widget is up, so
it covers the observer embed.ts installs and not the value read at boot.

max_shown_comments has no case. The setting reaches the widget, appears
in the iframe's query string and changes nothing: four comments render
with it set to two, which is #812, still open, where the reproduction is
now recorded. A case for it would be red on master.

The README gains what the suite cannot reach. Every service here speaks
http, so anything the browser gates on the page protocol is invisible: a
Secure cookie, anything keyed on window.location.protocol, and the
SameSite=None with Secure and Partitioned form that is the only one an
embedded frame can still use. That is not hypothetical, setAuthCookie
having decorated its cookies with __Host- on any https page and survived
precisely because nothing here runs on one. The cross-origin case names
the assertion to add if the stack ever gets TLS, which is the reload:
the widget holds its token in memory for the life of a page, so signing
in and posting without reloading passes while persistence is broken.

And the trap waiting for whoever acts on that: playwright's own default
--disable-features argument carries ThirdPartyStoragePartitioning, and it
beats both --test-third-party-cookie-phaseout and
--block-third-party-cookies passed through Args, so a run meaning to
prove the third-party case keeps an ordinary third-party cookie exactly
as it would with no flags at all. IgnoreDefaultArgs is the lever, and a
blocking run has to assert a control before anything it reports can be
believed. None of it reaches the widget's own storage fallback either:
IS_STORAGE_AVAILABLE stays true with partitioning enforced, chromium
partitioning localStorage instead of denying it, so comments.html needs
webkit and not a flag.

The downvote case now actually corrects. It claimed the score ends where
the second vote leaves it and never cast one, so #728, a reader taking a
vote back, could break with it green. It also turns out the opposite
vote takes the first one back instead of flipping it, so the score
returns to zero and never reaches +1, which is what the case asserts,
before and after a reload. Renamed for what it covers.
2026-08-22 11:59:37 -05:00
UmputunandGitHub a82dc8d3f1 Restore the legacy /web/*.js URLs and fix iframe reuse (#2192)
* Serve the legacy /web/*.js names from their .mjs siblings

The build emitted <name>.js alongside <name>.mjs until the two compilations
were collapsed into one. Dropping the second compilation was right, but it
removed URLs the project itself had published: the v1.16.4 SPA documentation
named /web/embed.js directly and its loader snippet requested .js. Pages that
hard-coded those names now 404 with no deprecation.

webFiles.Open retries a missing .js against the .mjs sibling. The bundles
contain no import or export, so the same bytes serve both names. The retry
runs only once both sources report the name missing, so a real .js still
wins, and an unreadable sibling reports its own error rather than being
flattened into the requested file's 404.

Related to #2178

* Reuse only the comments iframe embed created

createInstance took root.firstElementChild as its iframe, so anything a page
left inside #remark42 was adopted instead. A <noscript> fallback became the
"iframe", createIframe never ran, and the height messages went to an element
that cannot show comments.

That also defeats the placeholder support, which promises content in the root
is cleared once the iframe reports inited: a text placeholder works, but any
element placeholder is mistaken for the iframe, so inited never arrives and
the cleanup never runs.

The iframe now carries data-remark42-iframe and the lookup is scoped to a
direct child, so a second createInstance still reuses it while nothing else
in the root can be adopted.

Related to #1990

* Assert the backup contents rather than the compressed size

TestBackup_MakeBackup and TestBackup_Do pinned the gzip output at 52 bytes,
which ties them to the exact output of compress/flate. The same input encodes
to 57 bytes on go 1.27, so both fail for anyone building on a toolchain newer
than the one CI pins.

They now read the backup back and compare it against what the exporter wrote,
which is what the tests were reaching for and does not move with the
compressor. The payload is a shared constant so the two cannot drift.
2026-08-22 03:09:40 -05:00
Dmitry VerkhoturovandGitHub e3d1d0e23e Create the e2e trace directory before writing a trace (#2194)
`newPageOn` writes a trace into `traces/` when a test fails, and never created
that directory. It is gitignored, so a fresh checkout does not have it.

Traces were not in fact being dropped: the driver creates the parent of the
trace path itself, checked against the version this module pins rather than
assumed. The directory is created here anyway because nothing in the suite
states or tests that dependency, and the missing directory has been raised in
review on #2180 and again on #2193, each time needing the driver checked before
it could be answered.

One visible difference on a fresh checkout: the directory now arrives at 0750
rather than the 0755 the driver's own mkdir leaves, both measured. It runs only
on a test that has already failed, and logs its error rather than swallowing
it, matching the Stop call below it.
2026-08-22 02:47:33 -05:00
Dmitry VerkhoturovandGitHub 49bf83b09c Address the review follow-ups from #2188, #2189 and #2190 (#2193)
* Read the collapsed-threads key through getJsonItem

`getFromLocalStorage` parsed the stored string directly, so anything
malformed under `__remarkCollapsed` threw out of `restoreCollapsedThreads`.
That call sits in `remark.tsx` ahead of the `render`, so the throw took the
whole widget with it: the reader was left on the preloader, over a view
preference.

`getJsonItem` in `common/local-storage.ts` already wraps a parse of a
localStorage key and returns null on failure, and null is a shape the check
below already reads as empty. The rest of that function is total against
whatever the browser holds, and the bare parse was the one way in.

* Stop retrying a failed e2e test in CI

The suite went in with one gotestsum rerun. It has no failures on record to
justify that: 31 CI runs since it landed, all green, and no rerun report has
ever been produced. A retry is what turns an intermittent regression into a
green build, and while the suite is this young its own failures are the
evidence worth keeping.

`E2E_RUN_ID` stays. It stamps the threads a run works on with the CI run id, so
a thread url in a trace or a log names the run it came from. It carries no data
across: the stack is disposable, and a local run under the same id gets those
urls on an empty database.

* Stop two chooseUnusedPort comments claiming collisions cannot happen

All four copies listen on :0, read the assigned port, close the listener
and bind later, so nothing holds the number across that gap and another
binary can take it. The copies in app/cmd and app/rest/api call a collision
very unlikely, which is accurate; the ones in app and the example module
said binaries never land on the same number, which is not, and a comment
ruling out a port collision is what would send the next person chasing one
somewhere else. All four now read the same.

Closing the window rather than describing it means the server binding :0
itself and reporting the address it got, which is a larger change.
2026-08-22 02:23:56 -05:00
Dmitry VerkhoturovandGitHub 0b651dddd4 Make backend tests wait on conditions instead of durations (#2190)
* Make backend tests wait on conditions instead of durations

The backend workflow has a long tail of runs that fail once and pass on
a rerun. Every one of them comes down to a test assuming an operation
finishes within some duration rather than waiting for the state it
needs. Three were reproducible and each was reproduced against the old
code before being changed: TestServerAuthHooks minted a token that lived
one second and never tested expiry, so a slow runner turned the first
POST into a 401; TestServerApp_AnonMode saw "connection refused" because
waitForHTTPServerStart returned silently after three seconds and left a
later assertion to fail with something unrelated; TestFsStore_Cleanup
slept 200ms against a 300ms ttl that Cleanup widens to 400ms with its
commit grace, so roughly 100ms of stall collected an image meant to
survive.

Fixed sleeps before asserting on asynchronous work are replaced with
polls on the condition itself, using require.Eventually and
require.EventuallyWithT, and require.Never where the assertion is that
something did not happen. Polling closures assert on the CollectT they
are handed rather than on t, since testify runs them on another
goroutine, and polls that issue HTTP requests stay under the rate limit
on the routes they poll through.

Where a test needs time to have passed, the clock input is pinned
instead: staging ages are stamped with os.Chtimes on both sides of the
cleanup boundary right before each call, which also makes the 100ms
commit grace an exact case rather than something no assertion reaches,
and the RSS tests set store.Comment.Timestamp explicitly rather than
racing the wall clock into the first 100ms of a second so pubDate
matches.

chooseUnusedPort takes a port from the kernel's ephemeral range. Picking
at random out of a fixed 10000-port window let two package binaries,
which go test ./... runs concurrently, land on the same number between
the probe closing and the server binding. The start helpers fail naming
the port they waited on, and the SSL tests wait on the redirect port as
well as the TLS one.

Arbitrary budgets that nothing tests are gone: ten HTTP clients with a
one-second timeout against bolt-backed import and export, the "should
take about 100msec" assertions, and a one-second bound on noticing an
already cancelled context. Shutdown stays bounded at ten seconds so a
hang is still caught.

Two assertions get stronger. TestServerAuthHooks accepted 403 or 401
from a blocked user, an alternative that existed only because the short
token could expire mid-test; it is deterministically 403 now.
TestAdmin_BlockedList asserted two users blocked while one carried the
same 150ms ttl the next step waits to lapse, so the halves raced each
other.

goleak stops reporting the regexp2 clock goroutine, which chroma pulls
in for syntax highlighting and which lives for up to a second after the
last match with a timeout; it ends on its own but a binary finishing
inside that window was reported as leaking, and this suite now finishes
sooner. The ignore for net/http.(*Server).Shutdown goes the other way:
it no longer matches anything, with both packages run fifteen times each
under CPU oversubscription to confirm.

Two gaps the change would otherwise have opened are covered directly
rather than left to the side effects that used to cover them. The
one-second token was the only thing exercising the authenticator's
ClaimsUpd hook on refresh, so TestServerApp_ClaimsUpd now calls the hook
itself and checks admin, blocked, email and restricted-name
impersonation, including the two pass-through cases. Lifting the
open-route limit removed the last incidental exercise of the rate
limiter, so TestRateLimiter drives a burst past the allowance and checks
the refusals and that the limit is per client. Both run without a wall
clock, and both were confirmed to fail when the behaviour they cover is
removed.

Production code is untouched. The two sleeps outside test code, the 429
backoff in cmd/cleanup.go and the submit poll in store/image/image.go,
are left alone: no CI failure implicates them.

Test sleeps drop from 67 to 21, all of them either inside a
testing/synctest bubble or a poll interval. The suite runs in about 22
seconds instead of 46, mostly because
TestPublic_FindCommentsCtrl_ConsistentCount no longer paces a hundred
subtests with an 80ms sleep each to stay under the open route limit. The
300s per-package budget now matches across both workflows, the race_test
target and the documented command, and CLAUDE.md records the convention.

with '#' will be ignored, and an empty message aborts the commit. # #
Date: Sat Aug 22 01:12:31 2026 +0100 # # interactive rebase in progress;
onto 7c312da1 # Last command done (1 command done): # reword deb6cbf1 #
Make backend tests wait on conditions instead of durations # Next
command to do (1 remaining command): # reword 262e6dc2 # Apply go fix
under Go 1.27 # You are currently editing a commit while rebasing branch
'fix/backend-test-flakiness' on '7c312da1'. # # Changes to be committed:
.github/workflows/release.yml # modified: CLAUDE.md # modified: Makefile
modified: backend/_example/memory_store/server/rpc_test.go # modified:
backend/app/cmd/import_test.go # modified:
backend/app/cmd/server_test.go # modified: backend/app/main_test.go #
modified: backend/app/rest/api/admin_test.go # modified:
backend/app/rest/api/middleware_test.go # modified:
backend/app/rest/api/migrator_test.go # modified:
backend/app/rest/api/rest_private_test.go # modified:
backend/app/rest/api/rest_public_test.go # modified:
backend/app/rest/api/rest_test.go # modified:
backend/app/rest/api/rss_test.go # modified:
backend/app/rest/proxy/image_test.go # modified:
backend/app/store/image/fs_store_test.go # modified:
backend/app/store/service/service_test.go # modified:
docs/backlog/api-tests-deadlock-on-macos.md #

* Apply go fix under Go 1.27

Go 1.27 extends go fix with the modernizers, so `go fix ./...` now
rewrites patterns the language has since replaced. Running it across all
three modules produces this: legacy sync/atomic calls on plain integers
become the atomic types (notify.Service.closed, image.Service.term and
submitCount, and several test counters), reverse index loops become
slices.Backward, a Split-then-index becomes strings.Cut, counted loops
become range over an int, and interface{} becomes any in the e2e suite.

The example module needed no changes. The e2e module is behind a build
tag, so it only matches with `go fix -tags e2e ./...`.

One knock-on: prealloc can see the bound of a loop once it is written as
range over an int, so the slice it feeds is now preallocated.

with '#' will be ignored, and an empty message aborts the commit. # #
Date: Sat Aug 22 01:32:09 2026 +0100 # # interactive rebase in progress;
onto 7c312da1 # Last commands done (2 commands done): # reword deb6cbf1
262e6dc2 # Apply go fix under Go 1.27 # No commands remaining. # You are
currently editing a commit while rebasing branch
'fix/backend-test-flakiness' on '7c312da1'. # # Changes to be committed:
backend/app/migrator/native.go # modified: backend/app/notify/notify.go
backend/app/rest/api/rest_private_test.go # modified:
backend/app/store/comment.go # modified:
backend/app/store/image/image.go # modified:
backend/app/store/service/service_test.go # modified:
backend/app/store/service/title_test.go # modified: e2e/e2e_test.go #
modified: e2e/widgets_test.go #
2026-08-21 22:17:44 -05:00
Dmitry VerkhoturovandGitHub b6975af63c Fix collapsed threads not restoring, and the clock skew correction (#2188)
* Fix collapsed threads not restoring, and the clock skew correction

Collapse state was kept as a flat list of `siteID_url_commentID` strings
and read back by splitting on `_`. Any underscore in the url, the site id
or the comment id made the pieces impossible to tell apart, so a page
whose url contains one lost its collapsed threads on every reload, and one
page's entries could be read or deleted as another's: `/post` matched
everything stored for `/post_2`, and a site id of `blog` matched `blog_ru`.
No separator fixes that, since every candidate can occur inside the values,
so the ids are now nested under the site and the url instead. Anything
stored in the old shape reads as empty: collapsed threads are a view
preference, and re-expanding them once is not worth a migration.

The e2e suite had been stripping underscores out of its own thread urls to
work around this, which left its collapse test unable to fail on the bug it
covers. That workaround is gone, and the test now fails without this fix.

`serverClientTimeDiff` was written in seconds and added to an epoch in
milliseconds, so the correction it exists to apply was a thousandth of the
real skew. It is now milliseconds, and named for the unit.

A response with no usable `date` used to fall back to a zero timestamp,
which already made the "skew" about twenty days and would have made it
fifty-five years once the units were right. Nothing is stored now unless
the reading is plausible, since `Date.parse` is lenient enough to turn junk
into a date and let an absurd value through the branch that parses.

The score tooltip reports controversy again when there is any. It has been
dead since the vote component was rewritten in 0e4ae6e0, which moved the
score into its own component and left the line behind commented out; the
value has been passed in and dropped ever since. Unlike the original it
stays out of the way when there is no controversy, which the backend sends
as an absent field rather than a zero.

* Drop the nested frontend dockerignore

Docker reads `.dockerignore` from the build context root only, and nothing
builds from `frontend/`: every context in the repo is the repository root,
apart from the site, which has its own. There is no Dockerfile under
`frontend/` any more either. So the file was never consulted, and both
lines it carried, `/.vscode/` and `/.idea/`, are already in the root
`.dockerignore` verbatim.
2026-08-21 22:12:19 -05:00
Dmitry VerkhoturovandGitHub 4fca268dc6 Pin staging ages in TestFsStore_Cleanup instead of sleeping (#2191)
The test slept 200ms, ran Cleanup with a 300ms TTL and then asserted the
second and third staged images survived. Cleanup collects anything older than
the TTL plus a 100ms commit grace, and the second image was already 300ms old
by then, so a runner that stalled ~100ms anywhere in the setup aged it past
the line and the assertion failed with "file on staging".

Age comes from the file's modification time, so the test now sets it with
os.Chtimes on both sides of the boundary immediately before each Cleanup call:
the image meant to be collected is backdated an hour, the ones meant to
survive are stamped at now. That leaves no window for a stall to age a file
into the wrong bucket, and drops 600ms of sleeping.

Verified by injecting a stall into the setup: 250ms reproduces the failure on
the current code, while the version here survives 2s.
2026-08-21 22:06:58 -05:00
Dmitry VerkhoturovandGitHub a0879b2336 Measure the iframe reveal budgets from inside the page (#2189)
The three reveal tests timed their budgets from before `page.Goto`, so a
slow navigation was spent against a window that belongs to the iframe. In
`TestIframe_StaysHiddenUntilTheDocumentReportsInited` that made the test
vacuous rather than flaky: on a navigation between 2.5 and 5 seconds the
loop bounding the visibility assertion had no budget left, ran zero times,
and the test passed having asserted nothing. Reproduced by delaying the
demo document by three seconds, where the assertion ran 0 times before and
runs 23 after. The timeout test had the mirror of it, with navigation
counting toward the lower bound that exists to catch a shortened fallback.

An init script now records, in the page, when the widget's iframe element
enters the document and when its visibility first flips. `create-iframe.ts`
arms its fallback a moment earlier, on the detached element, so these read
a shade short and every bound is conservative in the same direction.

Both bounds were also wider than the thing they guard. The hidden window
now runs almost to the fallback rather than half of it, and the lower bound
sits just under it rather than at three quarters, which a fallback
shortened to four seconds used to clear.

CI reruns a failing test once rather than failing the build on the first
flake. A browser suite has a floor no amount of care removes, and one flake
failing the build is what stops people trusting the suite. Once rather than
twice, because a rerun stops at the first pass and each further attempt
only widens the window where a real intermittent regression is absorbed.
What needed a rerun is written to a report and uploaded with the traces,
which are kept whether or not the job went green: a run that recovered on
the rerun is exactly the one whose evidence used to be discarded.
2026-08-21 22:05:51 -05:00
Dmitry VerkhoturovandGitHub 7c312da199 Stop the Telegram paragraph rendering with spaces in Japanese and Chinese (#2187)
`telegram-link.tsx` assembles that paragraph from five separate messages
with the anchor and the QR clause in the middle, joining them with a
hardcoded space. Japanese and Chinese do not put spaces between words, so
the assembled sentence carried them mid-clause: `通过 此链接 或扫描二维码
打开 Telegram,` separated a preposition from its object and an adverbial
phrase from its verb. The separator now comes from the locale and is
empty for `ja`, `zh` and `zh-tw`. Korean keeps its spaces, because Korean
uses them, as do Thai's phrase boundaries.

The locale is matched exactly as `loadLocale` matches it. Comparing case
insensitively would have been worse than the bug: `remark_config.locale`
is forwarded verbatim and `loadLocale` is case sensitive, so a
conventional `zh-TW` loads the English catalogue, and a lowercased
comparison would then join English words with nothing between them. The
test covers that case alongside `ja` and `en`, and fails if either the
comparison loosens or the separator stops depending on the locale.

Macedonian labelled the replies feed as comments. `subscribeByRSS.replies`
carried `Коментари`, the same value as `user.comments`, in a catalogue
whose two reply strings are both `Одговори`. That option subscribes to
`/rss/reply?user=`, which `UserReplies` documents as comments replied to
that user, so the feed is replies.

`auth.user-not-found` is removed. It reached every catalogue but could not
render: the only dynamic path to it is `messages[invalidReason]`, and
`invalidReason` comes from `getTokenInvalidReason`, which returns
`expiredToken`, `invalidToken` or null, or from a backend error string,
and the backend emits nothing matching. Catalogues go from 181 keys to
180.
2026-08-21 19:17:49 -05:00
Dmitry VerkhoturovandGitHub a5b2fe3cfc Consolidate the frontend toolchain onto babel, and ship one bundle (#2178)
Four upgrades that were finished but never merged, the compiler collapse
they enable, and the dependency sweep that follows. Direct
devDependencies go from 78 to 60 and dependencies from 10 to 9.

Three were doing the same job: `ts-loader` stripped types in webpack,
`babel-loader` did everything else, and `@swc/jest` repeated both for the
tests with its own copy of the JSX settings. Babel is the one that
survives, because the `data-testid` stripper has no equivalent elsewhere.

`ts-loader` ran `transpileOnly: true`, so it only stripped types, which
`@babel/preset-typescript` does; `fork-ts-checker-webpack-plugin` was
already what type-checks. Jest runs `babel-jest` against the same
`.babelrc.js` the bundle uses, passed as `configFile` because a
file-relative babel config does not reach the `node_modules` packages in
`transformIgnorePatterns`, and `jest.config.mjs` is plain ESM because a
`.ts` config is compiled against `tsconfig.json`, whose
`verbatimModuleSyntax` rejects ESM syntax in a file the package has not
declared as a module.

That removes `ts-loader`, `@swc/jest` and `@swc/core`. The last was
pinned to 1.2.205 from 2022 with no way forward, because newer builds
emit non-configurable exports and break `jest.spyOn` across 13 suites.

Babel compiles a file at a time with no type information, so it cannot
tell a type-only import from a real one and keeps the module. One line,
`import { boundActions } from './connected-comment'`, pulled the whole
redux store into `last-comments.mjs` and doubled it. `verbatimModuleSyntax`
and `@typescript-eslint/consistent-type-imports` mark them properly; the
statement has to be a separate `import type`, since verbatim semantics
keep an inline `import { type X }` and load the module anyway.

The legacy and modern compilations produced the same bytes. Both read the
same browserslist query, `defaults, not IE 11, not samsung 12` resolves to
chrome 109 and up, and nothing in the source needs transforming for that
set, so 28 of the 29 output pairs were byte-identical.

That made the module/nomodule switch worse than redundant: it served the
`.js` file to browsers with no ES module support, and those files carried
`??`, `?.` and class fields, so the fallback handed its own audience a
syntax error. There is now one bundle, always loaded as a module, in the
five templates and in the seven `site/` documents integrators copy from.
A production build emits 29 files rather than 58, in about 3 seconds
rather than 17. Two of those documents did not work at all beforehand:
the SPA snippet could not parse, and the subdomain example had an
unterminated string.

`@babel/core` 8 declares `^22.18 || >=24.11` and `size-limit` 13 declares
`^22.18 || ^24 || >=26`, so 20 was below the floor of two things installed
here; pnpm only warns, which is why every build passed. All seven places
the frontend pins it move together. `site/` is untouched: it builds with
yarn and eleventy and installs neither.

`eslint --print-config` before and after gives 173 active rules on an
application file against 172, and 172 on a spec file and a plain JS file
against 171. What is gone is three `flowtype` rules with no Flow here,
`no-new-object` and `no-new-symbol` whose upstream replacements are on,
`react/forbid-foreign-prop-types` with no propTypes anywhere, and, on TS
only, `no-useless-constructor`, whose typescript-eslint version is on at
error. `@babel/core` is pinned to 8 across the workspace because
`@jest/transform` and `istanbul-lib-instrument` depend on 7 outright; a
second scoped override holds `eslint-config-preact` on 7, since its
`@babel/eslint-parser` loads babel 7 syntax plugins.

`fast-async` rewrote every async function into nodent promise chains,
calls babel's `transform` synchronously, which babel 8 removed, and every
browser in the target list runs async natively. `prefresh` blew its stack
on `createContext` under babel 8 with no newer release to move to, which
compiled `intl.tsx` and `store/context.tsx` into throwing stubs, so
`pnpm dev:app` could not run the widget at all. `core-js` is not injected
now that `useBuiltIns` is gone, `postcss-custom-properties` was reached
directly although nothing declared it and resolved only through pnpm's
private hoist directory, and `cssnano` ran in both postcss chains although
`CssMinimizerPlugin` already uses it.

`pnpm lint`, `pnpm test` and `pnpm build` now work from `frontend/` as
`CLAUDE.md` and the contributing guide have always said they do; the
workspace root defined none of them.
2026-08-21 19:13:25 -05:00
Dmitry VerkhoturovandUmputun 7ee3a0da48 Tidy the example module for the testify bump
Dependabot updates `backend/` only, so the example module that replaces
it with `../../` keeps the old versions as indirect entries and the
`test examples` job fails with `go: updates to go.mod needed`.

Beyond testify itself this picks up the yaml module move, from
`gopkg.in/yaml.v3` to `go.yaml.in/yaml/v3`, and drops two indirect
entries nothing needs any more.
2026-08-21 18:44:10 -05:00
dependabot[bot]andUmputun 4aaba0fb61 chore(deps): bump github.com/stretchr/testify
Bumps the go-modules-updates group in /backend with 1 update: [github.com/stretchr/testify](https://github.com/stretchr/testify).


Updates `github.com/stretchr/testify` from 1.12.0 to 1.12.1
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.12.0...v1.12.1)

---
updated-dependencies:
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-21 18:44:10 -05:00
Dmitry VerkhoturovandGitHub fb7b6c2cdd Serve the build-independent web assets from the backend (#2181)
* Serve the build-independent web assets from the backend

`privacy.html`, `markdown-help.html` and the `400x400.jpeg` it embeds carry
no template variable, link no script or stylesheet, and are imported by
nothing in the widget. They now live in `backend/app/webassets/assets`,
embedded there, and are served under `/web` alongside the frontend build.

`/web` reads the frontend build first and falls back to them, which is what
lets an operator replace one by dropping a file into `--web-root`. That is
what `privacy.html` needs: it describes remark42.com, while the
authorization guide tells operators to hand its URL to Google and Facebook
as their own application's privacy policy.

Only a missing file falls through. An unreadable file in the web root keeps
reporting as unreadable rather than being silently replaced by the embedded
copy, and a name the filesystem rejects reports as missing rather than as a
server error, both matching what `http.Dir` did.

The dev server serves the same directory, so the Markdown help link in the
comment form resolves on the dev port as well as in production.

The two pages are served as they are written. `markdown-help.html` was
minified before, and its formatted inline stylesheet is most of its 8.5 kB;
that is 2.4 kB more over the wire, behind the hour-long cache header the
file server already sets.

Drops `copy-webpack-plugin`, which had no other pattern, and the stylelint
entries that only ever matched these files.

* Make pnpm dev:app start again

The dev server has been failing to start on two counts, so the flow the
contributing guide documents does not run at all.

`webpack-cli` 4 drives `webpack-dev-server` 5 through the argument order
of an older major, handing it the compiler where it expects the options
object. It rejects that against its schema and exits, complaining about an
unknown `_assetEmittingPreviousFiles` property, which is a field of the
compiler. `webpack-cli` 7 is the release that declares
`webpack-dev-server` 5 as a peer.

Past that, `http-proxy-middleware` resolves to 4.1.1, which no longer
accepts the two-argument call `webpack-dev-server` makes, so the `/api`
and `/auth` proxies throw on startup. It is pulled in by the security
override for CVE-2025-32996, the only override in the file with no upper
bound: `>=2.0.10` matches every later major. Bounding it to the 2.x line
keeps the fix and the API `webpack-dev-server` calls.

With both in place `pnpm dev:app` serves the widget and the pages under
`/web` on port 9000.
2026-08-21 18:43:06 -05:00
dependabot[bot]andUmputun 123b9328d9 chore(deps): bump alpine in /site in the site-image-updates group
Bumps the site-image-updates group in /site with 1 update: alpine.


Updates `alpine` from 3.22 to 3.24

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.24'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: site-image-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-21 18:20:34 -05:00
dependabot[bot]andUmputun 2bfad021e3 chore(deps): bump github.com/mxschmitt/playwright-go
Bumps the go-modules-updates group in /e2e with 1 update: [github.com/mxschmitt/playwright-go](https://github.com/mxschmitt/playwright-go).


Updates `github.com/mxschmitt/playwright-go` from 0.6201.0 to 0.6201.1
- [Release notes](https://github.com/mxschmitt/playwright-go/releases)
- [Commits](https://github.com/mxschmitt/playwright-go/compare/v0.6201.0...v0.6201.1)

---
updated-dependencies:
- dependency-name: github.com/mxschmitt/playwright-go
  dependency-version: 0.6201.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-21 18:20:27 -05:00
Dmitry VerkhoturovandGitHub 4c9ef37cf1 Move the site from eleventy to hugo (#2179)
* Move the site from eleventy to hugo

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

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

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

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

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

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

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

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

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

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

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

* Keep the heading anchors markdown-it generated

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

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

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

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

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

* Harden the site image build and its CI

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

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

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

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

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

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

Recorded in the backlog: `master` has `required_status_checks` off with an
empty check list, so the new job surfaces a red X and does not block a
merge. That is a settings decision rather than a code fix.
2026-08-21 18:05:56 -05:00
Dmitry VerkhoturovandGitHub ff77f41a3a Move the e2e suite to Go and playwright-go (#2180)
* Move the e2e suite to Go and playwright-go

The seven playwright tests in `frontend/e2e` become twenty in `e2e/`, a
separate Go module driving the same browsers through playwright-go. The
npm project, its lockfile entries, its prettier config and
`Dockerfile.e2e` go with it, leaving `frontend/` a single-member
workspace.

The suite covers posting with markdown, replying and the nesting that
implies, editing inside the deadline and the backend refusing one outside
it, deleting, voting with the optimistic score observed mid-flight and
rolled back on failure, changing the sort, collapse persistence across a
reload, dev, anonymous and email sign-in end to end, the profile iframe,
and the two scripts that render into the host page rather than the
widget's own frame.

The rendering tests run in chromium, firefox and webkit. The rest sign in,
sign-in needs the dev oauth2 provider, and reaching that by name from the
host is chromium-only, so they run there alone.

`compose-e2e-test.yml` runs remark42, a second instance with a short edit
window so that path does not need a five-minute test, and mailpit, which
catches the email verification message the suite reads back. Everything
binds to the loopback interface: the stack holds a known secret and an
admin shared id, and `go test` can start it unattended. The tests run on
the host rather than in a container.

Three settings there exist for the tests rather than for realism.
`REMARK_URL` uses a hostname because the dev oauth2 server binds whatever
host it reads out of it, and a loopback bind inside a container cannot be
published. `UPDATE_LIMIT` is raised because the default of 0.5/sec rejects
any test posting twice in a row. The suite also paces its own `/auth/`
calls, which are capped at 2/sec by a bare literal in `rest.go` rather
than by a setting.

Each test gets its own comment thread from a query string on the demo
page, so nothing has to reset the database between runs.

CI gains a vet and lint job for the module, since the build tag keeps it
out of a plain `go test ./...`, and uploads a browser trace for any test
that fails.

`e2e/README.md` carries the rest: how to run it, what the stack is for,
and the widget behaviour the assertions have to work around.

* Update golangci-lint to 2.13.1 in the backend workflow

The pin sat three minors behind what the linter installs locally, so CI
checked the backend with an older set of rules than anyone running it by
hand. 2.10.1 also fetches its config schema over the network on every
`config verify`, which is a failure mode with no bearing on the code.

Both targets are clean on 2.13.1, `backend/app` and the memory_store
example.
2026-08-21 17:53:12 -05:00
Dmitry VerkhoturovandGitHub 1bb002348a Complete and correct every translation catalogue (#2177)
* Fix wrong and missing translations across 17 locales

`errors.8` is `ErrReadOnly` (`backend/app/rest/httperrors.go:29`), but 13
catalogues carried a copy of `errors.7`, which is `ErrUserBlocked`. A
reader who simply hit a read-only thread was told they had been blocked,
in Belarusian, Bulgarian, Brazilian Portuguese, German, Finnish, French,
Japanese, Polish, Russian, Turkish, Ukrainian, Vietnamese and Simplified
Chinese. Each now says the page is read-only, in the terminology that
catalogue already uses for its read-only badge.

Czech had an off-by-one: `errors.19`, restricted words, carried the text
of `errors.18`, file not found, and `errors.18` was left in English. So a
comment caught by the word filter reported a missing file. Both rewritten.

Also corrected, all of the same class:

- `de` `vote.downvote` had a leading space
- `mk` had dropped `{shortcut}` from the bold, italic and link tooltips,
  losing the keyboard hints
- `fi` `comment.pin` was "Sitoo", which means "it binds", and `comment.unpin`
  followed from it; `errors.forbidden` was misspelled "Kieletty."
- `be` `errors.failed-fetch` ended in a stray "r"
- `zh-tw` `authPanel.read-only` wrote 唯獨 for 唯讀
- `ja` `errors.conflict` used 相衝, which is not Japanese usage
- `it` `auth.symbols-restriction` misspelled "numberi" and used "username"
  where `auth.username` now says "Nome utente"

Nine Finnish strings, `it` `auth.username` and `zh-tw` `comment.time` were
left in English; `comment.time` is a format string and now matches the
other CJK locales at `{day} {time}`.

No English source string changes, and every runtime placeholder is
preserved. Reviewed by two independent passes, which between them reworded
five of these and found four of the pre-existing defects above.

* Complete every translation catalogue

No locale carries English text any more, and the Telegram authorisation
paragraph now reads as a sentence in all 23 of them.

That paragraph is assembled in `telegram-link.tsx` from five separate
keys with the link and the QR clause in the middle, so a catalogue that
translates each key in isolation produces word salad in any language
whose verb does not sit where English puts it. Japanese rendered as
"テレグラムを開く リンクで または QR コードをスキャン そこで..." and Korean, Persian,
Traditional Chinese and Czech had the same break. Each of those now
splits the sentence at the point its own grammar wants, and every
catalogue was checked by rendering both the wide and the narrow layout,
since the QR clause only appears above 768px.

Ten catalogues also had `auth.telegram-link`, which is the anchor text
and reads "by the link", left as "Telegram bot" from an older source
wording, so the paragraph named the bot twice and never said what the
link was for. Six of the block's keys had never been translated at all
in the 16 locales that ship it, and `auth.telegram-message-1/2/3` were
English in nine. None of these were visible to a check for "value equals
the English string", because none of them equalled it.

Two of the six untranslated keys are word for word the English of their
`subscribeByEmail` siblings, so each catalogue's own existing wording was
reused rather than a second phrasing invented for the same sentence.

Quotation marks in that paragraph now follow each language rather than
the English source: «» for be, ua, ru, fa, fr and ar, „“ for bg, cs, de
and mk, „” for pl and ro, ”” for fi, 「」 for ja and zh-tw.

The button the paragraph tells the reader to press is Telegram's own, and
Telegram ships no interface translation for Japanese, Thai or Vietnamese,
so those three now name it the way Russian and Traditional Chinese
already did, with the Latin label alongside the translated one.

Also swept and fixed: Vietnamese "Bằng đã huỷ đăng kí" for "Bạn", Spanish
"ó" for "o", Thai "คลิ๊ก" for "คลิก", an unclosed quotation mark in Arabic
`commentForm.upload-file-fail`, German alternating between tippen and
klicken for the same action, Czech infinitive "Otevřít" where the rest of
the paragraph is imperative, Macedonian "СО ЛИНК" in caps, French
"Sélectionner" on a button the text calls "Vérifier", a missing space
after a full stop in `ua` `errors.9`, and double spaces in `mk`, `pl` and
`vi`.

Left identical to English on purpose, because the word is the same in
that language: `RSS` and `Telegram` everywhere, "Email" in be, it, pl,
ro, ua and vi, "Site" in bp, fr and ro, and "Conflict." in ro. German and
Turkish do not use bare "Site" and say "Website" and "Web sitesi".
2026-08-21 17:42:41 -05:00
Dmitry VerkhoturovandGitHub fc4e10573c Replace react-intl and remove React from the widget (#2176)
Second and final step of #2166. `react-intl` is replaced by
`app/common/intl.tsx`, a small i18n binding over Preact context, and with
`react-redux` already gone nothing holds the React compatibility alias.

React is now absent from the lockfile, the installed tree, the config and
the bundles: `react`, `react-dom`, `react-intl`, `@types/react`,
`@preact/compat`, `use-sync-external-store` and `intl-messageformat` are
all gone, along with the `paths` entries in `tsconfig.json` and three
babel-loader excludes. Runtime dependencies go from 15 to 10.

`preact/compat` goes too, which matters more than its 3.8 kB. Importing
it anywhere installs hooks on preact's shared `options` that remap
`onFocus`/`onBlur` to `focusin`/`focusout` for every element and make
`@testing-library/preact` rewrite `change` to `input`, the two bugs
behind #2166, still live until now. `Button` was wrapped in `forwardRef`
with no caller passing one, and `TextareaAutosize` now takes its ref as
an ordinary prop. The workaround in `sort-picker.spec.tsx` is gone with
them, since `fireEvent.change` reaches a `<select>` again.

Gzipped, against master: `remark.mjs` 76.17 kB to 56.47, `last-comments.mjs`
37.97 to 18.26, `deleteme.mjs` 14.51 to 8.42. The limits move with them and
keep more relative headroom than master shipped.

### The binding

`IntlProvider`, `useIntl`, `createIntl`, `defineMessages`,
`FormattedMessage` and `IntlShape`. 32 files change only their import.

The export names copy react-intl's deliberately: `formatjs extract` finds
messages by recognising `defineMessages`, `FormattedMessage` and
`intl.formatMessage` in the AST rather than by import source, so renaming
one silently empties the catalogue. `frontend/CLAUDE.md` records that,
along with the destructive part: `translation:generate` would then strip
the unextracted keys from all 24 catalogues and the check would pass.

A message the binding cannot parse falls back to the message in the
source: a broken, unhandled or nested tag, a brace that is not a
well-formed placeholder, and a placeholder naming a value the caller did
not supply. `mk.json` and `th.json` carried broken markup and rendered in
English; both are repaired, so a catalogue sweep over every locale can now
require well-formed markup with no exceptions listed.

`translation:check` gained the validation that would have caught them when
they were proposed: a translation's tags have to be well-formed pairs of the
names the English string uses, with no attributes, and its placeholders have
to be ones the English string provides. Leaving a tag or a placeholder out
stays allowed. Run against master's catalogues it reports both.

### enzyme

`@types/enzyme` was the last thing pulling `@types/react`, so React could
not leave while enzyme stayed. Its three test files move to
`@testing-library/preact`, which now has no rival: `@testing-library/preact-hooks`
had one import left and its own unmet peer warning. `intersection-observer`
was a runtime dependency nothing imported, and the `cheerio` override lost
its last dependent with enzyme.

Enzyme's `.find(X).prop()` threw unless exactly one node matched, so the
converted tests assert node counts explicitly to keep that.

### Verified

All 181 message ids formatted across all 24 catalogues through both real
react-intl and this binding: 4344 comparisons, no differences. From a wiped
`node_modules`: `pnpm install --frozen-lockfile`, `pnpm lint`,
`pnpm type-check`, `pnpm test` (392 tests, 42 suites), `pnpm build`,
`pnpm size-check`, `pnpm translation-check`.
2026-08-21 02:30:26 -05:00
Dmitry VerkhoturovandGitHub a91e322d5c Replace react-redux with a preact context binding (#2175)
* Replace react-redux with a preact context binding

One of the two packages holding the @preact/compat alias in place, and
the contained one: the store is plain redux, and the only react-redux
import inside it was a single line re-exporting typed hooks.

* Drop the now-unused react-redux types

* Subscribe before paint and check once on subscribe

Previously, useSelector subscribed to the store inside useEffect, which
runs after paint. A dispatch landing between render and that effect was
never delivered, since the listener did not exist yet, so the component
kept rendering a stale value until some later unrelated dispatch happened
to differ from the stale ref.

Subscribing in useLayoutEffect narrows the window to before paint, and
running the check once immediately on subscribe closes it, which is what
react-redux does for the same reason.

Adds the first tests for the binding, one of which fails without this
change: the store holds 1 while the DOM still shows 0.

* Only re-check on subscribe when the state actually moved

The subscribe-time check ran unconditionally, so it re-ran the selector
at mount. A selector building a fresh object fails Object.is against the
value the render already computed, which forced a second render of every
connected component: ConnectedRoot and every ConnectedComment, so around
201 extra renders for a 200-comment thread.

Reducers return a new root object on every change, so an unchanged state
reference means no dispatch was missed and the check has nothing to find.
Comparing against the state the render used keeps the property the check
exists for while dropping the extra render.

The race test still exercises the guarded path, since its dispatch
produces a new state object, and a new test pins the mount case: it fails
without the guard.

Raised by umputun in review.
2026-08-21 00:47:08 -05:00
Dmitry VerkhoturovandUmputun 931f2db4e3 Drop turbo
CI never invoked it, and after #2172 removed the four api scripts its
only remaining job was orchestrating one script in one package.
2026-08-20 18:12:34 -05:00
Dmitry VerkhoturovandGitHub b8f6dc5f91 Require node 20 and record every place the version is pinned (#2168)
* Require node 20 and record every place the version is pinned

The declared floor was >=18 while CI, Docker and both .nvmrc files had
been on 20 since the pnpm 8 to 10 migration, and transitive dependencies
now require 20.18.1. The docs had drifted further still, telling
contributors to install Node 16 and PNPM 8.

* Set the node floor to the strictest dependency and keep one checklist

undici needs >=20.18.1, so a bare >=20 advertised support for 20.0 to
20.18.0, which fail dependency engine checks. frontend/CLAUDE.md already
carried a pinning checklist, so the new entries fold into it rather than
starting a rival list in the root file.

* Keep the node floor at the major, not a patch version

engines.node states the major we support. Individual dev dependencies
can be stricter within it, and chasing those patch floors into engines
and the docs would turn every lockfile refresh into a docs change.
2026-08-20 18:12:30 -05:00
Dmitry VerkhoturovandGitHub b03dc366f9 Update preact to 10.29.8 (#2163)
* Update preact to 10.29.8

Also moves TypeScript to 5.9, which preact 10.29 typings require, and the
compat and testing library pins that go with it. Type checking resolves
JSX from preact via the automatic runtime; the bundle keeps the classic
transform so babel still strips test ids.

* Move babel to the automatic JSX runtime and refresh frontend notes

Leaving babel on the classic h pragma while tsconfig used the automatic
runtime meant a tsx file without an h import would type-check and lint
clean, then throw at runtime, since eslint-config-preact disables
react/react-in-jsx-scope and no-undef is off.

* Address review findings on the preact upgrade

Forward the textarea ref with useImperativeHandle so it clears on unmount
and lands during commit rather than after paint. Pair typescript-eslint
with the TypeScript it now has to parse. Use the preact namespace types
rather than the deprecated JSX aliases, and drop the redundant type
re-declarations the element-specific interfaces already provide.

* Drive the focus tests through real DOM focus and blur

Dispatching a synthetic focusin hard-coded preact/compat's internal
alias for onFocus. Calling focus() and blur() exercises the sequence a
browser produces and stays correct if that mapping changes.

* Raise the two bundle limits the preact upgrade pushes past

CI measures remark.mjs at 78024 bytes against a limit size-limit reads as
78000, so it failed by 24. last-comments.mjs had 36 bytes of headroom and
would have tripped on the next change.

* Regenerate the lockfile after the rebase

The rebase resolution left it missing the @typescript-eslint entries, so
every CI job failed at pnpm install --frozen-lockfile.
2026-08-20 02:31:43 -05:00
Umputun 36062de0e7 docs: drop the npm deprecation backlog item
remark42 deferred work belongs in the pull request response where paskal and
akellbl4 will see it, not in a file.
2026-08-20 02:31:15 -05:00
Umputun 90766d6637 ci: add umputun as a frontend code owner
frontend/* required @akellbl4 or @Mavrin, so umputun could not satisfy the
code-owner rule on any frontend pull request. #2172 needed an admin override
and #2163 could not use one, because GitHub routes stacked pull requests
through the async merge endpoint, which applies no override.
2026-08-20 02:31:15 -05:00
Umputun a1dbb2cb92 ci: run frontend checks on any frontend change
The path filter matched only frontend/apps/remark42/**, so a change to the
workspace root ran nothing: no lint, type-check, tests or size-limit, and no
docker build either since docker.yml waits on this workflow by name.

#2160 rewrote pnpm-lock.yaml and the override block, and #2172 removed a
workspace package and its CI workflow. Neither ran a single frontend check on
its PR or on master.
2026-08-19 23:53:52 -05:00
Dmitry VerkhoturovandGitHub d370b78613 Drop the @remark42/api package (#2172)
* Drop the @remark42/api package

It cannot authenticate anyone: clients/auth.ts exposes only anonymous,
email and telegram, with no OAuth method, and the fetcher never sets
credentials so its cookie auth cannot work cross-origin. Nothing in the
repo consumes it, no third-party consumer exists, and npm has served an
alpha from July 2022 that CI never publishes.

* Drop the removed workflow from the pnpm pinning checklist

frontend/CLAUDE.md still counted ci-frontend-api.yml among the places the
pnpm version is pinned, and stated a fixed total that no longer holds.
2026-08-19 23:28:52 -05:00
Umputun 439ccfa83c docs: note @remark42/api is still published and undeprecated on npm 2026-08-19 23:19:15 -05:00
Dmitry VerkhoturovandUmputun 29627f4bf0 Raise site resolution floors to clear remaining advisories
Both floors are bounded on the upper side, as an open-ended resolution
lets yarn cross a major version.
2026-08-19 03:39:33 -05:00
Dmitry VerkhoturovandUmputun 164eb89c60 Raise pnpm override floors to clear all frontend advisories
All 23 open Dependabot alerts against frontend/pnpm-lock.yaml resolve to
packages whose override floor sat below the patched release. Every floor
now carries an explicit upper bound, as an open-ended floor lets pnpm
resolve across a major version.
2026-08-19 03:39:25 -05:00
Dmitry VerkhoturovandUmputun 09110c792f Bump backend Go modules to latest
Updates every backend dependency with a newer release available, and
tidies the example module alongside as any change to backend/go.mod
requires.
2026-08-19 03:39:11 -05:00
3f5b3cdd98 feat: add configurable SMTP HELO hostname (#2146)
* feat: add configurable SMTP HELO hostname

Allow the SMTP HELO/EHLO hostname to be configured separately from
the SMTP server hostname.

This is useful when the SMTP server requires clients to identify
themselves with a fully qualified hostname different from the server
address.

* chore: remove vendored dependency changes

* Bump go-pkgz/notify to v1.4.0 and document SMTP_HELO_HOST

The HELOHost field lands in go-pkgz/notify v1.4.0, so the branch needs the
bump to compile; v1.3.0 in master has no such field. The example module is
tidied alongside, as any change to backend/go.mod requires.

Documents the parameter in the parameters table and, separately, in the email
setup page: what it does, that leaving it unset keeps the previous `localhost`
greeting, and the case it exists for, a relay refusing the greeting under
Postfix `reject_non_fqdn_helo_hostname`.

Also records the current limit: verification emails for email authentication
go through go-pkgz/auth's own sender, which has no equivalent setting, so the
greeting there is unchanged.

* Bump go-pkgz/auth to v2.2.0 and apply SMTP_HELO_HOST to verification email

The verification email sender had no way to set the greeting, so a relay that
refuses the HELO would accept notifications and still reject sign-in emails.
EmailParams gains HELOHost in go-pkgz/auth v2.2.0, so the same SMTP_HELO_HOST
now drives both paths.

The example module is tidied alongside, as any change to backend/go.mod
requires.

---------

Co-authored-by: oli <someone@somewhere.tld>
Co-authored-by: Dmitry Verkhoturov <paskal.07@gmail.com>
2026-08-19 02:52:39 -05:00
dependabot[bot]andUmputun 43fccf3bc9 chore(deps): bump the github-actions-updates group across 1 directory with 3 updates
Bumps the github-actions-updates group with 3 updates in the / directory: [actions/setup-go](https://github.com/actions/setup-go), [pnpm/action-setup](https://github.com/pnpm/action-setup) and [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/setup-go` from 6 to 7
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v6...v7)

Updates `pnpm/action-setup` from 6.0.9 to 6.0.10
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6.0.9...v6.0.10)

Updates `actions/setup-node` from 6 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-updates
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-19 00:49:19 -05:00
Dmitry VerkhoturovandGitHub 1f34984dab Bump go-pkgz/rest to v1.24.0 and opt in to wildcard origins with credentials (#2157)
rest.CORS refuses "*" together with credentials since go-pkgz/rest#52, so the
bump and the option have to land together: the option does not exist in v1.22.0
and the panic fires at construction, inside routes(), which makes it a startup
failure rather than a request-time one.

The wildcard stays. The comment widget is embedded on arbitrary third-party
sites, so the set of origins is not knowable, which is why the escape hatch was
asked for upstream instead of accepting the panic. What it costs is unchanged
and now written next to the call: any site a signed-in user visits can read
authenticated responses, so state-changing requests have to keep being protected
by something other than the origin, X-XSRF-Token today.

The example module is tidied in the same commit, as it reaches go-pkgz/rest
through the replace directive and its indirect graph would otherwise keep the
old pin and fail the readonly module check in CI.

The bump also carries testify to v1.12.0, which drops go-spew and go-difflib
from the module graph.
2026-08-19 00:33:13 -05:00
Dmitry VerkhoturovandGitHub 455d770899 ci: track the latest Go 1.25 patch instead of pinning one (#2156)
govulncheck fails on master against the 1.25.12 pin with seven stdlib
advisories, all fixed in 1.25.13: GO-2026-5026, GO-2026-5972, GO-2026-6088,
GO-2026-6089, GO-2026-6090, GO-2026-6091 and GO-2026-6218, across crypto/tls,
encoding/asn1, encoding/xml, html/template, net/http and net/url.

Pinning the next patch would only move the problem to the following advisory,
as it did in 76d0cc2c. setup-go accepts a minor-only spec, so "1.25" resolves
to a patch on that line at run time. Staying on 1.25 rather than "stable"
keeps a move to a new minor a deliberate change, matching the `go 1.25.0`
directive in both go.mod files.

check-latest is required with it: by default setup-go uses the patch already
cached on the runner image, so a minor-only spec alone would keep resolving
to whatever that image ships, currently 1.25.12, and the scan would stay red.
2026-08-19 00:31:22 -05:00
Dmitry VerkhoturovandUmputun bf67c251c5 docs(claude): require an example tidy on any go.mod change
Previously the rule was scoped to "updating Go modules", which reads as
version bumps only. Adding or removing a dependency, or changing the `go`
directive, puts the example module out of step in exactly the same way, and
the failure is the same `test examples` step reporting "updates to go.mod
needed".

Also records that Dependabot Go module PRs need this, since the bot updates
`backend/` alone.
2026-08-18 20:27:21 -05:00
Umputun cebba4cee4 docs: add backlog item for the macOS api test deadlock 2026-08-18 20:24:30 -05:00
Umputun 35a389cb75 fix: bump golang.org/x/image to v0.45.0 for GO-2026-6222
Excessive memory allocation during VP8L decoding, reachable from
app/store/image/image.go:333 where image.Decode runs on uploaded data.
The existing DecodeConfig dimension guard doesn't cover it, since the
over-allocation happens during decode rather than from declared dimensions.

Tidies backend/_example/memory_store in the same commit: it carries the
backend's deps as indirect entries and would otherwise fail the example CI step.
2026-08-18 20:24:30 -05:00
Umputun a725d990ed docs: add backlog item for the CORS wildcard credentials opt-in 2026-08-18 18:58:07 -05:00
Dmitry VerkhoturovandUmputun fdfce6495c Remove the widget body padding and the surplus reported height
Previously the widget document had `padding: 6px` on the body, so every
embedded widget sat 6px inside its container and could not align flush with
the host layout. `updateIframeHeight` then reported
`document.body.offsetHeight + 12`, but the body is `box-sizing: border-box`
and `offsetHeight` already includes padding, so the addition double-counted
it.

Measured against the deployed widget: the content needs 20610px, the body
reported 20622px with the padding, and the parent was told 20634px, leaving
24px of empty space below every embed on top of the horizontal inset.

Removing the padding does not clip anything. With it at zero, offsetHeight,
body scrollHeight and documentElement scrollHeight all agree, and the last
child carries no bottom margin, so no margin collapses through the body edge.

Resolves #1487.
2026-08-18 18:46:05 -05:00
Dmitry VerkhoturovandUmputun 8801903d01 Derive host from the page URL on self-served pages
Previously the pages Remark42 serves from /web/ carried a build-time host.
The `{% REMARK_URL %}` placeholder is substituted during the image and
release-asset builds, both of which write `http://127.0.0.1:8080`. Docker
rewrites it again at container start from REMARK_URL, but a release binary
has no equivalent step, so it serves demo, counter, last-comments and
deleteme pages pointing at the visitor's own loopback address. `counter.ejs`
additionally had that address hardcoded in two "note" links, which no
substitution touched.

These pages are served by Remark42 itself, so the host is whatever origin and
path prefix delivered them. Deriving it from `location` is correct at the root
and under a path prefix alike, and needs no build-time value. Sibling links
are now relative for the same reason.

`site_id` is left as a literal so the startup substitution in docker-init.sh
keeps matching it.

Reported by @andreas-hempel.

Resolves #1996.
2026-08-18 18:45:59 -05:00
Dmitry VerkhoturovandUmputun 8bcfd9e456 Close the login dropdown only on a genuine outside click
Previously any message reaching the widget closed the Sign In dropdown,
because the handler returned early only for a clickOutside payload while
closing was disabled and fell through to closing in every other case. The
embedding page posts hash, title and theme messages of its own, and
`embed.ts` installs a MutationObserver on the host page title that posts on
every mutation, so a host page whose title changes closes an open login form
and discards whatever was typed into it. Browser extensions that post into
the page have the same effect.

After this change the dropdown closes only for a clickOutside payload from
`window.parent`. Reproduced against the deployed demo: with the form open and
filled, a single `document.title` assignment on the host page removed it,
while three seconds of inactivity did not.

Resolves #2139.
2026-08-18 18:45:54 -05:00