Files
remark42/site/content/docs/contributing/api/index.md
T
Dmitry VerkhoturovandGitHub 4c9ef37cf1 Move the site from eleventy to hugo (#2179)
* Move the site from eleventy to hugo

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

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

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

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

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

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

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

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

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

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

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

* Keep the heading anchors markdown-it generated

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

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

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

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

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

* Harden the site image build and its CI

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

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

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

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

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

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

Recorded in the backlog: `master` has `required_status_checks` off with an
empty check list, so the new job surfaces a red X and does not block a
merge. That is a settings decision rather than a code fix.
2026-08-21 18:05:56 -05:00

11 KiB

title
title
API

Authorization

  • GET /auth/{provider}/login?from=http://url&site=site_id&session=1 - perform "social" login with one of supported 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
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
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:

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

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

Not available 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.

Response example
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"}

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
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?token=token - process a user's deleteme request; already-deleted or dataless users return success (idempotent)

all admin calls require auth and admin privilege