remark42 Documentation

Go comment-system & commenting repository by @umputun. Docker Compose & container deployment with 5.6k stars.

RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy remark42 on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name remark42 -p 6379:6379 --restart always ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.10.0}
Configured Services & Ports
⚙️ redis
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.10.0}
🔌 Ports: 6379:6379, 6666:6666
⚙️ osscluster
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.10.0}
🔌 Ports: 16600-16605:16600-16605
⚙️ cae-resp-proxy
image: redislabs/client-resp-proxy:latest
🔌 Ports: 17000:17000, 17001:17001, 17002:17002, 17003:17003, 18100:3000
⚙️ proxy-fault-injector
image: build locally
🔌 Ports: 15000:5000
⚙️ osscluster-tls
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.10.0}
🔌 Ports: 6430-6435:6430-6435, 5430-5435:5430-5435, 16430-16435:16430-16435
⚙️ sentinel-cluster
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.10.0}
⚙️ sentinel
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.10.0}
⚙️ ring-cluster
image: ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.10.0}
🔌 Ports: 6390:6390, 6391:6391, 6392:6392
docker-compose.yml
yaml (docker-compose.yml)
---

x-default-image: &default-image ${CLIENT_LIBS_TEST_IMAGE:-redislabs/client-libs-test:8.10.0}

services:
  redis:
    image: *default-image
    platform: linux/amd64
    container_name: redis-standalone
    environment:
      - TLS_ENABLED=yes
      - TLS_CLIENT_CNS=testcertuser
      - TLS_AUTH_CLIENTS_USER=CN
      - REDIS_CLUSTER=no
      - PORT=6379
      - TLS_PORT=6666
    command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --tls-auth-clients optional --save ""}
    ports:
      - 6379:6379
      - 6666:6666 # TLS port
    volumes:
      - "./dockers/standalone:/redis/work"
    profiles:
      - standalone
      - sentinel
      - all-stack
      - all
      - e2e

  osscluster:
    image: *default-image
    platform: linux/amd64
    container_name: redis-osscluster
    environment:
      - NODES=6
      - PORT=16600
    command: "--cluster-enabled yes"
    ports:
      - "16600-16605:16600-16605"
    volumes:
      - "./dockers/osscluster:/redis/work"
    profiles:
      - cluster
      - all-stack
      - all

  cae-resp-proxy:
    image: redislabs/client-resp-proxy:latest
    container_name: cae-resp-proxy
    environment:
      - TARGET_HOST=redis
      - TARGET_PORT=6379
      - LISTEN_PORT=17000,17001,17002,17003  # 4 proxy nodes: initially show 3, swap in 4th during SMIGRATED
      - LISTEN_HOST=0.0.0.0
      - API_PORT=3000
      - DEFAULT_INTERCEPTORS=cluster,hitless
    ports:
      - "17000:17000"  # Proxy node 1 (host:container)
      - "17001:17001"  # Proxy node 2 (host:container)
      - "17002:17002"  # Proxy node 3 (host:container)
      - "17003:17003"  # Proxy node 4 (host:container) - hidden initially, swapped in during SMIGRATED
      - "18100:3000"  # HTTP API port (host:container)
    depends_on:
      - redis
    profiles:
      - e2e
      - all

  proxy-fault-injector:
    build:
      context: .
      dockerfile: maintnotifications/e2e/cmd/proxy-fi-server/Dockerfile
    container_name: proxy-fault-injector
    ports:
      - "15000:5000"  # Fault injector API port (host:container)
    depends_on:
      - cae-resp-proxy
    environment:
      - PROXY_API_URL=http://cae-resp-proxy:3000
    profiles:
      - e2e
      - all

  osscluster-tls:
    image: *default-image
    platform: linux/amd64
    container_name: redis-osscluster-tls
    environment:
      - NODES=6
      - PORT=6430
      - TLS_PORT=5430
      - TLS_ENABLED=yes
      - TLS_CLIENT_CNS=testcertuser
      - TLS_AUTH_CLIENTS_USER=CN
      - REDIS_CLUSTER=yes
      - REPLICAS=1
    command: "--tls-auth-clients optional --cluster-announce-ip 127.0.0.1"
    ports:
      - "6430-6435:6430-6435"      # Regular ports
      - "5430-5435:5430-5435"      # TLS ports (set via TLS_PORT env var)
      - "16430-16435:16430-16435"  # Cluster bus ports (PORT + 10000)
    volumes:
      - "./dockers/osscluster-tls:/redis/work"
    profiles:
      - cluster-tls
      - all

  sentinel-cluster:
    image: *default-image
    platform: linux/amd64
    container_name: redis-sentinel-cluster
    network_mode: "host"
    environment:
      - NODES=3
      - TLS_ENABLED=yes
      - TLS_CLIENT_CNS=testcertuser
      - TLS_AUTH_CLIENTS_USER=CN
      - REDIS_CLUSTER=no
      - PORT=9121
    command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --tls-auth-clients optional --save ""}
    #ports:
    #  - "9121-9123:9121-9123"
    volumes:
      - "./dockers/sentinel-cluster:/redis/work"
    profiles:
      - sentinel
      - all-stack
      - all

  sentinel:
    image: *default-image
    platform: linux/amd64
    container_name: redis-sentinel
    depends_on:
      - sentinel-cluster
    environment:
      - NODES=3
      - REDIS_CLUSTER=no
      - PORT=26379
    command: ${REDIS_EXTRA_ARGS:---sentinel}
    network_mode: "host"
    #ports:
    #  - 26379:26379
    #  - 26380:26380
    #  - 26381:26381
    volumes:
      - "./dockers/sentinel.conf:/redis/config-default/redis.conf"
      - "./dockers/sentinel:/redis/work"
    profiles:
      - sentinel
      - all-stack
      - all

  ring-cluster:
    image: *default-image
    platform: linux/amd64
    container_name: redis-ring-cluster
    environment:
      - NODES=3
      - TLS_ENABLED=yes
      - TLS_CLIENT_CNS=testcertuser
      - TLS_AUTH_CLIENTS_USER=CN
      - REDIS_CLUSTER=no
      - PORT=6390
    command: ${REDIS_EXTRA_ARGS:---enable-debug-command yes --enable-module-command yes --tls-auth-clients optional --save ""}
    ports:
      - "6390:6390"
      - "6391:6391"
      - "6392:6392"
    volumes:
      - "./dockers/ring:/redis/work"
    profiles:
      - ring
      - cluster
      - all-stack
      - all

File: README.md

Remark42 [](https://github.com/umputun/remark42/actions) [](https://hub.docker.com/r/umputun/remark42) [](https://goreportcard.com/report/github.com/umputun/remark42) [](https://coveralls.io/github/umputun/remark42?branch=master) [](https://app.codecov.io/gh/umputun/remark42)

Remark42 is a self-hosted, lightweight and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles, or any other place where readers add comments.

  • Social login via Google, Facebook, Microsoft, GitHub, Apple, Yandex, Patreon, Discord, Telegram and custom OAuth2 providers
  • Login via email
  • Optional anonymous access
  • Multi-level nested comments with both tree and plain presentations
  • Import from Disqus and WordPress
  • Markdown support with friendly formatter toolbar
  • Moderator can remove comments and block users
  • Voting, pinning and verification system
  • Sortable comments
  • Images upload with drag-and-drop
  • Extractor for recent comments, cross-post
  • RSS for all comments and each post
  • Telegram, Slack, Webhook and email notifications for Admins (get notified for each new comment)
  • Email and Telegram notifications for users (get notified when someone responds to your comment)
  • Export data to JSON with automatic backups
  • No external databases, everything embedded in a single data file
  • Fully dockerized and can be deployed in a single command
  • Self-contained executable can be deployed directly to Linux, Windows and macOS
  • Clean, lightweight and customizable UI with white and dark themes
  • Multi-site mode from a single instance
  • Integration with automatic SSL (direct and via nginx-le)
  • Privacy focused

Demo site available with all authentication methods, including email auth and anonymous access.

Screenshots

Comments example:

For admin screenshots see Admin UI documentation

All remark42 documentation is available by the link.

Contribution

In order to start and work on the project locally in development mode check our contribution documentation for backend and frontend.

If you are interested in adding a new localization please check these docs.


File: CLAUDE.md

Remark42 Development Guidelines

Build/Test/Lint Commands

  • Backend:
    • Run server: make rundev
    • Build: make backend
    • Race test: make race_test
  • Backend Testing:
    • Run all tests: cd backend/app && go test -timeout=300s -count 1 ./...
    • Run single test: cd backend/app && go test -run TestName ./path/to/package
    • IMPORTANT: Run example tests: cd backend/_example/memory_store && go test -race ./... && go build -race ./...
  • Frontend:
    • Development: cd frontend/apps/remark42 && pnpm dev
    • Tests: cd frontend/apps/remark42 && pnpm test
    • Dependency-free tests: cd frontend/apps/remark42 && pnpm test:unit.
      *.unit.test.ts files run straight from source with no node_modules, and the unit CI job
      installs nothing so a test that needs node_modules goes red. Their value imports write the
      extension in full (./types.ts) and use a relative path, never the common/… alias, because
      node resolves neither; a type-only import erases and keeps the ordinary style. See
      frontend/CLAUDE.md.
  • End-to-end: make e2e drives the widget in a real browser; see e2e/README.md. Build-tagged, so go test ./... never runs it.
  • Lint:
    • Backend: cd backend && golangci-lint run
    • IMPORTANT: Example lint: cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml
    • Frontend: cd frontend/apps/remark42 && pnpm lint
    • Before committing: Always run tests and linter on both main backend AND examples
  • Go module changes:
    • Any change to backend/go.mod or backend/go.sum requires go mod tidy in backend/_example/memory_store in the same commit. That covers dependency bumps, adding or removing a dependency, and changing the go directive, not only version updates.
    • Only go mod tidy there, not go mod vendor: the example's vendor directory is gitignored (.gitignore:26), so its output is never committed, while a stale local copy silently becomes what the example resolves against.
    • The example module replaces github.com/umputun/remark42/backend with ../../, so it carries the backend's dependencies as indirect entries. Leaving them stale fails the test examples CI step with go: updates to go.mod needed; to update it: go mod tidy.
    • This applies to Dependabot pull requests too: the bot updates backend/ only, so its Go module PRs need the example tidied before they can go green.

Backend Test Determinism

Backend tests must never depend on how fast the machine is. CI runs them under -race with coverage on a shared runner, so any test that assumes an operation finishes within some duration eventually fails on a rerun-and-it-passes basis.

  • Wait on a condition, never on a duration. Use require.Eventually / require.EventuallyWithT to poll for the state the assertion needs, and require.Never when the point is that something did not happen. A bare time.Sleep before an assertion is a defect; sleeping until a deadline you computed, as waitPastMillisecond does, is not.
  • Polling closures must not touch *testing.T. testify runs them on a separate goroutine, where t.FailNow is undefined behaviour. Assert on the *assert.CollectT that EventuallyWithT hands the closure, so the real error also lands in the failure message.
  • Mind the rate limiter when polling over HTTP. Route groups are capped independently and most of the caps are hard-coded in rest.go, out of reach of a test: /auth/ at 2 req/s and the admin, protected and image routes at 10 req/s. Only the open-route group is settable, via openRouteLimiter (100 in startupT). Poll with the existing constants rather than a new number, httpPoll for anything issuing an HTTP request and pollInterval only for in-process or filesystem checks, or the poll manufactures the 429s it then has to interpret.
  • When a test needs time to have passed, pin the clock input rather than waiting for it: os.Chtimes for file ages, an explicit store.Comment.Timestamp for anything that formats a timestamp.
  • Prefer a testing/synctest bubble where the code under test has no real I/O. Inside one the clock is fake, so time.Sleep is instant and deterministic. app/notify, app/store/service, app/store/image, app/store/engine, app/providers, app/migrator and _example/memory_store/accessor already use it, and most surviving time.Sleep calls live in them.
  • Helpers fail loudly. A wait that gives up must call t.Fatal/require naming what it was waiting for, never return silently and leave the next assertion to fail with something unrelated. Because these packages run goleak.VerifyTestMain, a failing helper also exits the test goroutine, so anything that started a server in a goroutine must defer cancel() or defer srv.Shutdown() right after launching it; otherwise a failed readiness wait is reported as a goroutine leak rather than the failure that caused it.
  • Take ports and paths from outside the test. Ports come from the kernel with net.Listen("tcp", ":0"), files from t.TempDir(). go test ./... runs package binaries concurrently, so a number out of a fixed range or a fixed name under /tmp lets two of them collide.
  • Close idle connections before shutting a test server down. Clients built as http.Client{Timeout: x} share http.DefaultTransport, and Shutdown waits on their keep-alive connections until its own deadline expires.
  • Keep the test timeout budgets aligned. Makefile, ci-backend.yml, release.yml and the command above all use -timeout=300s; the wait helpers allow 30s per condition, so a shorter per-package budget turns a slow runner into a timeout panic instead of a readable failure.

chooseUnusedPort and the server-start wait helpers are duplicated in app, app/cmd, app/rest/api and _example/memory_store/server. Nothing shares them today; keep the copies in step when changing one.

Release Procedure

Remark42 uses two tags for each release:

  • vX.Y.Z - product release tag used by GitHub releases, GoReleaser binary artifacts, and Docker image publishing.
  • backend/vX.Y.Z - nested Go module tag for github.com/umputun/remark42/backend.

Release flow:

  1. Create the GitHub release for vX.Y.Z with title Version X.Y.Z. The GitHub release must exist before the vX.Y.Z tag reaches the remote; gh release create vX.Y.Z satisfies this because it creates and pushes the tag.
  2. The vX.Y.Z tag triggers GoReleaser, which builds and uploads binary artifacts to the existing release.
  3. Create and push the matching backend module tag pointing at the same commit:
bash
git fetch origin --tags
git tag backend/vX.Y.Z vX.Y.Z
git push origin backend/vX.Y.Z

GoReleaser must ignore backend/* tags in .goreleaser.yml so release notes and current-tag detection use only product tags. Docker image publishing stays separate and is handled by the existing Docker workflow.

For local artifact runs, install GoReleaser, Go 1.27, Node 24+ and PNPM 10, then use make release. The target runs a snapshot/no-publish GoReleaser build, leaves local artifacts and metadata in dist/, and cleans generated frontend embed files after GoReleaser exits. Do not run raw goreleaser release for local artifacts unless you also run ./scripts/cleanup-release-assets.sh afterward.

Milestones and Issue Labels

Milestones — one vX.Y.Z milestone per release. Assign every merged PR, and every issue closed by a code change, to the milestone of the release it shipped in.

  • Decide which release a PR belongs to by whether its merge commit is contained in a release tag — not by comparing dates (a tag can be cut from an earlier commit, or moved). git fetch --tags, then git tag --contains <merge_sha> | grep '^v' | sort -V | head -1 is its release. If no release tag contains it yet, it belongs to the next (unreleased) version's milestone — create it if missing (gh api repos/umputun/remark42/milestones -f title="vX.Y.Z").
  • An issue gets a milestone only when it was closed by a code change (a linked closing PR/commit); take the milestone from that PR/commit (via the commit-in-tag rule). Issues closed as duplicate/invalid/wontfix/answered get no milestone.
  • Find unassigned: gh pr list --state merged --search "no:milestone", gh issue list --state closed --search "no:milestone". Assign with gh pr edit N --milestone "vX.Y.Z" / gh issue edit N --milestone "vX.Y.Z".

Issue labels — classify each issue with a type and an area (add priority when relevant):

  • Type: bug, enhancement, question, documentation, discussion
  • Area: backend, frontend, site, CI, design, localization
  • Priority: important, minor, some day
  • Contribution: help wanted, good-first-issue
  • Resolution (on close, when applicable): duplicate, invalid, wontfix, no-action-needed
  • PR auto-labels (applied by Dependabot/Actions, not manual PRs): dependencies, go, javascript, github_actions

Code Style

  • Backend: Formatting with golangci-lint, strict error handling
  • Frontend: TypeScript with ESLint, Stylelint and Prettier
  • Imports: Group stdlib, external packages, then internal packages
  • CSS: All components use CSS Modules (component.module.css). Class naming: BEM block = .root, elements = camelCase, modifiers = camelCase. Use clsx for conditional class composition. raw-content.css is the only global CSS file (syntax highlighting utility). Root wrapper keeps bare .dark/.light theme class — 8+ module CSS files depend on :global(.dark) ancestor. comment_highlighting uses :global() for imperative classList usage in root.tsx

Key Backend Packages

  • Web/API: github.com/go-pkgz/routegroup, github.com/go-pkgz/rest
  • Auth: github.com/go-pkgz/auth/v2
  • Logging: github.com/go-pkgz/lgr
  • Testing: github.com/stretchr/testify
  • Notifications: github.com/go-pkgz/notify

Repository Structure

  • Backend: Go server using BoltDB for storage
  • Frontend: Preact/Redux-based UI with iframe embedding
  • /web is served from two sources, in lookup order: the frontend build output
    (frontend/apps/remark42/public, embedded at backend/app/cmd/web or read from --web-root),
    then backend/app/webassets/assets, embedded in the binary. A plain page or image the bundler
    does not process belongs in webassets; anything needing templating or the widget's CSS/JS goes
    through webpack. A name present in both is served from the frontend build.

File: codecov.yml

The project gate fails on any drop by default. The frontend suite is shedding jest cases whose

behaviour the browser suite asserts instead, and codecov measures only the jest half, so every

such removal reads as a drop. Half a percent is enough to pass a deliberate removal and still

fail a change that stops covering something.

coverage:
status:
project:
default:
threshold: 0.5%


File: compose-dev-backend.yml

compose file for local development

starts backend on 8080 with basic auth "dev:password" and Dev oauth2 provider on port 8084

UI on http://127.0.0.1:8080/web

build remark42 docker image - docker compose -f compose-dev-backend.yml build

start remark42 service - docker compose -f compose-dev-backend.yml up

services:
remark42:
build:
context: .
dockerfile: Dockerfile
args:
- SKIP_BACKEND_TEST
- BACKEND_TEST_TIMEOUT
- SKIP_FRONTEND_TEST=true
- CI
- GITHUB_REF
- GITHUB_SHA
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-dev"
hostname: "remark42-dev"

text
logging:
  driver: json-file
  options:
    max-size: "10m"
    max-file: "5"

ports:
  - "8080:8080" # primary rest server
  - "8084:8084" # local oauth2 server

environment:
  # - APP_UID=1000
  # - TIME_ZONE=GMT
  # REMARK_URL below would be used for the dev auth,
  # so that it would work on http://127.0.0.1 but wouldn't on http://localhost
  - REMARK_URL=http://127.0.0.1:8080
  - SECRET=12345
  - DEBUG=true
  - ADMIN_PASSWD=password
  - AUTH_DEV=true # activate local OAuth "dev" on REMARK_URL hostname
  - ADMIN_SHARED_ID=dev_user # set admin flag for default user on local oauth2
  - NOTIFY_USERS
  - NOTIFY_ADMINS
  - TELEGRAM_TOKEN
  - NOTIFY_TELEGRAM_CHAN
  - NOTIFY_EMAIL_FROM
  - ADMIN_SHARED_EMAIL
  - SMTP_HOST
  - SMTP_HELO_HOST
  - SMTP_USERNAME
  - SMTP_PASSWORD
  - SMTP_PORT
  - SMTP_TLS
  - EMOJI=true
  - ANON_VOTE=true
  - VOTES_IP=true
  - AUTH_EMAIL_ENABLE=true
  - AUTH_TELEGRAM=true
  - AUTH_ANON=true
  - AUTH_GOOGLE_CID=1111
  - AUTH_GOOGLE_CSEC=1111
  - AUTH_GITHUB_CID=1111
  - AUTH_GITHUB_CSEC=1111
  - AUTH_FACEBOOK_CID=1111
  - AUTH_FACEBOOK_CSEC=1111
  - AUTH_PATREON_CID=1111
  - AUTH_PATREON_CSEC=1111
  - AUTH_DISCORD_CID=1111
  - AUTH_DISCORD_CSEC=1111
volumes:
  - ./var:/srv/var

File: compose-dev-frontend.yml

compose file for local development

starts backend on 8080 with basic auth "dev:password" and Dev oauth2 provider on port 8084

UI on http://127.0.0.1:8080/web

build remark42 docker image - docker compose -f compose-dev-frontend.yml build

start remark42 service - docker compose -f compose-dev-frontend.yml up

services:
remark42:
build:
context: .
dockerfile: Dockerfile
args:
- SKIP_BACKEND_TEST=true
- SKIP_FRONTEND_BUILD=true
# - NODE_ENV=development

text
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-dev"
hostname: "remark42-dev"

logging:
  driver: json-file
  options:
    max-size: "10m"
    max-file: "5"

ports:
  - "8080:8080" # primary rest server
  - "8084:8084" # local oauth2 server

environment:
  # REMARK_URL below would be used for the dev auth,
  # so that it would work on http://127.0.0.1 but wouldn't on http://localhost
  - REMARK_URL=http://127.0.0.1:8080
  - SECRET=12345
  - DEBUG=true
  - ADMIN_PASSWD=password
  - AUTH_DEV=true # activate local OAuth "dev" on REMARK_URL hostname
  - ADMIN_SHARED_ID=dev_user # set admin flag for default user on local oauth2
  - AUTH_ANON=true
  - AUTH_EMAIL_ENABLE=true
volumes:
  - ./var:/srv/var

File: compose-e2e-coverage.yml

coverage overlay for the e2e stack, used only by `make e2e-cover`.

Kept out of compose-e2e-test.yml on purpose. A bind mount in the short form creates its host

path if it is missing, so carrying these in the base file would have an ordinary `make e2e`

create e2e/coverage itself, owned by whatever the daemon runs as. A plain run must leave no

trace of coverage at all.

Only the first service carries the build argument: every instance runs the one image the first

builds, so instrumenting it instruments all of them.

services:
remark42:
build:
args:
- COVERAGE=1
environment:
- GOCOVERDIR=/coverage
volumes:
- ./e2e/coverage/remark42:/coverage

remark42-telegram:
environment:
- GOCOVERDIR=/coverage
volumes:
- ./e2e/coverage/remark42-telegram:/coverage

remark42-shortedit:
environment:
- GOCOVERDIR=/coverage
volumes:
- ./e2e/coverage/remark42-shortedit:/coverage

remark42-adminedit:
environment:
- GOCOVERDIR=/coverage
volumes:
- ./e2e/coverage/remark42-adminedit:/coverage

remark42-jwtheader:
environment:
- GOCOVERDIR=/coverage
volumes:
- ./e2e/coverage/remark42-jwtheader:/coverage

remark42-noauth:
environment:
- GOCOVERDIR=/coverage
volumes:
- ./e2e/coverage/remark42-noauth:/coverage

remark42-anonvote:
environment:
- GOCOVERDIR=/coverage
volumes:
- ./e2e/coverage/remark42-anonvote:/coverage

remark42-https:
environment:
- GOCOVERDIR=/coverage
volumes:
- ./e2e/coverage/remark42-https:/coverage


File: compose-e2e-test.yml

compose for the e2e suite; the tests themselves run on the host, see e2e/

TRUSTED_PROXY stays unset on every instance. The loopback-only harness gives each browser context

a distinct X-Real-IP, and the backend honours a forwarding header from any client only while no

trusted proxy is configured. Setting one collapses every context onto the docker gateway address

and puts the whole parallel run in one rate-limit and vote-dedup bucket.

every port is bound to the loopback interface on purpose. this stack runs with a known

secret, dev oauth2 and an admin shared id, and `go test` can start it unattended, so it

must not be reachable from the network. the suite only ever talks to 127.0.0.1.

services:
remark42:
build:
context: .
dockerfile: Dockerfile
args:
- SKIP_BACKEND_TEST=true
- SKIP_FRONTEND_TEST=true

text
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e"

ports:
  - "127.0.0.1:8080:8080"
  # dev oauth2 provider, registered from the REMARK_URL hostname
  - "127.0.0.1:8084:8084"

# the hostname has to be a name, not 127.0.0.1: the dev oauth2 server binds the
# host it reads out of REMARK_URL, and a loopback bind inside the container cannot be
# published. the suite maps the name back to 127.0.0.1 in the browser, see e2e/
environment:
  - REMARK_URL=http://remark42:8080
  - SECRET=12345
  - DEBUG=true
  - ADMIN_PASSWD=password
  - AUTH_DEV=true # local oauth2 "dev" provider, bound to the REMARK_URL host on :8084
  # ADMIN_EDIT stays off: it gives admins an infinite edit window, which removes the
  # countdown TestComment_EditWithinTheDeadline asserts on
  - ADMIN_SHARED_ID=dev_user # set admin flag for default user on local oauth2
  - AUTH_ANON=true
  - AUTH_EMAIL_ENABLE=true
  # the auth sender reads AUTH_EMAIL_FROM, the notify module reads NOTIFY_EMAIL_FROM. both
  # go to mailpit, which the suite reads back over HTTP
  - [email protected]
  # without this email_notifications is false and the subscribe control never renders, so
  # the whole subscribe, confirm and unsubscribe flow is unreachable from a browser
  - NOTIFY_USERS=email
  - [email protected]
  - SMTP_HOST=mailpit
  - SMTP_PORT=1025
  # default is 0.5/sec, which the suite exceeds whenever a test posts twice in a row
  - UPDATE_LIMIT=100
  # digest of the sources this stack was brought up from, from e2e/stamp.sh, which the suite
  # reads back and compares against the checkout it is running in. every checkout builds the
  # image tag above, so a stack from another worktree answers on these ports and passes every
  # readiness probe while serving code nobody is looking at. remark42 ignores it.
  #
  # set as the environment and not as a build argument on purpose: an argument reaching
  # the backend stage is part of the go build's cache key, so every frontend edit would
  # rebuild the backend too. the cost is that `docker compose up` without --build would stamp
  # a stale image, which neither `make e2e-up` nor the suite does
  - E2E_SOURCE_STAMP=${E2E_STAMP:-unstamped}
volumes:
  - remark42-e2e-var:/srv/var
depends_on:
  mailpit:
    condition: service_healthy
healthcheck:
  test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
  interval: 2s
  timeout: 3s
  retries: 30

a second instance whose edit window expires almost immediately, so the expired-edit

path can be exercised without holding a test open for the default five minutes

remark42-shortedit:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-shortedit"
# this service has no build of its own, so without this a pull could substitute the
# published image for the one built from this checkout
pull_policy: never
# only needs the image the first service builds, not a running one
depends_on:
remark42:
condition: service_started

text
ports:
  - "127.0.0.1:8081:8080"

# anonymous only: the dev oauth2 provider's port is hardcoded to 8084 against the
# REMARK_URL hostname, so a second instance on the same host would register the
# first one's provider
environment:
  - REMARK_URL=http://remark42-shortedit:8081
  - SECRET=12345
  - AUTH_ANON=true
  # long enough that the post round trip and the first assertion comfortably fit inside
  # the window, short enough that waiting it out costs a few seconds
  - EDIT_TIME=15s
  - UPDATE_LIMIT=100
volumes:
  - remark42-e2e-shortedit-var:/srv/var
healthcheck:
  test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
  interval: 2s
  timeout: 3s
  retries: 30

ADMIN_EDIT gives admins an unlimited edit window, which #1986 reported the frontend ignoring.

a separate instance because the setting is global and removes the countdown the ordinary

deadline cases assert on

remark42-adminedit:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-adminedit"
pull_policy: never
depends_on:
remark42:
condition: service_started
mailpit:
condition: service_healthy

text
ports:
  - "127.0.0.1:8082:8080"

environment:
  - REMARK_URL=http://remark42-adminedit:8082
  - SECRET=12345
  - ADMIN_EDIT=true
  # email auth, because its user id is the only one that can be written down ahead of time.
  # the dev provider's port is fixed at 8084 and cannot be published twice, and the anonymous
  # id is hashed from the name AND the client address (server.go:1220), which differs between
  # a laptop and a runner, so an id pinned here would make an admin of nobody on CI.
  # the email id is sha1 of the address alone (go-pkgz/auth provider/verify.go:92), so this is
  # email_$(printf [email protected] | shasum) and the test signs in as that address
  - AUTH_EMAIL_ENABLE=true
  - [email protected]
  - SMTP_HOST=mailpit
  - SMTP_PORT=1025
  - ADMIN_SHARED_ID=email_0212c0d9ebf846e898888d617f82d4147747bdc3
  # short, so "after the deadline" is a few seconds instead of the default five minutes
  - EDIT_TIME=15s
  - UPDATE_LIMIT=100
volumes:
  - remark42-e2e-adminedit-var:/srv/var
healthcheck:
  test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
  interval: 2s
  timeout: 3s
  retries: 30

keep for itself, which #1877 reported losing on every reload

remark42-jwtheader:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-jwtheader"
pull_policy: never
depends_on:
remark42:
condition: service_started

text
ports:
  - "127.0.0.1:8083:8080"

environment:
  - REMARK_URL=http://remark42-jwtheader:8083
  - SECRET=12345
  - AUTH_ANON=true
  - AUTH_SEND_JWT_HEADER=true
  - UPDATE_LIMIT=100
volumes:
  - remark42-e2e-jwtheader-var:/srv/var
healthcheck:
  test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
  interval: 2s
  timeout: 3s
  retries: 30

answers as the Telegram bot API, so the auth flow that ends inside Telegram becomes drivable.

the provider's configurable base url directs both authentication and notifications here

telegram-stub:
build:
context: ./e2e/telegramstub
container_name: "remark42-e2e-telegram-stub"
ports:
- "127.0.0.1:8091:8091"
environment:
- STUB_ADDR=:8091
- STUB_BOT_USERNAME=remark42_e2e_bot
# the token the stub demands in the path, and the one the instance below presents
- STUB_BOT_TOKEN=stub-token
healthcheck:
test: ["CMD", "curl", "--fail", "http://localhost:8091/botstub-token/getMe"]
interval: 2s
timeout: 3s
retries: 30

telegram auth on its own instance: adding it to the main one would change the provider list

every other case reads out of the auth panel

remark42-telegram:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-telegram"
pull_policy: never
depends_on:
remark42:
condition: service_started
telegram-stub:
condition: service_healthy

text
ports:
  - "127.0.0.1:8087:8080"

environment:
  - REMARK_URL=http://remark42-telegram:8087
  - SECRET=12345
  - AUTH_TELEGRAM=true
  - TELEGRAM_TOKEN=stub-token
  # the whole reason this instance exists: every bot call goes to the stub instead of Telegram
  - TELEGRAM_API_URL=http://telegram-stub:8091
  # notifications through the same stub, which is what makes the telegram subscription flow
  # drivable: the update poll runs through the notify service, so both halves have to agree
  - NOTIFY_USERS=telegram
  - UPDATE_LIMIT=100
volumes:
  - remark42-e2e-telegram-var:/srv/var
healthcheck:
  test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
  interval: 2s
  timeout: 3s
  retries: 30

no auth provider at all, which #1456 reported rendering as a widget with nothing to say. it

cannot be a setting on another instance: the absence is the configuration

remark42-noauth:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-noauth"
pull_policy: never
depends_on:
remark42:
condition: service_started

text
ports:
  - "127.0.0.1:8085:8080"

environment:
  - REMARK_URL=http://remark42-noauth:8085
  - SECRET=12345
  # its own address and nothing else, so this instance also serves as the negative case for
  # embedding: the browser refuses to frame it anywhere but here, and the widget document
  # never runs to report itself inited
  - ALLOWED_HOSTS=http://remark42-noauth:8085
  - UPDATE_LIMIT=100
volumes:
  - remark42-e2e-noauth-var:/srv/var
healthcheck:
  test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
  interval: 2s
  timeout: 3s
  retries: 30

anonymous voting, which the default configuration refuses: rest_private.go turns down a vote

from an anonymous user unless ANON_VOTE is set, and it only works alongside VOTES_IP, which

is what scopes it

remark42-anonvote:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-anonvote"
pull_policy: never
depends_on:
remark42:
condition: service_started

text
ports:
  - "127.0.0.1:8086:8080"

environment:
  - REMARK_URL=http://remark42-anonvote:8086
  - SECRET=12345
  - AUTH_ANON=true
  - ANON_VOTE=true
  - VOTES_IP=true
  - UPDATE_LIMIT=100
volumes:
  - remark42-e2e-anonvote-var:/srv/var
healthcheck:
  test: ["CMD", "curl", "--fail", "http://localhost:8080/ping"]
  interval: 2s
  timeout: 3s
  retries: 30

a page on an origin the widget is not served from, which is the separate-domain setup the

manuals describe and the one readers actually hit problems with. every other host page in the

suite is served by remark42 itself, so nothing else exercises a cross-origin embed

host-site:
image: nginx:1.29-alpine
container_name: "remark42-e2e-hostsite"
ports:
- "127.0.0.1:8090:80"
volumes:
- ./e2e/hostsite:/usr/share/nginx/html:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost/post.html"]
interval: 2s
timeout: 3s
retries: 30

the same widget over TLS, which is the only way to reach anything the browser gates on the

page protocol: Secure cookies, SameSite=None, Partitioned, and any code reading

location.protocol. The certificate is self-signed and the suite passes IgnoreHTTPSErrors

remark42-https:
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-e2e-https"
pull_policy: never
depends_on:
remark42:
condition: service_started
mailpit:
condition: service_started

text
ports:
  - "127.0.0.1:8443:8443"

environment:
  - REMARK_URL=https://remark42-https:8443
  - SECRET=12345
  - AUTH_ANON=true
  - SSL_TYPE=static
  - SSL_PORT=8443
  - SSL_CERT=/srv/tls/cert.pem
  - SSL_KEY=/srv/tls/key.pem
  # the widget is embedded from another origin here, so its cookies have to survive a
  # third-party frame; this is the setting that decides whether they do
  - AUTH_SAME_SITE=none
  # and the token arrives in a header, so the widget writes the cookies itself through
  # setAuthCookie. Without this the client-side writer never runs on any https page in the
  # stack, and every assertion about the attributes it chooses is vacuous
  - AUTH_SEND_JWT_HEADER=true
  # email as well as anonymous, so the flow most operators run off-domain is measured here
  # and not inferred from the anonymous one. the writer keys off the X-JWT header rather than
  # off the provider, which is exactly the assumption a case signing in by email checks
  - AUTH_EMAIL_ENABLE=true
  - [email protected]
  - SMTP_HOST=mailpit
  - SMTP_PORT=1025
  - UPDATE_LIMIT=100
volumes:
  - remark42-e2e-https-var:/srv/var
  - ./e2e/tls:/srv/tls:ro
healthcheck:
  test: ["CMD", "curl", "--fail", "--insecure", "https://localhost:8443/ping"]
  interval: 2s
  timeout: 3s
  retries: 30

and a host page for it, on its own name, so the embed is genuinely cross-site over TLS

host-site-https:
image: nginx:1.29-alpine
container_name: "remark42-e2e-hostsite-https"
ports:
- "127.0.0.1:8444:443"
volumes:
- ./e2e/hostsite:/usr/share/nginx/html:ro
- ./e2e/tls:/etc/nginx/tls:ro
- ./e2e/tls/nginx-tls.conf:/etc/nginx/conf.d/default.conf:ro
healthcheck:
# 127.0.0.1 and not localhost: nginx listens on IPv4 only and busybox wget tries ::1 first
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/post-https.html"]
interval: 2s
timeout: 3s
retries: 30

catches the email-auth verification message; the suite reads it back over the HTTP API

mailpit:
image: axllent/mailpit:v1.30.7
container_name: "remark42-e2e-mailpit"
ports:
- "127.0.0.1:8025:8025"
# without this --wait returns as soon as the container starts, and remark42 can be
# healthy before anything is listening on 1025
healthcheck:
test: ["CMD", "/mailpit", "readyz"]
interval: 2s
timeout: 3s
retries: 30

named volumes, not bind mounts, so `docker compose down -v` really does discard the

databases and a run can start from an empty site

volumes:
remark42-e2e-var:
remark42-e2e-shortedit-var:
remark42-e2e-adminedit-var:
remark42-e2e-telegram-var:
remark42-e2e-jwtheader-var:
remark42-e2e-noauth-var:
remark42-e2e-anonvote-var:
remark42-e2e-https-var:


File: docker-compose.yml

services:
remark:
# remove the next line in case you want to use this Docker Compose file separately
# as otherwise it would complain for absence of Dockerfile
build: .
image: ghcr.io/umputun/remark42:latest
container_name: "remark42"
hostname: "remark42"
restart: always

text
logging:
  driver: json-file
  options:
    max-size: "10m"
    max-file: "5"

# uncomment to expose directly (no proxy)
#ports:
#  - "80:8080"
#  - "443:8443"

# uncomment to expose to reverse proxy running on the same host but not in docker
#ports:
#  - "127.0.0.1:8080:8080"

environment:
  - REMARK_URL
  - SECRET
  - DEBUG=true
  - AUTH_GOOGLE_CID
  - AUTH_GOOGLE_CSEC
  - AUTH_GITHUB_CID
  - AUTH_GITHUB_CSEC
  - AUTH_FACEBOOK_CID
  - AUTH_FACEBOOK_CSEC
  - AUTH_DISQUS_CID
  - AUTH_DISQUS_CSEC
  # Enable it only for the initial comment import or for manual backups.
  # Do not leave the server running with the ADMIN_PASSWD set if you don't have an intention
  # to keep creating backups manually!
  # - ADMIN_PASSWD=<your secret password>
volumes:
  - ./var:/srv/var

File: SECURITY.md

Security Policy

Supported Versions

We release patches for security vulnerabilities.

Version Supported
current :white_check_mark:
>=1.6.x :white_check_mark:
<1.5.x :x:

Reporting a Vulnerability

Please report (suspected) security vulnerabilities either by using GitHub's private vulnerability reporting (click the "Report a vulnerability" button on the Security tab) or by emailing [email protected]. You will receive a response within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.


File: .goreleaser.yml

version: 2

project_name: remark42

git:
ignore_tags:
- backend/*

before:
hooks:
- ./scripts/prepare-release-assets.sh

builds:

  • id: remark42
    dir: backend
    main: ./app
    binary: "remark42.{{ .Os }}-{{ .Arch }}"
    env:
    • CGO_ENABLED=0
      goos:
    • linux
    • darwin
    • freebsd
    • windows
      goarch:
    • amd64
    • arm64
    • "386"
      ignore:
    • goos: darwin
      goarch: "386"
    • goos: freebsd
      goarch: arm64
    • goos: freebsd
      goarch: "386"
    • goos: windows
      goarch: arm64
    • goos: windows
      goarch: "386"
      ldflags:
    • -s -w -X main.revision={{ .Tag }}-{{ .ShortCommit }}-{{ trimsuffix (replace (replace .CommitDate "-" "") ":" "") "Z" }}

archives:

  • id: remark42
    ids:
    • remark42
      name_template: "{{ .ProjectName }}.{{ .Os }}-{{ .Arch }}"
      formats:
    • tar.gz
      format_overrides:
    • goos: windows
      formats:
      • zip
        files:
    • LICENSE
    • README.md

release:
name_template: "Version {{ .Version }}"
mode: keep-existing


File: backend/.golangci.yml

version: "2"
linters:
default: none
enable:
- bodyclose
- copyloopvar
- dupl
- gochecknoinits
- gocritic
- gocyclo
- gosec
- govet
- ineffassign
- misspell
- nakedret
- prealloc
- revive
- staticcheck
- unconvert
- unparam
- unused
settings:
gosec:
excludes:
- G117 # false positive: struct field name matches "secret" pattern
gocritic:
disabled-checks:
- wrapperFunc
- hugeParam
- rangeValCopy
enabled-tags:
- performance
- style
- experimental
govet:
enable:
- shadow
misspell:
locale: US
exclusions:
generated: lax
rules:
- linters:
- staticcheck
text: at least one file in a package should have a package comment
- linters:
- revive
text: 'package-comments: should have a package comment'
- linters:
- revive
text: 'var-naming: avoid meaningless package names'
- linters:
- revive
text: 'var-naming: avoid package names that conflict with Go standard library package names'
- linters:
- dupl
- gosec
path: _test.go
paths:
- vendor
- third_party$
- builtin$
- examples$
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$


File: backend/_example/memory_store/README.md

sample store implementation

memory_store illustrates how to make a custom storage plugin for remark42.

In order to run remark42 with memory_store copy provided compose-dev-memstore.yml to the root directory and run:

  1. docker compose -f compose-dev-memstore.yml build
  2. docker compose -f compose-dev-memstore.yml up

As usual, demo site will run on http://127.0.0.1:8080/web/

note: in order to work with the latest (current) version of master go.mod uses replacement directive for the backend package. In real-life usage replace github.com/umputun/remark42/backend => ../../ should not be used.


File: backend/_example/memory_store/compose-dev-memstore.yml

compose file demonstrating custom storage use. The memory_store (see backend/_example/memory_store) starts

in a separate container and remark42 communicates to mem_store.r42 via STORE_RPC_API url

version: "2"

services:
remark42:
build:
context: ../../..
dockerfile: Dockerfile
args:
- SKIP_BACKEND_TEST=true
- SKIP_FRONTEND_TEST=true
image: ghcr.io/umputun/remark42:dev
container_name: "remark42-dev"
hostname: "remark42-dev"
restart: always

text
logging:
  driver: json-file
  options:
    max-size: "10m"
    max-file: "5"

ports:
  - "8080:8080" # primary rest server
  - "8084:8084" # local oauth2 server

environment:
  - REMARK_URL=http://127.0.0.1:8080
  - SECRET=123456
  - DEBUG=true
  - EMOJI=true
  - AUTH_ANON=true
  - AUTH_DEV=true
  - ADMIN_TYPE=rpc
  - ADMIN_RPC_API=http://mem_store.r42:8080/cmd
  - STORE_TYPE=rpc
  - STORE_RPC_API=http://mem_store.r42:8080/cmd
  - IMAGE_TYPE=rpc
  - IMAGE_RPC_API=http://mem_store.r42:8080/cmd

volumes:
  - ../../../var:/srv/var

mem_store.r42:
image: umputun/mem_store.r42
build:
context: ../../..
dockerfile: backend/_example/memory_store/Dockerfile
container_name: "mem_store.r42"
hostname: "mem_store.r42"

text
logging:
  driver: json-file
  options:
    max-size: "10m"
    max-file: "5"

environment:
  - API=/cmd
  - SECRET=123456
  - DEBUG=true

File: backend/app/cmd/testdata/import.txt

blah
blah2
12345678


File: backend/app/cmd/testdata/remap_urls.txt

http://oldsite.com* https://newsite.com*
http://oldsite.com/from-old-page/1 https://newsite.com/to-new-page/1


File: backend/app/migrator/testdata/commento.json

{
"version": 1,
"comments": [
{
"commentHex": "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945",
"domain": "example.com",
"url": "/blog/post/2",
"commenterHex": "anonymous",
"markdown": "Example comment created by user.",
"html": "",
"parentHex": "root",
"score": 1,
"state": "approved",
"creationDate": "2021-03-12T11:21:56Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
"domain": "example.com",
"url": "/blog/post/1",
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
"markdown": "Example 2 comment created by user.",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2021-03-17T12:09:47.722181Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9",
"domain": "example.com",
"url": "/blog/post/1",
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
"markdown": "Great reply!",
"html": "",
"parentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
"score": 0,
"state": "approved",
"creationDate": "2021-05-11T15:43:01.852651Z",
"direction": 0,
"deleted": false
}
],
"commenters": [
{
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
"email": "[email protected]",
"name": "User5276",
"link": "https://example.com/profile/257",
"photo": "https://secure.gravatar.com/avatar/8f279626d26175134b0d5c88648172f7",
"provider": "sso:example.com",
"joinDate": "2021-03-19T19:27:25.954285Z",
"isModerator": false
},
{
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
"email": "[email protected]",
"name": "Saturnin Uf",
"link": "https://example.com/profile/259",
"photo": "https://secure.gravatar.com/avatar/6481228d190f0286a42bee9041f9b1a1",
"provider": "sso:example.com",
"joinDate": "2021-03-21T12:15:37.536035Z",
"isModerator": false
}
]
}


File: backend/app/rest/api/testdata/commento.json

{
"version": 1,
"comments": [
{
"commentHex": "e7a2ef4b4aa1414a7ee65a989889aaecd9d5e7e3bca598ea7a967b4dbcaa8e11",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2022-10-25T07:25:46.807555Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "a29e741145daceb4ca5b3e5e279e05b56f73c04703d93b944718ef757e15317f",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2023-07-26T12:24:55.058552Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "46baf36433830a4e8bda1de56290cf5fd74c08bfa844fee4ec1744985dc77010",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2023-10-31T11:03:25.403282Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "6d3bb64ff73b5f9d6a959212ffde472a51abf8bdefaa5ed843659796bceef9de",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "46baf36433830a4e8bda1de56290cf5fd74c08bfa844fee4ec1744985dc77010",
"score": 0,
"state": "approved",
"creationDate": "2023-11-01T22:23:47.112062Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "23fcfcd03745ed71a9d23a9b59387a313df57e5c0faad8ba5dc96112766312c5",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2023-10-23T12:33:03.370182Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "d0ad6f11cf0c5f8e17457a378a6bb789f412c6b7ef7ada4ae06ec8451f7a18aa",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "root",
"score": 0,
"state": "approved",
"creationDate": "2023-10-18T01:18:38.193625Z",
"direction": 0,
"deleted": false
},
{
"commentHex": "098960fd01c1fc7c0d3ea428f52fab97ea5c18aa52f3565bba679224daddc687",
"domain": "example.com",
"url": "/example",
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"markdown": "",
"html": "",
"parentHex": "23fcfcd03745ed71a9d23a9b59387a313df57e5c0faad8ba5dc96112766312c5",
"score": 0,
"state": "approved",
"creationDate": "2023-11-01T22:24:04.639965Z",
"direction": 0,
"deleted": false
}
],
"commenters": [
{
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
"email": "undefined",
"name": "blank",
"link": "undefined",
"photo": "undefined",
"provider": "anon",
"joinDate": "2022-06-09T15:54:29.865919Z",
"isModerator": false,
"deleted": false
}
]
}


File: backend/app/store/service/testdata/tree.json

{
"comments": [{
"comment": {
"id": "1",
"pid": "",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:46:01Z"
},
"replies": [{
"comment": {
"id": "11",
"pid": "1",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:46:11Z"
}
},
{
"comment": {
"id": "12",
"pid": "1",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:46:12Z"
}
},
{
"comment": {
"id": "13",
"pid": "1",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:46:13Z"
},
"replies": [{
"comment": {
"id": "131",
"pid": "13",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:46:31Z"
}
},
{
"comment": {
"id": "132",
"pid": "13",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:46:32Z"
}
}
]
},
{
"comment": {
"id": "14",
"pid": "1",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:46:14Z"
}
}
]
},
{
"comment": {
"id": "2",
"pid": "",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:47:02Z"
},
"replies": [{
"comment": {
"id": "21",
"pid": "2",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:47:21Z"
}
},
{
"comment": {
"id": "22",
"pid": "2",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:47:22Z"
}
}
]
},
{
"comment": {
"id": "4",
"pid": "",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:47:22Z"
}
},
{
"comment": {
"id": "3",
"pid": "",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:47:22Z"
}
}
]
}


File: backend/app/store/service/testdata/tree_bench.json

[{
"id": "3665976683",
"pid": "",
"text": "Новая реклама - позор! Отписка, немедленно!!111",
"user": {
"name": "bobuk",
"id": "grigorybakunov",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 1,
"votes": {
"dev": true
},
"time": "2017-12-17T04:47:50Z"
},
{
"id": "3665991835",
"pid": "",
"text": "cmd умеет копировать текст мышкой, вот что реально в нем не хватает это поддержка emoji. Ну вместо аяксов придумали <a href="https://hyper.is/\" rel="nofollow">https://hyper.is/ который написан на веб технологиях 😊",
"user": {
"name": "sultan99",
"id": "sultanarziev",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": -1,
"votes": {
"dev": false
},
"time": "2017-12-17T05:12:59Z"
},
{
"id": "3666020878",
"pid": "3665976683",
"text": "Проведите с Греем воспитательную беседу, чтобы больше таких позоров не было.",
"user": {
"name": "zedxxx",
"id": "zedxxx",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 1,
"votes": {
"dev": true
},
"time": "2017-12-17T06:05:20Z"
},
{
"id": "3666132431",
"pid": "",
"text": "Это забавно, но не очень приятно. Примерно как если бы хабре на главной был баннер на пол страницы с тематикой типа "Увеличение члена" или "Максим Галкин выходит замуж".",
"user": {
"name": "visortelle",
"id": "visortelle",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": -1,
"votes": {
"dev": false
},
"time": "2017-12-17T09:59:12Z"
},
{
"id": "3666627554",
"pid": "3665976683",
"text": "Это прикол такой, я надеюсь",
"user": {
"name": "1235711",
"id": "import_1235711",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": -1,
"votes": {
"dev": false
},
"time": "2017-12-17T18:38:31Z"
},
{
"id": "3666695848",
"pid": "",
"text": "И после этого вы будете говорить, что Грэй не бесполезен? Или теперь в принципе всё равно, чего и сколько рекламировать?",
"user": {
"name": "Andrew I.",
"id": "andrew_i",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-17T19:33:09Z"
},
{
"id": "3666697340",
"pid": "3666132431",
"text": "Аналогия хуёвая, примерно как на Хабре.",
"user": {
"name": "2538",
"id": "kpmy",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-17T19:34:25Z"
},
{
"id": "3666698618",
"pid": "3666695848",
"text": "Капитализм это когда личные корыстные устремления всяких подонков в итоге приводят к общему благу. Чего не так-то? Казино и казино.",
"user": {
"name": "2538",
"id": "kpmy",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-17T19:35:29Z"
},
{
"id": "3666702637",
"pid": "3665976683",
"text": "Полнейший восторг!Обожаю вас ребята!)Было бы концептуально включить вообще без предупреждения)Типа, первые несколько секунд - "А! Что вообще происходит!?"И лучший коментарий:"10 лет слушать радиот, чтобы дождаться рекламы от витиАка казино"",
"user": {
"name": "dadaBit",
"id": "dadabit",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-17T19:38:51Z"
},
{
"id": "3666795222",
"pid": "3665976683",
"text": "Новая реклама - восторг! Подписка, немедленно!",
"user": {
"name": "Никита Пинчук",
"id": "disqus_Oi6XHPJ605",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-17T20:52:06Z"
},
{
"id": "3666865144",
"pid": "",
"text": "Растёте на глазах. Бинарные опционы еще можно рекламировать и единую россиию.",
"user": {
"name": "garkin garkin",
"id": "garkingarkin",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-17T21:46:10Z"
},
{
"id": "3667232165",
"pid": "",
"text": "Под винду есть очень хороший xshell как терминал. И как xshell enterprise - как набор из sftp клиента и ещё пары вещей",
"user": {
"name": "Ilya Kuzminykh",
"id": "morgraves",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T03:49:25Z"
},
{
"id": "3667269427",
"pid": "3666132431",
"text": "Если бы была реклама Pornhub'а то все бы радовались, так ведь? ;)",
"user": {
"name": "Ilirium",
"id": "Ilirium",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T04:39:16Z"
},
{
"id": "3667351134",
"pid": "",
"text": "А ничего что размещение такой рекламы - нарушение законодательства?",
"user": {
"name": "Denis Gubanov",
"id": "denisgubanov",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T06:59:48Z"
},
{
"id": "3667359662",
"pid": "3667269427",
"text": "Главное что бы не так жестоко для ушей. А в остальном и эта реклама была ниче)",
"user": {
"name": "visortelle",
"id": "visortelle",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T07:16:15Z"
},
{
"id": "3667371871",
"pid": "",
"text": "Плевок в лицо у них веб страница в excel, плевок в лицо реклама азино 777 это всеравно что взять рекламу у нарко торговцев... мир перевернулся господи, что вообще у людей в голове начало твориться",
"user": {
"name": "Илья",
"id": "import_Илья",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T07:40:47Z"
},
{
"id": "3667378015",
"pid": "3667371871",
"text": "Считайте это очередным Великим Фильтром.",
"user": {
"name": "2538",
"id": "kpmy",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T07:53:16Z"
},
{
"id": "3667387972",
"pid": "",
"text": "Достигнуто ли Дно рекламой Казино? Осталось только пустить рекламу какой-нибудь Форекс кухни.",
"user": {
"name": "Ilya Isakov",
"id": "ilya_isakov",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T08:13:02Z"
},
{
"id": "3667494116",
"pid": "",
"text": "Сколько нужно денег собрать чтобы не крутить больше рекламу азино ?",
"user": {
"name": "Dmitry Shapovalov",
"id": "dmitryshapovalov",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T11:06:56Z"
},
{
"id": "3667503865",
"pid": "",
"text": "Реклама прелесная. Не сарказм. Не слушайте этих снобов.",
"user": {
"name": "Сергей",
"id": "disqus_QI2cNMlN4Q",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T11:20:58Z"
},
{
"id": "3667680582",
"pid": "",
"text": "Удивили рекламой казино. Интересно, что самим ведущим в эфире было стыдно. Люблю слушать Радио-Т, надеюсь касаться дна, как в этот раз, будут не часто.",
"user": {
"name": "Sergii Smyrnov",
"id": "smirnoffs",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T14:23:43Z"
},
{
"id": "3667758012",
"pid": "",
"text": ""Семён Семёныч, у меня для тебя есть новость. Она выражается четырьмя буквами: RTFM..." бгггггг))))",
"user": {
"name": "Ilja",
"id": "disqus_FAYMvy6uby",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T15:20:12Z"
},
{
"id": "3667828140",
"pid": "",
"text": "Просто надо сделать так, чтобы казино само не захотело давать рекламу в радиоте. Например, после каждой открутки этой УГ-рекламы можно скидываться на DDOS рекламируемого объекта.",
"user": {
"name": "Ilya Isakov",
"id": "ilya_isakov",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T16:08:45Z"
},
{
"id": "3667830590",
"pid": "3667828140",
"text": "Ну а чтоб это еще и красиво было - запилить смарт контракт, который будет срабатывать, когда умная NN задетектит рекламку.",
"user": {
"name": "Ilya Isakov",
"id": "ilya_isakov",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T16:10:27Z"
},
{
"id": "3667889376",
"pid": "",
"text": "А давайте на митинг выйдем чтоб в радио-т не было казино! Я бы в Питере сходил!",
"user": {
"name": "shpaker",
"id": "shpaker",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T16:49:17Z"
},
{
"id": "3668214084",
"pid": "",
"text": "У меня нет особых претензий к наличию рекламы казино. Лишняя копеечка за 30 секунд эфира шоу не помешает. У меня есть претензия к форме подачи.Грей правда думает, что шоу ориентировано на школоту? Просто иначе нет никаких причин в использовании русского рэпа.",
"user": {
"name": "gimlis",
"id": "disgimlis",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T20:22:49Z"
},
{
"id": "3668223685",
"pid": "3667494116",
"text": "Я просто напомню, что за 2017 год появилось:1. Два дорогих друга (>= $100)2. Два хороших друга (>=$50)3. И только один подписной (>=$3 в месяц)Итого ~$350 за весь годСтоимость рекламы аудиоблоком вряд ли стоит меньше $400 <a href="https://radio-t.com/advertising/\" rel="nofollow">https://radio-t.com/adverti...О каком сборе денег вы хотите поговорить?",
"user": {
"name": "gimlis",
"id": "disgimlis",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-18T20:29:10Z"
},
{
"id": "3668717308",
"pid": "3668223685",
"text": "Да, это прискорбно.У меня 2 практических предложения.1. Я, наверно как и многие слушают подкаст в какой нибудь подкаст-качалке и на сайт не ходят, кнопку с донатами не видят. То что есть донаты и не плохо бы поддержать - быстро забывается. Ведущие не напоминают. Думаю было бы полезно каждый раз перед рекламой проговаривать про донаты.2. Минимальная цена подписки - 3 бакса. Имхо это много. Это примерная цена месяца подписки google music или itunes. Если бы подписка начиналась с 1 бакса то денег с постоянных подписок было бы больше. Имхо )",
"user": {
"name": "Dmitry Shapovalov",
"id": "dmitryshapovalov",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T03:14:01Z"
},
{
"id": "3668724911",
"pid": "3668717308",
"text": "Хм. А там когда любую сумму донатиш в пэйпале есть галка "Make this a monthly donation". Тыкнул. Посмотрим как это работает.",
"user": {
"name": "Dmitry Shapovalov",
"id": "dmitryshapovalov",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T03:20:28Z"
},
{
"id": "3668782715",
"pid": "",
"text": "> Uber’s Secret Screen Recording"Фу, какая в Android примитивная система разграничения прав приложений! Вот у нас система безопасности -- всем системам система!", ехидно похихикивали несколько выпусков назад.> Face ID FailsА вот тут время покажет. Даже не сомневался, что система распознавания лиц от компании "Держите его правильно" будет фейлить.",
"user": {
"name": "Ivan Zharenkov",
"id": "ivn86",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T04:22:26Z"
},
{
"id": "3668868823",
"pid": "3668214084",
"text": "А меня все таки смущает сам фак рекламы казино, я просто понять не могу, они там с голоду умирают? Зачем рекламировать эту порочную дрянь?",
"user": {
"name": "shvartsd",
"id": "shvartsd",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T06:23:52Z"
},
{
"id": "3668873377",
"pid": "3667378015",
"text": "А в чем смысл фильтра? Отфильтровывать людей которые считаю казино разводом и манипуляцией азартом и слабой устойчивостью к этому делу некоторых людей? Вот действительно говнюки, таких слушателей не должно быть в радиоте!",
"user": {
"name": "shvartsd",
"id": "shvartsd",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T06:30:38Z"
},
{
"id": "3668923903",
"pid": "3667232165",
"text": "Как замена Putty - да, лучшее из того, что пробовал. Как эмулятор терминала - нет, там он совсем уж в зачаточном состоянии.",
"user": {
"name": "mr j",
"id": "disqus_QmSENjZQP5",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T07:57:46Z"
},
{
"id": "3668931485",
"pid": "3668868823",
"text": "Меня вот смущает засилье рекламы про мужские клубы и средства от импотенции во время трансляций Формулы 1.А реклама казино не смущает, если она нормально интегрирована.",
"user": {
"name": "gimlis",
"id": "disgimlis",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T08:11:10Z"
},
{
"id": "3668962039",
"pid": "3668873377",
"text": "Да уж.",
"user": {
"name": "2538",
"id": "kpmy",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T09:03:27Z"
},
{
"id": "3668979815",
"pid": "",
"text": "Внезапно, коммент не про казино.Почему-то подкаст перестал нормально заливаться на <a href="http://podfm.ru\" title="podfm.ru" rel="nofollow">podfm.ru. А через podfm синхронизируется Podcast Addict - замечательное приложение для подкастов под андроид. И подозреваю, что не только оно.Началось всё с падения podfm с месяц назад. Потом оно поднялось, но вот чёткость синхронизации радиота с приложением пропала.",
"user": {
"name": "Артемий",
"id": "SmplT",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T09:32:14Z"
},
{
"id": "3669505618",
"pid": "3668979815",
"text": "это надо у подфм спрашивать. Но то, что для получения подкастов какой-то программе нужен именно podfm – это очень странно. Такие программы получают все через RSS, который у нас конечно есть и на который стоит подписаться напрямую, вместо таких кружных путей.",
"user": {
"name": "Umputun",
"id": "umputun",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T16:56:58Z"
},
{
"id": "3669562070",
"pid": "3668873377",
"text": "это и на самом деле неожиданно оказалось своеобразным, но вполне показательным фильтром. Однако, его смысл вовсе не в том, чтоб отгонять тех, кто что-то там считает про казино, но совсем наоборот.",
"user": {
"name": "Umputun",
"id": "umputun",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T17:33:50Z"
},
{
"id": "3669585031",
"pid": "3667828140",
"text": "я подозреваю, что казино в безопасности. Опыт показывает, что те кто тут пишет "а давайте сделаем ....", "давайте соберемся", "давайте скинимся" и генерит прочие громкие идеи, но при этом на призывы о практической помощи тихо сидит в сторонке - они вряд-ли что-то сделают и вряд-ли что-то организуют.",
"user": {
"name": "Umputun",
"id": "umputun",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T17:43:21Z"
},
{
"id": "3669673215",
"pid": "3666020878",
"text": "Предлагаю и вам воспитательную, не с глаголами пишется ...... Позор конечно",
"user": {
"name": "Antony",
"id": "Antonamana",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 1,
"votes": {
"dev": true
},
"time": "2017-12-19T18:15:52Z"
},
{
"id": "3669687769",
"pid": "3669673215",
"text": "Спасибо. И да, про не с глаголами - образовательная, а не воспитательная.",
"user": {
"name": "zedxxx",
"id": "zedxxx",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": -1,
"votes": {
"dev": false
},
"time": "2017-12-19T18:20:23Z"
},
{
"id": "3669787380",
"pid": "",
"text": "Комментарии в правой колонке как-то кэшируются? Там долго висел какой-то матерный комментарий, но на дискассе и сайте его не было.",
"user": {
"name": "Alexander Shalugin",
"id": "alexandershalugin",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": -1,
"votes": {
"dev": false
},
"time": "2017-12-19T19:05:51Z"
},
{
"id": "3669839554",
"pid": "3669787380",
"text": "это все к disqus вопросы. он это позволяет (коменты к неправильным url) и кеширует на неопределенное время после удаления.",
"user": {
"name": "Umputun",
"id": "umputun",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-19T19:38:58Z"
},
{
"id": "3670021783",
"pid": "3665991835",
"text": "Бобук, наверное, давно не пользовался cmd. Левой мышью выделяет текст, правой отправляет выделенный в буфер, правой опять же вставляет. Также поддерживает цветовые схемы, горячие клавиши, прозрачность, а также curl и tar в последнем инсайдере)",
"user": {
"name": "ame",
"id": "disqus_mwtaXmhAOT",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 1,
"votes": {
"dev": true
},
"time": "2017-12-19T21:34:02Z"
},
{
"id": "3670471089",
"pid": "3668214084",
"text": "Все проще, это аудиодорожка от видеоролика, которые они крутят на других сайтах. Думаю заказывать изготовление специальной аудиорекламы для подкаста, не зная какой выхлоп будет в итоге, посчитали неоправданным.",
"user": {
"name": "Ilirium",
"id": "Ilirium",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-20T04:20:11Z"
},
{
"id": "3670517832",
"pid": "",
"text": "Не писал еще комментов, но тут бомбануло. Реклама азино на радио-т? Серьезно? мда...",
"user": {
"name": "krim",
"id": "disqus_mUJHltGFr9",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-20T05:25:50Z"
},
{
"id": "3670585938",
"pid": "3669585031",
"text": "Правильно опыт подсказывает. Но надо же как-то фидбек доносить. А рассудит время, неудачные решения eventually все равно быстро отвалятся.",
"user": {
"name": "Ilya Isakov",
"id": "ilya_isakov",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-20T07:11:02Z"
},
{
"id": "3670614315",
"pid": "3669505618",
"text": "Через RSS конечно тоже работает. Просто поиск и добавление по названию только через крупных агрегаторов, в т.ч. подфм.",
"user": {
"name": "Артемий",
"id": "SmplT",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-20T07:55:41Z"
},
{
"id": "3670626122",
"pid": "",
"text": "Капец. Азино три топора и сюда добрались. Я в шоке. Нет слов. Это бич Рунета если вы не знали. Грэя в шэю или сделайте эксклюзив про блокчейн)",
"user": {
"name": "Артем Петрашов",
"id": "disqus_qSdSDWB9y5",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-20T08:15:45Z"
},
{
"id": "3670866069",
"pid": "",
"text": "Меня всегда удивляло существование подобной рекламы. Кто в своём уме пойдет и отдаст деньги какому-то дядьке из интернета за просто так? И я искренне надеюсь, что это какой-то стёб. Потому что можно по-разному относиться к своей аудитории, но когда включают такую рекламу, сразу возникает предположение, что ведущие считают своих слушателей полными идиотами. Опять же, повторюсь, может это какой-то троллинг рекламодателей или еще что-то.Небольшой экскурс в историю: я давно уже вижу подобную рекламу и раньше сайты назывались проще: "casino-x" или "babki-darom". А теперь они начали добавлять цифры в название, вроде 777 (три топора), потому что их постоянно банят. Подозреваю, что Роскомнадзор. Интересно, могут ли забанить сайт <a href="http://radio-t.com\" title="radio-t.com" rel="nofollow">radio-t.com за такую рекламу?",
"user": {
"name": "Omnom",
"id": "disqus_5onmwmJ7G1",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-20T13:22:58Z"
},
{
"id": "3671229653",
"pid": "",
"text": "+ в чат, если тоже засыпая думаете про Ксюшу, iOS и асинхронные пользовательские интерфейсы )))",
"user": {
"name": "Linker Nick",
"id": "nlinker",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-20T17:31:34Z"
},
{
"id": "3671336326",
"pid": "3670866069",
"text": "Если реклама станет постоянной (чего не будет), то вполне могут и забанить. Т.к. эта штука вне закона (в РФ):"Контроль над деятельностью азартных заведений в Российской Федерации описан в законе «О государственном регулировании деятельности по организации и проведению азартных игр» от 1 января 2007 года. Согласно этому законопроекту, наземные игровые заведения могут размещаться исключительно в специальных игорных зонах, а также запрещена регистрация и лицензирование интернет казино. Из-за этого решения все русскоязычные азартные онлайн проекты получают лицензии в других странах."<a href="https://ru.wikipedia.org/wiki/%D0%98%D0%BD%D1%82%D0%B5%D1%80%D0%BD%D0%B5%D1%82-%D0%BA%D0%B0%D0%B7%D0%B8%D0%BD%D0%BE\" rel="nofollow">https://ru.wikipedia.org/wi...",
"user": {
"name": "zedxxx",
"id": "zedxxx",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-20T18:37:13Z"
},
{
"id": "3671372026",
"pid": "",
"text": "Зашёл просто погреться в комментариях :3",
"user": {
"name": "overmind88",
"id": "overmind88",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-20T18:59:12Z"
},
{
"id": "3671605285",
"pid": "3667889376",
"text": "На самом деле это новая схема монетизации. Заплатите нам, чтобы в радио-т не было казино!",
"user": {
"name": "Gray",
"id": "gray_ru",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-20T21:15:24Z"
},
{
"id": "3672705056",
"pid": "3670614315",
"text": "если это на самом деле так, и эта программа считает podfm "крупным агрегатором" a its считает мелким - то такую программу надо поменять на нормальную. Я ставил на андроиде разные клиенты и все они позволяли найти этот подкаст по названию без всяих костылей.",
"user": {
"name": "Umputun",
"id": "umputun",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-21T15:56:02Z"
},
{
"id": "3673239769",
"pid": "",
"text": "Насчет Python в Excel, все не совсем так. MS рассматривает сценарий использование Python как scripting language, т.е. для написания макросов продвинутыми пользователями изнутри Excel.Для задач, которые стоят перед Умпутуном (генерация файлов Excel налету) это совсем не подходит. Конечно через VBA это можно сделать, но всякий раз при использовании VBA запускается скрытый экземпляр Excel, который потом может сам не выгрузиться. Лет 10-15 назад народ все равно этим активно занимался, что побудило MS писать всякие гайдлайны, например, <a href="https://support.microsoft.com/en-us/help/257757/considerations-for-server-side-automation-of-office\" rel="nofollow">https://support.microsoft.c... . Там, кстати, жирным шрифтом написано "Microsoft does not currently recommend, and does not support, Automation of Microsoft Office applications from any unattended, non-interactive client application or component (including ASP, ASP.NET, DCOM, and NT Services), because Office may exhibit unstable behavior and/or deadlock when Office is run in this environment."@Umputun, когда вы год назад жаловались на эту проблему, я советовал вам смотреть в сторону Open XML SDK 2.5 for Microsoft Office. В этом SDK есть библиотеки, которые могут генерить офисные файлы с расширением .***x, например, .docx или .xlsx. По сути это XML документы. И, если вам не хочется "пачкать" руки, можно генерить конечно xml самим. Но с библиотеками MS это гораздо проще.И да, я понимаю, Ваших клиентов, которые предпочитают файлы Excel. Это свобода. Их можно отредактировать, изменить цвет ячеек, формулы, создать 5 разных копий, отправить по почте или распечатать, как хочешь, а не как решил Google. Excel rules) <a href="https://www.youtube.com/watch?v=0nbkaYsR94c&t=1261s\" rel="nofollow">https://www.youtube.com/wat...И в пику Бобуку (про странный Excel) товарищ с видео давным-давно писал вот это: <a href="https://www.joelonsoftware.com/2001/10/14/in-defense-of-not-invented-here-syndrome/\" rel="nofollow">https://www.joelonsoftware....",
"user": {
"name": "badorb",
"id": "badorb",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-21T19:09:51Z"
},
{
"id": "3675490865",
"pid": "",
"text": "Так вот почему у меня в яндекс музыке черная тема стала, не только в приложении на андройд, но на десктопе в хроме.",
"user": {
"name": "Sergey Prokopyev",
"id": "Sergey_Prokopyev",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-23T08:52:35Z"
},
{
"id": "3675940086",
"pid": "",
"text": "Реклама казино - ужас, хочется закрыть ее создателя в абсолютно изолированной комнате со встроенными динамиками и включить его же творенее нонстопом в цикле. Это наверное самое ужастное из того что когда либо было в радио-т",
"user": {
"name": "nsrb",
"id": "navisrob",
"picture": "",
"profile": "",
"admin": false
},
"locator": {
"site": "site",
"url": "https://radio-t.com/p/2017/12/16/podcast-576/"
},
"score": 0,
"votes": {},
"time": "2017-12-23T17:49:02Z"
}
]


File: backend/app/store/service/testdata/tree_del.json

{
"comments": [{
"comment": {
"id": "1",
"pid": "",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:46:01Z"
},
"replies": [{
"comment": {
"id": "11",
"pid": "1",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:46:11Z"
},
"replies": [{
"comment": {
"id": "111",
"pid": "11",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:46:12Z"
}
}]
}]
}, {
"comment": {
"id": "2",
"pid": "",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:47:01Z"
},
"replies": [{
"comment": {
"id": "22",
"pid": "2",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:47:02Z"
},
"replies": [{
"comment": {
"id": "223",
"pid": "22",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "0001-01-01T00:00:00Z",
"delete": true
},
"replies": [{
"comment": {
"id": "2241",
"pid": "223",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:47:05Z"
}
}]
}, {
"comment": {
"id": "221",
"pid": "22",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:47:03Z"
}
}, {
"comment": {
"id": "222",
"pid": "22",
"text": "",
"user": {
"name": "",
"id": "",
"picture": "",
"admin": false
},
"locator": {
"site": "site",
"url": "url"
},
"score": 0,
"votes": null,
"time": "2017-12-25T19:47:04Z"
}
}]
}]
}]
}


File: docs/backlog/api-tests-deadlock-on-macos.md


worth: later where: backend/app/rest/api/middleware_test.go:246 added: 2026-08-18

TestRest_securityHeaders deadlocks the whole api suite on macOS

cd backend/app && go test -timeout=300s -count 1 ./... fails on macOS with the app/rest/api package
timing out. It reproduced 3 times out of 3 in isolation with
go test -run 'TestRest_securityHeaders$' -timeout=40s, so it is deterministic rather than flaky, and it
takes the whole package down with it: 14 of 15 packages pass, that one never finishes. CI is unaffected,
the Tests job passes on Linux.

The test blocks in startupT's teardown (rest_test.go:676), inside httptest.Server.Close(), which
waits on the server's WaitGroup for handlers still in flight. The handler it is waiting on sits in IO
wait on a socket write:

text
internal/poll.(*FD).Write  ... waitWrite
net/http.(*response).write(..., 0x3c2c13, ...)
api.addFileServer.Timeout.func3.1  vendor/github.com/go-pkgz/rest/timeout.go:67

0x3c2c13 is 3,942,931 bytes. So the file server is writing a ~3.9MB response to a client that has
stopped reading, because the test already moved on to closing the server. On Linux the write fits in the
socket buffers and completes; on macOS the buffers are smaller, the write blocks, and Close() waits
forever.

Not caused by the golang.org/x/image bump in the same session: that changed only
golang.org/x/image, golang.org/x/sys, golang.org/x/text and vendor/modules.txt, while every
package in the stack above (go-pkgz/rest, didip/tollbooth, go-pkgz/routegroup) is untouched.

Worth fixing because it makes the documented local test command unusable on a Mac, and the failure mode
is an eight minute silent hang rather than an error. Likely fix is on the test side: have the client
drain or close the response body before teardown, or stop serving a multi-megabyte asset from that
fixture. [Unverified] which of those it is, the handler was not traced past the write.


File: docs/backlog/backend-error-codes-never-reach-their-messages.md


worth: yes where: frontend/apps/remark42/app/common/fetcher.ts added: 2026-10-17

the backend's error codes never reach their messages

The catalogue carries a message for every backend error code, errors.1 to errors.20 ("You cannot
vote for your own comment.", "Comments on this page are read-only." and so on), but a reader never
sees any of them. The fetcher loses the code on two paths:

  • A status with an entry in errorMessages (401, 403, 409, 429, 500) becomes a RequestError
    carrying that status, and the body is never read. The backend sends a blocked user (ErrUserBlocked), a
    read-only post (ErrReadOnly) and a refused data-removal token (ErrNoAccess) as 403 with the code in
    the body (rest_private.go, admin.go), so all three read as errors.forbidden.
  • Any other status, in practice 400, has its JSON body parsed and thrown as a plain object.
    extractErrorMessageFromResponse looks a code up only on a RequestError, so these read as
    errors.0, "Something went wrong. Please try again a bit later." errorUtils.unit.test.ts pins
    this down in "does not read the code off a plain error object".

Fix: have the fetcher read a JSON error body first and, when it carries a non-zero numeric code
with a catalogue entry, throw a RequestError with that code, falling back to the HTTP status
otherwise. Code 0 is ErrInternal, which the backend uses for many unrelated failures, so letting it
win would replace today's specific 403, 409 and 500 messages with the generic one. Change that unit
test to expect the code's own message.


File: docs/backlog/direct-signin-user-lacks-site-fields.md


worth: maybe where: frontend/apps/remark42/app/components/auth/auth.tsx:141 added: 2026-08-26

email and telegram sign-in put a user in the store without its site-scoped fields

GET /user?site= is the only endpoint that fills the site-scoped parts of a user: userInfoCtrl
(backend/app/rest/api/rest_private.go:236-249) sets Verified and EmailSubscription from the store.
The /auth/* login handlers render claims.User instead, which is token.User from vendored
go-pkgz/auth (backend/vendor/github.com/go-pkgz/auth/v2/token/user.go:22-34). That struct has no
email_subscription, verified, admin, block or paid_sub field at all.

auth.tsx:141 (email) and auth.tsx:170 (telegram) dispatch that raw response through setUser, so the
store holds a user missing those fields until something calls fetchUser again. oauthSignin is not
affected: it resolves through getUser() and receives the enriched shape.

Two consequences seen so far, both cleared by a reload:

  • Dropdown mounts its children only when opened, so SubscribeByEmailForm reads the missing flag on
    every open. An already-subscribed reader who signs in by email or telegram and opens the panel in the
    same page view is offered the Subscribe step. Submitting the same address returns 409, which the
    component turns into the subscribed state, so the usual path self-corrects; a reader whose subscription
    address differs from the address the form prefills does not get that 409.
  • user.admin is undefined on the same path, so the read-only switch (auth-panel.tsx:150) and the
    settings block (settings.tsx:143) stay hidden for an admin who signed in that way.

Two candidate fixes, and choosing between them is why this is not done: route every direct sign-in
through /user after the auth call, or have the auth responses carry the site-scoped fields. The first
keeps one source for that data and costs a round trip.

Surfaced while investigating #2206, which reports a different symptom on the reload path and does not
reproduce.


File: docs/backlog/hardcoded-english-outside-the-catalogue.md


worth: yes where: frontend/apps/remark42/app/components/auth/auth.tsx added: 2026-10-17

some sign-in form text never reaches the translation catalogue

Two pieces of the sign-in dropdown are English whatever the locale, because they are written into
the component instead of going through defineMessages, so translation:extract never sees them:

  • The close button carries title="Close sign-in dropdown", in two places: the Telegram step and
    the email token step.
  • When more than one form provider is enabled, the provider tabs show {p.slice(0, 6)}, the
    provider id cut to six letters, so a visitor sees "anonym" and "email" in every language.

Fix: give the close button's title a message id, and give each form provider a translated label
in place of the truncated id. Both need new keys in all catalogues.


File: docs/backlog/jest-cases-the-browser-suite-could-take.md


worth: later where: frontend/apps/remark42/app, e2e/ added: 2026-08-30

What keeps jest cases out of the browser suite, and what would let them go

Removing a jest case is only safe when a named e2e case asserts the same behaviour, assertion for
assertion. Working through the suites on that rule, the cases that stay divide into a few recurring
causes, and most of them are a gap in the browser suite rather than something it cannot reach.

This was the list to work from. The items marked done below have their browser case and their
jest cases have gone with them; what is left is what is still open, and the reasons it is open are
worth more than the list itself.

Selectors the browser cannot reach

tasks/babel-plugin-remove-test-id.js removes data-testid from anything but a test build, so a
browser case cannot select by it. Only one of the elements these jest cases assert through is
actually stuck behind that:

  • done. comments-counter in profile.spec.tsx is
    ``. Its only class is hashed by
    the CSS modules, so nothing outside the bundle can name it. Giving it a stable class, the way
    .auth-button, .auth-submit, .comment-actions and .sort-picker are kept outside the
    modules for this reason, is what makes the counter assertable in a browser

Two others are already reachable and simply have no browser case written yet, which is a smaller
job than it looked:

  • spinner renders clsx('spinner', styles.root, …) and is still open. It is the between-pages
    indicator at profile.tsx:162-165, shown in place of the Load more button once a page is being
    fetched, so it is reachable only when comments is not null. The profile's initial load and its
    failure are a different element, the Preloader at profile.tsx:221, and that one is covered by
    TestProfile_LoadingAndFailureStates; the two are easy to conflate and are not the same thing
  • done for the telegram panel. preloader renders clsx('preloader', className), asserted
    by TestTelegramSub_ThePanelSaysItIsWorking
  • done. comment-actions-additional carries a stable class, and the order is asserted by
    TestComment_AdminActionsKeepTheirOrder

Transient states nothing waits on

  • done. buttons disabled while a vote request is in flight, TestVote_BothButtonsAreDisabledWhileTheVoteIsInFlight
  • done. the loading indicator in the telegram subscription panel
  • done. the profile's loading and error states
  • the preloader that must not reappear after a load-more click

Each needs a browser case that holds the request open, which the suite already knows how to do:
TestVote_FailureShowsAnErrorAndRestoresTheScore blocks a route and asserts the optimistic state
before releasing it.

Absence with no positive control

The browser suite asserts what appears far more readily than what does not. Cases kept for this:

  • done. the Reply action gone in a read-only thread, now asserted in
    TestComment_ReadOnlyThreadTakesTheFormAway, which posts a comment first so the assertion has
    something to be about
  • done. Hide absent on a reader's own comment, Delete absent on another reader's,
    TestComment_ActionsDependOnWhoseCommentItIs
  • done. the verification icon absent before an admin verifies
  • the auth dropdown starting closed

Configurations no instance runs

  • done. email_notifications and telegram_notifications off, each instance being the
    other's negative case
  • upvote-only voting, and voting hidden altogether
  • the controversy tooltip, which needs a comment with controversy in it

Values inside an element, where the browser case only waits for the element

  • done. the edit countdown's value, TestComment_EditCountdownCountsDown
  • the telegram link's full https://t.me//?start=<token>, where the browser helper parses out
    the start parameter and never looks at the host or the bot name

Error branches that are not the one the browser drives

  • the generic fallback message for an unrecognised code. The browser suite fulfils a 409 and
    asserts the catalogued string, which never enters that branch
  • done. a failed check or unsubscribe cleared by a later success, driven separately because
    the two handlers clear their own error

What is not worth moving

Call counts and call arguments (api.telegramSubscribe called once, getUserComments called with
a page size) assert how a client method was used, not what a reader gets. The browser suite asserts
the request and the response instead, which is the better test of the same thing, so these stay in
jest or go away on their own when the code changes.

What the static build would settle on its own

Thirty-four of the cases still in jest exist because of the build rather than because of the
behaviour: eighteen assert a hashed css-module class and sixteen select by a data-testid the
production bundle strips. With static css there is no hash to pin and the class in the source is
the class in the browser, so those assertions have nothing left to say and the browser can select
what ships. Covering them now would mean writing browser cases whose purpose disappears with the
build, which is why they are left here rather than done.


File: docs/backlog/public-widget-contract-docs-and-types.md


worth: yes where: site/content/docs/configuration/frontend/_index.md, frontend/apps/remark42/app/typings/global.d.ts added: 2026-08-21

The public embed contract has no reference page and no shippable type declaration

Discussion #1714 asked for a description of window.REMARK42 and TypeScript types, in December 2023.
The contract was written out in a reply on 2026-08-20, but a discussion comment is not documentation,
and the two halves of the request are still open.

The docs half

The pieces exist and are scattered. configuration/frontend/_index.md:81 documents changeTheme only,
in a section about theming. configuration/frontend/spa.md shows createInstance, destroy and the
REMARK42::ready event, but as a single-page-app recipe rather than as a reference. The Astro and
Gatsby manuals repeat fragments again. There is no page that states the surface once.

Three behaviours are documented nowhere and will bite anyone building against it, all in app/embed.ts:

  • createInstance throws rather than returning an error in three cases: no element with id remark42,
    window.remark_config undefined, or site_id unset
  • those checks read the global window.remark_config, while the rest of the function reads the
    object passed as the argument, so passing a config does not remove the need for a valid global
  • createInstance reuses a direct-child iframe carrying data-remark42-iframe rather than creating one,
    and the config passed to that second call is ignored, so calling it again without a destroy silently
    does nothing. The reuse also leaves the first instance's message, hashchange and click listeners
    installed, so each call adds another set

Also undocumented: the embed script creates an instance before dispatching REMARK42::ready, so the
event means the global is safe to touch rather than that nothing is mounted; and __colors__ reaches
the iframe through window.name (templates/iframe.ejs:42-44) rather than the URL, so it is read once
at boot and is not a runtime API.

The types half, and the open decision

app/typings/global.d.ts is internal: it imports jest-fetch-mock and common/types, so it cannot be
shipped as-is. A public declaration would be a standalone file with Theme, RemarkConfig and the
window.REMARK42 surface and no internal imports. That part is easy.

How it reaches a consumer is not decided. tsc picks up types from node_modules, and npm is the
direction we have just moved away from: #1715 declined packaging the widgets and #2172 removed
@remark42/api. Options, in the order I would rank them:

  1. Publish the file on the site as a copy-paste block, with no package. Zero infrastructure, works
    today, and consumers paste it into their own global.d.ts. This is what the Astro and Gatsby
    manuals need, since both currently declare REMARK42: any and remark_config: any
    (integration-with-astro/index.md:150, integration-with-gatsby/index.md:109), which is our own
    documentation admitting the gap.
  2. A types-only npm package. Note this does not contradict #1715: that was declined because shipping
    widget code through npm puts the widget in the host origin and breaks OAuth. A package containing
    no runtime code has none of that problem. It does add a publish step and a version to keep in sync.

Option 1 clears the reported need. Option 2 only pays off if we want npm i -D ergonomics, which is a
separate call.

Whatever is chosen, fixing the two integration manuals to stop using any is the visible win, and
#1714 should get the pull request linked when it lands, as promised there.


File: docs/backlog/site-checks-do-not-gate-merge.md


worth: yes where: repository settings, not a file added: 2026-08-21

nothing on master is a required status check

master is protected, but required_status_checks is {"checks": [], "contexts": [], "enforcement_level": "off"},
and the only ruleset on the branch ("Copilot review for default branch", id 15225632) carries just
deletion, non_fast_forward and copilot_code_review. Verified with
gh api repos/umputun/remark42/branches/master.

Every workflow in the repository is therefore advisory. A red X on any backend, frontend or site job
shows in the checks list and does not stop the merge button, so a pull request that fails CI can still
be merged by anyone who does not read the list.

Fix: configure required status checks on master for the jobs that should block. Worth deciding
deliberately rather than by default, since it also governs what happens to Dependabot pull requests
and to any job that turns out to be flaky.


File: docs/plans/2026-08-19-frontend-simplification.md

Frontend simplification: shipping the widget as static HTML, CSS and JavaScript

The goal is a widget that ships without npm: no installed dependency tree, no bundler, no node in
the build, and static HTML, CSS and JavaScript as the output. This document plans that and nothing
else.

Two subjects that earlier revisions carried have been taken out. The server-rendered direction of
#825 is a different proposal and is not costed here; removing it from this document does not decide
against it. Multi-domain hosting is an authentication question that constrains this work at exactly
one point, noted in its place below, and is tracked separately so that a backend decision does not
block a build decision.

Where this stands

The direction is settled. What was open was whether the verification could survive the move, and
that is now half answered.

The test suite has split in two. Pure logic, meaning anything that is a function over strings,
objects and dates, runs under node --test directly from source with nothing installed, and its CI
job installs nothing so that a test reaching for a dependency fails. What remains on jest is the
half that renders components into jsdom.

That retires the earlier framing, which held that dropping npm drops the whole unit suite and that
the only route was to close every gap in the e2e suite first. The question is now specifically about
DOM-rendering cases, and each has three possible answers: move the subject's logic out and test it
under node, cover the behaviour in the e2e suite, or accept the loss.

It also settles, by precedent, a question earlier revisions listed as open. Dependency-free node
scripts may stay, so the translation tooling written against node built-ins does not have to be
ported to Go to satisfy the goal.

The extraction pattern

Value imports in the node layer write the extension in full and use a relative path, never the
common/… alias, because node resolves neither. Type-only imports erase and keep the ordinary
style. This is recorded in both CLAUDE.md files; it is repeated here because it decides whether a
given jest case can move at all. A case whose subject imports through the alias cannot move until
the subject does, which is why the split extracted the pure half of each module first.

The pattern is the reusable part. Pull the logic out of the module that touches the DOM, test that
half under node, and leave the thin DOM half to jest or to the e2e suite. It found several defects
on its first pass, which is the argument for continuing it.

Standing requirement: the language must be selectable per page

The locale is read once from remark_config.locale and resolved to one catalogue. There is no
runtime switcher and no locale entry in the postMessage contract, so the language cannot change
during an instance's life, and a site with several language versions embeds each page with its own
locale.

Must survive any build change:

  • remark_config.locale keeps selecting the language per instance
  • every supported language keeps working. Adding one is not catalogue-only today: the language goes
    into a registry file, and generation then rewrites the loader from it. Preserve that
    registry-plus-generation shape or replace it deliberately
  • the translation completeness check survives in some form
  • date and time rendering keeps resolving in the viewer's timezone. The locale is not the viewer's,
    since it is configured and the server knows it too. Only the timezone is genuinely client-side, so
    the carve-out is timezone-local rendering and not a whole date-formatting layer

Sequence: parity first, changes second, CSS excepted

The build change must reproduce current behaviour exactly, with no feature change, no markup change
and no visible difference, so that any regression it causes is attributable to the build and to
nothing else. Interleaving a rendering bug with a deliberate redesign in one diff is expensive to
untangle, and the widget's most stateful code is the composer and authentication, which is where it
is most expensive.

CSS is the exception and has to be correct on the first pass. Reproducing the hashed class names
exactly would freeze an accidental surface as though it had been designed, and renaming later breaks
every override written against the first set. Public names ship once, and #5 has been open since
2018 asking for stable ones.

One piece of work in flight collides with this: #2128 rewrites the CSS layer on the assumption that
hashed names stay. Settling the class-name direction first is what stops that work being redone.

What replaces each npm gate

gate replacement state
dependency-free unit tests none needed in place
jest extract logic into the node layer, cover the rest in the e2e suite, accept the remainder in progress
type checking undecided open
lint and style lint undecided, and an accepted loss is a real option open
size budgets a Go test over the embedded filesystem designed
translation completeness only message extraction is bound to npm; everything downstream of it is already node built-ins narrowed

Type checking is the hard one, and the only gate with no route. Dropping the bundler without
answering it means either shipping untyped source or finding a checker that is not npm, and there is
no third option that keeps the current guarantee.

The translation failure mode is worth carrying forward because it is silent. Extraction matches the
identifiers the widget calls, not an import source, so moving strings into Go templates would
extract nothing with a zero exit code, after which generation deletes them from every catalogue.

What the bundler still does

The list any no-bundler proposal has to answer for.

  • transpiles TypeScript and JSX
  • CSS modules, with nesting and with one composes that crosses a file boundary. That one has no
    plain-CSS equivalent and has to be flattened instead of translated
  • code-splits the locale catalogues and the emoji data
  • runs an autoprefixer pass against a browserslist query. Without a transpiler that query becomes the
    literal browser floor and source syntax ships as written
  • builds the HTML entry pages, including the fallback page a reader is offered when the widget
    detects a third-party context with storage unavailable. This is the one point where multi-domain
    hosting constrains this work: that page went unbuilt from the January 2021 rewrite until #2197
    restored it, so it returned 404 for four years in exactly the configuration that needs it, and
    nothing in the e2e suite pins it. Any replacement build has to keep emitting it
  • derives asset URLs. Since #2197 the public path is computed at runtime from the URL the bundle was
    loaded from, so a sub-path install resolves its own assets. A build with no bundler inherits that
    requirement instead of the fix, and #2224 is what it costs when it goes wrong
  • strips test-only attributes through a local plugin, which nothing else replicates
  • minifies
  • substitutes build-time constants. The instance URL was the one with teeth, and #2198 moved that
    substitution to serve time in the Go file server, so a build with no bundler inherits a working
    mechanism and not a broken one

What is already out

Worth stating because the widget build is now the last npm consumer in the repository.

  • the documentation site is a Go static generator since #2179 and has no manifest at all
  • the e2e suite has been a Go module since #2180. Its browser driver fetches a node runtime at test
    time, which is a fetched runtime and not a tracked dependency, so it survives whatever happens here
  • the published API package and the shared frontend packages went with #2172
  • static assets needing no processing are embedded in the Go binary since #2181, which is the working
    proof that the no-build serving path carries real files
  • since #2197 there is one manifest and one lockfile. The release pipeline still runs pnpm to build
    the widget, and that step goes when the widget build does

Still open

Each of these can move the answer.

  • A definition of done. Which files and jobs actually go: the node stages in the image build, the
    remaining npm CI jobs, the pnpm steps in the release pipeline, and the git hooks. For each, the
    replacement or the accepted loss
  • Type checking, the one gate with no route yet
  • A runtime-dependency inventory. For every package the widget actually ships with: whether a
    usable ESM build exists, the licence, and whether it is vendored or dropped. Vendoring the custom
    elements understates them, because they mutate the textarea selection, emit their own events and
    lazily load data, so rendering the tags is not the same as the behaviour working
  • A build-output acceptance matrix. The generated pages, the catalogue and emoji payloads, the
    copied images, the stripped attributes and the size budgets are today the emergent result of
    separate bundler rules and plugins. Without a statement of what the output must contain, a
    replacement cannot be checked against anything
  • The integrator contract. The documented /web URLs, the component loader, and the legacy .js
    names, which must stay module-free. /web is an overlay: a --web-root file wins and only a
    missing name falls through to the embedded assets, so moving a page between the two sources
    silently changes whether an operator can override it
  • The development loop. The dev server goes with npm, and make rundev with --web-root
    replaces it
  • Message-channel hardening, available and not done. Both ends post with '*' and check the
    sender instead of the target. The parent builds the iframe URL itself, so it knows the widget
    origin and could pass it as targetOrigin and compare the origin alongside the source

Rejected

  • A full vanilla rewrite: the render layer becomes an unowned in-house framework
  • lit, alpine, solid, petite-vue: none removes more tooling than this path, and each trades a
    known small library for a less-known one
  • preact with htm over native ESM: dropping the JSX transpile drops type-checked markup, since
    the JSX pragma is what types templates today and tagged literals are opaque to the type checker.
    Unbundling every source file and locale chunk into individually fetched modules also multiplies a
    cold load's request count against the rate limit the file server applies, and the cache headers
    revalidate each one
  • Packaging the widget for npm (#1715): it must run in an iframe on the remark42 origin for OAuth
    popups, so importing it into a host bundle breaks authentication. A types-only package is a
    separate question

File: docs/plans/completed/2026-02-24-bem-to-css-modules-batch1.md

BEM → CSS Modules migration, batch 1: leaf components

Overview

  • Migrate 4 leaf BEM components to CSS Modules: button, dropdown, thread, auth-panel
  • Consolidates 19 BEM CSS files into 4 CSS module files
  • Cleans up dead CSS classes and unused props discovered during analysis
  • Follows the pattern established in PR #2013 (batch 0: dropdown-item, list-comments, subscribe-by-rss, settings)

Context

  • PR #2013 migrated 4 small components with zero visual regression (verified pixel-by-pixel on built artefacts)
  • Key learning from batch 0: mix is pure string concatenation — parent and child can be migrated independently in any order
  • These 4 "leaf" components are migrated first because they have no inward mix coupling with unmigrated parents (or the coupling is dead)
  • Batch 2 (comment-form, subscribe-by-email, comment, root) follows after this lands

Development Approach

  • Testing approach: Regular (run existing tests after each change; no new tests needed as these are CSS-only changes with no logic)
  • Complete each task fully before moving to the next
  • Make small, focused changes
  • Run cd frontend && pnpm lint and cd frontend && pnpm test after each task

Progress Tracking

  • Mark completed items with [x] immediately when done
  • Add newly discovered tasks with ➕ prefix
  • Document issues/blockers with ⚠️ prefix

Implementation Steps

Task 1: Migrate `button` to CSS Modules

7 BEM CSS files → 1 button.module.css. Uses lookup objects for dynamic kind/size/theme props.

Files to modify:

  • frontend/apps/remark42/app/components/button/button.tsx
  • frontend/apps/remark42/app/components/button/index.ts

Files to create:

  • frontend/apps/remark42/app/components/button/button.module.css

Files to delete (7):

  • button/button.css
  • button/_kind/_link/button_kind_link.css + parent dirs
  • button/_kind/_primary/button_kind_primary.css + parent dirs
  • button/_kind/_secondary/button_kind_secondary.css + parent dirs
  • button/_size/_large/button_size_large.css + parent dirs
  • button/_size/_middle/button_size_middle.css + parent dirs
  • button/_theme/_dark/button_theme_dark.css + parent dirs

Steps:

  • Create button.module.css consolidating all 7 CSS files:
    css
    .root {
      background: none;
      border: 0;
      padding: 0;
      margin: 0;
      border-radius: 4px;
      font-family: inherit;
      font-size: inherit;
      cursor: pointer;
      white-space: nowrap;
    
      &:focus {
        box-shadow: 0 0 0 2px var(--color47);
        outline: none;
      }
    
      &:disabled {
        opacity: 0.6;
        cursor: default;
      }
    }
    
    .kindLink {
      background: transparent;
      font-weight: 600;
      color: var(--color9);
    
      &:hover { color: var(--color33); }
      &:disabled, &:hover:disabled { color: var(--color9); }
    }
    
    .kindPrimary {
      background: var(--color15);
      color: var(--color6);
    
      &:hover { background: var(--color33); }
      &:hover:disabled { background: var(--color15); }
    }
    
    .kindSecondary {
      background: var(--color6);
      color: inherit;
    
      &:hover { box-shadow: inset 0 0 0 2px var(--color33); }
    }
    
    .sizeMiddle {
      height: 2rem;
      padding: 0 12px;
    }
    
    .sizeLarge {
      height: 36px;
      padding: 0 12px;
      font-size: 16px;
    }
    
    .themeDark {
      &.kindSecondary {
        background: var(--color8);
        color: var(--color20);
      }
    
      &.kindLink {
        &:disabled, &:hover:disabled { color: var(--color6); }
      }
    }
  • Update button.tsx:
    • Remove import b, { Mods, Mix } from 'bem-react-helper'
    • Add import styles from './button.module.css'
    • Add lookup objects:
      tsx
      const kindStyles: Record<string, string> = {
        primary: styles.kindPrimary,
        secondary: styles.kindSecondary,
        link: styles.kindLink,
      };
      
      const sizeStyles: Record<string, string> = {
        middle: styles.sizeMiddle,
        large: styles.sizeLarge,
      };
    • Remove mods from ButtonProps type and destructuring (never passed by any caller)
    • Change Mix type import to plain string | string[] for mix prop
    • Change className to: clsx(styles.root, kind && kindStyles[kind], size && sizeStyles[size], theme === 'dark' && styles.themeDark, mix, className)
  • Update index.ts: remove all 7 CSS imports, keep only export { Button } from './button'
  • Delete all 7 old CSS files and their BEM directories
  • Run cd frontend && pnpm lint && pnpm test — must pass before next task

Task 2: Migrate `dropdown` to CSS Modules

7 BEM CSS files → 1 dropdown.module.css. Replaces 3 DOM class queries with refs and data-dropdown attribute. Removes dead heading prop (no callers) and dead dropdown__heading class (no CSS). Updates already-migrated dropdown-item.module.css.

Files to modify:

  • frontend/apps/remark42/app/components/dropdown/dropdown.tsx
  • frontend/apps/remark42/app/components/dropdown/index.ts
  • frontend/apps/remark42/app/components/dropdown/__item/dropdown-item.module.css (change :global(.dropdown) → [data-dropdown])

Files to create:

  • frontend/apps/remark42/app/components/dropdown/dropdown.module.css

Files to delete (7):

  • dropdown/dropdown.css
  • dropdown/__content/dropdown__content.css + dir
  • dropdown/__items/dropdown__items.css + dir
  • dropdown/__title/dropdown__title.css + dir
  • dropdown/_active/dropdown_active.css + dir
  • dropdown/_theme/_dark/dropdown_theme_dark.css + dirs
  • dropdown/_theme/_light/dropdown_theme_light.css + dirs

Steps:

  • Create dropdown.module.css consolidating all 7 CSS files:
    css
    .root {
      display: inline-block;
      position: relative;
    }
    
    .content {
      position: absolute;
      z-index: 20;
      outline-width: 0;
      display: none;
      top: 100%;
      left: 0;
      transform: translate(-0.5em, 5px);
      min-width: 120px;
      max-width: 260px;
      border: 2px solid var(--color15);
      border-radius: 3px;
      padding: 0 0 5px;
    }
    
    .items {
      padding: 5px 0;
    
      &:last-child { padding-bottom: 0; }
    }
    
    .title {
      &::after {
        content: '\25BE';
        margin-left: 2px;
      }
    }
    
    .active > .content {
      display: block;
    }
    
    .themeDark > .content {
      background-color: var(--color8);
    }
    
    .themeLight > .content {
      background-color: var(--color6);
    }
  • Update dropdown.tsx:
    • Replace import b from 'bem-react-helper' with import clsx from 'clsx' and import styles from './dropdown.module.css'
    • Add contentRef = createRef<HTMLDivElement>() alongside existing rootNode ref
    • Add data-dropdown attribute to root div (for nested dropdown detection by parent traversal)
    • Replace 3 DOM queries with ref:
      • Line 78: parent.classList.contains('dropdown') → parent.hasAttribute('data-dropdown')
      • Line 95: Array.from(...).find(c => c.classList.contains('dropdown__content')) → this.contentRef.current
      • Line 118: this.rootNode.current.querySelector('.dropdown__content') → this.contentRef.current
    • Root div: className={clsx(styles.root, isActive && styles.active, theme === 'dark' ? styles.themeDark : styles.themeLight, mix)}
    • Content div: className={styles.content} with ref={this.contentRef}
    • Items div: className={styles.items}
    • Button mix: mix={[styles.title, titleClass]} (clsx in Button handles arrays)
    • Remove dead heading prop from Props type and the heading div from JSX (prop is never passed by any caller, class has no CSS)
  • Update dropdown-item.module.css: change & > :global(.dropdown) to & > [data-dropdown]
  • Update index.ts: remove all 7 CSS imports, keep export { Dropdown } and export { DropdownItem }
  • Delete all 7 old CSS files and their BEM directories
  • Run cd frontend && pnpm lint && pnpm test — must pass before next task

Task 3: Migrate `thread` to CSS Modules

3 BEM CSS files → 1 thread.module.css. Levels 0-5 had no CSS rules — only level 6 matters. The mix prop (receives "root__thread" from root component) is passed through as a plain class string.

Files to modify:

  • frontend/apps/remark42/app/components/thread/thread.tsx
  • frontend/apps/remark42/app/components/thread/index.ts

Files to create:

  • frontend/apps/remark42/app/components/thread/thread.module.css

Files to delete (3):

  • thread/thread.css
  • thread/__collapse/thread__collapse.css + dir
  • thread/_theme_dark/thread_theme_dark.css + dir

Steps:

  • Create thread.module.css consolidating all 3 CSS files:
    css
    .root {
      position: relative;
    }
    
    .indented {
      margin-left: 17px;
    }
    
    .level6 .level6 {
      margin-left: 0;
    }
    
    .collapse {
      height: calc(100% - 50px);
      width: 11px;
      position: absolute;
      top: 50px;
      left: -4px;
      cursor: pointer;
    
      &::after {
        display: block;
        content: '';
        position: absolute;
        left: 5px;
        top: 0;
        border-left: 1px dotted var(--color35);
        height: 100%;
      }
    
      &:hover::after {
        transform: translateX(-1px);
        border-left: 3px solid var(--color10);
        z-index: 10;
      }
    }
    
    .collapsed {
      composes: collapse;
      width: 18px;
      height: 18px;
      top: 12px;
      left: 0;
      display: flex;
      text-align: center;
      opacity: 0.8;
      border-radius: 2px;
      border: 1px solid;
    
      &::after { display: none; }
      &:hover { opacity: 1; }
      &:hover::after { transform: translateX(0); }
    
      & > div {
        position: relative;
        top: 6px;
        left: 3px;
        width: 12px;
        height: 2px;
        border-bottom: 2px solid;
    
        &::before, &::after {
          content: '';
          width: 100%;
          height: 2px;
          border-bottom: 2px solid;
          position: absolute;
          top: -4px;
          left: 0;
        }
    
        &::after { top: 4px !important; }
      }
    }
    
    .themeDark {
      & .collapse {
        &::after { border-color: var(--color36); }
        &:hover::after { border-color: var(--color6); }
      }
    }
  • Update thread.tsx:
    • Replace import b from 'bem-react-helper' with import clsx from 'clsx' and import styles from './thread.module.css'
    • Root div: className={clsx(styles.root, indented && styles.indented, level === 6 && styles.level6, theme === 'dark' && styles.themeDark, mix)}
    • Collapse div: className={collapsed ? styles.collapsed : styles.collapse}
  • Update index.ts: remove all 3 CSS imports, keep only export { Thread } from './thread'
  • Delete all 3 old CSS files and their BEM directories
  • Run cd frontend && pnpm lint && pnpm test — must pass before next task

Task 4: Migrate `auth-panel` BEM remnants to CSS Modules

2 BEM CSS files → merge into existing auth-panel.module.css. Removes dead global class strings alongside module classes. Has test file to update.

Dead code to clean up:

  • auth-panel__pseudo-link — no CSS rules, remove from JSX
  • auth-panel_loggedIn / auth-panel_theme_* — dead mods from b(), no CSS rules
  • clsx('user', styles.user) etc. — bare global strings ('user', 'user-profile-button', 'user-avatar', 'user-logout-button') have no CSS; remove from clsx()

Files to modify:

  • frontend/apps/remark42/app/components/auth-panel/auth-panel.tsx
  • frontend/apps/remark42/app/components/auth-panel/auth-panel.module.css
  • frontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsx
  • frontend/apps/remark42/app/components/auth-panel/index.ts

Files to delete (2):

  • auth-panel/auth-panel.css
  • auth-panel/__column/auth-panel__column.css + dir

Steps:

  • Merge BEM styles into existing auth-panel.module.css — add these classes after existing ones:
    css
    .root {
      display: flex;
      justify-content: space-between;
      font-size: 14px;
      line-height: 16px;
      align-items: center;
    }
    
    .column:last-child {
      margin-left: 8px;
      text-align: right;
    }
    
    .columnSeparated > * + * {
      position: relative;
      display: inline-block;
      margin-left: 20px;
    
      &::before {
        position: absolute;
        left: -15px;
        display: inline-block;
        width: 10px;
        text-align: center;
        content: '•';
      }
    }
    
    .adminAction { }
  • Update auth-panel.tsx:
    • Remove import b from 'bem-react-helper'
    • Root div: className={styles.root} (drop dead theme/loggedIn mods)
    • Column divs: className={styles.column}
    • Separated column: className={clsx(styles.column, styles.columnSeparated)}
    • Remove className="auth-panel__pseudo-link" from <a> (dead class, no CSS)
    • Clean up dual class patterns: clsx('user', styles.user) → styles.user, same for userButton/userAvatar/userLogoutButton
    • Change mix="auth-panel__admin-action" → className={styles.adminAction} on both Button calls
  • Update auth-panel.test.tsx:
    • Add import styles from './auth-panel.module.css'
    • .find('.auth-panel__admin-action') → .find(`.${styles.adminAction}`)
    • .find('.auth-panel__column') → .find(`.${styles.column}`)
  • Update index.ts: remove 2 CSS imports, keep export * from './auth-panel'
  • Delete auth-panel.css and __column/ directory
  • Run cd frontend && pnpm lint && pnpm test — must pass before next task

Task 5: Verify acceptance criteria

  • Verify all 4 components use CSS Modules (no remaining b() calls in migrated files)
  • Run full frontend test suite: cd frontend && pnpm test
  • Run frontend linter: cd frontend && pnpm lint
  • Grep for old BEM class names to verify no remaining references to deleted CSS files
  • Verify bem-react-helper is no longer imported in any of the 4 migrated components

Technical Details

Class naming convention

  • BEM block → root
  • BEM element → camelCase of element name (dropdown__content → content, auth-panel__column → column)
  • BEM modifier → camelCase (button_kind_primary → kindPrimary, thread_theme_dark → themeDark)
  • Combined modifier → compound &. nesting (button_theme_dark.button_kind_secondary → .themeDark { &.kindSecondary { ... } })

Button `mix` prop after migration

  • Type changes from Mix (bem-react-helper) to string | string[] | undefined
  • Passed directly to clsx() which handles all these types
  • Callers in batch 2 (not yet migrated) continue passing BEM strings — works fine
  • Already-migrated callers pass module hashes — works fine
Before After
classList.contains('dropdown') hasAttribute('data-dropdown')
querySelector('.dropdown__content') this.contentRef.current
Array.from(...).find(c => c.classList.contains('dropdown__content')) this.contentRef.current

Files to delete (total: 19 CSS files + BEM directories)

  • Button: 7 CSS files
  • Dropdown: 7 CSS files
  • Thread: 3 CSS files
  • Auth-panel: 2 CSS files

Post-Completion

  • Visual smoke test: cd frontend && pnpm dev:app, verify button variants, dropdown open/close, thread collapse/expand, auth-panel admin actions in both light and dark themes
  • Built artefact comparison: build docker image from branch, compare against master (same method as PR #2013)
  • Batch 2 (comment-form, subscribe-by-email, comment, root) as follow-up

File: e2e/README.md

End-to-end tests

Drives the widget in a real browser through playwright-go against a remark42 built from this checkout.

The import path is github.com/mxschmitt/playwright-go, which is what the module declares even though its repository is playwright-community/playwright-go. Do not rewrite it to match the repository URL: the versions that carry the matching path cannot install their driver.

Prerequisites

  • Docker with compose, which the suite shells out to
  • A Go toolchain matching e2e/go.mod
  • Network access on the first run: the Playwright driver and the browsers are downloaded into the user cache directory, ~/.cache/… on Linux and ~/Library/Caches/… on macOS, and that download is the slowest part of a cold run
  • make e2e-cover additionally needs the frontend dependencies installed, since the widget report is made with nyc: run pnpm install in frontend/apps/remark42. The plain make e2e does not.

Running

text
make e2e

The suite brings the compose stack up itself when it does not find one already answering, and tears it down again afterwards. To keep the containers between runs, start them first:

text
make e2e-up
make e2e
make e2e-down

The default run admits four top-level tests at once. Each browser context has isolated cookies, storage, a unique thread URL and a stable test-only client IP, so the backend's per-IP controls remain local to one reader. The Telegram cases share one bot stub, and the last-comments case reads a site-wide feed, so those groups stay serial.

Run from e2e/; the compose path is relative to it. A single test:

text
cd e2e && go test -tags=e2e -run TestComment_ReplyNestsUnderItsParent -v ./...

make e2e-ui runs with a visible browser and leaves the stack up. The env vars behind it:

  • E2E_HEADLESS=false shows the browser and slows it to 50ms a step
  • E2E_KEEP=1 leaves the containers running afterwards, which only matters when the suite brought them up itself
  • E2E_DEBUG=1 logs every HTTP response of status 400 or above
  • E2E_BROWSERS=chromium narrows the engines the rendering tests use, which is the quickest way to shorten a local run

Rate-limit responses are logged whether or not E2E_DEBUG is set, because they surface otherwise as unexplained locator timeouts.

The build tag keeps these out of go test ./...; nothing runs without -tags=e2e.

Coverage

text
make e2e-cover

Reports what the suite reaches, in the backend and in the widget. Neither is visible to the tool
that usually measures it: the backend runs in a container instead of in the test process, so
go test -cover sees nothing, and the widget runs in the browser, where jest reports only what
its own suites reach. compose-e2e-coverage.yml overlays the stack so one COVERAGE argument
instruments both, and e2e/coverage.sh runs the suite and reports each.

The backend is built with go build -cover and mounts a profile directory per instance. The
profiles are merged into e2e/coverage/e2e.cov and the total printed.

The widget is built with babel-plugin-istanbul, so the bundle counts what runs and keeps the
counters on the page. The suite reads them out of every frame into e2e/coverage/frontend,
whenever a document is about to be replaced and again at cleanup, and nyc merges that directory
and reports it.

The overlay is a separate file because a bind mount in the short form creates its host path when
it is missing. Carried in compose-e2e-test.yml, it would have an ordinary make e2e create
e2e/coverage itself, owned by whatever the daemon runs as. A plain run leaves no trace of
coverage, and compose-e2e-test.yml is what a plain run uses.

Three ways this could report nothing and still look like a result, so each one fails instead:

  • A killed instance writes no profile. The instances are asked to stop with a 30 second grace,
    and every directory is then checked rather than the total, because six clean exits and one kill
    merge into a plausible report with that instance's code reading as unreached. The failure names
    the services that produced nothing.
  • A failing suite still has coverage worth keeping. The run's exit status is preserved, but the
    instances are stopped and the profiles merged either way, so the run that most needed explaining
    is not the one that discards its evidence.
  • A plain stack answers for an instrumented one. E2E_COVERAGE feeds e2e/stamp.sh as well as
    the image build, so the two stacks carry different stamps and the suite refuses the one it was
    not built for. Only the value 1 instruments, in the stamp and in the Dockerfile alike, so
    E2E_COVERAGE=0 is the plain build and not a third variant.

The profile is written in atomic mode and with the same _mock.go exclusion the unit run
applies, so the two are one population and can be reported as a single figure. Appending it to the
unit profile without repeating the mode: header is enough:

text
cat profile.cov > combined.cov && tail -n +2 e2e/coverage/e2e.cov >> combined.cov
cd backend && go tool cover -func=../combined.cov

The read has to run from backend/. covdata writes file names as full import paths, and
go tool cover resolves them through go list, so from the repository root, which has no
go.mod, it reports that no module provides the package instead of anything about coverage.

atomic is also the only mode -race accepts, so the instrumented build stays compatible if the
binary is ever built with it.

Three things the widget half has to get right, and each bites silently when it is wrong:

  • Both the page and its iframe carry counters. The embed script runs in the page the test
    navigated, the widget runs in the frame below it, and a test may open more frames still, so
    every frame is asked and one with no bundle in it is not an error.
  • They belong to the document, so anything that replaces it throws them away. A snapshot is
    taken before every navigation, every reload and the widget's own destroy, as well as at
    cleanup, because a test that acts and then reloads would otherwise report only what its last
    document ran, and the total would look reasonable while missing the steps the test exists for.
    Reading only at the end cost six points, and nothing about the figure said so.
  • The paths are the paths the bundle was built at. The image builds under
    /srv/frontend/apps/remark42, which resolves nowhere on the host, so the prefix is stripped as
    the counters are read and the report runs from the package the remaining paths are relative to.

The one configuration that is not optional is coverageGlobalScopeFunc: false in .babelrc.js.
The default preamble reaches the global object through new Function, and the widget document is
served with script-src 'self' 'unsafe-inline'; the browser refuses it and the bundle throws
before rendering anything, so the widget never appears at all.

The instrumentation is a build argument on both halves and never what a published image ships.

When something fails

A failed test writes a Playwright trace to e2e/traces/, which CI uploads as an artifact. Nothing else writes one, so a run carrying the artifact is a run with a failure to look at. Open one with npx playwright show-trace e2e/traces/<name>.zip.

CI does not retry a failing test. The suite is young enough that a failure is evidence about the suite itself, and a retry is what would hide an intermittent regression. E2E_RUN_ID stamps the threads a run uses with the CI run id, so a thread url in a trace names the run it came from. It does not carry the data across: the stack is disposable, and a local run against the same id gets those urls on an empty database.

Beyond that: docker compose -f compose-e2e-test.yml logs for the server side, and mailpit's web UI on http://127.0.0.1:8025 for anything email.

A failing test also logs whatever the browser wrote to its console, which is where a failed request or a widget-side error shows up. Those are context only. What does fail a test on its own is an uncaught exception in the page and a rate-limit response, both of which otherwise corrupt a run silently: a widget that throws while rendering still satisfies most assertions here, and a refused /auth/status renders as a signed-out reader.

The stack the suite runs against

The suite refuses a running stack that was not brought up from 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.

e2e/stamp.sh digests the content of backend, frontend, Dockerfile, docker-init.sh and e2e/telegramstub, which is the second image compose builds from sources here; compose puts it in the container's environment and the suite reads it back. It digests content and not HEAD, so a commit touching only the suite does not invalidate a stack. make e2e-up stamps the same way, so a stack started by hand is accepted. On a mismatch the failure says to run make e2e-down.

Before pushing, cd e2e && go vet -tags=e2e ./... and golangci-lint run --build-tags=e2e --config ../backend/.golangci.yml. CI runs both, and neither is covered by a plain go vet ./... because of the build tag.

The stack

compose-e2e-test.yml at the repository root runs twelve services, each bound to the loopback interface since it holds a known secret and an admin shared id:

  • remark42 on :8080, with the dev oauth2 provider on :8084, anonymous and email sign-in
  • remark42-shortedit on :8081, with EDIT_TIME=15s and anonymous sign-in only, since the dev oauth2 provider's port is fixed at 8084 and cannot be published twice. It exists so the expired-edit path is observable without holding a test open for the default five minutes
  • remark42-adminedit on :8082, with ADMIN_EDIT=true and the same short window, for the unlimited window an admin is supposed to get
  • remark42-jwtheader on :8083, with AUTH_SEND_JWT_HEADER=true, where the token arrives in a header instead of a cookie and the frontend has to keep it itself
  • remark42-noauth on :8085, with no auth provider at all, which the widget has to say something about, and with ALLOWED_HOSTS set to its own address so it doubles as the instance that refuses to be framed elsewhere
  • remark42-anonvote on :8086, with ANON_VOTE and the VOTES_IP it depends on, since the default configuration turns an anonymous vote down
  • host-site on :8090, an nginx serving e2e/hostsite/, which is a page on an origin the widget is not served from. Every other host page here is served by remark42 itself, so without it the separate-domain setup the manuals describe is never exercised. post.html embeds the main instance; restricted.html embeds the one whose ALLOWED_HOSTS names only itself, which is the refusal case
  • remark42-https on :8443, the widget over TLS with AUTH_SAME_SITE=none and AUTH_SEND_JWT_HEADER=true. The header mode is what makes the widget write its own cookies through setAuthCookie, so the attributes it chooses are observable at all. Anonymous and email sign-in are both enabled, since the third-party cases run each flow: the writer keys off the X-JWT header rather than off the provider, and that is an assumption worth measuring rather than asserting
  • host-site-https on :8444, an nginx serving the same e2e/hostsite/ over TLS, so the embed is cross-site and secure. post-https.html is its page
  • remark42-telegram on :8087, telegram auth pointed at the stub below through TELEGRAM_API_URL. It has its own instance because telegram is an auth provider, and adding it to the main one would change the provider list every other case reads out of the auth panel
  • telegram-stub on :8091, which answers as the Telegram bot API for the four calls the auth flow makes, takes an injected update through /control/send, and exposes the bot's replies through /control/sent. Those endpoints represent the exchange inside Telegram that a browser cannot perform. Its source is e2e/telegramstub, the one file in this module without the e2e build tag, and compose builds it as its own image from a throwaway module so the suite's browser tooling stays out of it
  • mailpit on :8025, which catches the email-auth verification message and the subscription token for the suite to read back

Both TLS services read a self-signed certificate from e2e/tls/, which e2e/tls/generate.sh writes and .gitignore keeps out of the tree. make e2e-up, the workflow and ensureStack all run it before compose, so bringing the stack up by hand with a bare docker compose up is the one path that needs it run first. Every browser context and the readiness client accept that certificate, and they talk to nothing else.

The main instance enables the notify module (NOTIFY_USERS=email). Without it email_notifications is false in the config, the widget never renders the subscribe control, and the whole subscribe, confirm and unsubscribe flow is unreachable from a browser.

The remark42 instances beyond the first and remark42-telegram offer anonymous sign-in only, for the reason remark42-shortedit does: the dev oauth2 provider binds a port fixed at 8084 and cannot be published twice. remark42-adminedit enables email authentication and sets ADMIN_SHARED_ID to the SHA-1-derived ID of [email protected], which is the address its browser case uses.

Four settings exist for the tests and not for realism, and each is there for a reason:

  • REMARK_URL uses a hostname, not 127.0.0.1. The dev oauth2 server binds whatever host it reads out of REMARK_URL (localBindAddr in go-pkgz/auth), and a loopback bind inside a container cannot be published. The browser maps the names back with --host-resolver-rules.
  • UPDATE_LIMIT=100, because the default of 0.5 updates a second rejects any test that posts twice in a row.
  • Each browser context has its own forwarded client IP. Fresh contexts use the limiter's initial allowance; later auth actions wait one token-refill interval through pauseForAuthLimit. The limit is a bare literal at backend/app/rest/api/rest.go:242, not a setting.
  • TRUSTED_PROXY stays unset. The stack is loopback-only, and the harness verifies that its per-context X-Real-IP reaches the backend and separates auth limiter buckets.

VOTES_IP follows the same reader model: one browser context is one voter IP. A vote-deduplication case that needs repeated actions from one reader must perform them in the same context.

What this suite cannot reach

The stack carries TLS on two services, so behaviour the browser gates on the page protocol is reachable: Secure cookies, SameSite=None, Partitioned, and anything keyed on window.location.protocol. https_test.go is where those cases live. What is still out of reach is a browser engine other than Chromium for them, since the resolver rules the hostnames need are a Chromium flag.

The Telegram fixture reaches both authentication and subscription. The stub answers getMe, getUpdates, sendMessage and getUserProfilePhotos, so it does not hold the widget against the public bot API's semantics, and the reader's handoff into the Telegram client is injected from the test process. The suite exercises both consumers of TELEGRAM_API_URL: auth signs a reader in through the bot, while notifications confirm, persist, remove and repeat a subscription through the same update poll.

The trap that remains is which cookie policy a run is under. 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. A run configured that way keeps an ordinary third-party cookie exactly as it would with no flags at all, so it proves nothing while looking like it proved something. The lever is IgnoreDefaultArgs on the launch options: drop that default entry and re-supply --disable-features without that one feature, which is what TestHTTPS_SessionSurvivesThirdPartyCookieBlocking does. Measured on a cross-site https embed:

ordinary third-party cookie Partitioned cookie
Playwright defaults kept kept
partitioning left enabled dropped stored, with its partition key

So a blocking run has to assert a control before anything it reports can be believed: set an ordinary SameSite=None cookie from inside the widget frame and require the browser to drop it. If it survives, the run is not blocking anything. The argument list is Playwright's own and version-specific, so that control is what keeps it from rotting silently.

That control has to read the cookie back through document.cookie in the same frame evaluate that writes it, never through page.Context().Cookies(). The two are separate channels with no ordering between them: in the Chromium that Playwright 1.62.1 ships, CookieJar::SetCookie queues the write and returns, and the renderer's own document.cookie getter is the barrier that forces it to settle, while Playwright's context read is a browser-session Storage.getCookies that never touches that frame's jar. A control read that way can find the name absent because the write has not landed, which is the one outcome it exists to rule out. It also writes a valid Secure; SameSite=None; Partitioned sentinel and requires that one to be present, so "the browser refused the control" is distinguishable from "nothing was written at all".

None of that reaches the widget's own storage fallback, which the auth panel offers as comments.html when IS_THIRD_PARTY && !IS_STORAGE_AVAILABLE. IS_STORAGE_AVAILABLE stays true even with partitioning properly enforced, because Chromium partitions localStorage instead of denying it, so the probe behind that constant never throws and the condition cannot fire. That case needs WebKit, not a Chromium flag.

Partitioned cookies do not make cross-domain OAuth work, whatever the plan once implied. The partition key is the top-level site at the moment the cookie is set, and oauthSignin opens a popup, which is its own top-level context: the callback cookie is keyed to the auth host while the frame is keyed to the embedder, and the two never match. The forms that do work embedded are the ones whose cookie is written inside the frame, which is anonymous, email and telegram. A case asserting OAuth works cross-domain would be asserting something untrue.

Isolation

Each test gets its own comment thread from a query string on the demo page, since the demo page passes window.location.href as remark_config.url and remark42 keys comments by it. A per-run id keeps threads apart from those an earlier run left behind.

Thread URLs deliberately keep the underscores a test name carries, since collapse persistence keys off the page url and a url containing an underscore is the case worth covering.

Browsers

The iframe_test.go group runs in Chromium, Firefox and WebKit. It is about rendering and not logic: the widget holds the frame hidden until its document reports itself inited, and the opaque canvas that guards against is a WebKit behaviour, so Chromium alone would not exercise it.

Those tests address the server as 127.0.0.1 and not by name, since --host-resolver-rules is a Chromium flag and they need no dev oauth2, which is the only reason the hostname exists.

Everything else runs in Chromium alone, for the same reason inverted: those tests sign in, sign-in needs the dev oauth2 provider, and reaching it by name from the host is Chromium-only. Running them in the other engines would mean putting the suite back inside the compose network.

Selectors

The production bundle strips data-testid, so tests use what ships: the stable class hooks the widget keeps outside CSS modules (.auth-button, .auth-submit, .comment-actions, .comment-actions-additional, .sort-picker, .preloader, .comments-counter), title attributes on icon-only controls, and visible text. These shapes are worth knowing:

  • .auth only exists while signed out, so waiting on it hangs after sign-in. widget() waits on the comment form, which is present either way.
  • The production build hashes every css-module class name to a short opaque id, so a component's own class is not something a test can hold. role is: the footer is [role="contentinfo"] and the edit countdown is [role="timer"].
  • Comments render through an IntersectionObserver, so one below the fold is an empty article with no text in it. That makes any absence assertion written as a text filter pass whether the comment is gone or merely off screen; count articles instead, which is what articleCount is for.
  • Collapsing a thread hides the comment text, so a locator filtered by that text stops matching the element under test. TestThread_CollapsePersistsAcrossReload anchors on the comment's id instead.
  • text=Foo matches a case-insensitive substring, so a comment whose own text contains the phrase satisfies a locator meant for the page's own copy, and two matches is a strict-mode failure. text="Foo" matches exactly. This surfaces late: a comment below the fold is empty under the IntersectionObserver, so the collision can pass locally and fail in CI.
  • ? is a single-character wildcard in the glob page.Route takes, so a pattern written with a query string never matches the request it names, the route is never intercepted, and the case passes against an unmodified response. Use a regexp for anything with a query string, as TestProfile_LoadingAndFailureStates does.

File: frontend/CLAUDE.md

Frontend gotchas

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

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

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

  • Dockerfile (production image) — FROM node:X-alpine and npm i [email protected]
  • frontend/.nvmrc — not read by CI at all; only matters to a human running nvm use locally. This is the one that drifted unnoticed: it sat at 16 through the whole node-20 migration because nothing red ever pointed at it.
  • frontend/apps/remark42/package.json, both its packageManager field and its engines block
  • pnpm/action-setup@vN blocks in .github/workflows/ci-frontend.yml (5) and release.yml (2) — pin version: to the exact patch (e.g. 10.10.0), matching packageManager, not just the major. A floating major here is silent in CI (it just resolves to whatever the latest patch is at run time) but breaks the "Dockerfile and CI use the same pnpm" guarantee.
  • node: matrices in .github/workflows/ci-frontend.yml (every entry, not just the first) and the node-version: values in release.yml
  • the two dependency-free test commands live in package.json as test:unit and test:unit:node, are called by name from release.yml, and are repeated inline in the unit job of ci-frontend.yml, which carries no pnpm by design. Change either script and that job keeps running the old one, green

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

engines.node states the major we support, currently >=24, which is the active LTS; 22 has dropped to maintenance. @babel/core 8 wants ^22.18 || >=24.11 and size-limit 13 wants ^22.18 || ^24 || >=26, so 22 was the floor rather than the target. Individual dev dependencies can be stricter within that major (undici wants >=20.18.1); do not chase those patch floors into engines or the docs, or every lockfile refresh becomes a documentation change.

One manifest, at `frontend/apps/remark42`

frontend/ holds no package.json, no lockfile and no pnpm-workspace.yaml. Everything pnpm reads
lives in frontend/apps/remark42: the dependencies, packageManager, engines and the
pnpm.overrides block. Install and run from there, not from frontend/.

The directory nesting is kept because every path in the repository points at it, from the Dockerfile
and the workflows to the published contributing docs. frontend/ still carries .nvmrc, .husky
and this file, none of which pnpm reads.

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

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

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

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

node's native fetch requires absolute URLs in tests

It rejects relative request URLs, and the failure is
silent: requests simply never match. Any test harness that mocks fetch needs absolute base URLs and
a jsdom base URL set.

Babel is the only thing that compiles

.babelrc.js drives both the bundle, through babel-loader, and the tests, which get it as
babel-jest's configFile in jest.config.mjs. That indirection is not decoration: .babelrc.js
is a file-relative config and would not reach node_modules, so the ESM-only packages in
transformIgnorePatterns would arrive at jest untransformed.

Babel merges an env block over the root rather than replacing it, so anything the test run must
not see has to be decided before the object is built rather than put in an env. jest.config.mjs
sets BABEL_ENV itself and .babelrc.js reads it for two things: the compile targets, and the
data-testid stripper, which the suites query by and the bundle must not carry. Verify with
grep -c data-testid public/*.mjs after a production build; it must be 0. The plugin matches a
literal JSXAttribute, which is every use in the tree; a spread such as
{...{'data-testid': x}} would reach production, and that grep is what would catch it.

tsconfig.json types but never emits, so its target has no effect on output; browserslist in
.babelrc.js decides that. Type checking runs out of band through
fork-ts-checker-webpack-plugin.

Type-only imports have to say so

Babel compiles one file at a time with no type information, so it cannot tell that an import is
types-only. Left unmarked, the module and everything it imports stay in the bundle, and a single
type import of a store-connected component is enough to pull the whole redux graph into an entry
that never uses it.

verbatimModuleSyntax in tsconfig.json and @typescript-eslint/consistent-type-imports are what
enforce this, and the fix has to be a separate import type { ... } statement. Inline
import { type X } does not work here: verbatim semantics keep the statement, so the module is
still loaded. That is also why no-duplicate-imports runs with allowSeparateTypeImports, since a
module legitimately appears twice.

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

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

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

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

`@babel/core` is pinned to 8 for the whole dependency tree

@jest/transform and istanbul-lib-instrument depend on @babel/core 7 outright, and a babel 8
preset loaded into a babel 7 core fails on the first enum it meets. The pnpm.overrides entry in
frontend/apps/remark42/package.json is what stops that. eslint-config-preact is the one consumer that cannot
take it: its @babel/eslint-parser loads babel 7 syntax plugins that babel 8 rejects, so a second
scoped override, eslint-config-preact>@babel/core, holds that subtree on 7.

Held-back majors

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

  • eslint 10 (eslint-config-preact peers on ^9), typescript 7, redux 5 with redux-thunk 3, node-emoji 2 (renames the field search() returns), @formatjs/cli 6 (the translation extraction pipeline), and postcss-preset-env 11 with cssnano 8 and postcss-html 2, which change generated CSS
  • redux 4

`/web` has a second source

privacy.html, markdown-help.html and 400x400.jpeg live in backend/app/webassets/assets and
are embedded in the Go binary. The frontend build wins for any name present in both, which is what
lets an operator override one by dropping a file next to the frontend files in --web-root. That
directory replaces the embedded frontend outright when it exists, so an override belongs in a
populated one. These three sit outside this toolchain: prettier, stylelint and pnpm lint do not
see them, and they are served unminified. devServer.static lists the build output first and that
directory second, matching the backend's order, so links to them resolve on the dev port too.

eslint config resolves from the process cwd

eslint.config.mjs lives in apps/remark42, and eslint loads the config next to the directory it
is run from rather than the one nearest the file being linted. Anything that invokes eslint has
to have apps/remark42 as its working directory: it is where every script runs, .husky/pre-commit
cd's into it, and an IDE integration needs
"eslint.workingDirectories": ["frontend/apps/remark42"].

Rules that only exist under a plugin's flat-config export are spread in explicitly; the block that
re-enables project rules is the last entry in the array, because eslint-config-prettier (pulled in
by prettierRecommended) switches several of them off and later entries win.

Verifying a build didn't regress

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

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

Where the alerts actually were

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

Don't import `preact/compat`

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

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

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

app/common/intl.tsx provides IntlProvider, useIntl, createIntl, defineMessages,
FormattedMessage and IntlShape. The formatting itself lives in app/common/intl-message.ts,
whose only preact reference is a type-only import that erases at build time, so it pulls in nothing at runtime, and which is where every rule below is implemented; intl.tsx is the
binding over it and re-exports createIntl and defineMessages unchanged, so no call site can tell
the two apart. formatjs extract (translation:extract) finds messages
by recognising the identifiers defineMessages, FormattedMessage and intl.formatMessage
in the AST, not by import source, so those three names are fixed. Rename any of them and
extraction returns nothing, with no error and a zero exit code.

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

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

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

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

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

translation:check validates every translated value's markup and placeholders against the
English string it translates, mirroring the binding's rule, and the catalogue sweep in
app/common/intl.test.tsx additionally renders the two messages that carry a link.
app/common/intl-message.unit.test.ts covers the formatter's own edge cases -- broken markup, an
unclosed tag, a regex metacharacter in a tag name -- which are the ones no shipped catalogue can
produce and so no sweep can reach. What
neither catches is unsupported ICU syntax, since a plural form is well-formed text as far as
both are concerned. A message the binding cannot resolve falls back to the English source
rather than reaching the page, so without these checks a broken translation is invisible in
the interface.

`*.unit.test.ts` runs with no toolchain, and that is what it is for

Two test layers, split by what they need to run. Jest owns anything with a DOM in it; *.unit.test.ts
files import nothing but the module under test, its fixtures and node:test, and run under plain
node straight from source:

sh
pnpm test:unit

The unit job in ci-frontend.yml runs them with no pnpm install step, deliberately.
release.yml runs the same script after an install, as a second gate. The layer's value is that a
function over plain values can be tested without a bundler, a jsdom or a node_modules, and the
absent install is the only thing that keeps that true: add an import that needs resolving and the
job goes red instead of quietly acquiring the dependency. jest.config.mjs excludes the suffix, so
a file is in exactly one layer.

node --test exits 0 on a glob that matches nothing, so it cannot tell a renamed suffix from a
passing run. The CI step counts the files first and fails when none match. Two import rules follow
from node's ESM resolver, and they contradict the repository's usual style, so they are local to
this layer:

  • write the extension in full, ./types.ts. This is why app/store/thread/reducers.ts and
    app/store/user/reducers.ts import ./types.ts while their neighbours do not, and why
    app/common/json-store.ts and app/utils/errorUtils.ts carry it too. allowImportingTsExtensions in tsconfig.json is what
    stops tsc rejecting it, and webpack and jest resolve the explicit path unchanged
  • import by relative path, never through the common/… alias, which only webpack, jest and
    tsc know about. A type-only import of an alias is fine, since it erases before either runtime
    sees the file

A module that mixes computation with browser state gets split instead of skipped:
app/common/intl-message.ts and app/hooks/session-storage.ts are the pure halves of intl.tsx
and useSessionState.ts, and session-storage.ts takes the store as an argument instead of reading
the sessionStorage global, which is what lets a test hand it a plain object.


File: frontend/apps/remark42/codecov.yml

codecov:
require_ci_to_pass: false

ignore:

jest leaves these out of collectCoverageFrom; the node dependency-free run reports them, so the

exclusion has to be here to apply to both uploads

  • app/stubs
  • app/mocks
  • .editorconfig
  • .eslitnrc.js
  • .lintstagedrc.cjs
  • .prettierignore
  • .prettierrc.js
  • .size-limit.js
  • README.md

comment: false


File: frontend/apps/remark42/package.json

{
"name": "@remark42/app",
"version": "0.16.0",
"license": "MIT",
"scripts": {
"dev": "cross-env REMARK_URL=http://127.0.0.1:8080 pnpm dev:custom",
"dev:demo": "cross-env REMARK_API_BASE_URL=https://demo.remark42.com pnpm dev:custom",
"dev:custom": "webpack serve --mode development",
"build": "webpack --mode production",
"build:analyze": "webpack --mode production --analyze",
"lint": "run-p lint:",
"lint:styles": "stylelint "app/**/
.css" "templates/"",
"lint:scripts": "eslint --max-warnings=0 "
/.{ts?(x),?(c)js,mjs}"",
"test": "jest",
"test:unit": "node --test "app/**/
.unit.test.ts"",
"coverage": "jest --coverage",
"format": "prettier --write ".//*.{js,mjs,ts,tsx,css,html}"",
"size-check": "cross-env NODE_ENV=production npm run build && size-limit",
"type-check": "tsc -p tsconfig.json --noEmit",
"translation-check": "run-s translation:extract translation:check",
"translation:extract": "formatjs extract --out-file=./extracted-messages/messages.json "
/.{ts,tsx}" --ignore="**/.d.ts" --ignore="/*.{test,spec}.{ts,tsx}" --ignore="app/tests/" --ignore="app/mocks/" --ignore="app/stubs/"",
"translation:generate": "node ./tasks/generateDictionary.js",
"translation:check": "node ./tasks/checkTranslation.js",
"prepare": "if [ -z "$CI" ]; then cd ../../.. && husky install frontend/.husky; else echo "Skip Husky Hooks"; fi"
},
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"packageManager": "[email protected]",
"dependencies": {
"@github/markdown-toolbar-element": "^2.2.3",
"@github/text-expander-element": "^2.9.4",
"@ungap/custom-elements": "^1.3.0",
"clsx": "^2.1.1",
"lodash-es": "^4.18.1",
"node-emoji": "^1.11.0",
"preact": "10.29.8",
"redux": "^4.2.0",
"redux-thunk": "^2.4.1"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/preset-env": "^8.0.2",
"@babel/preset-react": "^8.0.1",
"@babel/preset-typescript": "^8.0.1",
"@formatjs/cli": "^5.0.2",
"@size-limit/file": "^13.0.3",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/preact": "3.2.4",
"@types/jest": "^30.0.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^26.2.0",
"@types/node-emoji": "^1.8.2",
"@types/redux-mock-store": "^1.5.0",
"@types/webpack-env": "^1.18.8",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-istanbul": "7.0.1",
"clean-webpack-plugin": "^4.0.0",
"confusing-browser-globals": "^1.0.11",
"cross-env": "^10.1.0",
"css-loader": "^7.1.4",
"css-minimizer-webpack-plugin": "^8.0.0",
"dotenv": "^17.4.2",
"eslint": "^9.39.1",
"eslint-config-preact": "^2.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.6",
"file-loader": "^6.2.0",
"fork-ts-checker-webpack-plugin": "^9.1.0",
"html-webpack-plugin": "^5.6.8",
"husky": "^8.0.1",
"identity-obj-proxy": "^3.0.0",
"incstr": "^1.2.3",
"jest": "^30.4.2",
"jest-environment-jsdom": "^30.4.1",
"jest-fetch-mock": "^4.2.0",
"jest-localstorage-mock": "^2.4.26",
"lint-staged": "^13.0.3",
"mini-css-extract-plugin": "^2.10.2",
"npm-run-all": "^4.1.5",
"nyc": "17.1.0",
"postcss": "^8.5.26",
"postcss-html": "^1.8.1",
"postcss-loader": "^8.2.1",
"postcss-preset-env": "^7.7.2",
"prettier": "^3.9.6",
"redux-mock-store": "^1.5.5",
"size-limit": "^13.0.3",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"stylelint-config-standard": "^40.0.0",
"stylelint-declaration-strict-value": "^1.11.1",
"stylelint-value-no-unknown-custom-properties": "^6.1.1",
"tsconfig-paths-webpack-plugin": "^4.2.0",
"typescript": "^5.9.3",
"webpack": "^5.109.2",
"webpack-bundle-analyzer": "^5.3.1",
"webpack-cli": "^7.2.2",
"webpack-dev-server": "^5.2.5"
},
"pnpm": {
"overrides": {
"decode-uri-component@<0.2.1": ">=0.2.1",
"loader-utils@>=2.0.0 <2.0.3": ">=2.0.3 <3.0.0",
"loader-utils@>=2.0.0 <2.0.4": ">=2.0.4 <3.0.0",
"json5@<1.0.2": ">=1.0.2",
"json5@>=2.0.0 <2.2.2": ">=2.2.2",
"tough-cookie@<4.1.3": ">=4.1.3",
"micromatch@<4.0.8": ">=4.0.8",
"esbuild@<=0.24.2": ">=0.25.0",
"word-wrap@<1.2.4": ">=1.2.4",
"cookie@<0.7.0": ">=0.7.0",
"@babel/runtime-corejs3@<7.26.10": ">=7.26.10 <8.0.0",
"@babel/runtime@<7.26.10": ">=7.26.10 <8.0.0",
"@babel/helpers@<7.26.10": ">=7.26.10 <8.0.0",
"cross-spawn@<6.0.6": ">=6.0.6",
"cross-spawn@>=7.0.0 <7.0.5": ">=7.0.5",
"tmp@<=0.2.3": ">=0.2.4",
"form-data@>=4.0.0 <4.0.4": ">=4.0.4",
"nanoid@>=4.0.0 <5.0.9": ">=5.0.9",
"glob@>=10.2.0 <10.5.0": ">=10.5.0 <11.0.0",
"diff@>=4.0.0 <4.0.4": ">=4.0.4",
"semver@>=2.0.0-alpha <5.7.2": ">=5.7.2",
"semver@>=7.0.0 <7.5.2": ">=7.5.2",
"semver@>=6.0.0 <6.3.1": ">=6.3.1",
"serialize-javascript@<=7.0.2": ">=7.0.3",
"flatted@<3.4.0": ">=3.4.0",
"flatted@<=3.4.1": ">=3.4.2",
"lodash-es@>=4.0.0 <=4.17.23": ">=4.18.0",
"lodash@>=4.0.0 <=4.17.23": ">=4.18.0",
"lodash-es@<=4.17.23": ">=4.18.0",
"lodash@<=4.17.23": ">=4.18.0",
"serialize-javascript@>=6.0.0 <6.0.2": ">=6.0.2",
"@xmldom/xmldom@<0.8.12": ">=0.8.12",
"@babel/traverse@<7.23.2": ">=7.23.2 <8.0.0",
"@xmldom/xmldom@<0.8.13": ">=0.8.13",
"@tootallnate/once@<2.0.1": ">=2.0.1",
"serialize-javascript@>=5.0.0 <7.0.5": ">=7.0.5",
"uuid@<11.1.1": ">=11.1.1",
"@babel/plugin-transform-modules-systemjs@>=7.12.0 <=7.29.3": ">=7.29.4 <8.0.0",
"lodash@>=4.0.0 <=4.17.22": ">=4.17.23",
"shell-quote@>=1.1.0 <=1.8.3": ">=1.8.4",
"tmp@<0.2.6": ">=0.2.6",
"form-data@>=4.0.0 <4.0.6": ">=4.0.6",
"launch-editor@<=2.14.0": ">=2.14.1",
"@babel/core@<=7.29.0": ">=7.29.6 <8.0.0",
"webpack-dev-server@<5.2.6": ">=5.2.6 <6.0.0",
"http-proxy-middleware@>=0.16.0 <2.0.10": ">=2.0.10 <3.0.0",
"yaml@>=1.0.0 <2.0.0": ">=1.10.3 <2.0.0",
"yaml@>=2.0.0 <3.0.0": ">=2.9.0 <3.0.0",
"js-yaml@>=3.0.0 <4.0.0": ">=3.15.1 <4.0.0",
"js-yaml@>=4.0.0 <5.0.0": ">=5.2.2 <6.0.0",
"minimatch@>=3.0.0 <4.0.0": ">=3.1.3 <4.0.0",
"minimatch@>=9.0.0 <10.0.0": ">=9.0.7 <10.0.0",
"ajv@>=8.0.0 <9.0.0": ">=8.18.0 <9.0.0",
"ws@>=7.0.0 <8.0.0": ">=7.5.11 <8.0.0",
"ws@>=8.0.0 <9.0.0": ">=8.21.0 <9.0.0",
"brace-expansion@>=1.0.0 <2.0.0": ">=1.1.18 <2.0.0",
"brace-expansion@>=2.0.0 <3.0.0": ">=2.0.2 <3.0.0",
"brace-expansion@>=4.0.0 <6.0.0": ">=5.0.9 <6.0.0",
"fast-uri": ">=4.1.4 <5.0.0",
"nanoid@>=3.0.0 <4.0.0": ">=3.3.18 <4.0.0",
"postcss@<8.5.23": ">=8.5.23 <9.0.0",
"svgo": ">=4.0.2 <5.0.0",
"undici@>=7.0.0 <8.0.0": ">=7.29.0 <8.0.0",
"body-parser@<1.20.6": ">=1.20.6 <2.0.0",
"preact": "10.29.8",
"@types/minimatch": "5.1.2",
"@babel/core": "^8.0.1",
"eslint-config-preact>@babel/core": "^7.29.7"
}
}
}