# Technical Documentation: umputun/remark42

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/umputun/remark42)

### Overview (`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](https://github.com/nginx-le/nginx-le))
* [Privacy focused](https://remark42.com/#privacy)

[Demo site](https://remark42.com/demo/) available with all authentication methods, including email auth and anonymous access.

<details><summary>Screenshots</summary>

Comments example:


For admin screenshots see [Admin UI documentation](https://remark42.com/docs/manuals/admin-interface/)
</details>

All remark42 documentation is available [by the link](https://remark42.com/docs/getting-started/installation/).

## Contribution

In order to start and work on the project locally in development mode check our contribution documentation for [backend](https://remark42.com/docs/contributing/backend/) and [frontend](https://remark42.com/docs/contributing/frontend/).

If you are interested in adding a new localization please check [these docs](https://remark42.com/docs/contributing/translations/).

## Related projects

* [A Helm chart for Remark42 on Kubernetes](https://github.com/groundhog2k/helm-charts/tree/master/charts/remark42)
* [django-remark42](https://github.com/andrewp-as-is/django-remark42.py)

---

### Overview (`backend/README.md`)

../site/content/docs/contributing/backend/index.md

---

### Overview (`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](https://github.com/playwright-community/playwright-go). Do not rewrite it to match the repository URL: the versions that carry the matching path cannot install their driver.

## Prerequisites

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

## Running

```
make e2e
```

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

```
make e2e-up
make e2e
make e2e-down
```

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

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

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

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

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

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

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

## Coverage

```
make e2e-cover
```

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## When something fails

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

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

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

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

## The stack the suite runs against

The suite refuses a running stack that was not brought up from the sources under test. Every checkout builds the image tag the compose file names, so a stack from another worktree, or from this one before an edit, answers on these ports and passes every readiness probe while serving code nobody is looking at.

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

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

## The stack

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

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

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

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

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

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

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

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

## What this suite cannot reach

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

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

The trap that remains is which cookie policy a run is under. Playwright's own default `--disable-features` argument carries `ThirdPartyStoragePartitioning`, and it beats both `--test-third-party-cookie-phaseout` and `--block-third-party-cookies` passed through `Args`. A run configured that way keeps an ordinary third-party cookie exactly as it would with no flags at all, so it proves nothing while looking like it proved something. The lever is `IgnoreDefaultArgs` on the launch options: drop that default entry and re-supply `--disable-features` without that one feature, which is what `TestHTTPS_SessionSurvivesThirdPartyCookieBlocking` does. Measured on a cross-site https embed:

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

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

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

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

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

## Isolation

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

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

## Browsers

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

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

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

## Selectors

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

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

---

### Overview (`site/README.md`)

# Remark42 site

Sources for [remark42.com](https://remark42.com), built with [Hugo](https://gohugo.io).

## Requirements

Hugo, the plain build; nothing here needs the extended one. The version the published image builds with is `ARG HUGO_VERSION` in `Dockerfile`, and any release at or above it works locally. Nothing updates that pin automatically: Dependabot's docker ecosystem reads `FROM` references, and the Hugo version is a bare string in a download URL, so bumping it is a manual edit. `brew install hugo`, or see the [installation guide](https://gohugo.io/installation/). Nothing else: no Node, no package manager.

## Development

```shell
hugo server
```

Serves the site on <http://localhost:1313> and rebuilds on change.

Alternatively, without installing Hugo:

```shell
docker compose up
```

## Build

```shell
hugo --minify --cleanDestinationDir --destination build
```

Writes the static site to `build/`, which is what the Docker image serves. `--cleanDestinationDir` matters on a rebuild: without it a page you deleted, and the fingerprinted stylesheets of earlier builds, stay behind.

## Layout

| Path                  | Contents                                                             |
| --------------------- | -------------------------------------------------------------------- |
| `content/`            | Pages as markdown. `content/docs/` is the documentation tree          |
| `layouts/`            | Templates. `partials/`, `shortcodes/` and `_markup/` render hooks     |
| `assets/`             | `styles.css` and the scripts, fingerprinted at build time             |
| `static/`             | Files copied to the site root as-is: favicons, manifest, `robots.txt` |
| `data/nav.json`       | The documentation sidebar                                             |
| `hugo.toml`           | Site configuration                                                    |

## Writing docs

A page is a markdown file with a `title` in its front matter. A directory becomes a section when it holds `_index.md`, and a page that carries its own images is a directory with `index.md` and the images beside it.

Adding a page to the sidebar means adding an entry to `data/nav.json`; the paths there are relative to `/docs`.

Callouts use the `note` shortcode, which takes the emoji to show:

```markdown
{{< note "💡" >}}
Anything markdown here.
{{< /note >}}
```

Two documentation pages are symlinked into the repository as `backend/README.md` and `frontend/apps/remark42/README.md`, so moving or renaming `content/docs/contributing/backend/index.md` or `content/docs/contributing/frontend/index.md` means repointing those symlinks.

## Styling

`assets/styles.css` is hand-written, with the palette and light/dark values as custom properties at the top. The dark theme is applied by a `dark` class on `<html>`, set before first paint by `assets/inline.js` and toggled by `assets/script.js`.

Code highlighting is Hugo's built-in Chroma. The rules at the end of the stylesheet come from `hugo gen chromastyles --style=github` and `--style=github-dark`.

## Dates

The "Updated" line on a documentation page comes from `.Lastmod`. `hugo.toml` resolves it through `[frontmatter] lastmod`, which tries git, then a `lastmod` in the page's front matter, then the file's modification time; without that chain it would resolve to `.Date` and every page would read `Jan 01, 0001`. A deployed page therefore shows the build date, since a checkout does not preserve modification times.

`HUGO_ENABLEGITINFO=true hugo` gives real per-page commit dates. It is off by default because the image build context is `site/` alone, where there is no `.git` for Hugo to read and it fails rather than falling back.

---

### Overview (`backend/_example/memory_store/README.md`)

# sample store implementation

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

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

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

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

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

---

### Overview (`frontend/apps/remark42/README.md`)

../../../site/content/docs/contributing/frontend/index.md

---

### Site Content Docs  Index (`site/content/docs/_index.md`)

---
title: Documentation
build:
  render: never
  list: never
---

---

### Site Content Docs Backup  Index (`site/content/docs/backup/_index.md`)

---
title: Backup
build:
  render: never
  list: never
---

---

### Site Content Docs Configuration  Index (`site/content/docs/configuration/_index.md`)

---
title: Configuration
build:
  render: never
  list: never
---

---

### Site Content Docs Contributing  Index (`site/content/docs/contributing/_index.md`)

---
title: Contributing
build:
  render: never
  list: never
---

---

### Site Content Docs Getting Started  Index (`site/content/docs/getting-started/_index.md`)

---
title: Getting Started
build:
  render: never
  list: never
---

---

### Site Content Docs Manuals  Index (`site/content/docs/manuals/_index.md`)

---
title: Manuals
build:
  render: never
  list: never
---

---

### Site Content Docs Backup Backup Index (`site/content/docs/backup/backup/index.md`)

---
title: Automatic and Manual Backup
aliases:
  - /docs/backup/
---

## Automatic

Remark42 by default makes daily backup files under `${BACKUP_PATH}` (default `./var/backup`). Backups kept up to `${MAX_BACKUP_FILES}` (default 10). Each backup file contains exported and gzipped content, i.e., all comments. At any point, the user can restore such backup and revert all comments to the desired state.

**Note:** The [restore procedure](https://remark42.com/docs/backup/restore/) cleans the current data store and replaces all comments from the backup file.

## Manual

You can make a backup manually whenever you want. Run the command (`ADMIN_PASSWD` must be enabled on the server for it to work):
`docker exec -it remark42 backup -s {your site ID}`

This command creates `userbackup-{site ID}-{timestamp}.gz` file by default.

## Backup format

The backup file is a text file with all exported comments separated by EOL. Each backup record is a valid JSON with all key/value unmarshaled from the `Comment` struct (see [here](https://remark42.com/docs/contributing/api/#commenting)).

Avatars and images are stored separately. By default avatars are stored in `./var/avatars` and images in `./var/pictures` and need to be backed up, as well. For more information, check the [technical details of the backend documentation](https://remark42.com/docs/contributing/backend/#technical-details).

---

### Site Content Docs Backup Migration Index (`site/content/docs/backup/migration/index.md`)

---
title: Migration from Disqus/WordPress/Commento to Remark42
---

Remark42 supports importing comments from Disqus, WordPress, Commento, or native backup format. All imported comments have an `Imported` field set to `true`. All methods below remove existing comments from the site if they are present, please see the [restoration documentation](https://remark42.com/docs/backup/restore/) for instructions on import preserving existing comments.

### Initial import from Disqus

1. Disqus provides export of all comments on your site in a gzipped file. This option is available in your Moderation panel at Disqus Admin > Setup > Export. The export will be sent into a queue and then emailed to the address associated with your account once it's ready. Direct link to export will be something like `https://<siteud>.disqus.com/admin/discussions/export/`. See [importing-exporting](https://help.disqus.com/en/articles/1717199-importing-exporting) for more details
2. Move this file to your Remark42 host within `./var` and extract, i.e., `gunzip <disqus-export-name>.xml.gz`
3. Run import command (`ADMIN_PASSWD` must be enabled on the server for it to work) - `docker exec -it remark42 import -p disqus -f /srv/var/{disqus-export-name}.xml -s {your site ID}`

### Initial import from WordPress

1. Use [that instruction](https://wordpress.com/support/export/) to export comments to file using standard WordPress functionality
2. Move this file to your Remark42 host within `./var`
3. Run import command (`ADMIN_PASSWD` must be enabled on the server for it to work) - `docker exec -it remark42 import -p wordpress -f /srv/var/{wordpress-export-name}.xml -s {your site ID}`

### Initial import from Commento

1. Move exported json file to your Remark42 host within `./var`
2. Run import command (`ADMIN_PASSWD` must be enabled on the server for it to work) - `docker exec -it remark42 import -p commento -f /srv/var/{commento-export-name}.json -s {your site ID}`

Comments are imported for the domain specified in the provided file, with `https://` prefix. If you want to import comments for a different domain or for `http://` domain, you'll need to export them after importing, alter the export file `url` property and re-import them.

---

### Site Content Docs Backup Restore Index (`site/content/docs/backup/restore/index.md`)

---
title: Restore Backup
---

Restore removes all existing comments before importing the contents of the supplied file.

For safety and security reasons, restore functionality is not exposed outside your server by default. The recommended way to restore from the backup is to use the provided [`backend/scripts/restore.sh`](https://github.com/umputun/remark42/blob/master/backend/scripts/restore.sh). It can run inside the container (`ADMIN_PASSWD` must be enabled on the server for it to work):

`docker exec -it remark42 restore -f {backup-filename.gz} -s {your site ID}`

### Import/restore without removing existing comments

The `restore` command nukes the existing comments on the site. You should make two backup files to preserve them, one for the current remark42 content and another for WP/Discuss/Commento content you want to import. The format of backups is plain JSON with EOL (JSON line) and can be easily constructed from multiple sources. Merge them and restore them from the resulting file:

```shell
cat wp-export.json | grep -v '{"version":1' >> combined-export.json
docker exec -it remark42 restore -f combined-export.json -s {your site ID}
```

---

### Site Content Docs Backup Url Migration Index (`site/content/docs/backup/url-migration/index.md`)

---
title: Site URL migration
---

Here is an example of how to move your comments after your posts are moved, from `https://example.org/blog/<slug>` to `https://example.org/post/<slug>` in that example.

### Rules file

First, you must create a `rules` file in the remark's `/var` folder.

Format is simply `old_url new_url` like following:

```
https://example.org/old-url-1/ https://example.org/new-url-1/
https://example.org/old-url-2/ https://example.org/new-url-2/
```

### Applying the remap

After rules file is ready, run the following command (`ADMIN_PASSWD` must be enabled on the server for it to work):

```shell
remark42 remap --admin-passwd <password> -s <your site ID> -f var/rules
```

If running in a docker container, the command becomes (`ADMIN_PASSWD` will be taken from the environment):

```shell
docker exec -it remark42 remap -s <your site ID> -f var/rules
```

The command above sends a _request_ to start the remap job. You can see the job execution logs by running:

```shell
docker logs <container>
```

---

### Site Content Docs Configuration Authorization Index (`site/content/docs/configuration/authorization/index.md`)

---
title: Authorization
---

## OAuth Providers

Users can sign in through external providers, [email](https://remark42.com/docs/configuration/email/) or [anonymous login](#anonymous). Configure at least one method to allow commenting; OAuth2 is optional when email or anonymous login is enabled.

### Apple

1. Log in [to the developer account](https://developer.apple.com/account).
1. If you don't have an App ID yet, [create one](https://developer.apple.com/account/resources/identifiers/add/bundleId). Later on, you'll need **TeamID**, which is an "App ID Prefix" value.
1. Enable the "Sign in with Apple" capability for your App ID in [the Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list) section.
1. Create [Service ID](https://developer.apple.com/account/resources/identifiers/list/serviceId) and bind with App ID from the previous step. Apple will display the description field value to end-users on sign-in. You'll need that service **Identifier as a ClientID** later on.
1. Configure "Sign in with Apple" for created Service ID. Add the domain where you will use that auth to "Domains and subdomains" and its Return URLs (like `https://example.com/auth/apple/callback` to "Return URLs".
1. Register a [New Key](https://developer.apple.com/account/resources/authkeys/list) (**private key**) for the "Sign in with Apple" feature and download it, you'll need to put it to `/srv/var/apple.p8` path inside the container. Also, write down the private **Key ID**.
1. Add your Remark42 domain name and sender email in the Certificates, Identifiers & Profiles >> [More](https://developer.apple.com/account/resources/services/configure) section as a new Email Source.

After completing the previous steps, you can configure the Apple auth provider. You'll need to set the following environment variables:

- `AUTH_APPLE_CID` (**required**) - Client ID (App ID or Services ID)
- `AUTH_APPLE_TID` (**required**) - Team ID
- `AUTH_APPLE_KID` (**required**) - Private Key ID
- `AUTH_APPLE_PRIVATE_KEY_FILEPATH` (default `/srv/var/apple.p8`) - Private key file location

### Facebook

1. Open the list of apps on the [Facebook Developers Platform](https://developers.facebook.com/apps)
2. Create a new app with [this manual](https://developers.facebook.com/docs/development/create-an-app/) or use an existing app
3. Open your app and choose **"Facebook Login"** and then **"Web"**
4. Set **"Site URL"** to your domain, e.g., `https://remark42.mysite.com`
5. Under **"Facebook login"**/**"Settings"** fill in "Valid OAuth redirect URIs" with your callback URL constructed as domain plus `/auth/facebook/callback`, e.g. `https://remark42.mysite.com/auth/facebook/callback`
6. Select **"App Review"** and turn the public flag on. This step may ask you to provide a link to your privacy policy
7. Write down the client ID and secret as `AUTH_FACEBOOK_CID` and `AUTH_FACEBOOK_CSEC`

### GitHub

1. Create a new **"OAuth App"**: https://github.com/settings/developers
2. Fill **"Application Name"** and **"Homepage URL"** for your site
3. Under **"Authorization callback URL"** enter the correct URL constructed as domain + `/auth/github/callback`, i.e., `https://remark42.mysite.com/auth/github/callback`
4. Take note of the **Client ID** (as `AUTH_GITHUB_CID`) and **Client Secret** (`AUTH_GITHUB_CSEC`)

### Google

1. Create a new project: https://console.cloud.google.com/projectcreate
2. Choose the new project from the top right project dropdown (only if another project is selected)
3. In the project Dashboard center pane, choose **"APIs & Services"**
4. In the left Nav pane, choose **"Credentials"**
5. In the center pane, choose the **"OAuth consent screen"** tab.

   - Select "**External**" and click "Create"
   - Fill in **"App name"** and select **User support email**
   - Upload a logo, if you want to
   - In the **App Domain** section:
     - **Application home page** - your site URL, e.g., `https://mysite.com`
     - **Application privacy policy link** - `/web/privacy.html` of your Remark42 installation, e.g. `https://remark42.mysite.com/web/privacy.html` (please check that it works). The page shipped with Remark42 describes remark42.com rather than your site; to serve your own, add a `privacy.html` alongside the frontend files in the `web-root` / `REMARK_WEB_ROOT` directory (`/srv/web` in the Docker image), which is served in preference to the built-in one. Add it to that directory rather than creating one: once the directory exists it supplies the whole frontend, so a web root holding only a `privacy.html` leaves the widget unreachable
     - **Terms of service** - leave empty
   - **Authorized domains** - your site domain, e.g., `mysite.com`
   - **Developer contact information** - add your email, and then click **Save and continue**
   - On the **Scopes** tab, just click **Save and continue**
   - On the **Test users**, add your email, then click **Save and continue**
   - Before going to the next step, set the app to "Production" and send it to verification

6. In the center pane, choose the **"Credentials"** tab

   - Open the **"Create credentials"** drop-down
   - Choose **"OAuth client ID"**
   - Choose **"Web application"**
   - Application **Name** is freeform; choose something appropriate, like "Comments on mysite.com"
   - **Authorized JavaScript Origins** should be your domain, e.g., `https://remark42.mysite.com`
   - **Authorized redirect URIs** is the location of OAuth2/callback constructed as domain + `/auth/google/callback`, e.g., `https://remark42.mysite.com/auth/google/callback`
   - Click **"Create"**

7. Take note of the **Client ID** (`AUTH_GOOGLE_CID`) and **Client Secret** (`AUTH_GOOGLE_CSEC`)

_instructions for Google OAuth2 setup borrowed from [oauth2_proxy](https://github.com/bitly/oauth2_proxy)_

### Microsoft

1. Register a new application [using the Azure portal](https://docs.microsoft.com/en-us/graph/auth-register-app-v2)
2. Under **"Supported account types"** select **"Accounts in any organizational directory (Any Microsoft Entra directory - Multitenant) and personal Microsoft accounts"**
3. Under **"Authentication/Platform configurations/Web"** enter the correct URL constructed as domain + `/auth/microsoft/callback`, i.e., `https://example.mysite.com/auth/microsoft/callback`
4. In **"Overview"** take note of the **Application (client) ID** (`AUTH_MICROSOFT_CID`)
5. Choose the new project from the top right project dropdown (only if another project is selected)
6. Select **"Certificates & secrets"** and click on **"+ New Client Secret"** (`AUTH_MICROSOFT_CSEC`)

By default, Remark42 authenticates against the `common` endpoint, which requires a multi-tenant application. A single-tenant application (`signInAudience: AzureADMyOrg`) is rejected there with [`AADSTS50194`](https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes). The account types the application actually accepts are the intersection of the endpoint and the registration, so selecting personal Microsoft accounts as well is what makes them usable; choose `signInAudience: AzureADMultipleOrgs` instead to keep sign-in to work or school accounts only. You can check and change the value under **"Manifest"** for an application that already exists.

To use a different endpoint, set `AUTH_MICROSOFT_TENANT` to your tenant ID or domain name for a single-tenant Entra ID application (`AzureADMyOrg`), to `organizations` for a multi-tenant application accepting work or school accounts only (`AzureADMultipleOrgs`), or to `consumers` for an application limited to personal Microsoft accounts (`PersonalMicrosoftAccount`).

### Yandex

1. Create a new **"OAuth App"**: https://oauth.yandex.com/client/new
2. Fill **"App name"** for your site
3. Under **Platforms** select **"Web services"** and enter **"Callback URI #1"** constructed as domain + `/auth/yandex/callback`, i.e., `https://remark42.mysite.com/auth/yandex/callback`
4. Select **Permissions**. You need the following permissions only from the **"Yandex.Passport API"** section:

- Access to the user avatar
- Access to username, first name and surname, gender

5. Fill out the rest of the fields if needed
6. Take note of the **ID** (`AUTH_YANDEX_CID`) and **Password** (`AUTH_YANDEX_CSEC`)

For more details refer to [Yandex OAuth](https://yandex.com/dev/oauth/doc/dg/concepts/about.html) and [Yandex.Passport](https://yandex.com/dev/passport/doc/dg/index.html) API documentation.

### Patreon

1. Create a new Patreon client https://www.patreon.com/portal/registration/register-clients
2. Fill **App Name**, **Description**
3. In the field **Redirect URIs** enter the correct URI constructed as domain + `/auth/patreon/callback`, i.e., `https://example.mysite.com/auth/patreon/callback`
4. Expand client details and note the **Client ID** and **Client Secret**. Those will be used as `AUTH_PATREON_CID` and `AUTH_PATREON_CSEC`

### Discord Auth Provider

1. Click on **New Application** to create Oauth client https://discord.com/developers/applications
2. After filling **"NAME"**, navigate to **"OAuth2"** option on the left sidebar
3. Under **"Redirects"** enter the correct url constructed as domain + `/auth/discord/callback`. ie `https://remark42.mysite.com/auth/discord/callback`
4. Take note of the **CLIENT ID** and **CLIENT SECRET**, as they are values for `AUTH_DISCORD_CID` and `AUTH_DISCORD_CSEC` respectively

### Custom OAuth2 Provider

You can configure any OAuth2-compatible provider by setting these variables:

- `AUTH_CUSTOM_NAME` - provider name used in auth routes
- `AUTH_CUSTOM_CID` - OAuth client ID
- `AUTH_CUSTOM_CSEC` - OAuth client secret
- `AUTH_CUSTOM_AUTH_URL` - authorization endpoint
- `AUTH_CUSTOM_TOKEN_URL` - token endpoint
- `AUTH_CUSTOM_INFO_URL` - user info endpoint
- `AUTH_CUSTOM_SCOPES` - optional scopes, comma-separated
- `AUTH_CUSTOM_ID_FIELD` - optional user info field used as unique id (default `sub`)
- `AUTH_CUSTOM_NAME_FIELD` - optional user info field used as display name (default `name`)
- `AUTH_CUSTOM_PICTURE_FIELD` - optional user info field used as avatar URL (default `picture`)
- `AUTH_CUSTOM_EMAIL_FIELD` - optional user info field used as email (default `email`)

Callback URL format:

`https://<remark42-url>/auth/<AUTH_CUSTOM_NAME>/callback`

Notes:

- `AUTH_CUSTOM_NAME` must match `^[a-z0-9][a-z0-9_-]*$` and should not conflict with built-in providers: `email`, `anonymous`, `google`, `github`, `facebook`, `yandex`, `twitter`, `microsoft`, `patreon`, `discord`, `telegram`, `dev`, `apple`.
- If any required custom variable is missing, Remark42 will fail to start.
- Remark42 currently supports only one custom OAuth2 provider at a time.

### Telegram

1. Contact [@BotFather](https://t.me/botfather) and follow his instructions to create your bot (call it, for example, "My site auth bot")
1. Write down the resulting token as `TELEGRAM_TOKEN` into remark42 config, and also set `AUTH_TELEGRAM` to `true` to enable telegram auth for your users.

### Anonymous

Optionally, anonymous access can be turned on. In this case, an extra `anonymous` provider will allow logins without any social login with any name satisfying two conditions:

- the name should be at least three characters long
- the name has contains only letters, numbers, underscores and spaces

---

### Site Content Docs Configuration Email Index (`site/content/docs/configuration/email/index.md`)

---
title: Email Settings
---

## Overview

This documentation describes how to enable the email-related capabilities of Remark.

- email authentication for users:

  enabling this will let the user log in using their emails:

  

- email notifications for any users except anonymous:

  GitHub or Google or any other kind of user gets the ability to get email notifications about new replies to their comments (and any of the responses down the tree):

  

## Setup email server connection

To enable any email functionality, you need to set up an email (SMTP) server connection using these variables:

```
SMTP_HOST
SMTP_PORT
SMTP_HELO_HOST
SMTP_TLS
SMTP_INSECURE_SKIP_VERIFY
SMTP_STARTTLS
SMTP_USERNAME
SMTP_PASSWORD
SMTP_TIMEOUT
```

### SMTP_HELO_HOST

`SMTP_HELO_HOST` sets the hostname Remark42 announces in the SMTP greeting. Left unset it stays `localhost`, which is what Remark42 has always sent, so existing setups need no change.

Set it when the mail server rejects the greeting. Postfix with `reject_non_fqdn_helo_hostname` or `reject_unknown_helo_hostname`, which is common on hardened relays, refuses `localhost` and the message never leaves, typically with a 504 in the log. Give it a fully qualified name that resolves to the sending host, for example:

```
SMTP_HELO_HOST=comments.example.com
```

It applies to both paths that send mail: notification emails and the verification emails used by email authentication.

## Setup email notifications

### User notifications

Here is the list of variables that affect user email notifications:

```yaml
NOTIFY_USERS=email
NOTIFY_EMAIL_FROM="Remark42 notifications"<notify@example.com>
NOTIFY_EMAIL_VERIFICATION_SUBJ # "Email verification" by default
```

### Admin notifications

Admin would receive a message for each new comment on your site. Here is the list of variables that affect them:

```yaml
NOTIFY_ADMINS=email
NOTIFY_EMAIL_FROM="Remark42 notifications"<notify@example.com>
ADMIN_SHARED_EMAIL=admin@example.com
```

### Mailgun

Here is an example of a configuration using the [Mailgun](https://www.mailgun.com/) email service:

```yaml
- SMTP_HOST=smtp.eu.mailgun.org
- SMTP_PORT=465
- SMTP_TLS=true
- SMTP_USERNAME=postmaster@mg.example.com
- SMTP_PASSWORD=secretpassword
- AUTH_EMAIL_FROM="Remark42 login"<notify@example.com>
- NOTIFY_EMAIL_FROM="Remark42 notifications"<notify@example.com>
```

### SendGrid

Here is an example of a configuration using the [SendGrid](https://sendgrid.com/) email service:

```yaml
- SMTP_HOST=smtp.sendgrid.net
- SMTP_PORT=465
- SMTP_TLS=true
- SMTP_USERNAME=apikey
- SMTP_PASSWORD=key-123456789
- AUTH_EMAIL_FROM="Remark42 login"<notify@example.com>
- NOTIFY_EMAIL_FROM="Remark42 notifications"<notify@example.com>
```

### Mailgun or SendGrid without exposing your server's IP

When you don't want to expose your IP (which is impossible with any SMTP provider) or when connecting to an external SMTP server is impossible due to firewall settings, set up an SMTP-to-API bridge and send messages through it.

To use any of the containers below within the Remark42 environment, set the following two `SMTP` variables:

```yaml
- SMTP_HOST=mail
- SMTP_PORT=25
```

#### stevenolen/mailgun-smtp-server

Here is the `docker-compose.yml` configuration part spinning up a container for
[stevenolen/mailgun-smtp-server](https://hub.docker.com/r/stevenolen/mailgun-smtp-server):

```yaml
mailgun:
  image: stevenolen/mailgun-smtp-server
  container_name: "mail"
  hostname: "mail"

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

  environment:
    - MG_KEY=key-123456789
    - MG_DOMAIN=example.com
```

Please note that this Docker image is unmaintained and Europe domain names are not supported by this tool.

#### fgribreau/smtp-to-sendgrid-gateway

Here is the `docker-compose.yml` configuration part spinning up a container for
[fgribreau/smtp-to-sendgrid-gateway](https://hub.docker.com/r/fgribreau/smtp-to-sendgrid-gateway):

```yaml
sendgrid:
  image: fgribreau/smtp-to-sendgrid-gateway
  container_name: "mail"
  hostname: "mail"

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

  environment:
    - SENDGRID_API=key-123456789
```

### Gmail

If 2FA is enabled for your account, separate [app password](https://support.google.com/accounts/answer/185833?hl=en) has to be generated. Configuration example for Gmail:

```yaml
- SMTP_HOST=smtp.gmail.com
- SMTP_PORT=465
- SMTP_TLS=true
- SMTP_USERNAME=example.user@gmail.com
- SMTP_PASSWORD=aaaa bbbb cccc dddd
- AUTH_EMAIL_FROM="Remark42 login"<example.user@gmail.com>
- NOTIFY_EMAIL_FROM="Remark42 notifications"<example.user@gmail.com>
```

### Amazon SES

Configuration example for [Amazon SES](https://aws.amazon.com/ses/) (us-east-1 region):

```yaml
- SMTP_HOST=email-smtp.us-east-1.amazonaws.com
- SMTP_PORT=465
- SMTP_TLS=true
- SMTP_USERNAME=access_key_id
- SMTP_PASSWORD=secret_access_key
- AUTH_EMAIL_FROM="Remark42 login"<notify@example.com>
- NOTIFY_EMAIL_FROM="Remark42 notifications"<notify@example.com>
```

You must first [verify](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-domain-procedure.html) a domain or email you will use in `AUTH_EMAIL_FROM` or `NOTIFY_EMAIL_FROM`.

Then you should obtain [SMTP Credentials](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/smtp-credentials.html) from [Amazon SES Console](https://console.aws.amazon.com/ses/home?region=us-east-1#/account).

## Setup email authentication

Here is the list of variables that affect email authentication:

```
AUTH_EMAIL_ENABLE
AUTH_EMAIL_FROM
AUTH_EMAIL_SUBJ ("remark42 confirmation" by default)
AUTH_EMAIL_CONTENT_TYPE ("text/html" by default)
```

After you set `SMTP_` variables, you can allow email authentication by setting the first two:

```yaml
- AUTH_EMAIL_ENABLE=true
- AUTH_EMAIL_FROM="Remark42 login"<notify@example.com>
```

## HTML templates for emails and error messages

Remark42 uses golang templates for email templating. Templates are located in `backend/app/templates/static` and embedded into binary by `go:embed` [directive](https://pkg.go.dev/embed).

Now we have the following templates:

- `email_confirmation_login.html.tmpl` – used for confirmation of login
- `email_confirmation_subscription.html.tmpl` – used for confirmation of subscription
- `email_reply.html.tmpl` – used for sending replies to user comments (when the user subscribed to it) and for noticing admins about new comments on a site
- `email_unsubscribe.html.tmpl` – used for notification about successful unsubscribing from replies
- `error_response.html.tmpl` – used for HTML errors

To replace any template, add the file with the same name to the directory with the remark42 executable file. In case you run Remark42 inside Docker Compose, you can put customised templates into a directory like `customised_templates` and then mount it like that:

```yaml
volumes:
  - ./var:/srv/var
  - ./customised_templates/email_confirmation_login.html.tmpl:/srv/email_confirmation_login.html.tmpl:ro
  - ./customised_templates/email_confirmation_subscription.html.tmpl:/srv/email_confirmation_subscription.html.tmpl:ro
  - ./customised_templates/email_reply.html.tmpl:/srv/email_reply.html.tmpl:ro
  - ./customised_templates/email_unsubscribe.html.tmpl:/srv/email_unsubscribe.html.tmpl:ro
  - ./customised_templates/error_response.html.tmpl:/srv/error_response.html.tmpl:ro
```

The easiest way to test it is to mount `error_response.html.tmpl`, and then head to <http://127.0.0.1:8080/email/unsubscribe.html>, where you are supposed to see the page like the following:



If the file is mounted correctly, the page will render the new file content immediately after hitting the refresh button in your browser once you change the file.

### Template variables

Each template has access to different variables. Email templates use Go's [html/template](https://pkg.go.dev/html/template), which escapes ordinary values for HTML output.

#### `email_reply.html.tmpl` — comment notification

Used for notifying users about replies to their comments and for admin notifications about new comments.

| Variable | Type | Description |
|----------|------|-------------|
| `{{.UserName}}` | string | Comment author name |
| `{{.UserPicture}}` | string | Comment author avatar URL |
| `{{.CommentText}}` | string | Comment body (HTML) |
| `{{.CommentLink}}` | string | Direct link to the comment |
| `{{.CommentDate}}` | time.Time | Comment timestamp, format with `{{.CommentDate.Format "02.01.2006 at 15:04"}}` |
| `{{.ParentUserName}}` | string | Parent comment author name |
| `{{.ParentUserPicture}}` | string | Parent comment author avatar URL |
| `{{.ParentCommentText}}` | string | Parent comment body (HTML) |
| `{{.ParentCommentLink}}` | string | Direct link to parent comment |
| `{{.ParentCommentDate}}` | time.Time | Parent comment timestamp |
| `{{.PostTitle}}` | string | Title of the post |
| `{{.Email}}` | string | Recipient email address |
| `{{.UnsubscribeLink}}` | string | Unsubscribe URL |
| `{{.ForAdmin}}` | bool | True when this is an admin notification |

#### `email_confirmation_subscription.html.tmpl` — subscription confirmation

Sent when a user subscribes to email notifications for a comment thread.

| Variable | Type | Description |
|----------|------|-------------|
| `{{.User}}` | string | Username |
| `{{.Token}}` | string | Confirmation token |
| `{{.Email}}` | string | Recipient email address |
| `{{.Site}}` | string | Site name |
| `{{.SubscribeURL}}` | string | Subscription confirmation base URL |

#### `email_confirmation_login.html.tmpl` — login confirmation

Sent when a user logs in via email authentication.

| Variable | Type | Description |
|----------|------|-------------|
| `{{.User}}` | string | Username |
| `{{.Token}}` | string | Login token |
| `{{.Site}}` | string | Site name |
| `{{.Address}}` | string | Recipient address |

### Plain-text emails

For plain-text login confirmations, set `AUTH_EMAIL_CONTENT_TYPE=text/plain` and provide a plain-text `email_confirmation_login.html.tmpl`:

```text
Confirmation for {{.User}} on site {{.Site}}

Copy this token into the login form:
{{.Token}}

Sent to {{.Address}}
```

The template still uses `html/template`, so values are HTML-escaped even with a plain-text content type. Comment and subscription notification emails always use `text/html`; there is no `NOTIFY_EMAIL_CONTENT_TYPE` setting. Keep their custom templates in HTML.

---

### Site Content Docs Configuration Frontend  Index (`site/content/docs/configuration/frontend/_index.md`)

---
title: Frontend Configuration
aliases:
  - /docs/configuration/
---

## Configuration

- **`host`**`: string` (required) – hostname of Remark42 server, same as REMARK_URL in backend config, e.g. "https://demo.remark42.com"
- **`site_id`**`: string` (required) – the `SITE` that you passed to Remark42 instance on start of backend.
- **`url`**`: string` (optional, `window.location.origin + window.location.pathname` by default) – url to the page with comments, it is used as unique identificator for comments thread
  Note that if you use query parameters as significant part of URL (the one that actually changes content on page) you will have to configure URL manually to keep query params, as `window.location.origin + window.location.pathname` doesn't contain query params and hash. For example, default URL for `https://example/com/example-post?id=1#hash` would be `https://example/com/example-post`
- **`components`**`: ['embed' | 'last-comments' | 'counter']` (optional, `['embed']` by default) – an array of widgets that should be rendered on a page. You may use more than one widget on a page.
  Available components are:
  - `'embed'` – basic comments widget
  - `'last-comments'` – last comments widget, see [Last Comments](#last-comments-widget) section below
  - `'counter'` – counter widget, see [Counter](#counter-widget) section below
- **`max_shown_comments`**`: number` (optional, `10` by default) – initial number of top-level comments shown on mobile
- **`max_last_comments`**`: number` (optional, `15` by default) – maximum number of comments in the last comments widget
- **`theme`**`: 'light' | 'dark'` (optional, `'light'` by default) – changes UI theme
- **`page_title`**`: string` (optional, `document.title` by default) – title for current comments page
- **`locale`**`: enum` (optional, `'en'` by default) – interface localization, [check possible localizations](#locales)
- **`show_email_subscription`**`: boolean` (optional, `true` by default) – enables email subscription feature in interface when enable it from backend side, if you set this param in `false` you will get notifications email notifications as admin but your users won't have interface for subscription
- **`show_telegram_subscription`**`: boolean` (optional, `true` by default) – shows Telegram subscription controls when Telegram user notifications are enabled on the backend
- **`show_rss_subscription`**`: boolean` (optional, `true` by default) – enables RSS subscription feature in interface
- **`simple_view`**`: boolean` (optional, `false` by default) – enables a minimal UI with basic information only; `false` does not disable `SIMPLE_VIEW` enabled on the backend
- **`no_footer`**`: boolean` (optional, `false` by default) – hides footer with signature and links to Remark42

Configuration example:

```html
<script>
	var remark_config = {
		host: 'https://remark42.example.com',
		site_id: 'my_site',
		components: ['embed', 'last-comments'],
		max_shown_comments: 100,
		theme: 'dark',
		page_title: 'My custom title for a page',
		locale: 'es',
		show_email_subscription: false,
		simple_view: true,
		no_footer: false
	}
</script>
```

## Basic configuration

Place configuration on a page of your site.
Add following **initialization** script after it.


```html
<script>!function(e,n){for(var o=0;o<e.length;o++){var r=n.createElement("script"),d=n.head||n.body;r.type="module",r.async=!0,r.defer=!0,r.src=remark_config.host+"/web/"+e[o]+".mjs",d.appendChild(r)}}(remark_config.components||["embed"],document);</script>
```


## Comments

It's the main widget that renders a list of comments with ability of commenting.
Add following snippet in the place where you want to see Remark42 widget. The comments widget will be rendered in that place.

```html
<div id="remark42"></div>
```

{{< note "💡" >}}
**Note:** The initialization script should be placed after the code mentioned above.
{{< /note >}}

You can place any placeholder content inside the `remark42` div — it will be automatically removed once the comments widget has loaded. This is useful for showing a loading indicator or message while the widget initialises:

```html
<div id="remark42">Comments loading...</div>
```

If you want to set this up on a Single Page App, see the [appropriate doc page](https://remark42.com/docs/configuration/frontend/spa/).

#### Themes

Remark42 has two themes: light and dark. You can pick one using a configuration object, but there is also a possibility to switch between themes in runtime. For this purpose, Remark42 adds to the `window` object named `REMARK42`, which contains a function `changeTheme`. Just call this function and pass a name of the theme that you want to turn on:

```js
window.REMARK42.changeTheme("light")
```

`changeTheme` can be called as soon as `window.REMARK42` exists, even before the comments have finished loading: the widget opens in the last theme it was given. Set `theme` in the configuration object to the theme your page is in when it loads, so the widget does not have to switch after it appears.

#### Matching your site's font

To match the widget's font to your site's, set `remark_config.custom_properties['--font-family']`
to the font your site uses. You don't need to modify or rebuild Remark42 itself to do this:

```js
var remark_config = {
	host: "REMARK_URL",
	site_id: "YOUR_SITE_ID",
	custom_properties: {
		"--font-family": "Georgia, serif",
	},
}
```

The widget reads this once, when it loads. Changing it after the widget is already showing has no
effect until the page is reloaded.

**Note:** `custom_properties` technically accepts any CSS custom property the widget's stylesheets
define (see `custom-properties.css`), not only `--font-family`. Only `--font-family` is supported,
though — `--font-size`, for example, isn't wired to anything: most components hardcode their own
font-size instead of reading a variable, so setting it has no visible effect. Even a property that
does work today isn't guaranteed to keep working, or keep meaning the same thing, in a future
version. Treat anything beyond `--font-family` as unsupported and best-effort, not a documented API.

**Note:** `__colors__` is an older name for the same option. It still works, kept as an alias, but
use `custom_properties` for anything new.


#### Locales

Right now Remark42 is translated to English (en), Russian (ru), German (de), Finnish (fi), Spanish (es), Chinese (zh), Turkish (tr), Bulgarian (bg), Ukrainian (ua), Polish (pl), Vietnamese (vi), Belarusian (be), French (fr), Japanese (ja), Korean (ko), Brazilian Portuguese (bp), Italian (it), Arabic (ar), Traditional Chinese (zh-tw), Thai (th), Czech (cs), Persian (fa), Macedonian (mk), Romanian (ro), Swedish (sv), Hebrew (he), Dutch (nl), Icelandic (is) and Hungarian (hu). You can pick one using a [configuration object](https://remark42.com/docs/getting-started/installation/#setup-on-your-website).

Do you want to translate Remark42 to other locales? Please see [this documentation](https://remark42.com/docs/contributing/translations/) for details.

## Widgets

### Last comments widget

It's a widget that renders the list of last comments from your site.

Add this snippet to the bottom of web page, or adjust already present `remark_config` to have `last-comments` in `components` list:

```html
<script>
	var remark_config = {
		host: "REMARK_URL",
		site_id: "YOUR_SITE_ID",
		components: ["last-comments"],
	}
</script>
```

{{< note "💡" >}}
**Note:** If you want to render not only last comments widget you need to add all of the names of widget that you want to initialize.
{{< /note >}}

And then add this node in the place where you want to see last comments widget:

```html
<div class="remark42__last-comments" data-max="50"></div>
```

`data-max` sets the maximum number of comments for this widget, overriding `max_last_comments` in `remark_config`. If neither is set, the default is `15`.

### Counter widget

This widget displays the number of comments for the specified page.
Add this snippet to the bottom of web page, or adjust already present `remark_config` to have `counter` in `components` list:

```html
<script>
	var remark_config = {
		host: "REMARK_URL",
		site_id: "YOUR_SITE_ID",
		components: ["counter"],
	}
</script>
```

{{< note "💡" >}}
**Note:** If you want to render not only comments widget you need to add all of the names of widget that you want to initialize.
{{< /note >}}

And then add a node like this in the place where you want to see a number of comments:

```html
<span
	class="remark42__counter"
	data-url="https://domain.com/path/to/article/"
></span>
```

You can use as many nodes like this as you need to. The script will find all of them by the class `remark42__counter`, and it will use the `data-url` attribute to define the page with comments.

Also, the script can use `url` property from `remark_config` object or `window.location.origin + window.location.pathname` if nothing else is defined.

---

### Site Content Docs Configuration Notifications Index (`site/content/docs/configuration/notifications/index.md`)

---
title: Notification
---

There are two types of notifications, "Admin" and "User" notifications. Admin notifications will forward every new comment on the site to your desired location, like email or Telegram channel. User notifications will allow users to subscribe to replies to their comments. Enabling user notifications doesn't enable them by default; for example, users need to click a button in the interface to subscribe to email notifications even if they are logged in by email.

[Email](https://remark42.com/docs/configuration/email/) and [Telegram](https://remark42.com/docs/configuration/telegram/) notifications are described on separate pages.

## Slack admin notifications

To integrate notifications from remark42 with [Slack](https://slack.com), you should create [a bot](https://slack.com/intl/en-cn/help/articles/115005265703-Create-a-bot-for-your-workspace) and obtain a token.

### Create a Slack Bot

1. Create a [Slack app](https://api.slack.com/apps/new) if you don't already have one, or select an existing app you've created.
2. Click the OAuth & Permissions tab in the left sidebar.
3. Below Bot Token Scopes, select the `chat:write`, `chat:write.public`, and `channels:read` scopes. Then click Add an OAuth Scope.
4. Scroll to the top of the page, and click on Install to workspace.
5. You should see the "_View basic information about public channels in your workspace_", "_Send Message as ..._" and "_Send messages to channels ... isn't a member of_" as the permission, then click allow.
6. You can then see the token, in the form of `xoxb-...-...-...`

### Remark42 configuration

The Slack token which you obtained before should be used as `NOTIFY_SLACK_TOKEN`.

You also need to set `NOTIFY_ADMINS=slack` for the Slack notification to be active.

By default, the notifications are sent to the `general` channel on Slack. If you need another channel, you can specify it with `NOTIFY_SLACK_CHAN=channel_name`.

```
    - NOTIFY_ADMINS=slack
    - NOTIFY_SLACK_CHAN=general
    - NOTIFY_SLACK_TOKEN=xoxb-....
```

### Verify the notifications on Slack

If all goes fine, you should be able to see the following message on your Slack notification channel:

> New comment from _author_ -> _original author_
>
> > [Demo | Remark42](http://127.0.0.1:8080/web/#remark42__comment-11288987987)
> > This is the comment written by the _author_

## WebHook admin notifications

You need to set `NOTIFY_ADMINS=webhook` to enable WebHook notifications on all new comments and set at least `NOTIFY_WEBHOOK_URL` for them to start working.

Additionally, you might want to set `NOTIFY_WEBHOOK_TEMPLATE` (which is Go Template, `{"text": {{.Text | escapeJSONString}}}` by default) and `NOTIFY_WEBHOOK_HEADERS`, which is HTTP header(s) in format `Header1:Value1,Header2:Value2,...`.

Here are some additional common variables that you can use with your template:

| Variable      | Type        | Description                                                                                           |
|---------------|-------------|-------------------------------------------------------------------------------------------------------|
| `ID`          | `string`    |                                                                                                       |
| `Text`        | `string`    |                                                                                                       |
| `User.Name`   | `string`    | [Full struct defined here](https://github.com/umputun/remark42/blob/master/backend/app/store/user.go) |
| `Locator.URL` | `string`    | Post URL                                                                                              |
| `Score`       | `int`       |                                                                                                       |
| `Timestamp`   | `time.Time` |                                                                                                       |

All possible variables are available in the [`Comment struct`](https://github.com/umputun/remark42/blob/master/backend/app/store/comment.go)

---

### Site Content Docs Configuration Parameters Index (`site/content/docs/configuration/parameters/index.md`)

---
title: Command-Line Interface parameters
---

### Required parameters

Only two parameters are required to start the server:

1. `SECRET` - secret key, can be any long and hard-to-guess string
2. `REMARK_URL` - URL pointing to your Remark42 server, i.e., `https://demo.remark42.com`

To let users sign in and comment, enable at least one [authentication method](https://remark42.com/docs/configuration/authorization/), such as OAuth2, email (`AUTH_EMAIL_ENABLE=true`) or anonymous login (`AUTH_ANON=true`). OAuth2 is not required when using email or anonymous login.

The minimal `docker-compose.yml` has to include all required parameters:

```yaml
version: "2"

services:
  remark42:
    image: ghcr.io/umputun/remark42:latest
    restart: always
    container_name: "remark42"
    environment:
      - REMARK_URL=https://demo.remark42.com # URL pointing to your Remark42 server
      - SITE=YOUR_SITE_ID # site ID, same as used for `site_id`, see "Setup on your website"
      - SECRET=abcd-123456-xyz-$%^& # secret key
      - AUTH_ANON=true # enable anonymous commenting
      - AUTH_GITHUB_CID=12345667890 # OAuth2 client ID
      - AUTH_GITHUB_CSEC=abcdefg12345678 # OAuth2 client secret
    volumes:
      - ./var:/srv/var # persistent volume to store all Remark42 data
```

### Complete parameters list

| Command line                   | Environment                    | Default                 | Description                                              |
|--------------------------------|--------------------------------|-------------------------|----------------------------------------------------------|
| url                            | REMARK_URL                     |                         | URL to Remark42 server, _required_                       |
| secret                         | SECRET                         |                         | the shared secret key used to sign JWT, should be a random, long, hard-to-guess string, _required_ |
| site                           | SITE                           | `remark`                | site name(s), _multi_                                    |
| store.type                     | STORE_TYPE                     | `bolt`                  | type of storage, `bolt` or `rpc`                         |
| store.bolt.path                | STORE_BOLT_PATH                | `./var`                 | parent directory for the bolt files                      |
| store.bolt.timeout             | STORE_BOLT_TIMEOUT             | `30s`                   | boltdb access timeout                                    |
| store.rpc.api                  | STORE_RPC_API                  |                         | rpc extension api url                                    |
| store.rpc.timeout              | STORE_RPC_TIMEOUT              |                         | http timeout (default: 5s)                               |
| store.rpc.auth_user            | STORE_RPC_AUTH_USER            |                         | basic auth user name                                     |
| store.rpc.auth_passwd          | STORE_RPC_AUTH_PASSWD          |                         | basic auth user password                                 |
| admin.type                     | ADMIN_TYPE                     | `shared`                | type of admin store, `shared` or `rpc`                   |
| admin.rpc.api                  | ADMIN_RPC_API                  |                         | rpc extension api url                                    |
| admin.rpc.timeout              | ADMIN_RPC_TIMEOUT              |                         | http timeout (default: 5s)                               |
| admin.rpc.auth_user            | ADMIN_RPC_AUTH_USER            |                         | basic auth user name                                     |
| admin.rpc.auth_passwd          | ADMIN_RPC_AUTH_PASSWD          |                         | basic auth user password                                 |
| admin.rpc.secret_per_site      | ADMIN_RPC_SECRET_PER_SITE      |                         | enable JWT secret retrieval per aud, which is site_id in this case |
| admin.shared.id                | ADMIN_SHARED_ID                |                         | admin IDs (list of user IDs), _multi_                    |
| admin.shared.email             | ADMIN_SHARED_EMAIL             | `admin@${REMARK_URL}`   | admin emails, _multi_                                    |
| backup                         | BACKUP_PATH                    | `./var/backup`          | backups location                                         |
| max-back                       | MAX_BACKUP_FILES               | `10`                    | max backup files to keep                                 |
| cache.type                     | CACHE_TYPE                     | `mem`                   | type of cache, `redis_pub_sub` or `mem` or `none`        |
| cache.redis_addr               | CACHE_REDIS_ADDR               | `127.0.0.1:6379`        | address of Redis PubSub instance, turn `redis_pub_sub` cache on for distributed cache |
| cache.max.items                | CACHE_MAX_ITEMS                | `1000`                  | max number of cached items, `0` - unlimited              |
| cache.max.value                | CACHE_MAX_VALUE                | `0`                     | max size of the cached value, `0` - unlimited            |
| cache.max.size                 | CACHE_MAX_SIZE                 | `50000000`              | max size of all cached values, `0` - unlimited           |
| avatar.type                    | AVATAR_TYPE                    | `fs`                    | type of avatar storage, `fs`, `bolt`, or `uri`           |
| avatar.fs.path                 | AVATAR_FS_PATH                 | `./var/avatars`         | avatars location for `fs` store                          |
| avatar.bolt.file               | AVATAR_BOLT_FILE               | `./var/avatars.db`      | avatars `bolt` file location                             |
| avatar.uri                     | AVATAR_URI                     | `./var/avatars`         | avatars store URI                                        |
| avatar.rsz-lmt                 | AVATAR_RESIZE                  | `0` (disabled)          | max image size for resizing avatars on save              |
| image.type                     | IMAGE_TYPE                     | `fs`                    | type of image storage, `fs`, `bolt` or `rpc`             |
| image.fs.path                  | IMAGE_FS_PATH                  | `./var/pictures`        | permanent location of images                             |
| image.fs.staging               | IMAGE_FS_STAGING               | `./var/pictures.staging` | staging location of images                               |
| image.fs.partitions            | IMAGE_FS_PARTITIONS            | `100`                   | number of image partitions                               |
| image.bolt.file                | IMAGE_BOLT_FILE                | `./var/pictures.db`     | images bolt file location                                |
| image.rpc.api                  | IMAGE_RPC_API                  |                         | rpc extension api url                                    |
| image.rpc.timeout              | IMAGE_RPC_TIMEOUT              |                         | http timeout (default: 5s)                               |
| image.rpc.auth_user            | IMAGE_RPC_AUTH_USER            |                         | basic auth user name                                     |
| image.rpc.auth_passwd          | IMAGE_RPC_AUTH_PASSWD          |                         | basic auth user password                                 |
| image.max-size                 | IMAGE_MAX_SIZE                 | `5000000`               | max size of image file                                   |
| image.resize-width             | IMAGE_RESIZE_WIDTH             | `2400`                  | width of a resized image                                 |
| image.resize-height            | IMAGE_RESIZE_HEIGHT            | `900`                   | height of a resized image                                |
| auth.ttl.jwt                   | AUTH_TTL_JWT                   | `5m`                    | JWT TTL                                                  |
| auth.ttl.cookie                | AUTH_TTL_COOKIE                | `200h`                  | TTL of the server-set auth cookie. Note it does not govern the cookie the frontend writes under `auth.send-jwt-header`, which is fixed at 200h |
| auth.send-jwt-header           | AUTH_SEND_JWT_HEADER           | `false`                 | also send JWT as a header, so the frontend can store it in a client-side cookie that survives third-party cookie blocking; the server-set cookies are still sent. [See security considerations](#security-considerations-for-authsend-jwt-header). |
| auth.same-site                 | AUTH_SAME_SITE                 | `default`               | SameSite attribute for the server-set auth cookies (`default`, `none`, `lax` or `strict`). `default` emits no attribute at all and leaves the choice to the browser, which is not the same as `lax` |
| auth.apple.cid                 | AUTH_APPLE_CID                 |                         | Apple client ID (App ID or Services ID)                  |
| auth.apple.tid                 | AUTH_APPLE_TID                 |                         | Apple Team ID                                            |
| auth.apple.kid                 | AUTH_APPLE_KID                 |                         | Apple Private key ID                                     |
| auth.apple.private-key-filepath | AUTH_APPLE_PRIVATE_KEY_FILEPATH | `/srv/var/apple.p8`       | Apple Private key file location                          |
| auth.google.cid                | AUTH_GOOGLE_CID                |                         | Google OAuth client ID                                   |
| auth.google.csec               | AUTH_GOOGLE_CSEC               |                         | Google OAuth client secret                               |
| auth.facebook.cid              | AUTH_FACEBOOK_CID              |                         | Facebook OAuth client ID                                 |
| auth.facebook.csec             | AUTH_FACEBOOK_CSEC             |                         | Facebook OAuth client secret                             |
| auth.microsoft.cid             | AUTH_MICROSOFT_CID             |                         | Microsoft OAuth client ID                                |
| auth.microsoft.csec            | AUTH_MICROSOFT_CSEC            |                         | Microsoft OAuth client secret                            |
| auth.microsoft.tenant          | AUTH_MICROSOFT_TENANT          | `common`                | Azure AD tenant ID, domain, or "common"                  |
| auth.github.cid                | AUTH_GITHUB_CID                |                         | GitHub OAuth client ID                                   |
| auth.github.csec               | AUTH_GITHUB_CSEC               |                         | GitHub OAuth client secret                               |
| auth.patreon.cid               | AUTH_PATREON_CID               |                         | Patreon OAuth Client ID                                  |
| auth.patreon.csec              | AUTH_PATREON_CSEC              |                         | Patreon OAuth Client Secret                              |
| auth.discord.cid               | AUTH_DISCORD_CID               |                         | Discord OAuth Client ID                                  |
| auth.discord.csec              | AUTH_DISCORD_CSEC              |                         | Discord OAuth Client Secret                              |
| auth.custom.name               | AUTH_CUSTOM_NAME               |                         | custom OAuth provider name (used in `/auth/<name>/...`) |
| auth.custom.cid                | AUTH_CUSTOM_CID                |                         | custom OAuth client ID                                   |
| auth.custom.csec               | AUTH_CUSTOM_CSEC               |                         | custom OAuth client secret                               |
| auth.custom.auth-url           | AUTH_CUSTOM_AUTH_URL           |                         | custom OAuth authorization endpoint                      |
| auth.custom.token-url          | AUTH_CUSTOM_TOKEN_URL          |                         | custom OAuth token endpoint                              |
| auth.custom.info-url           | AUTH_CUSTOM_INFO_URL           |                         | custom OAuth user info endpoint                          |
| auth.custom.scopes             | AUTH_CUSTOM_SCOPES             | none                    | custom OAuth scopes, comma-separated                     |
| auth.custom.id-field           | AUTH_CUSTOM_ID_FIELD           | `sub`                   | user info field used as unique id                        |
| auth.custom.name-field         | AUTH_CUSTOM_NAME_FIELD         | `name`                  | user info field used as display name                     |
| auth.custom.picture-field      | AUTH_CUSTOM_PICTURE_FIELD      | `picture`               | user info field used as avatar URL                       |
| auth.custom.email-field        | AUTH_CUSTOM_EMAIL_FIELD        | `email`                 | user info field used as email                            |
| auth.telegram                  | AUTH_TELEGRAM                  | `false`                 | Enable Telegram auth (telegram.token must be present)    |
| auth.yandex.cid                | AUTH_YANDEX_CID                |                         | Yandex OAuth client ID                                   |
| auth.yandex.csec               | AUTH_YANDEX_CSEC               |                         | Yandex OAuth client secret                               |
| auth.dev                       | AUTH_DEV                       | `false`                 | local OAuth2 server, development mode only               |
| auth.anon                      | AUTH_ANON                      | `false`                 | enable anonymous login                                   |
| auth.email.enable              | AUTH_EMAIL_ENABLE              | `false`                 | enable auth via email                                    |
| auth.email.from                | AUTH_EMAIL_FROM                |                         | email from (e.g. `john.doe@example.com` or `"John Doe"<john.doe@example.com>`) |
| auth.email.subj                | AUTH_EMAIL_SUBJ                | `remark42 confirmation` | email subject                                            |
| auth.email.content-type        | AUTH_EMAIL_CONTENT_TYPE        | `text/html`             | email content type                                       |
| notify.users                   | NOTIFY_USERS                   | none                    | type of user notifications (`telegram`, `email`), _multi_ |
| notify.admins                  | NOTIFY_ADMINS                  | none                    | type of admin notifications (`telegram`, `slack`, `webhook` and/or `email`), _multi_ |
| notify.queue                   | NOTIFY_QUEUE                   | `100`                   | size of notification queue                               |
| notify.telegram.chan           | NOTIFY_TELEGRAM_CHAN           |                         | the ID of telegram channel for admin notifications       |
| notify.slack.token             | NOTIFY_SLACK_TOKEN             |                         | Slack token                                              |
| notify.slack.chan              | NOTIFY_SLACK_CHAN              | `general`               | Slack channel for admin notifications                    |
| notify.webhook.url             | NOTIFY_WEBHOOK_URL             |                         | Webhook notification URL for admin notifications         |
| notify.webhook.template        | NOTIFY_WEBHOOK_TEMPLATE        | `{"text": {{.Text \| escapeJSONString}}}` | Webhook payload template (Go text/template) |
| notify.webhook.headers         | NOTIFY_WEBHOOK_HEADERS         |                         | HTTP header in format Header1:Value1,Header2:Value2,...  |
| notify.webhook.timeout         | NOTIFY_WEBHOOK_TIMEOUT         | `5s`                    | Webhook connection timeout                               |
| notify.email.from_address      | NOTIFY_EMAIL_FROM              |                         | from email address (e.g. `john.doe@example.com` or `"John Doe"<john.doe@example.com>`) |
| notify.email.verification_subj | NOTIFY_EMAIL_VERIFICATION_SUBJ | `Email verification`    | verification message subject                             |
| telegram.token                 | TELEGRAM_TOKEN                 |                         | Telegram token (used for auth and Telegram notifications) |
| telegram.timeout               | TELEGRAM_TIMEOUT               | `5s`                    | Telegram connection timeout                              |
| telegram.api-url               | TELEGRAM_API_URL               |                         | Telegram Bot API origin/base path for auth and notifications, without `/bot`; the bot token is appended to request paths, and the public API is used when unset |
| smtp.host                      | SMTP_HOST                      |                         | SMTP host                                                |
| smtp.port                      | SMTP_PORT                      |                         | SMTP port                                                |
| smtp.helo_host                 | SMTP_HELO_HOST                 |                         | SMTP HELO/EHLO hostname                                  |
| smtp.username                  | SMTP_USERNAME                  |                         | SMTP user name                                           |
| smtp.password                  | SMTP_PASSWORD                  |                         | SMTP password                                            |
| smtp.login_auth                | SMTP_LOGIN_AUTH                | `false`                 | enable LOGIN auth instead of PLAIN                       |
| smtp.tls                       | SMTP_TLS                       | `false`                 | enable TLS for SMTP                                      |
| smtp.starttls                  | SMTP_STARTTLS                  | `false`                 | enable StartTLS for SMTP                                 |
| smtp.insecure_skip_verify      | SMTP_INSECURE_SKIP_VERIFY      | `false`                 | skip certificate verification for SMTP                   |
| smtp.timeout                   | SMTP_TIMEOUT                   | `10s`                   | SMTP TCP connection timeout                              |
| ssl.type                       | SSL_TYPE                       | none                    | `none`-HTTP, `static`-HTTPS, `auto`-HTTPS + le           |
| ssl.port                       | SSL_PORT                       | `8443`                  | port for HTTPS server                                    |
| ssl.cert                       | SSL_CERT                       |                         | path to the cert.pem file                                |
| ssl.key                        | SSL_KEY                        |                         | path to the key.pem file                                 |
| ssl.acme-location              | SSL_ACME_LOCATION              | `./var/acme`            | dir where obtained le-certs will be stored               |
| ssl.acme-email                 | SSL_ACME_EMAIL                 |                         | admin email for receiving notifications from LE          |
| max-comment                    | MAX_COMMENT_SIZE               | `2048`                  | comment's size limit                                     |
| min-comment                    | MIN_COMMENT_SIZE               | `0`                     | comment's minimal size limit, `0` - unlimited            |
| max-votes                      | MAX_VOTES                      | `-1`                    | votes limit per comment, `-1` - unlimited                |
| votes-ip                       | VOTES_IP                       | `false`                 | restrict votes from the same IP                          |
| anon-vote                      | ANON_VOTE                      | `false`                 | allow voting for anonymous users, require VOTES_IP to be enabled as well |
| votes-ip-time                  | VOTES_IP_TIME                  | `5m`                    | same IP vote restriction time, `0s` - unlimited          |
| low-score                      | LOW_SCORE                      | `-5`                    | low score threshold                                      |
| critical-score                 | CRITICAL_SCORE                 | `-10`                   | critical score threshold                                 |
| positive-score                 | POSITIVE_SCORE                 | `false`                 | restricts comment's score to be only positive            |
| restricted-words               | RESTRICTED_WORDS               |                         | words banned in comments (can use `*`), _multi_          |
| restricted-names               | RESTRICTED_NAMES               |                         | names prohibited to use by the user, _multi_             |
| edit-time                      | EDIT_TIME                      | `5m`                    | edit window; set to `0` to disable comment editing and staged image cleanup |
| admin-edit                     | ADMIN_EDIT                     | `false`                 | unlimited edit for admins                                |
| read-age                       | READONLY_AGE                   |                         | read-only age of comments, days                          |
| image-proxy.http2https         | IMAGE_PROXY_HTTP2HTTPS         | `false`                 | enable HTTP->HTTPS proxy for images                      |
| image-proxy.cache-external     | IMAGE_PROXY_CACHE_EXTERNAL     | `false`                 | enable caching external images to current image storage  |
| emoji                          | EMOJI                          | `false`                 | enable emoji support                                     |
| simple-view                    | SIMPLE_VIEW                    | `false`                 | minimized UI with basic info only                        |
| proxy-cors                     | PROXY_CORS                     | `false`                 | disable internal CORS and delegate it to proxy           |
| allowed-hosts                  | ALLOWED_HOSTS                  | enable all              | limit hosts/sources allowed to embed comments via CSP 'frame-ancestors' |
| address                        | REMARK_ADDRESS                 | all interfaces          | web server listening address                             |
| port                           | REMARK_PORT                    | `8080`                  | web server port                                          |
| web-root                       | REMARK_WEB_ROOT                | `./web`                 | web server root, supplies the frontend when it exists    |
| update-limit                   | UPDATE_LIMIT                   | `0.5`                   | updates/sec limit                                        |
| trusted-proxy                  | TRUSTED_PROXY                  | none (trust any)        | reverse-proxy networks (CIDR/IP, comma-separated) trusted to set the client IP; see [Trusted proxies and client IP](#trusted-proxies-and-client-ip) |
| subscribers-only               | SUBSCRIBERS_ONLY               | `false`                 | enable commenting only for Patreon subscribers           |
| disable-signature              | DISABLE_SIGNATURE              | `false`                 | disable server signature in headers                      |
| disable-fancy-text-formatting  | DISABLE_FANCY_TEXT_FORMATTING  | `false`                 | disable fancy comments text formatting (replacement of quotes, dashes, fractions, etc) |
| admin-passwd                   | ADMIN_PASSWD                   | none (disabled)         | password for `admin` basic auth                          |
| dbg                            | DEBUG                          | `false`                 | debug mode                                               |

- command-line parameters are long-form `--<key>=value`, i.e., `--site=https://demo.remark42.com`
- _multi_ parameters separated by `,` in the environment or repeated with command-line keys, like `--site=s1 --site=s2 ...`
- _required_ parameters have to be presented in the environment or provided in the command-line

### Custom OAuth2 integration

Custom OAuth2 integration currently supports only one custom provider at a time, and `AUTH_CUSTOM_NAME` must match `^[a-z0-9][a-z0-9_-]*$`.

### Security Considerations for auth.send-jwt-header

When `auth.send-jwt-header=true` is enabled:

- **Security Impact**: JWT tokens are stored in client-accessible cookies that can be accessed by JavaScript
- **Vulnerability**: This increases vulnerability to XSS attacks compared to server-set HttpOnly cookies
- **Implementation Mitigations**:
  - `SameSite=Strict` when the widget and the page share an origin, which is what prevents the
    cookie being sent from another site
  - `SameSite=None; Secure; Partitioned` when the widget is embedded on another domain, where
    `Strict` would never be sent at all. `Partitioned` keys the cookie to the embedding top-level
    site, so a different site gets a separate cookie and cannot reach this one. Pages and
    subdomains under that same site do share it, since the partition key is the site rather than
    the page
  - Secure flag automatically added on HTTPS connections
  - Double Submit Cookie pattern with XSRF token matching the JWT ID

This configuration should only be used when:
1. You need cross-domain authentication support
2. You understand and accept the increased XSS risk
3. You have implemented strong XSS protections on your site

### Trusted proxies and client IP

Remark42 keys per-IP rate limiting — and, when `--votes-ip` is enabled, vote de-duplication and the stored comment IP — on the client IP. When Remark42 runs behind a reverse proxy (nginx, Reproxy, Traefik, Cloudflare, an ALB, a k8s ingress, …) the TCP connection it sees comes from the **proxy**, not the visitor, so the proxy forwards the real client IP in a header and Remark42 reads it (priority: `X-Real-IP`, then `CF-Connecting-IP`, then `X-Forwarded-For`) to recover the real IP.

> ⚠️ **Security note.** Those headers can be set by any client. If Remark42 trusts them from everyone, a caller can send `X-Real-IP: <anything>` and rotate its apparent IP to **bypass rate limiting and vote de-duplication**. Use `--trusted-proxy` so Remark42 reads the forwarding headers **only** when the request actually arrives from your proxy.

`--trusted-proxy` / `TRUSTED_PROXY` takes a comma-separated list of networks (CIDR) or bare IPs. When set, the forwarding headers are honored only if the **direct peer** — the machine that opened the TCP connection to Remark42 — falls inside one of them; for a request from any other peer those headers are dropped and the real socket IP is used. When it is **not** set, the headers are trusted from any client: this preserves the historical behavior so existing deployments keep working, but leaves the bypass above open, and Remark42 prints a warning at startup. **If Remark42 is reachable from the internet, set this.**

Two conditions must **both** hold to be safe:

**1. Trust the right peer.** Point `--trusted-proxy` at the network your proxy connects _from_, and nothing wider.

**2. Your proxy must set the IP itself.** Trusting a proxy is not enough if the proxy relays what the client sent. Because Remark42 reads `X-Real-IP` first, the proxy must **set** `X-Real-IP` to the real connecting client and overwrite any client value. A proxy that only _appends_ to `X-Forwarded-For` (Traefik, most cloud ALBs, k8s ingress, HAProxy defaults) or that derives `X-Real-IP` from a client-controlled `X-Forwarded-For` leaves the visitor in control of the reported IP **even through a trusted proxy**:

- **nginx** — safe with the [manual's](../../manuals/nginx/) `proxy_set_header X-Real-IP $remote_addr;` (overwrites any client value).
- **Reproxy** — sets `X-Real-IP`, but derives it from an incoming `X-Forwarded-For` when present, so a directly-exposed Reproxy still lets a client choose it; front it with something that strips client `X-Forwarded-For`, or don't rely on it for per-IP controls.
- **Cloudflare** — does **not** set `X-Real-IP`, and a client-supplied `X-Real-IP` wins over `CF-Connecting-IP`; add a Cloudflare Transform Rule that sets `X-Real-IP` from `CF-Connecting-IP` and clears any inbound `X-Real-IP` / `X-Forwarded-For`.

**Recommended `--trusted-proxy` per deployment:**

| Deployment | Direct peer Remark42 sees | `--trusted-proxy` |
| --- | --- | --- |
| nginx in the **same Docker Compose** (the [nginx manual](../../manuals/nginx/)) | the proxy container's Docker IP | the Docker network, e.g. `172.16.0.0/12` (covers Docker's default bridge pool) — or pin your compose network's subnet |
| reverse proxy on the **host** / another machine | the proxy's host IP | that proxy's IP or CIDR, e.g. `10.0.0.5` |
| **Directly exposed**, no proxy | the real client IP already | `--trusted-proxy=127.0.0.1/32` — a value no client matches, so forwarding headers are always ignored and the real connecting IP is used |

Examples:

```
# docker-compose: nginx on the same network
TRUSTED_PROXY=172.16.0.0/12

# a single upstream proxy
--trusted-proxy=10.0.0.5

# multiple ranges (v4 and v6)
TRUSTED_PROXY=10.0.0.0/8,fd00::/8
```

Caveats:

- **IPv6.** CIDRs are matched by family — `172.16.0.0/12` (or `0.0.0.0/0`) never matches an IPv6 peer. If your proxy reaches Remark42 over IPv6 (dual-stack / IPv6-enabled Docker), add the IPv6 network too, or every visitor collapses onto the proxy's IP and gets over-throttled.
- **Don't publish Remark42's own port** when trusting a Docker range. If Remark42's port is exposed to the host, external traffic is SNAT'd to the Docker gateway (inside `172.16.0.0/12`) and appears trusted — re-opening the bypass. Publish only the proxy.
- `0.0.0.0/0` trusts everyone and re-opens the bypass; too narrow a range over-throttles real visitors. If unsure which network your proxy uses, check a Remark42 request log for the peer address it reports.

### Deprecated parameters

The following list of command-line options is deprecated and might be removed in the next major release after the version they were deprecated. After the Remark42 version update, please check the startup log once for deprecation warning messages to avoid trouble with unrecognized command-line options in the future.

<details>
<summary>Deprecated options</summary>

| Command line       | Replacement  | Environment         | Replacement   | Default | Description    | Deprecation version |
| ------------------ | ------------ | ------------------- | ------------- | ------- | -------------- | ------------------- |
| auth.email.template| none         | AUTH_EMAIL_TEMPLATE | none          | `email_confirmation_login.html.tmpl` | custom email message template file | 1.5.0 |
| auth.email.host    | smtp.host    | AUTH_EMAIL_HOST     | SMTP_HOST     |         | smtp host      | 1.5.0               |
| auth.email.port    | smtp.port    | AUTH_EMAIL_PORT     | SMTP_PORT     |         | smtp port      | 1.5.0               |
| auth.email.user    | smtp.username | AUTH_EMAIL_USER    | SMTP_USERNAME |         | smtp user name | 1.5.0               |
| auth.email.passwd  | smtp.password | AUTH_EMAIL_PASSWD  | SMTP_PASSWORD |         | smtp password  | 1.5.0               |
| auth.email.tls     | smtp.tls     | AUTH_EMAIL_TLS      | SMTP_TLS      | `false` | enable TLS     | 1.5.0               |
| auth.email.timeout | smtp.timeout | AUTH_EMAIL_TIMEOUT  | SMTP_TIMEOUT  | `10s`   | smtp timeout   | 1.5.0               |
| img-proxy          | image-proxy.http2https | IMG_PROXY | IMAGE_PROXY_HTTP2HTTPS | `false` | enable HTTP->HTTPS proxy for images | 1.5.0 |
| notify.type        | notify.admins, notify.users | NOTIFY_TYPE | NOTIFY_ADMINS, NOTIFY_USERS | |   | 1.9.0               |
| notify.email.notify_admin | notify.admins=email | NOTIFY_EMAIL_ADMIN | NOTIFY_ADMINS=email | |     | 1.9.0               |
| notify.telegram.token | telegram.token | NOTIFY_TELEGRAM_TOKEN | TELEGRAM_TOKEN | | Telegram token | 1.9.0               |
| notify.telegram.timeout | telegram.timeout | NOTIFY_TELEGRAM_TIMEOUT | TELEGRAM_TIMEOUT | | Telegram timeout | 1.9.0     |
| auth.twitter.cid    | none        | AUTH_TWITTER_CID      | none         |        | Twitter Consumer API Key | 1.14.0     |                                  |
| auth.twitter.csec   | none        | AUTH_TWITTER_CSEC     | none         |     | Twitter Consumer API Secret key | 1.14.0 |                           |

</details>

### Admin users

Admins/moderators should be defined in `docker-compose.yml` as a list of user IDs or passed in the command line.

```yaml
environment:
  - ADMIN_SHARED_ID=github_ef0f706a79cc24b17bbbb374cd234a691a034128,github_dae9983158e9e5e127ef2b87a411ef13c891e9e5
```

To get a user ID just log in and click on your username or any other user you want to promote to admins. It will expand login info and show the full user ID.

### Docker image

Two parameters allow customizing the Docker container on the system level:

- `APP_UID` - sets UID to run Remark42 application in container (default=1001)
- `TIME_ZONE` - sets time zone of Remark42 container, would be used only on the backend as comments shown with user's timezone in the web interface (default=America/Chicago)

_See [umputun/baseimage](https://github.com/umputun/baseimage) for more details._

---

### Site Content Docs Configuration Telegram Index (`site/content/docs/configuration/telegram/index.md`)

---
title: Telegram
---

You can enable Telegram for a user or admin [notifications](https://remark42.com/docs/configuration/notifications/) and user auth.

To set up notifications or auth with Telegram, first, you need to create a bot and write its access token to the remark42 configuration.

`TELEGRAM_API_URL` can point both features at a compatible proxy or Bot API server. Set it to an HTTP or HTTPS origin with an optional base path, without the trailing `/bot`; for example, `https://telegram-proxy.example.com/api`. Remark42 appends `/bot<TELEGRAM_TOKEN>/<method>`, so the token is present in every API request path. Use an endpoint and request logs that protect it as a secret. An invalid custom URL stops startup when Telegram authentication or notifications request it.

## Getting bot token for Telegram

To get a token, talk to [BotFather](https://core.telegram.org/bots#6-botfather). All you need is to send `/newbot` command and choose the name for your bot (it must end in `bot`). That is it, and you got a token which you'll need to write down into remark42 configuration as `TELEGRAM_TOKEN`.

_Example of such a "talk"_:

```
Umputun:
/newbot

BotFather:
Alright, a new bot. How are we going to call it? Please choose a name for your bot.

Umputun:
example_comments

BotFather:
Good. Now let's choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot.

Umputun:
example_comments_bot

BotFather:
Done! Congratulations on your new bot. You will find it at t.me/example_comments_bot. You can now add a description, about section and profile picture for your bot, see /help for a list of commands. By the way, when you've finished creating your cool bot, ping our Bot Support if you want a better username for it. Just make sure the bot is fully operational before you do this.

Use this token to access the HTTP API:
12345678:xy778Iltzsdr45tg

For a description of the Bot API, see this page: https://core.telegram.org/bots/api
```

## Authentication

To enable Telegram authentication for the users, set the variable `AUTH_TELEGRAM=true`.

## Notifications

### Notifications for administrators

To integrate notifications about any comment on your sites with remark42 with [Telegram](https://telegram.org)

1. Set `NOTIFY_ADMINS=telegram`
2. Set up your notification destination and add its ID to remark42 configuration as `NOTIFY_TELEGRAM_CHAN`

   Notifications can be sent to:
   - **Channels**: Create [a channel](https://telegram.org/faq_channels) and **add your bot as Administrator**
   - **Groups**: Create a group and add your bot to it
   - **Individual users**: Use the user's Telegram ID

   For the ID, you can use:
   - **Public channels/groups**: Use the username without the @ symbol (e.g., `mygroup` instead of `@mygroup`)
   - **Private channels/groups**: Use the numeric ID
   - **Users**: Use the user's numeric Telegram ID

   To obtain IDs:
   - For public channel/group ID: Forward any message from it to [@JsonDumpBot](https://t.me/JsonDumpBot) and look for `id` in `forward_from_chat`
   - For private channels/groups: Use [these instructions](https://github.com/GabrielRF/telegram-id#web-channel-id) or [@myidbot](https://t.me/myidbot)
   - For user ID: Users can get their ID from [@myidbot](https://t.me/myidbot)

   Note: When using channels or groups, ensure the bot has "Post messages" permission.

### Notifications for users

Enabling Telegram user notifications allows users to sign up for notifications about replies to their messages. To do it, set the variable `NOTIFY_USERS=telegram`.

### Technical details

Telegram notifications formatting is [limited](https://core.telegram.org/bots/api#html-style) by Telegram API and, because of that, lose most of the formatting of the original comment. Notification implementation of the remark42 backend takes the rendered HTML of the comment and strips it of the unsupported tags before sending it to Telegram. To mitigate this, `h1`-`h6` are replaced with supported `<b>` tag.

---

### Site Content Docs Contributing Api Index (`site/content/docs/contributing/api/index.md`)

---
title: API
---

## Authorization

- `GET /auth/{provider}/login?from=http://url&site=site_id&session=1` - perform "social" login with one of [supported providers](https://remark42.com/docs/configuration/authorization/#oauth-providers) and redirect to `url`. The presence of `session` (any non-zero value) change the default cookie expiration and makes them session-only
- `GET /auth/logout` - logout

```go
type User struct {
    Name     string `json:"name"`
    ID       string `json:"id"`
    Picture  string `json:"picture"`
    Admin    bool   `json:"admin"`
    Blocked  bool   `json:"block"`
    Verified bool   `json:"verified"`
    PaidSub  bool   `json:"paid_sub"` // is paid Patreon subscriber
}
```

## Commenting

- `POST /api/v1/comment` - add a comment, _auth required_

```go
type Comment struct {
    ID          string    `json:"id"`      // comment ID, read only
    ParentID    string    `json:"pid"`     // parent ID
    Text        string    `json:"text"`    // comment text, after md processing
    Orig        string    `json:"orig"`    // original comment text in Markdown, should never be rendered as HTML as-is!
    User        User      `json:"user"`    // user info, read only
    Locator     Locator   `json:"locator"` // post locator
    Score       int       `json:"score"`   // comment score, read only
    Vote        int       `json:"vote"`    // vote for the current user, -1/1/0
    Controversy float64   `json:"controversy,omitempty"` // comment controversy, read only
    Timestamp   time.Time `json:"time"`    // time stamp, read only
    Edit        *Edit     `json:"edit,omitempty" bson:"edit,omitempty"` // pointer to have empty default in JSON response
    Pin         bool      `json:"pin"`     // pinned status, read only
    Delete      bool      `json:"delete"`  // delete status, read only
    PostTitle   string    `json:"title"`   // post title
}

type Locator struct {
    SiteID string `json:"site"` // site ID
    URL    string `json:"url"`  // post URL
}

type Edit struct {
    Timestamp time.Time `json:"time" bson:"time"`
    Summary   string    `json:"summary"`
}
```

- `POST /api/v1/preview` - preview comment in HTML. Body is `Comment` to render
- `GET /api/v1/find?site=site-id&url=post-url&sort=fld&format=tree|plain` - find all comments for given post

This is the primary call UI uses to show comments for the given post. It can return comments in two formats - `plain` and `tree`. In plain format, the result will be a sorted list of `Comment`. In tree format, this is going to be a tree-like object with this structure:

```go
type Tree struct {
    Nodes []Node         `json:"comments"`
    Info  store.PostInfo `json:"info,omitempty"`
}

type Node struct {
    Comment store.Comment `json:"comment"`
    Replies []Node        `json:"replies,omitempty"`
}
```

Sort can be `time`, `active`, or `score`. Supported sort order with prefix -/+, i.e., `-time`. For `tree` mode, the sort will be applied to top-level comments only, and all replies are always sorted by time.

- `PUT /api/v1/comment/{id}?site=site-id&url=post-url` - edit comment, allowed once in `EDIT_TIME` minutes since creation. Body is `EditRequest` JSON

```go
type EditRequest struct {
    Text    string `json:"text"`    // updated text
    Summary string `json:"summary"` // optional, summary of the edit
    Delete  bool   `json:"delete"`  // delete flag
}{}
```

- `GET /api/v1/last/{max}?site=site-id&since=ts-msec` - get up to `{max}` last comments, `since` (epoch time, milliseconds) is optional
- `GET /api/v1/id/{id}?site=site-id` - get comment by `comment id`
- `GET /api/v1/comments?site=site-id&user=id&limit=N` - get comment by `user id`, returns `response` object.

**Important**: original comment text in Markdown in the `orig` field should never be rendered as HTML as-is, only `text` containing HTML is sanitized and safe for render.

```go
type response struct {
    Comments []store.Comment `json:"comments"`
    Count    int             `json:"count"`
}{}
```

- `GET /api/v1/count?site=site-id&url=post-url` - get comment's count for `{url}`
- `POST /api/v1/count?site=siteID` - get number of comments for posts from post body (list of post IDs)
- `GET /api/v1/list?site=site-id&limit=5&skip=2` - list commented posts, returns array or `PostInfo`, limit=0 will return all posts

```go
type PostInfo struct {
    URL      string    `json:"url"`
    Count    int       `json:"count"`
    ReadOnly bool      `json:"read_only,omitempty"`
    FirstTS  time.Time `json:"first_time,omitempty"`
    LastTS   time.Time `json:"last_time,omitempty"`
}
```

- `GET /api/v1/user` - get user info, _auth required_
- `PUT /api/v1/vote/{id}?site=site-id&url=post-url&vote=1` - vote for comment. `vote`=1 will increase score, -1 decrease, _auth required_
- `GET /api/v1/userdata?site=site-id` - export all user data to gz stream, _auth required_
- `POST /api/v1/deleteme?site=site-id` - request deletion of user data, _auth required_
- `GET /api/v1/config?site=site-id` - returns configuration (parameters) for given site

```go
type Config struct {
    Version         string   `json:"version"`
    EditDuration    int      `json:"edit_duration"`
    MaxCommentSize  int      `json:"max_comment_size"`
    Admins          []string `json:"admins"`
    AdminEmail      string   `json:"admin_email"`
    Auth            []string `json:"auth_providers"`
    LowScore        int      `json:"low_score"`
    CriticalScore   int      `json:"critical_score"`
    PositiveScore   bool     `json:"positive_score"`
    ReadOnlyAge     int      `json:"readonly_age"`
    MaxImageSize    int      `json:"max_image_size"`
    EmojiEnabled    bool     `json:"emoji_enabled"`
    SubscribersOnly bool     `json:"subscribers_only"` // enable commenting only for Patreon subscribers
}
```

- `GET /api/v1/info?site=site-idd&url=post-url` - returns `PostInfo` for site and URL

## Streaming API

<details><summary>Not available</summary>
Streaming API supposed to provide server-sent events for post updates as well as a site update:

- `GET /api/v1/stream/info?site=site-id&url=post-url&since=unix_ts_msec` - returns stream (`event: info`) with `PostInfo` records for the site and URL. `since` is optional
- `GET /api/v1/stream/last?site=site-id&since=unix_ts_msec` - returns updates stream (`event: last`) with comments for the site, `since` is optional

It was removed in https://github.com/umputun/remark42/pull/826 due to not being used and affecting tests flakiness and could be returned if there will be a developer who would be willing to write frontend support for it.

</details>

<details><summary>Response example</summary>

```
data: {"url":"https://radio-t.com/blah1","count":2,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.142872-05:00"}

event: info
data: {"url":"https://radio-t.com/blah1","count":3,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.157709-05:00"}

event: info
data: {"url":"https://radio-t.com/blah1","count":4,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.172991-05:00"}

event: info
data: {"url":"https://radio-t.com/blah1","count":5,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.188429-05:00"}

event: info
data: {"url":"https://radio-t.com/blah1","count":6,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.204742-05:00"}

event: info
data: {"url":"https://radio-t.com/blah1","count":7,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.220692-05:00"}

event: info
data: {"url":"https://radio-t.com/blah1","count":8,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.23817-05:00"}

event: info
data: {"url":"https://radio-t.com/blah1","count":9,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.254669-05:00"}
```

</details>

## RSS Feeds

- `GET /api/v1/rss/post?site=site-id&url=post-url` - RSS feed for a post
- `GET /api/v1/rss/site?site=site-id` - RSS feed for given site
- `GET /api/v1/rss/reply?site=site-id&user=user-id` - RSS feed for replies to user's comments

## Images Management

- `GET /api/v1/picture/{user}/{id}` - load stored image
- `POST /api/v1/picture` - upload and store image, uses post form with `FormFile("file")`. Returns `{"id": user/imgid}`, _auth required_

_returned ID should be appended to load image URL on the caller side_

## Email Subscription

- `GET /api/v1/email?site=site-id` - get user's email, _auth required_
- `POST /api/v1/email/subscribe?site=site-id&address=user@example.org` - makes confirmation token and sends it to the user over email, _auth required_

  Trying to subscribe to the same email a second time will return response code `409 Conflict` with explaining error message

- `POST /api/v1/email/confirm?site=site-id&tkn=token` - uses provided token parameter to set email for the user, _auth required_

  Setting email subscribe user for all first-level replies to his messages

- `DELETE /api/v1/email?site=siteID` - removes user's email, _auth required_

## Admin

- `DELETE /api/v1/admin/comment/{id}?site=site-id&url=post-url` - delete comment by `id`
- `PUT /api/v1/admin/user/{userid}?site=site-id&block=1&ttl=7d` - block or unblock user with optional TTL (default=permanent)
- `GET api/v1/admin/blocked&site=site-id` - list of blocked user IDs

```go
type BlockedUser struct {
    ID    string    `json:"id"`
    Name  string    `json:"name"`
    Until time.Time `json:"time"`
}
```

- `GET /api/v1/admin/export?site=site-id&mode=[stream|file]` - export all comments to JSON stream or gz file
- `POST /api/v1/admin/import?site=site-id` - import comments from the backup, uses post body
- `POST /api/v1/admin/import/form?site=site-id` - import comments from the backup, user post form
- `POST /api/v1/admin/remap?site=site-id` - remap comments to different URLs. Expect a list of "from-url new-url" pairs separated by \n. From-url and new-url parts are separated by space. If URLs end with an asterisk (\*), it means matching the prefix. Remap procedure based on export/import chain so make the backup first

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

- `GET /api/v1/admin/wait?site=site-id` - wait for completion for any async migration ops (import or remap)
- `PUT /api/v1/admin/pin/{id}?site=site-id&url=post-url&pin=1` - pin or unpin comment
- `GET /api/v1/admin/user/{userid}?site=site-id` - get user's info
- `DELETE /api/v1/admin/user/{userid}?site=site-id` - delete the user's comments and stored details; succeeds even if the user has no comments or is already absent
- `PUT /api/v1/admin/readonly?site=site-id&url=post-url&ro=1` - set read-only status
- `PUT /api/v1/admin/verify/{userid}?site=site-id&verified=1` - set verified status
- `GET /api/v1/admin/deleteme?site=site-id&token=token` - process a user's deleteme request; already-deleted or dataless users return success (idempotent)

_all admin calls require auth and admin privilege_

---

### Site Content Docs Contributing Backend Index (`site/content/docs/contributing/backend/index.md`)

---
title: Backend Development Guidelines
aliases:
  - /docs/contributing/
---

You can use a fully functional local version to develop and test both frontend and backend. It requires at least 2GB RAM or swap enabled.

To bring it up, run:

```shell
cp compose-dev-backend.yml compose-private.yml
# now, edit / debug `compose-private.yml` to your heart's content

# build and run
docker compose -f compose-private.yml up --build
```

It starts Remark42 on `127.0.0.1:8080` and adds local OAuth2 provider "Dev". To access the UI demo page go to <http://127.0.0.1:8080/web/>. By default, you would be logged in as `dev_user`, defined as admin. You can tweak any of the [supported parameters](https://remark42.com/docs/configuration/parameters/) in corresponded yml file.

{{< note "🚨" >}}
Please use `127.0.0.1` and not `localhost` to access the server; otherwise, CORS will prevent your browser from authentication to work correctly. You could alter the address for dev auth with the `REMARK_URL` environment variable.
{{< /note >}}

Backend Docker Compose config (`compose-dev-backend.yml`) by default skips running frontend related tests. Frontend Docker Compose config (`compose-dev-frontend.yml`) skips backend tests and the frontend build. Run the frontend separately with `pnpm dev` as described in the [frontend development guide](https://remark42.com/docs/contributing/frontend/#run-frontend-with-backend-locally).

### Backend development

#### With Docker

Run tests in your IDE, and re-run `make rundev` each time you want to see how your code changes behave to test them at <http://127.0.0.1:8080/web/>.

#### Without Docker

You have to [install](https://golang.org/doc/install) the latest stable `go` toolchain to run the backend locally.

In order to have working Remark42 installation you need once to copy frontend static files to `./backend/app/cmd/web` directory from `master` docker image, as it is expected to be where application compiles:

```shell
# frontend files
docker pull ghcr.io/umputun/remark42:master
docker create -ti --name remark42files ghcr.io/umputun/remark42:master sh
docker cp remark42files:/srv/web/ ./backend/app/cmd/
docker rm -f remark42files
# fix frontend files to point to the right URL
## Mac version
find -E ./backend/app/cmd/web -regex '.*\.(html|js|mjs)$' -print -exec sed -i '' "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \;
## Linux version
find ./backend/app/cmd/web -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \;
```

The assets under `/web` the frontend does not build (`privacy.html`, `markdown-help.html`,
`400x400.jpeg`) come from `backend/app/webassets/assets` and are embedded in the binary, so the copy
above neither covers them nor needs to. At runtime a file of the same name under `web-root` /
`REMARK_WEB_ROOT` is served in preference to the embedded one.

To run backend - `cd backend; go run app/main.go server --dbg --secret=12345 --url=http://127.0.0.1:8080 --admin-passwd=password --site=remark`. It stars backend service with embedded bolt store on port `8080` with basic auth, allowing to authenticate and run requests directly, like this:

`HTTP http://admin:password@127.0.0.1:8080/api/v1/find?site=remark&sort=-active&format=tree&url=http://127.0.0.1:8080`

## Technical Details

Data stored in [boltdb](https://github.com/etcd-io/bbolt) (embedded key/value database) files under `STORE_BOLT_PATH`. Each site is stored in a separate boltdb file.

To migrate/move Remark42 to another host, these boltdb files must be transferred. Additionally, the avatars directory `AVATAR_FS_PATH` (default `./var/avatars`) and images directory `IMAGE_FS_PATH` (default `./var/pictures`) must be transferred. As an alternative to storing avatars and images in the file system, boltdb can be used by setting `AVATAR_TYPE` or `IMAGE_TYPE` to `bolt`. Files in the `IMAGE_FS_STAGING` directory can be safely ignored because they are moved to the image store between app restarts.

The automatic backup process runs every 24h and exports all content in JSON-like format to `backup-remark-YYYYMMDD.gz`.

Authentication implemented with [go-pkgz/auth](https://github.com/go-pkgz/auth) stored in a cookie. It uses HttpOnly, secure cookies.

All heavy REST calls cached internally in LRU cache limited by `CACHE_MAX_ITEMS` and `CACHE_MAX_SIZE` with [go-pkgz/rest](https://github.com/go-pkgz/rest).

User's activity throttled globally (up to 1000 simultaneous requests) and limited locally (per user, usually up to 10 req/sec).

Request timeout set to 60sec.

Admin authentication (`--admin-passwd` set) allows to hit Remark42 API without social login and admin privileges. Adds basic-auth for username: `admin`, password: `${ADMIN_PASSWD}`. Enable it only for the initial comment import or for manual backups. Do not leave the server running with admin password set if you don't have an intention to keep creating backups manually!

User can vote for the comment multiple times but only to change the vote. Double voting is not allowed.

User can edit comments in 5 mins (configurable) window after creation.

User ID hashed and prefixed by OAuth provider name to avoid collisions and potential abuse.

All avatars resized and cached locally to prevent rate limiters from OAuth providers, part of [go-pkgz/auth](https://github.com/go-pkgz/auth) functionality.

Images served over HTTP can be proxied to HTTPS (`IMAGE_PROXY_HTTP2HTTPS=true`) to prevent mixed HTTP/HTTPS.

All images can be proxied and saved locally (`IMAGE_PROXY_CACHE_EXTERNAL=true`) instead of serving from the original location. Beware, images that are posted with this parameter enabled will be served from proxy even after it is disabled.

Docker build uses [publicly available](https://github.com/umputun/baseimage) base images.

---

### Site Content Docs Contributing Frontend Index (`site/content/docs/contributing/frontend/index.md`)

---
title: Frontend Development Guidelines
---

## Prerequisites

Frontend for Remark42 is built with [Preact](https://preactjs.com) and [Redux](https://redux.js.org).

{{< note "💡" >}}
We highly recommend checking out Preact [documentation](https://preactjs.com/guide/v10/getting-started).
React libraries are not usable here: the store bindings and the i18n helpers are small local
modules under `app/`.
{{< /note >}}

In order to inject Remark42 widgets into websites we use `iframe` and `postMessage` for communication between a site and the widget.
Simple widgets like [counter widget](https://remark42.com/docs/configuration/frontend/#counter-widget) can be injected as a script because it doesn't have its own interface.

While developing, we set up environment which imitates real world example. We serve the page which uses Remark42 config and inject all the widgets on it. You can check it on our [demo site](https://demo.remark42.com/web/). After successful installation you should have the same page running locally.

## Installation

You must have at least 2GB RAM or swap enabled for building.

- install [Node.js 24](https://nodejs.org/en/) or higher (we recommend using [NVM](https://github.com/nvm-sh/nvm) for node version autoswitch)
- install [PNPM 10](https://pnpm.io/installation)
- run `pnpm i` inside `./frontend/apps/remark42`

Running `pnpm i` will set up pre-commit hooks into your git repository. They are used to reformat your frontend code using `prettier` and lint with `eslint` and `stylelint` before every commit.

{{< note "🚨" >}}
Please use `127.0.0.1` and not `localhost` to access the server; otherwise, CORS will prevent your browser from authentication to work correctly. You could alter the address for dev auth with the `REMARK_URL` environment variable.
{{< /note >}}

## Development

### Run frontend with remote backend

You can run frontend against demo instance of Remark42. This method of running Remark42 frontend code is preferred when you make a translation or visual adjustments that are easy to see without extensive testing. For this method we use our demo instance of Remark42 served on https://demo.remark42.com

For local development mode with Hot Reloading, use `pnpm dev:demo`. In this case, `webpack` will serve files using `webpack-dev-server` on `127.0.0.1:9000`. By visiting <http://127.0.0.1:9000/web/>, you will get a page with the main comments' widget communicating with a demo server backend running on `https://demo.remark42.com`. But you will not be able to log in with any OAuth providers due to security reasons.

You can attach the frontend to the locally running backend from `frontend/apps/remark42` folder and providing the `REMARK_API_BASE_URL` environment variable.

```shell
pnpm exec cross-env REMARK_API_BASE_URL=http://127.0.0.1:8080 pnpm dev:custom
```

{{< note "ℹ️" >}}
If you want to redefine env variables such as `PORT` on your local instance, you can add the `.env` file to the `./frontend/apps/remark42` folder and rewrite variables as you wish. For such functional, we use `dotenv`.
{{< /note >}}

### Run frontend with backend locally

This option of running Remark42 frontend code is preferred when you need extensive testing of your code changes, as you'll have your backend and configure it as you want, for example, enable any auth and notifications method you need to test. You can use that set up to develop and test both frontend and backend.

To bring the backend up, run:

```shell
cp compose-dev-frontend.yml compose-private.yml
# now, edit / debug `compose-private.yml` to your heart's content

# build and run
docker compose -f compose-private.yml up --build
```

Then in the new terminal tab or window, run the following to start the frontend with Hot Reloading:

```shell
cd frontend/apps/remark42
pnpm dev
```

Developer build running by `webpack-dev-server` supports devtools for [React](https://reactjs.org/blog/2019/08/15/new-react-devtools.html#how-do-i-get-the-new-devtools) and [Redux](https://github.com/reduxjs/redux-devtools).

It starts Remark42 backend on `127.0.0.1:8080` and adds local OAuth2 provider "Dev". To access the frontend running by Node, go to <http://127.0.0.1:9000/web/>. By default, you would be logged in as `dev_user`, defined as admin. You can tweak any of the [supported parameters](https://remark42.com/docs/configuration/parameters/) in corresponded yml file.

### Manual testing after changes

Frontend Docker Compose config (`compose-dev-frontend.yml`) by default skips running backend related tests.

{{< note "🚨" >}}
Before submitting your changes as a Pull Request, run the backend using the `docker compose -f compose-dev-frontend.yml build --build-arg SKIP_FRONTEND_BUILD=""; docker compose -f compose-private.yml up` command and test your changes against <http://127.0.0.1:8080/web/>, frontend, built statically (unlike frontend on port 9000, which runs dynamically). That is how Remark42 authors will test your changes once you submit them.
{{< /note >}}

## Static build

Remark42 frontend can be built statically, and that's how the production version works: frontend is built and then resulting files embedded into the backend, which serves them as-is. Node is not running when a user starts Remark42, only the backend written in Go programming language, which also serves pre-built frontend HTML and JS and CSS files.

Run `pnpm build` inside `./frontend/apps/remark42`, and result files will be saved in `./frontend/apps/remark42/public`.

`/web` is served from two sources. This build output comes first; anything it does not emit is
served from `backend/app/webassets/assets`, embedded in the backend binary, which is where
`privacy.html`, `markdown-help.html` and `400x400.jpeg` live. A plain page or image the bundler
does not process belongs there rather than here. Those files sit outside the frontend toolchain,
so prettier, stylelint and `pnpm lint` do not see them.

## Code Style

- The project uses TypeScript to analyze code statically
- The project uses Eslint and Stylelint to check the frontend code. You can manually run via `pnpm lint`
- Git Hooks (via husky) installed automatically on `pnpm i`. They check and try to fix code style if possible, otherwise commit will be rejected
- If you want IDE integration, you need Eslint and Stylelint plugins to be installed. Eslint reads `eslint.config.mjs` from the directory it is run in, so point your editor at the app directory: for VSCode, `"eslint.workingDirectories": ["frontend/apps/remark42"]`

## CSS Styles

- Component styles use CSS Modules in `component.module.css` files.
- Use `root` for the component block and camelCase for element and modifier classes; combine classes with `clsx`.
- Keep the stylesheet next to its component, for example `app/components/list-comments/list-comments.module.css` and `app/components/list-comments/list-comments.tsx`.

## Imports

- Imports for TypeScript, JavaScript files should be without extension: `./index`, not `./index.ts`
- Both of the two rules above are reversed inside the dependency-free test layer described under
  Testing, and in any module it imports: node's ESM resolver refuses an extensionless specifier and
  knows nothing of the alias, so those files write `./types.ts` in full and never `common/…`.
  Webpack, Jest and `tsc` all resolve the explicit relative form too, which is why the exception
  costs nothing outside those files. It applies to value imports only; a type-only import erases
  before either runtime sees it, and keeps the ordinary style
- If the file resides in the same directory or subdirectory, the import should be relative: `./types/something`
- Otherwise, it should be imported by absolute path relative to the `app` folder like `common/types` which maps to `./app/common/types.ts` in webpack, tsconfig, and Jest

## Testing

Tests come in two layers, and which one a test belongs in is decided by what it needs to run.

### Component and integration tests, under Jest

- Project uses [Jest](https://jestjs.io) as test framework
- [Testing Library](https://testing-library.com) is used for UI tests
- Jest checks files that match regex `\.(test|spec)\.ts(x?)$`, i.e., `comment.test.tsx`, `comment.spec.ts`, and skips `*.unit.test.ts`
- Tests are running on push attempt
- Example tests can be found in `./app/components/auth/auth.spec.tsx`

### Dependency-free tests, `*.unit.test.ts`

Anything that is a function over plain values -- a parser, a reducer, a decoder -- is tested without
a framework, a bundler or `node_modules`. These files import nothing but the module under test, its
fixtures and `node:test`, and the runtime strips the types itself:

```shell
pnpm test:unit
```

It needs nothing you do not already have.

The `unit` job in `ci-frontend.yml` runs them with **no `pnpm install` step at all**. That missing
step is the point: a test that starts needing `node_modules` to pass has quietly acquired a
dependency, and the job fails instead of hiding it. `release.yml` runs the same script after an install,
as a second gate and not as the dependency check.

`node --test` exits 0 on a glob that matches nothing, so on its own it cannot tell a renamed
suffix from a passing run. The CI step counts the files first and fails when none match.

Two rules follow from having no bundler, and both apply to **value** imports only. A type-only
import is erased before node sees the file, so it may keep the alias and drop the extension, which is why `import type { Comment } from 'common/types'` is correct inside this layer:

- import with the extension in full, `./types.ts`, since node resolves nothing else
- import by relative path, never by the `common/…` alias, which only webpack, Jest and `tsc` know
  about

Prefer this layer whenever the thing under test does not genuinely need a DOM. Where a module mixes
the two -- a hook that both reads `sessionStorage` and computes something -- split the computation
out and test that half here; `app/common/intl-message.ts` and `app/hooks/session-storage.ts` are the
pure halves of `intl.tsx` and `useSessionState.ts`, kept apart for exactly this reason.

## Notes

Frontend part being bundled on docker env gets placed on `/src/web` and is available via `http://{host}/web`. For example, `embed.mjs` entry point will be available at `http://{host}/web/embed.mjs`

## Learn More

- [How to Add a New Locale](https://remark42.com/docs/contributing/translations/)
- [Remark42 Instance Parameters](https://remark42.com/docs/configuration/parameters/)

---

### Site Content Docs Getting Started Installation Index (`site/content/docs/getting-started/installation/index.md`)

---
title: Installation
aliases:
  - /docs/getting-started/
  - /docs/
---

## System Requirements

Remark42 is designed to be lightweight and efficient. Based on production usage statistics from busy installations:

- **CPU**: Minimal usage (typically under 0.1%)
- **Memory**: ~80MiB RAM (less than 4% of 2GB)
- **Network**: Moderate bandwidth requirements
- **Disk**: Small footprint (under 200MB for a 5-year-old installation with regular activity)

These requirements make Remark42 suitable for running on small VPS instances, shared hosting environments, or even on Raspberry Pi and similar devices.

## Setup Remark42 Instance on Your Server

### Installation in Docker

_This is the recommended way to run Remark42_

- copy provided [`docker-compose.yml`](https://github.com/umputun/remark42/blob/master/docker-compose.yml) and customize for your needs
- make sure you **don't keep** `ADMIN_PASSWD=something...` for any non-development deployments
- pull prepared images from the Docker Hub and start - `docker compose pull && docker compose up -d`
- alternatively, compile from the sources - `docker compose build && docker compose up -d`

### Installation with Binary

- download [archive for the stable release](https://github.com/umputun/remark42/releases)
- extract the `.tar.gz` archive with `tar -xzf <archive>.tar.gz` (Linux, macOS), or extract the `.zip` archive (Windows)
- run as `remark42.{os}-{arch} server {parameters...}`, i.e., `remark42.linux-amd64 server --secret=12345 --url=http://127.0.0.1:8080`
- alternatively compile from the sources - `make OS=[linux|darwin|windows] ARCH=[amd64,386,arm64,arm]`. Source binary builds require Go 1.27, Node 24+, PNPM 10, and Perl because the frontend assets are built and embedded locally.

#### Installation as a systemd Service

For a clean persistent setup without lengthy command line parameters:

1. Create an environment file `/etc/remark42.env`:
   ```
   SECRET=12345
   REMARK_URL=http://127.0.0.1:8080
   ```

1. Create a systemd service file `/etc/systemd/system/remark42.service`:
   ```
   [Unit]
   Description=Remark42 Commenting Server
   After=syslog.target
   After=network.target

   [Service]
   Type=simple
   EnvironmentFile=/etc/remark42.env
   ExecStart=/usr/local/bin/remark42 server
   WorkingDirectory=/var/www/remark42       # directory where data files are stored and automatic backups will be created
   Restart=on-failure
   User=nobody                              # another good alternative is `www-data`
   Group=nogroup                            # another good alternative is `www-data`

   [Install]
   WantedBy=multi-user.target
   ```

1. Enable and start the service:
   ```
   sudo systemctl enable remark42.service
   sudo systemctl start remark42.service
   ```

1. To update configuration, edit the environment file and restart the service:
   ```
   sudo systemctl restart remark42.service
   ```

## Setup on Your Website

Add config for Remark on a page of your site ([here](/docs/configuration/frontend/) is the full reference):

- `REMARK_URL` – the URL where is Remark42 instance is served, passed as `REMARK_URL` to backend
- `YOUR_SITE_ID` - the `SITE` that you passed to Remark42 instance on start, `remark` by default.

```html
<script>
	var remark_config = {
		host: "REMARK_URL",
		site_id: "YOUR_SITE_ID",
	}
</script>
```

For example:

```html
<script>
	var remark_config = {
		host: "https://demo.remark42.com",
		site_id: "remark",
	}
</script>
```

After that place the code snippet right after config.


```html
<script>!function(e,n){for(var o=0;o<e.length;o++){var r=n.createElement("script"),d=n.head||n.body;r.type="module",r.async=!0,r.defer=!0,r.src=remark_config.host+"/web/"+e[o]+".mjs",d.appendChild(r)}}(remark_config.components||["embed"],document);</script>
```


{{< note "💡" >}}
**Note:** You can place the config with the snippet in any place of the HTML code of your site. If it is closer to start of the HTML (for example in `<head>`) it will start loading sooner and show comments faster.
{{< /note >}}

Put the next code snippet on a page of your site where you want to have comments:

```html
<div id="remark42">Comments loading...</div>
```

After that widget will be rendered inside this node. Any content you place inside the div (such as "Comments loading..." above) is automatically removed once the widget initialises, so you can use it as a loading placeholder.

For more information about frontend configuration please [learn about other parameters here](https://remark42.com/docs/configuration/frontend/)
If you want to set this up on a Single Page App, see the [appropriate doc page](https://remark42.com/docs/configuration/frontend/spa/).

#### Quick installation test

To verify if Remark42 has been properly installed, check a demo page at `${REMARK_URL}/web` URL. Make sure to include `remark` site ID to the `${SITE}` list.

### Build from the source

- to build Docker container - `make docker`. This command will produce container `ghcr.io/umputun/remark42`
- to build a single binary for direct execution - `make OS=<linux|windows|darwin> ARCH=<amd64|386>`. This requires Go 1.27, Node 24+, PNPM 10, and Perl, builds frontend assets locally, and produces an executable `remark42` file with everything embedded

---

### Site Content Docs Manuals Admin Interface Index (`site/content/docs/manuals/admin-interface/index.md`)

---
title: Admin UI
---

Administrators defined by `admin.shared.id` / `$ADMIN_SHARED_ID` and get the following extra control elements:



1. **Show settings - lists of blocked and hidden users.**
   

2. **Disable comments for the post. Can be enabled back anytime.**

3. **Block user permanently or temporary.**
   

4. **Delete a comment**

---

### Site Content Docs Manuals Integration With Astro Index (`site/content/docs/manuals/integration-with-astro/index.md`)

---
title: Using Remark42 in Astro
---

[Astro](https://astro.build/) is an all-in-one web framework for building fast, content-focused websites.

- **Content-focused:** Astro was designed for content-rich websites.
- **Server-first:** Websites run faster when they render HTML on the server.
- **Fast by default:** It should be impossible to build a slow website in Astro.
- **Easy to use:** You don't need to be an expert to build something with Astro.
- **Fully-featured, but flexible:** Over 100+ Astro integrations to choose from.

While Astro supports numerous front-end framework [integrations](https://docs.astro.build/en/guides/integrations-guide/#official-integrations) (e.g. React, Vue, SolidJS, etc.)

## w/ Svelte Components

### Svelte Component (Embedded Frame)

#### remark42-embed.svelte

```js
<svelte:head>
  <script async lang="javascript">
    var remark_config = {
      host: "https://your.website.here",
      site_id: "yourwebsite",
      components: ["embed"],
      show_rss_subsription: false,
      theme: localStorage.getItem("color-theme") ?? "light",
    };
    !(function (e, n) {
      for (var o = 0; o < e.length; o++) {
        var r = n.createElement("script"),
          d = n.head || n.body;
        (r.type = "module"),
          (r.async = !0),
          (r.defer = !0),
          (r.src = remark_config.host + "/web/" + e[o] + ".mjs"),
          d.appendChild(r);
      }
    })(remark_config.components || ["embed"], document);
  </script>
</svelte:head>

<div id="remark42">Comments loading...</div>
```

### Astro Layout (Page)

#### blogpageLayout.astro (partial Astro layout)

```js
---
...
import Remark42Embed from "../components/remark42-embed.svelte";

export interface Props {
  frontmatter: Frontmatter;
}

const { frontmatter } = Astro.props;
---
<html lang="en">
    ...
          <article class="markdown">
            <slot />
          </article>
          {frontmatter.comments && <Remark42Embed client:visible />}
    ...
</html>
```

Note the use of the `client:visible` directive which will hydrate the component once the element becomes visible.

### Svelte Component (Counter)

#### remark42-counter.svelte

```js
<script>
  export let url;
</script>

<svelte:head>
  <script async lang="javascript">
    var remark_config = {
      host: "https://your.website.here",
      site_id: "yourwebsite",
      components: ["counter"],
      show_rss_subsription: false,
      theme: localStorage.getItem("color-theme") ?? "light",
    };
    !(function (e, n) {
      for (var o = 0; o < e.length; o++) {
        var r = n.createElement("script"),
          d = n.head || n.body;
        (r.type = "module"),
          (r.async = !0),
          (r.defer = !0),
          (r.src = remark_config.host + "/web/" + e[o] + ".mjs"),
          d.appendChild(r);
      }
    })(remark_config.components || ["embed"], document);
  </script>
</svelte:head>

<span class="remark42__counter" data-url={url.href} />
```

### Astro Component (Card)

#### blogCard.astro (partial Astro component)

```js
---
const { post } = Astro.props;

import Remark42Count from "../components/remark42-count.svelte";
...
---
<div>
    ...
    
    {
      post.frontmatter.comments && (
        <div class="my-2 text-center font-Roboto font-light text-gray-600 dark:text-gray-300">
          {post.frontmatter.minutes} minutes &nbsp;&bull;&nbsp;
          <Remark42Count url={new URL(post.url, Astro.site)} client:idle /> comments
        </div>
      )
    }
    ...
</div>
```

Note the use of the `client:idle` directive which will hydrate the component once the page has completed the initial load.

## w/ React/Preact Components

This example demonstrates how to integrate the Remark42 commenting system using a React/Preact component. The component manages the insertion and removal of the Remark42 script in the DOM, provides the ability to toggle between light and dark themes, and includes areas for embedding comments and displaying a comment count. This makes it easy to incorporate a commenting system into a React/Preact application.

#### Comment.tsx

```
/* Detailed source-code truncated for AI context efficiency. */
```

#### Here how to use it in your Astro component.

```js
---
import { Comments } from "@components/Comment";
---

<Comments client:visible />
```

---

### Site Content Docs Manuals Integration With Gatsby Index (`site/content/docs/manuals/integration-with-gatsby/index.md`)

---
title: Using Remark42 in Gatsby projects
---

## React component written in Javascript

```
/* Detailed source-code truncated for AI context efficiency. */
```

## React component written in Typescript

```
/* Detailed source-code truncated for AI context efficiency. */
```

---

### Site Content Docs Manuals Kubernetes Index (`site/content/docs/manuals/kubernetes/index.md`)

---
title: Deploy with Kubernetes
---

## Using Helm

Use [remark42 Helm chart](https://github.com/groundhog2k/helm-charts/tree/master/charts/remark42).

## Without Helm

Here's the sample manifest for running remark42 on Hetzner Cloud:

```
/* Detailed source-code truncated for AI context efficiency. */
```

Change `storageClassName` if you run on top of different cloud / bare metal.

This example assumes there is Nginx Ingress with a cert-manager already set up.
Adjust if you use different Ingress.

In addition you'd need to define secrets, e.g.

```yaml
apiVersion: v1
kind: Secret
metadata:
  name: remark42
  namespace: remark42
stringData:
  SECRET: <changeme>
  AUTH_GOOGLE_CID: <changeme>.apps.googleusercontent.com
  AUTH_GOOGLE_CSEC: <changeme>
  AUTH_GITHUB_CID: <changeme>
  AUTH_GITHUB_CSEC: <changeme>
```

Some more information (and comments!) may be found
[here](https://www.rusinov.ie/en/posts/2021/this-website-has-remark42-comments-now/).

---

### Site Content Docs Manuals Nginx Index (`site/content/docs/manuals/nginx/index.md`)

---
title: Configure with Nginx
---

## How to configure remark42 with Nginx reverse proxy

Example of nginx configuration (reverse proxy) running remark42 service on remark42.example.com

```nginx
server {
    listen      443;
    server_name remark42.example.com;
    ssl    on;
    ssl_certificate        /etc/nginx/ssl/remark42.example.com.crt;
    ssl_certificate_key    /etc/nginx/ssl/remark42.example.com.key;

    gzip on;
    gzip_types text/plain application/json text/css application/javascript application/x-javascript text/javascript text/xml application/xml application/rss+xml application/atom+xml application/rdf+xml;
    gzip_min_length 1000;
    gzip_proxied any;


    location ~ /\.git {
        deny all;
    }

    location /index.html {
         proxy_redirect          off;
         proxy_set_header        X-Real-IP $remote_addr;
         proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
         proxy_set_header        Host $http_host;
         proxy_pass              http://remark42:8080/web/index.html;
     }

    location / {
         proxy_redirect          off;
         proxy_set_header        X-Real-IP $remote_addr;
         proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
         proxy_set_header        Host $http_host;
         proxy_pass              http://remark42:8080/;
    }

    access_log   /var/log/nginx/remark42.log;

}

server {
  listen 80;
  server_name remark42.example.com;
  return      301 https://remark42.example.com$request_uri;
}
```

Note: `proxy_pass` points to internal DNS name `remark42` and is expected to run from the same compose. If Nginx runs outside compose, the real IP (or docker's bridge IP) should be used

Because this config sets `X-Real-IP`/`X-Forwarded-For`, set [`--trusted-proxy`](../../configuration/parameters/#trusted-proxies-and-client-ip) to the network Nginx connects from (for a same-compose Nginx that is the Docker network, e.g. `172.16.0.0/12`). Without it Remark42 trusts those headers from any client, which lets them spoof their IP and bypass rate limiting and vote de-duplication.

---

### Site Content Docs Manuals Reproxy Index (`site/content/docs/manuals/reproxy/index.md`)

---
title: Configure with Reproxy
---

## How to configure remark42 with [Reproxy](https://reproxy.io)

Example of Reproxy configuration (reverse proxy) running remark42 service on remark42.example.com with Docker Compose. Reproxy handles SSL termination with LE and gzip all the responses.

```
/* Detailed source-code truncated for AI context efficiency. */
```

To make per-IP rate limiting and vote de-duplication use the real client IP, set [`--trusted-proxy`](../../configuration/parameters/#trusted-proxies-and-client-ip) to the network Reproxy connects from (the Docker network for a same-compose Reproxy, e.g. `172.16.0.0/12`). Two Reproxy-specific cautions: Reproxy derives `X-Real-IP` from an incoming `X-Forwarded-For`, so a directly-exposed Reproxy still lets a client spoof the IP — front it with something that strips client `X-Forwarded-For`; and drop the `ports: - "8080"` mapping on the `remark42` service once Reproxy is the entry point, otherwise Remark42 is reachable directly and external traffic appears as a trusted Docker peer.

---

### Site Content Docs Manuals Separate Domain Index (`site/content/docs/manuals/separate-domain/index.md`)

---
title: Configure Instance on a different domain
---

## How to configure a single Remark42 instance for multiple domains

### What doesn't work so far?

Unless discussion [#1139](https://github.com/umputun/remark42/discussions/1139) has a marked answer, authorisation using oAuth like GitHub or Google is impossible on domains other than the original one. Telegram, Email and anonymous auth work on allowed HTTPS embedding domains when `AUTH_SEND_JWT_HEADER=true`.

### Setup

Set `ALLOWED_HOSTS="'self',https://example1.org,https://example2.org"` with your domain names, and `AUTH_SEND_JWT_HEADER=true`.

`AUTH_SEND_JWT_HEADER` is what keeps a reader signed in across a page reload on Safari, in Chrome Incognito, and in Chrome configured to block third-party cookies. The one setting it does not survive is Firefox's "block all third-party cookies", which discards partitioned cookies as well, and which no configuration survives. Read [its security considerations](../../configuration/parameters/#security-considerations-for-authsend-jwt-header) before enabling it: it puts the token in a cookie JavaScript can read, which costs XSS exposure that a server-set `HttpOnly` cookie does not.

**`AUTH_SAME_SITE=none` is not needed alongside it**, which reverses what this page recommended for years, so it is worth showing the measurement instead of asserting it. Signing in anonymously from an embedded widget over https on Chromium with its default third-party cookie policy, and dumping the browser's cookie jar, gives with the setting:

| name | httpOnly | partition key | written by |
| --- | --- | --- | --- |
| `JWT` | yes | none | the server |
| `XSRF-TOKEN` | no | none | the server |
| `JWT` | no | the embedding site | the widget |
| `XSRF-TOKEN` | no | the embedding site | the widget |

and without it, only the widget's own partitioned pair. Sign-in, posting and the reload all work either way, in a permissive browser and in one blocking third-party cookies. What the setting adds is the unpartitioned `HttpOnly` `JWT` in the first row, delivered as a third-party cookie to every listed domain wherever the browser still permits that. Nothing needs it, so leaving it at the default is the smaller exposure.

Keep `AUTH_SAME_SITE=none` if you are *not* setting `AUTH_SEND_JWT_HEADER`. Then the server's cookies are the only ones there are, and this is what allows them to be set off-domain at all, for as long as the reader's browser still accepts unpartitioned third-party cookies.

The `'self'` in `ALLOWED_HOSTS` value means "domain where Remark42 is installed on" and needed if you want `remark42.example.com/web/` to work in case you want to test something with it.

### Technical details

`ALLOWED_HOSTS` sets CSP [frame-ancestors](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors), which, once enabled, limits the domains where Remark42 would work. The default value is `*` so that it would work on any domain.

`AUTH_SAME_SITE` sets the [SameSite](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite) attribute on the cookies the server sets. `none` lets those cookies be set on any domain where Remark42 is shown.

The `default` setting does not mean `Lax`. It means Remark42 emits no `SameSite` attribute at all and leaves the choice to the browser, and browsers differ: Chromium treats a missing attribute as `Lax` and so refuses the cookie cross-site, while Firefox accepts it. That difference is not a detail, because it decides which of the two cookie pairs below a reader actually ends up with.

`SameSite=None` is not sufficient on its own, and with `AUTH_SEND_JWT_HEADER` it is not necessary either. A browser that blocks third-party cookies drops a cookie set by Remark42 for a reader on another domain no matter what its `SameSite` value is, unless the cookie is explicitly marked [`Partitioned`](https://developer.mozilla.org/en-US/docs/Web/Privacy/Privacy_sandbox/Partitioned_cookies), and the server-set cookies are not. `AUTH_SEND_JWT_HEADER=true` is what closes that: the token comes back in an `X-JWT` response header and the widget stores it in its own cookie, written from inside the embedded frame and marked `SameSite=None; Secure; Partitioned`, so the browser keeps it for that embedding site and sends it back after a reload. That cookie is the widget's own doing and owes nothing to `AUTH_SAME_SITE`, which reaches only the pair the server sets. The exception is Firefox's "block all third-party cookies", which discards partitioned cookies too, so the flag closes nothing there and no configuration does.

A browser that refuses a cross-site `Set-Cookie` lacking `SameSite=None` refuses it outright, so with the setting left at its default the server's pair is absent from the jar, not present with a stricter attribute.

Note that this applies to Email, Telegram and anonymous authorisation, which the widget performs from inside the frame. It does not rescue oAuth, which completes in a popup that is a top-level page of its own, so the cookie set there belongs to the Remark42 domain and the embedded frame never sees it.

### What each browser actually does

Measured on real domains over real certificates, with Remark42 on one registrable domain and the
host page on another, signing in and then reloading. Chrome and Firefox were driven through
Playwright, Safari 27 through its own WebDriver, so the Safari column is Safari itself and not an
approximation of it. Every "blocked" column below was verified with a control cookie: an ordinary
third-party cookie written from inside the widget frame has to be dropped, or the run is not
blocking anything and proves nothing.

| configuration | Chrome, default | Chrome, third-party cookies blocked | Firefox, default | Firefox, "block all third-party" | Safari |
| --- | --- | --- | --- | --- | --- |
| neither set | fails | fails | works | fails | fails |
| `AUTH_SEND_JWT_HEADER` only | works | works | works | fails | works |
| `AUTH_SAME_SITE=none` only | works | fails | works | fails | fails |
| both | works | works | works | fails | works |

Four things in that table are worth spelling out.

**Firefox keeps a reader signed in with nothing configured at all.** Its Total Cookie Protection files the server's attribute-less cookie under the embedding site's partition instead of refusing it, so Email, Telegram and anonymous authorisation survive a reload cross-domain on a stock Firefox with neither variable set. No other browser measured here does that: Chrome sends no `SameSite` attribute by default and treats the cookie as `Lax`, and Safari blocks it as third-party. Do not read that row as a recommendation, since it holds on one engine and breaks the moment the reader turns on "block all third-party cookies".

**Safari needs no configuring to break the old recipe.** It blocks third-party cookies out of the
box while still honouring `Partitioned`, so `AUTH_SAME_SITE=none` on its own has already stopped
working there for every reader. This is not a future deprecation to plan for. With the header flag
the widget's own partitioned cookie is readable in the frame and the session survives the reload.

**Firefox reaches "works" by a different route, and a weaker one.** Chrome and Safari refuse the
server's cookie when it carries no `SameSite` attribute, which leaves the field clear for the
widget to write its own partitioned pair. Firefox accepts that cookie, and because the server's
`JWT` is `HttpOnly`, the browser then refuses to let the widget's script overwrite it: a cookie set
by JavaScript may not replace an `HttpOnly` one of the same name. So on Firefox the session rides
on an ordinary unpartitioned third-party cookie even with the header flag on, and it disappears the
moment the reader blocks those.

**No configuration survives Firefox's "block all third-party cookies" setting.** That mode discards
partitioned cookies too, so the `Partitioned` escape hatch does not apply. A reader who has turned
it on cannot stay signed in on an embedded widget, and nothing in Remark42 can change that.

Here are all possible combinations of these two:

- Default setup with unaltered variables: comments are shown on any domain, and the authorisation wouldn't work anywhere, except on the same domain Remark42 is installed on and subdomains of it, and on Firefox with its default settings. Firefox stores the server's attribute-less cookie in a per-site partition instead of refusing it, so Email, Telegram and anonymous authorisation survive a reload cross-domain there with neither variable set. Every other browser measured below refuses that cookie cross-site.
- `ALLOWED_HOSTS` set to a set of domains: comments are shown only on listed domains, and authorisation wouldn't work anywhere, except on the same domain Remark42 is installed on and subdomains of it, and on Firefox with its default settings, for the reason above.
- `AUTH_SAME_SITE` set to `None`: comments are shown on any domain. Authorisation works on browsers that still permit third-party cookies, and stops working on the ones that block them.
- `ALLOWED_HOSTS` set to a set of domains and `AUTH_SAME_SITE` set to `None`: comments are shown on listed domains, with the same authorisation caveat.
- `ALLOWED_HOSTS` and `AUTH_SEND_JWT_HEADER=true`, with `AUTH_SAME_SITE` left alone: comments are shown on listed domains, and Email, Telegram and anonymous authorisation survives a reload under every third-party cookie policy except Firefox's "block all third-party cookies", which no configuration survives. This is the recommended arrangement. Adding `AUTH_SAME_SITE=none` on top changes nothing about whether a reader stays signed in; it only adds the server's unpartitioned cookies where the browser still takes them.

---

### Site Content Docs Manuals Spam Index (`site/content/docs/manuals/spam/index.md`)

---
title: Anti-Spam
---

## How anti-spam works

Basic real-time bot protection relies on simply testing if user sent invisible input form field and then login is rejected as it's known to be a bot.

More sophisticated mechanism is discussed in the issue [#754](https://github.com/umputun/remark42/issues/754) but not yet implemented.

## How `cleanup` anti-spam works

During the `cleanup` command run, spam comments are detected and removed (if `--dry-run` parameter was not specified). Reaching score of 50 is considered as spam, and here are the scores:

- 12.5 points for each occurrence of the provided bad words (so that 4 bad words will result in 50 points)
- 10 points if user is in the provided list of bad users
- 10 points if comment has any links, and another 10 if there are more than 5 of them
- 20 points if comment score is 0 (e.g. nobody voted for it)

---

### Site Content Docs Manuals Subdomain Index (`site/content/docs/manuals/subdomain/index.md`)

---
title: Configure Instance without Subdomain
aliases:
  - /docs/manuals/
---

## How to configure remark42 without a subdomain

All documentation examples show configurations with remark42 on its own subdomain, i.e., `https://remark42.example.com`. However, it is possible and sometimes desirable to run remark42 without a subdomain, but just under some path, i.e., `https://example.com/remark42`.

- The frontend URL looks like this: `s.src = 'https://example.com/remark42/web/embed.mjs'`

- The backend `REMARK_URL` parameter will be `https://example.com/remark42`. `ALLOWED_HOSTS="'self'"` and `AUTH_SAME_SITE=strict` to make comments work only from the same domain.

- And you also need to slightly modify the callback URL for the social media login APIs:
  - Facebook Valid OAuth Redirect URIs: `https://example.com/remark42/auth/facebook/callback`
  - Google Authorized redirect URIs: `https://example.com/remark42/auth/google/callback`
  - GitHub Authorised callback URL: `https://example.com/remark42/auth/github/callback`

### Docker Compose configuration

Both Nginx and Caddy configuration below rely on remark42 available on hostname `remark42`, which is achieved by having `container_name: remark42` in Docker Compose configuration file.

Example `docker-compose.yml`:

```yaml
version: "2"
services:
  remark42:
    image: ghcr.io/umputun/remark42:latest
    container_name: remark42
    restart: always
    environment:
      - REMARK_URL=https://example.com/remark42/
      - SITE=<site_ID>
      - SECRET=<secret>
      - ADMIN_SHARED_ID=<shared_id>
    volumes:
      - ./data:/srv/var
    logging:
      options:
        max-size: "10m"
        max-file: "1"
```

### Nginx configuration

The `nginx.conf` would then look something like:

```nginx
  location /remark42/ {
    rewrite /remark42/(.*) /$1 break;
    proxy_pass http://remark42:8080/; // use internal docker name of remark42 container for proxy
    proxy_set_header Host $http_host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
  }
```

### Caddy configuration

Example of Caddy 2 configuration (`Caddyfile`) running remark42 service on `example.com/remark42/`, proxying
requests under the path /remark42 through to the docker container:

```
example.com {
  root * /srv/www

  log {
    output file /logs/example.com.access.log
  }

  # remark42
  handle_path /remark42* {
    reverse_proxy remark42:8080
  }

  file_server
}
```

If you are using a legacy version (v1) of Caddy, the following configuration would be appropriate:

```
example.com {
	gzip
	tls mail@example.com

	root /srv/www
	log  /logs/example.com.access.log

	# remark42
	proxy /remark42/ http://remark42:8080/ {
		without /remark42
		transparent
	}
}
```

---

### Backlog Api Tests Deadlock On Macos (`docs/backlog/api-tests-deadlock-on-macos.md`)

---
worth: later
where: backend/app/rest/api/middleware_test.go:246
added: 2026-08-18
---
# TestRest_securityHeaders deadlocks the whole api suite on macOS

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

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

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

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

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

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

---

### Backlog Backend Error Codes Never Reach Their Messages (`docs/backlog/backend-error-codes-never-reach-their-messages.md`)

---
worth: yes
where: frontend/apps/remark42/app/common/fetcher.ts
added: 2026-10-17
---
# the backend's error codes never reach their messages

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

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

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

---

### Backlog Direct Signin User Lacks Site Fields (`docs/backlog/direct-signin-user-lacks-site-fields.md`)

---
worth: maybe
where: frontend/apps/remark42/app/components/auth/auth.tsx:141
added: 2026-08-26
---
# email and telegram sign-in put a user in the store without its site-scoped fields

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

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

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

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

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

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

---

### Backlog Hardcoded English Outside The Catalogue (`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.

---

### Backlog Jest Cases The Browser Suite Could Take (`docs/backlog/jest-cases-the-browser-suite-could-take.md`)

---
worth: later
where: frontend/apps/remark42/app, e2e/
added: 2026-08-30
---
# What keeps jest cases out of the browser suite, and what would let them go

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

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

## Selectors the browser cannot reach

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

- **done.** `comments-counter` in `profile.spec.tsx` is
  `<div className={styles.container} data-testid="comments-counter">`. Its only class is hashed by
  the CSS modules, so nothing outside the bundle can name it. Giving it a stable class, the way
  `.auth-button`, `.auth-submit`, `.comment-actions` and `.sort-picker` are kept outside the
  modules for this reason, is what makes the counter assertable in a browser

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

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

## Transient states nothing waits on

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

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

## Absence with no positive control

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

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

## Configurations no instance runs

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

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

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

## Error branches that are not the one the browser drives

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

## What is not worth moving

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


## What the static build would settle on its own

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

---

### Backlog Public Widget Contract Docs And Types (`docs/backlog/public-widget-contract-docs-and-types.md`)

---
worth: yes
where: site/content/docs/configuration/frontend/_index.md, frontend/apps/remark42/app/typings/global.d.ts
added: 2026-08-21
---
# The public embed contract has no reference page and no shippable type declaration

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

## The docs half

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

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

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

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

## The types half, and the open decision

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

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

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

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

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

---

### Backlog Site Checks Do Not Gate Merge (`docs/backlog/site-checks-do-not-gate-merge.md`)

---
worth: yes
where: repository settings, not a file
added: 2026-08-21
---
# nothing on master is a required status check

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

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

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

---

### Plans 2026 08 19 Frontend Simplification (`docs/plans/2026-08-19-frontend-simplification.md`)

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

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

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

## Where this stands

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

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

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

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

### The extraction pattern

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

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

## Standing requirement: the language must be selectable per page

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

Must survive any build change:

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

## Sequence: parity first, changes second, CSS excepted

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

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

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

## What replaces each npm gate

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

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

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

## What the bundler still does

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

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

## What is already out

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

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

## Still open

Each of these can move the answer.

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

## Rejected

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

---

### Plans Completed 2026 02 24 Bem To Css Modules Batch1 (`docs/plans/completed/2026-02-24-bem-to-css-modules-batch1.md`)

# BEM → CSS Modules migration, batch 1: leaf components

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

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

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

## Progress Tracking
- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with ➕ prefix
- Document issues/blockers with ⚠️ prefix

## Implementation Steps

### Task 1: Migrate `button` to CSS Modules

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

**Files to modify:**
- `frontend/apps/remark42/app/components/button/button.tsx`
- `frontend/apps/remark42/app/components/button/index.ts`

**Files to create:**
- `frontend/apps/remark42/app/components/button/button.module.css`

**Files to delete (7):**
- `button/button.css`
- `button/_kind/_link/button_kind_link.css` + parent dirs
- `button/_kind/_primary/button_kind_primary.css` + parent dirs
- `button/_kind/_secondary/button_kind_secondary.css` + parent dirs
- `button/_size/_large/button_size_large.css` + parent dirs
- `button/_size/_middle/button_size_middle.css` + parent dirs
- `button/_theme/_dark/button_theme_dark.css` + parent dirs

**Steps:**
- [x] Create `button.module.css` consolidating all 7 CSS files:
  ```css
  .root {
    background: none;
    border: 0;
    padding: 0;
    margin: 0;
    border-radius: 4px;
    font-family: inherit;
    font-size: inherit;
    cursor: pointer;
    white-space: nowrap;

    &:focus {
      box-shadow: 0 0 0 2px var(--color47);
      outline: none;
    }

    &:disabled {
      opacity: 0.6;
      cursor: default;
    }
  }

  .kindLink {
    background: transparent;
    font-weight: 600;
    color: var(--color9);

    &:hover { color: var(--color33); }
    &:disabled, &:hover:disabled { color: var(--color9); }
  }

  .kindPrimary {
    background: var(--color15);
    color: var(--color6);

    &:hover { background: var(--color33); }
    &:hover:disabled { background: var(--color15); }
  }

  .kindSecondary {
    background: var(--color6);
    color: inherit;

    &:hover { box-shadow: inset 0 0 0 2px var(--color33); }
  }

  .sizeMiddle {
    height: 2rem;
    padding: 0 12px;
  }

  .sizeLarge {
    height: 36px;
    padding: 0 12px;
    font-size: 16px;
  }

  .themeDark {
    &.kindSecondary {
      background: var(--color8);
      color: var(--color20);
    }

    &.kindLink {
      &:disabled, &:hover:disabled { color: var(--color6); }
    }
  }
  ```
- [x] Update `button.tsx`:
  - Remove `import b, { Mods, Mix } from 'bem-react-helper'`
  - Add `import styles from './button.module.css'`
  - Add lookup objects:
    ```tsx
    const kindStyles: Record<string, string> = {
      primary: styles.kindPrimary,
      secondary: styles.kindSecondary,
      link: styles.kindLink,
    };

    const sizeStyles: Record<string, string> = {
      middle: styles.sizeMiddle,
      large: styles.sizeLarge,
    };
    ```
  - Remove `mods` from `ButtonProps` type and destructuring (never passed by any caller)
  - Change `Mix` type import to plain `string | string[]` for `mix` prop
  - Change className to: `clsx(styles.root, kind && kindStyles[kind], size && sizeStyles[size], theme === 'dark' && styles.themeDark, mix, className)`
- [x] Update `index.ts`: remove all 7 CSS imports, keep only `export { Button } from './button'`
- [x] Delete all 7 old CSS files and their BEM directories
- [x] Run `cd frontend && pnpm lint && pnpm test` — must pass before next task

### Task 2: Migrate `dropdown` to CSS Modules

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

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

**Files to create:**
- `frontend/apps/remark42/app/components/dropdown/dropdown.module.css`

**Files to delete (7):**
- `dropdown/dropdown.css`
- `dropdown/__content/dropdown__content.css` + dir
- `dropdown/__items/dropdown__items.css` + dir
- `dropdown/__title/dropdown__title.css` + dir
- `dropdown/_active/dropdown_active.css` + dir
- `dropdown/_theme/_dark/dropdown_theme_dark.css` + dirs
- `dropdown/_theme/_light/dropdown_theme_light.css` + dirs

**Steps:**
- [x] Create `dropdown.module.css` consolidating all 7 CSS files:
  ```css
  .root {
    display: inline-block;
    position: relative;
  }

  .content {
    position: absolute;
    z-index: 20;
    outline-width: 0;
    display: none;
    top: 100%;
    left: 0;
    transform: translate(-0.5em, 5px);
    min-width: 120px;
    max-width: 260px;
    border: 2px solid var(--color15);
    border-radius: 3px;
    padding: 0 0 5px;
  }

  .items {
    padding: 5px 0;

    &:last-child { padding-bottom: 0; }
  }

  .title {
    &::after {
      content: '\25BE';
      margin-left: 2px;
    }
  }

  .active > .content {
    display: block;
  }

  .themeDark > .content {
    background-color: var(--color8);
  }

  .themeLight > .content {
    background-color: var(--color6);
  }
  ```
- [x] Update `dropdown.tsx`:
  - Replace `import b from 'bem-react-helper'` with `import clsx from 'clsx'` and `import styles from './dropdown.module.css'`
  - Add `contentRef = createRef<HTMLDivElement>()` alongside existing `rootNode` ref
  - Add `data-dropdown` attribute to root div (for nested dropdown detection by parent traversal)
  - Replace 3 DOM queries with ref:
    - Line 78: `parent.classList.contains('dropdown')` → `parent.hasAttribute('data-dropdown')`
    - Line 95: `Array.from(...).find(c => c.classList.contains('dropdown__content'))` → `this.contentRef.current`
    - Line 118: `this.rootNode.current.querySelector('.dropdown__content')` → `this.contentRef.current`
  - Root div: `className={clsx(styles.root, isActive && styles.active, theme === 'dark' ? styles.themeDark : styles.themeLight, mix)}`
  - Content div: `className={styles.content}` with `ref={this.contentRef}`
  - Items div: `className={styles.items}`
  - Button mix: `mix={[styles.title, titleClass]}` (clsx in Button handles arrays)
  - Remove dead `heading` prop from Props type and the heading div from JSX (prop is never passed by any caller, class has no CSS)
- [x] Update `dropdown-item.module.css`: change `& > :global(.dropdown)` to `& > [data-dropdown]`
- [x] Update `index.ts`: remove all 7 CSS imports, keep `export { Dropdown }` and `export { DropdownItem }`
- [x] Delete all 7 old CSS files and their BEM directories
- [x] Run `cd frontend && pnpm lint && pnpm test` — must pass before next task

### Task 3: Migrate `thread` to CSS Modules

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

**Files to modify:**
- `frontend/apps/remark42/app/components/thread/thread.tsx`
- `frontend/apps/remark42/app/components/thread/index.ts`

**Files to create:**
- `frontend/apps/remark42/app/components/thread/thread.module.css`

**Files to delete (3):**
- `thread/thread.css`
- `thread/__collapse/thread__collapse.css` + dir
- `thread/_theme_dark/thread_theme_dark.css` + dir

**Steps:**
- [x] Create `thread.module.css` consolidating all 3 CSS files:
  ```css
  .root {
    position: relative;
  }

  .indented {
    margin-left: 17px;
  }

  .level6 .level6 {
    margin-left: 0;
  }

  .collapse {
    height: calc(100% - 50px);
    width: 11px;
    position: absolute;
    top: 50px;
    left: -4px;
    cursor: pointer;

    &::after {
      display: block;
      content: '';
      position: absolute;
      left: 5px;
      top: 0;
      border-left: 1px dotted var(--color35);
      height: 100%;
    }

    &:hover::after {
      transform: translateX(-1px);
      border-left: 3px solid var(--color10);
      z-index: 10;
    }
  }

  .collapsed {
    composes: collapse;
    width: 18px;
    height: 18px;
    top: 12px;
    left: 0;
    display: flex;
    text-align: center;
    opacity: 0.8;
    border-radius: 2px;
    border: 1px solid;

    &::after { display: none; }
    &:hover { opacity: 1; }
    &:hover::after { transform: translateX(0); }

    & > div {
      position: relative;
      top: 6px;
      left: 3px;
      width: 12px;
      height: 2px;
      border-bottom: 2px solid;

      &::before, &::after {
        content: '';
        width: 100%;
        height: 2px;
        border-bottom: 2px solid;
        position: absolute;
        top: -4px;
        left: 0;
      }

      &::after { top: 4px !important; }
    }
  }

  .themeDark {
    & .collapse {
      &::after { border-color: var(--color36); }
      &:hover::after { border-color: var(--color6); }
    }
  }
  ```
- [x] Update `thread.tsx`:
  - Replace `import b from 'bem-react-helper'` with `import clsx from 'clsx'` and `import styles from './thread.module.css'`
  - Root div: `className={clsx(styles.root, indented && styles.indented, level === 6 && styles.level6, theme === 'dark' && styles.themeDark, mix)}`
  - Collapse div: `className={collapsed ? styles.collapsed : styles.collapse}`
- [x] Update `index.ts`: remove all 3 CSS imports, keep only `export { Thread } from './thread'`
- [x] Delete all 3 old CSS files and their BEM directories
- [x] Run `cd frontend && pnpm lint && pnpm test` — must pass before next task

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

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

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

**Files to modify:**
- `frontend/apps/remark42/app/components/auth-panel/auth-panel.tsx`
- `frontend/apps/remark42/app/components/auth-panel/auth-panel.module.css`
- `frontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsx`
- `frontend/apps/remark42/app/components/auth-panel/index.ts`

**Files to delete (2):**
- `auth-panel/auth-panel.css`
- `auth-panel/__column/auth-panel__column.css` + dir

**Steps:**
- [x] 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 { }
  ```
- [x] Update `auth-panel.tsx`:
  - Remove `import b from 'bem-react-helper'`
  - Root div: `className={styles.root}` (drop dead `theme`/`loggedIn` mods)
  - Column divs: `className={styles.column}`
  - Separated column: `className={clsx(styles.column, styles.columnSeparated)}`
  - Remove `className="auth-panel__pseudo-link"` from `<a>` (dead class, no CSS)
  - Clean up dual class patterns: `clsx('user', styles.user)` → `styles.user`, same for userButton/userAvatar/userLogoutButton
  - Change `mix="auth-panel__admin-action"` → `className={styles.adminAction}` on both Button calls
- [x] 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}`) ``
- [x] Update `index.ts`: remove 2 CSS imports, keep `export * from './auth-panel'`
- [x] Delete `auth-panel.css` and `__column/` directory
- [x] Run `cd frontend && pnpm lint && pnpm test` — must pass before next task

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

## Technical Details

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

### Button `mix` prop after migration
- Type changes from `Mix` (bem-react-helper) to `string | string[] | undefined`
- Passed directly to `clsx()` which handles all these types
- Callers in batch 2 (not yet migrated) continue passing BEM strings — works fine
- Already-migrated callers pass module hashes — works fine

### 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

---

### Site Content Docs Configuration Frontend Spa (`site/content/docs/configuration/frontend/spa.md`)

---
title: API for Single-Page Application
---

Add the following JavaScript to your `index.html`, which in this case, it is identical to `<script defer type="module" src="$HOST/web/embed.mjs"></script>`

```js
;(function () {
  var host = 'https://demo.remark42.com' // Your remark42 host
  var components = ['embed'] // Your choice of remark42 components

  for (var i = 0; i < components.length; i++) {
    var d = document
    var s = d.createElement('script')
    s.src = host + '/web/' + components[i] + '.mjs'
    s.type = 'module'
    s.defer = true
    ;(d.head || d.body).appendChild(s)
  }
})()
```

Created `remark42Instance` when the `div` containing remark42 has appeared, usually at `mounted` or `componentDidMount` of the SPA lifecycle. Destroy the previous instance first, if necessary.

```ts
  initRemark42() {
    if (window.REMARK42) {
      if (this.remark42Instance) {
        this.remark42Instance.destroy()
      }

      this.remark42Instance = window.REMARK42.createInstance({
        node: this.$refs.remark42 as HTMLElement,
        ...remark42_config  // See <https://remark42.com/docs/configuration/frontend/>
      })
    }
  }

  mounted() {
    if (window.REMARK42) {
      this.initRemark42()
    } else {
      window.addEventListener('REMARK42::ready', () => {
        this.initRemark42()
      })
    }
  }
```

- Ensure that this is called every time route changes

```ts
  @Watch('$route.path')
  onRouteChange() {
    this.initRemark42()
  }
```

- And, destroyed before routeLeave

```ts
  beforeRouteLeave() {
    if (this.remark42Instance) {
      this.remark42Instance.destroy()
    }
  }
```

---

--- METRICS ---
- Files Extracted: 45
- Estimated Token Budget: ~52992 tokens
- Canonical Reference: https://codewiki.google/github.com/umputun/remark42
