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.
Related projects
File: CLAUDE.md
Remark42 Development Guidelines
Build/Test/Lint Commands
- Backend:
- Run server:
make rundev - Build:
make backend - Race test:
make race_test
- Run server:
- 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 ./...
- Run all tests:
- 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.tsfiles run straight from source with nonode_modules, and theunitCI job
installs nothing so a test that needsnode_modulesgoes red. Their value imports write the
extension in full (./types.ts) and use a relative path, never thecommon/…alias, because
node resolves neither; a type-only import erases and keeps the ordinary style. Seefrontend/CLAUDE.md.
- Development:
- End-to-end:
make e2edrives the widget in a real browser; seee2e/README.md. Build-tagged, sogo 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
- Backend:
- Go module changes:
- Any change to
backend/go.modorbackend/go.sumrequiresgo mod tidyinbackend/_example/memory_storein the same commit. That covers dependency bumps, adding or removing a dependency, and changing thegodirective, not only version updates. - Only
go mod tidythere, notgo 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/backendwith../../, so it carries the backend's dependencies as indirect entries. Leaving them stale fails thetest examplesCI step withgo: 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.
- Any change to
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.EventuallyWithTto poll for the state the assertion needs, andrequire.Neverwhen the point is that something did not happen. A baretime.Sleepbefore an assertion is a defect; sleeping until a deadline you computed, aswaitPastMilliseconddoes, is not. - Polling closures must not touch
*testing.T. testify runs them on a separate goroutine, wheret.FailNowis undefined behaviour. Assert on the*assert.CollectTthatEventuallyWithThands 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, viaopenRouteLimiter(100 instartupT). Poll with the existing constants rather than a new number,httpPollfor anything issuing an HTTP request andpollIntervalonly 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.Chtimesfor file ages, an explicitstore.Comment.Timestampfor anything that formats a timestamp. - Prefer a
testing/synctestbubble where the code under test has no real I/O. Inside one the clock is fake, sotime.Sleepis instant and deterministic.app/notify,app/store/service,app/store/image,app/store/engine,app/providers,app/migratorand_example/memory_store/accessoralready use it, and most survivingtime.Sleepcalls live in them. - Helpers fail loudly. A wait that gives up must call
t.Fatal/requirenaming what it was waiting for, never return silently and leave the next assertion to fail with something unrelated. Because these packages rungoleak.VerifyTestMain, a failing helper also exits the test goroutine, so anything that started a server in a goroutine mustdefer cancel()ordefer 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 fromt.TempDir().go test ./...runs package binaries concurrently, so a number out of a fixed range or a fixed name under/tmplets two of them collide. - Close idle connections before shutting a test server down. Clients built as
http.Client{Timeout: x}sharehttp.DefaultTransport, andShutdownwaits on their keep-alive connections until its own deadline expires. - Keep the test timeout budgets aligned.
Makefile,ci-backend.yml,release.ymland 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 forgithub.com/umputun/remark42/backend.
Release flow:
- Create the GitHub release for
vX.Y.Zwith titleVersion X.Y.Z. The GitHub release must exist before thevX.Y.Ztag reaches the remote;gh release create vX.Y.Zsatisfies this because it creates and pushes the tag. - The
vX.Y.Ztag triggers GoReleaser, which builds and uploads binary artifacts to the existing release. - Create and push the matching backend module tag pointing at the same commit:
git fetch origin --tags
git tag backend/vX.Y.Z vX.Y.Z
git push origin backend/vX.Y.ZGoReleaser 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, thengit tag --contains <merge_sha> | grep '^v' | sort -V | head -1is 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 withgh 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. Useclsxfor conditional class composition.raw-content.cssis the only global CSS file (syntax highlighting utility). Root wrapper keeps bare.dark/.lighttheme class — 8+ module CSS files depend on:global(.dark)ancestor.comment_highlightinguses:global()for imperativeclassListusage 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
/webis served from two sources, in lookup order: the frontend build output
(frontend/apps/remark42/public, embedded atbackend/app/cmd/webor read from--web-root),
thenbackend/app/webassets/assets, embedded in the binary. A plain page or image the bundler
does not process belongs inwebassets; 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"
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/varFile: 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
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/varFile: 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
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: 30a 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
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: 30ADMIN_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
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: 30AUTH_SEND_JWT_HEADER moves the token out of the cookie and into a header the frontend has to
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
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: 30answers 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
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: 30no 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
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: 30anonymous 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
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: 30a 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
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: 30and 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
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/varFile: 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" }}
- CGO_ENABLED=0
archives:
- id: remark42
ids:- remark42
name_template: "{{ .ProjectName }}.{{ .Os }}-{{ .Arch }}"
formats: - tar.gz
format_overrides: - goos: windows
formats:- zip
files:
- zip
- LICENSE
- README.md
- remark42
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:
docker compose -f compose-dev-memstore.yml builddocker 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
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"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
environment:
- API=/cmd
- SECRET=123456
- DEBUG=trueFile: 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 withgo 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:
internal/poll.(*FD).Write ... waitWrite
net/http.(*response).write(..., 0x3c2c13, ...)
api.addFileServer.Timeout.func3.1 vendor/github.com/go-pkgz/rest/timeout.go:670x3c2c13 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 onlygolang.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 aRequestError
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 aserrors.forbidden. - Any other status, in practice 400, has its JSON body parsed and thrown as a plain object.
extractErrorMessageFromResponselooks a code up only on aRequestError, so these read aserrors.0, "Something went wrong. Please try again a bit later."errorUtils.unit.test.tspins
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 noemail_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:
Dropdownmounts its children only when opened, soSubscribeByEmailFormreads 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.adminis 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-counterinprofile.spec.tsxis
``. 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-actionsand.sort-pickerare 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:
spinnerrendersclsx('spinner', styles.root, …)and is still open. It is the between-pages
indicator atprofile.tsx:162-165, shown in place of the Load more button once a page is being
fetched, so it is reachable only whencommentsis not null. The profile's initial load and its
failure are a different element, thePreloaderatprofile.tsx:221, and that one is covered byTestProfile_LoadingAndFailureStates; the two are easy to conflate and are not the same thing- done for the telegram panel.
preloaderrendersclsx('preloader', className), asserted
byTestTelegramSub_ThePanelSaysItIsWorking - done.
comment-actions-additionalcarries a stable class, and the order is asserted byTestComment_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_notificationsandtelegram_notificationsoff, 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
thestartparameter 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 theREMARK42::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:
createInstancethrows rather than returning an error in three cases: no element with idremark42,window.remark_configundefined, orsite_idunset- 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 createInstancereuses a direct-child iframe carryingdata-remark42-iframerather than creating one,
and the config passed to that second call is ignored, so calling it again without adestroysilently
does nothing. The reuse also leaves the first instance'smessage,hashchangeandclicklisteners
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 thewindow.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:
- Publish the file on the site as a copy-paste block, with no package. Zero infrastructure, works
today, and consumers paste it into their ownglobal.d.ts. This is what the Astro and Gatsby
manuals need, since both currently declareREMARK42: anyandremark_config: any
(integration-with-astro/index.md:150,integration-with-gatsby/index.md:109), which is our own
documentation admitting the gap. - 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 justdeletion, non_fast_forward and copilot_code_review. Verified withgh 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 thecommon/… 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.localekeeps 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
composesthat 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
/webURLs, the component loader, and the legacy.js
names, which must stay module-free./webis an overlay: a--web-rootfile 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 rundevwith--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 astargetOriginand 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:
mixis 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
mixcoupling 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 lintandcd frontend && pnpm testafter 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.tsxfrontend/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.cssbutton/_kind/_link/button_kind_link.css+ parent dirsbutton/_kind/_primary/button_kind_primary.css+ parent dirsbutton/_kind/_secondary/button_kind_secondary.css+ parent dirsbutton/_size/_large/button_size_large.css+ parent dirsbutton/_size/_middle/button_size_middle.css+ parent dirsbutton/_theme/_dark/button_theme_dark.css+ parent dirs
Steps:
- Create
button.module.cssconsolidating 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
modsfromButtonPropstype and destructuring (never passed by any caller) - Change
Mixtype import to plainstring | string[]formixprop - Change className to:
clsx(styles.root, kind && kindStyles[kind], size && sizeStyles[size], theme === 'dark' && styles.themeDark, mix, className)
- Remove
- Update
index.ts: remove all 7 CSS imports, keep onlyexport { 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.tsxfrontend/apps/remark42/app/components/dropdown/index.tsfrontend/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.cssdropdown/__content/dropdown__content.css+ dirdropdown/__items/dropdown__items.css+ dirdropdown/__title/dropdown__title.css+ dirdropdown/_active/dropdown_active.css+ dirdropdown/_theme/_dark/dropdown_theme_dark.css+ dirsdropdown/_theme/_light/dropdown_theme_light.css+ dirs
Steps:
- Create
dropdown.module.cssconsolidating 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'withimport clsx from 'clsx'andimport styles from './dropdown.module.css' - Add
contentRef = createRef<HTMLDivElement>()alongside existingrootNoderef - Add
data-dropdownattribute 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
- Line 78:
- Root div:
className={clsx(styles.root, isActive && styles.active, theme === 'dark' ? styles.themeDark : styles.themeLight, mix)} - Content div:
className={styles.content}withref={this.contentRef} - Items div:
className={styles.items} - Button mix:
mix={[styles.title, titleClass]}(clsx in Button handles arrays) - Remove dead
headingprop from Props type and the heading div from JSX (prop is never passed by any caller, class has no CSS)
- Replace
- Update
dropdown-item.module.css: change& > :global(.dropdown)to& > [data-dropdown] - Update
index.ts: remove all 7 CSS imports, keepexport { Dropdown }andexport { 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.tsxfrontend/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.cssthread/__collapse/thread__collapse.css+ dirthread/_theme_dark/thread_theme_dark.css+ dir
Steps:
- Create
thread.module.cssconsolidating 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'withimport clsx from 'clsx'andimport 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}
- Replace
- Update
index.ts: remove all 3 CSS imports, keep onlyexport { 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 JSXauth-panel_loggedIn/auth-panel_theme_*— dead mods fromb(), no CSS rulesclsx('user', styles.user)etc. — bare global strings ('user','user-profile-button','user-avatar','user-logout-button') have no CSS; remove fromclsx()
Files to modify:
frontend/apps/remark42/app/components/auth-panel/auth-panel.tsxfrontend/apps/remark42/app/components/auth-panel/auth-panel.module.cssfrontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsxfrontend/apps/remark42/app/components/auth-panel/index.ts
Files to delete (2):
auth-panel/auth-panel.cssauth-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 deadtheme/loggedInmods) - 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
- Remove
- 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}`)
- Add
- Update
index.ts: remove 2 CSS imports, keepexport * from './auth-panel' - Delete
auth-panel.cssand__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-helperis 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) tostring | 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
Dropdown DOM query replacements
| 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-coveradditionally needs the frontend dependencies installed, since the widget report is made withnyc: runpnpm installinfrontend/apps/remark42. The plainmake e2edoes not.
Running
make e2eThe 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:
make e2e-up
make e2e
make e2e-downThe 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:
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=falseshows the browser and slows it to 50ms a stepE2E_KEEP=1leaves the containers running afterwards, which only matters when the suite brought them up itselfE2E_DEBUG=1logs every HTTP response of status 400 or aboveE2E_BROWSERS=chromiumnarrows 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
make e2e-coverReports 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, sogo 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 createe2e/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_COVERAGEfeedse2e/stamp.shas 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 value1instruments, in the stamp and in the Dockerfile alike, soE2E_COVERAGE=0is 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:
cat profile.cov > combined.cov && tail -n +2 e2e/coverage/e2e.cov >> combined.cov
cd backend && go tool cover -func=../combined.covThe read has to run from backend/. covdata writes file names as full import paths, andgo tool cover resolves them through go list, so from the repository root, which has nogo.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 owndestroy, 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, withEDIT_TIME=15sand 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, withADMIN_EDIT=trueand the same short window, for the unlimited window an admin is supposed to get - remark42-jwtheader on
:8083, withAUTH_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 withALLOWED_HOSTSset to its own address so it doubles as the instance that refuses to be framed elsewhere - remark42-anonvote on
:8086, withANON_VOTEand theVOTES_IPit depends on, since the default configuration turns an anonymous vote down - host-site on
:8090, an nginx servinge2e/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.htmlembeds the main instance;restricted.htmlembeds the one whoseALLOWED_HOSTSnames only itself, which is the refusal case - remark42-https on
:8443, the widget over TLS withAUTH_SAME_SITE=noneandAUTH_SEND_JWT_HEADER=true. The header mode is what makes the widget write its own cookies throughsetAuthCookie, 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 theX-JWTheader rather than off the provider, and that is an assumption worth measuring rather than asserting - host-site-https on
:8444, an nginx serving the samee2e/hostsite/over TLS, so the embed is cross-site and secure.post-https.htmlis its page - remark42-telegram on
:8087, telegram auth pointed at the stub below throughTELEGRAM_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 ise2e/telegramstub, the one file in this module without thee2ebuild 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_URLuses a hostname, not127.0.0.1. The dev oauth2 server binds whatever host it reads out ofREMARK_URL(localBindAddrin 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 atbackend/app/rest/api/rest.go:242, not a setting. TRUSTED_PROXYstays unset. The stack is loopback-only, and the harness verifies that its per-contextX-Real-IPreaches 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:
.authonly 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.
roleis: 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
articlewith 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 whatarticleCountis for. - Collapsing a thread hides the comment text, so a locator filtered by that text stops matching the element under test.
TestThread_CollapsePersistsAcrossReloadanchors on the comment's id instead. text=Foomatches 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 globpage.Routetakes, 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, asTestProfile_LoadingAndFailureStatesdoes.
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-alpineandnpm i [email protected]frontend/.nvmrc— not read by CI at all; only matters to a human runningnvm uselocally. This is the one that drifted unnoticed: it sat at16through the whole node-20 migration because nothing red ever pointed at it.frontend/apps/remark42/package.json, both itspackageManagerfield and itsenginesblockpnpm/action-setup@vNblocks in.github/workflows/ci-frontend.yml(5) andrelease.yml(2) — pinversion:to the exact patch (e.g.10.10.0), matchingpackageManager, 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 thenode-version:values inrelease.yml- the two dependency-free test commands live in
package.jsonastest:unitandtest:unit:node, are called by name fromrelease.yml, and are repeated inline in theunitjob ofci-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 thepnpm.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/minimatch5.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 asbabel-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 intransformIgnorePatterns 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 thedata-testid stripper, which the suites query by and the bundle must not carry. Verify withgrep -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 throughfork-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. Inlineimport { 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-jsxwithjsxImportSource: preact.babelrc.js:@babel/preset-reactwithruntime: '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, becauseeslint-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 infrontend/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:
eslint10 (eslint-config-preactpeers on ^9),typescript7,redux5 withredux-thunk3,node-emoji2 (renames the fieldsearch()returns),@formatjs/cli6 (the translation extraction pipeline), andpostcss-preset-env11 withcssnano8 andpostcss-html2, which change generated CSSredux4
`/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 tofocusin/focusout on every element and make @testing-library/preact rewrite change toinput, 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, sotranslation:generate after a rename deletes the now-unextracted keys from all 24 files inapp/locales/ and writes them back, after which the check passes. Extract-then-generate is
the documented translator workflow, so the destructive order is the normal one.
After any rename or refactor touching those identifiers, verify the count rather than the exit
code:
pnpm translation:extract
node -e "console.log(Object.keys(require('./extracted-messages/messages')).length)"It must match the key count in app/locales/en.json, not drop to 0.
The binding implements {name} interpolation and paired <tag>text</tag> rich text, and
nothing else of ICU: no plural, select or selectordinal, no typed arguments such as{n, number}, and no apostrophe quoting. A plural or select form has braces the placeholder
rule rejects, so it falls back to English wherever values are passed and shows its raw text
where they are not; '' simply stays as two apostrophes.
translation:check validates every translated value's markup and placeholders against the
English string it translates, mirroring the binding's rule, and the catalogue sweep inapp/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:
pnpm test:unitThe 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 whyapp/store/thread/reducers.tsandapp/store/user/reducers.tsimport./types.tswhile their neighbours do not, and whyapp/common/json-store.tsandapp/utils/errorUtils.tscarry it too.allowImportingTsExtensionsintsconfig.jsonis what
stopstscrejecting it, and webpack and jest resolve the explicit path unchanged - import by relative path, never through the
common/…alias, which only webpack, jest andtscknow 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"
}
}
}