npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@coolpany/cesta-comments

v1.1.0

Published

cesta.cz discussions on your own page: a sandboxed iframe thread plus a standalone interaction row.

Readme

@coolpany/cesta-comments

A read-only comment thread partner sites embed on their own pages.

The thread itself is served by Cesta.cz, from a route at /embed/v1/prispevek/{shortId}. What this package ships is the partner-side wrapper: it creates a sandboxed <iframe> pointing at that route, sizes it to its content, and hands it a colour theme. No comment data ever reaches the partner page — the iframe's content is an already-rendered Cesta.cz page.

Two delivery surfaces, one config type.

What npm ships

npm i @coolpany/cesta-comments is the React integration, and it is a loader rather than the widget itself. Its components render the frame, then fetch https://www.cesta.cz/embed/v1/comments.js — the same hosted script the plain-JS surface below writes by hand — and drive it from there. Both surfaces therefore run one build, deployed by Cesta.cz, and what npm carries around it is the React part: mounting and unmounting, the live theme, and the frame's placeholder. The partner's own policy has to allow that script; see Content-Security-Policy.

The ./embed and ./embed.css entries in exports are the server-side renderer for both served documents — the thread and the interaction row. They belong to the Cesta.cz routes that serve them, and they externalise @cool-cms/api, a package that is not published — they resolve inside this monorepo and nowhere else. The tarball therefore leaves dist/embed/ out, along with its declarations and every sourcemap. What remains is this package's own code alone, which is what the licence covers.

Comments for React

import { useCommentsEmbed } from "@coolpany/cesta-comments/react";

function ArticlePage() {
  const { CommentsEmbed, CommentsStats } = useCommentsEmbed({ shortId: "A4r5z" });

  return (
    <section>
      <h2>Diskuse</h2>
      <CommentsStats />
      <CommentsEmbed />
    </section>
  );
}

useCommentsEmbed(config) returns components pre-bound to the config it was called with. Neither takes props of its own — everything they need is in the config.

  • CommentsEmbed is the thread. Render it wherever the discussion belongs; it fills the width of its container and grows to the height of the thread.
  • CommentsStats is the root article's interaction row — rating, comment count and rewards. It is a frame of its own rather than part of the thread's, so it can go wherever the design puts it: above the thread, beside a headline, in a sticky bar, or twice on one page. See The interaction row.

Comments for JS

<script defer src="https://www.cesta.cz/embed/v1/comments.js"></script>
<script>
  document.addEventListener("DOMContentLoaded", function () {
    Comments.configure({ shortId: "A4r5z" });
  });
</script>

<div data-comments-stats></div>
<div data-comments-embed></div>

One shared Comments.configure(config) call plus attribute-less marker divs. The article is page-level, so it lives in the one shared config rather than on each marker.

  • Wait for the loader. A defer script runs after parsing, so an inline <script> next to it runs first and Comments is not there yet. Any hook that fires after deferred scripts works — DOMContentLoaded above, or the tag's own onload. Without one the call throws and nothing mounts.
  • Call order does not matter beyond that. configure() may run in <head> with the markers arriving later in the body, or after the document is built. A scan with no config yet does nothing; the next configure() picks everything up.
  • Late-inserted markers still work. Containers added by tabs, infinite scroll or a client-side router are picked up automatically, and a marker removed from the document is torn down with it.
  • The inside of a marker belongs to the widget. Whatever a marker div holds is removed when the frame is mounted, so put nothing in it. One element carries one marker: not both attributes at once, and not a marker nested inside another.
  • A second configure() re-points future scans only. Markers already carrying an iframe keep the config they were attached with — including their theme. See below.

Content-Security-Policy

If your page sends a Content-Security-Policy, it has to name Cesta.cz twice — once for the script above and once for the frame it creates:

script-src https://www.cesta.cz;
frame-src  https://www.cesta.cz;

That is the origin cestaBaseUrl defaults to; a partner who sets it to another one names that one instead. Nothing else about the embed touches your policy: the frame's document is served from Cesta.cz with a policy of its own, and the webfonts and avatars it loads answer to that one rather than to yours.

React needs script-src just as much, even though no <script> for it appears in your HTML. The first CommentsEmbed or CommentsStats to mount injects the tag from Comments for JS itself, and every frame on the page shares that one load.

A policy that refuses the script — or a network that cannot reach it — leaves the page as it was: the frame stands at its placeholder height, no widget appears, and nothing is thrown into your own render. Server-rendered markup is the same placeholder for the same reason, so a page that renders on the server does not move when the script lands.

The interaction row

The root article's three badges — rating, comment count, rewards — are a frame of their own, placed wherever you put its marker: [data-comments-stats] for the JS entry point, <CommentsStats /> for React. The thread's frame states the discussion's size as plain text (29 příspěvků) beside its title and renders no badges of its own, so no number appears twice.

  • It counts for itself. The row's frame reads the article's numbers and keeps them current on the same schedule the thread uses, so a page that mounts a row and no thread shows the real counts — and a page that mounts the row twice, which is the usual header-and-footer pair on an article detail, is two frames counting one article.
  • Its frame is the badges wide. Where the thread fills the column you give it, the row's frame is sized to what it draws and follows its own counts as they grow, so a row beside a headline is not a headline followed by an empty column. Your container is still the ceiling: in a box narrower than the row, what is past the edge is cut off rather than put behind a scrollbar.
  • Order does not matter. The row may be mounted before, after or far away from the thread, or without one at all.
  • The comment count scrolls to the thread on your own page rather than leaving it — the discussion it counts is usually right there. It stops 80 px above the thread rather than flush against it, so a bar you have fixed to the top of the page does not cover it. It stays a real link underneath, so a middle- or ctrl-click still opens Cesta.cz, and so does a plain click on a page where no [data-comments-embed] is mounted.
  • The other two are links to the discussion on Cesta.cz, opened in a new tab: rating and rewards are given there and nowhere else. With no resolvable cestaBaseUrl all three render as plain text instead — never as a relative link into your own site.
  • It is styled from the same theme tokens as the thread. interaction.color paints it and interaction.radius shapes its corners. Name interaction.color and the badges on the cards follow it too, unless card.interaction.color says otherwise. The typeface is the thread's, not your page's: the row is a document of its own and carries the same Montserrat the cards are set in.
  • Each badge names its act: the link carries ?action=rating, ?action=comment or ?action=reward, so cesta can offer the thing the reader clicked on arrival.

Leaving for Cesta.cz

Every link this widget renders leads to Cesta.cz, and a reader clicking one is leaving your site. Ask them first by opting into a dialog drawn on your page — over the whole viewport, not inside the iframe, because a scrim that stopped at the frame's edge would read as a panel in the page rather than as a decision about leaving it. It is off until you ask for it: dialog: true for the default wording, or an object to write your own.

Comments.configure({
  shortId: "A4r5z",
  dialog: {
    body: "Diskuse k článkům Echo24 probíhá na partnerském webu Cesta.cz. Stránka bude otevřena v nové záložce.",
    note: "Pokud nejste přihlášeni, na Cesta.cz se přihlásíte stejným účtem jako na Echo24.cz.",
  },
});
  • It covers both frames. A link in either one holds its own click and asks your page to put the question. One dialog, one wording, wherever the reader clicked.
  • Links inside a comment's own text are left alone. Those are urls a commenter pasted — they lead wherever they lead, and "you are leaving for Cesta.cz" in front of one would be untrue.
  • A modified click is never intercepted. Ctrl, meta, shift and the middle button are the reader asking the browser for a tab, and the browser answers better than a dialog does.
  • It asks before every departure. Turning the dialog on says your readers should be told they are leaving, and that is as true of the fourth link as of the first. dialog: { showOnce: true } reads it as an explanation to be given rather than a confirmation to be taken: the moment a reader takes the link, that is remembered in your page's own localStorage and every later link goes straight through. A reader who cancels has answered the question with no, so nothing is remembered and the next link asks again — and a reader whose storage is unavailable is asked every time, which is the safe way round.
  • The copy is yours, line by line: title, body, note, confirm, cancel. Anything you leave out keeps its default, and the defaults name Cesta.cz and no partner, so they read correctly on any site. Cesta.cz is picked out in colorLink wherever it appears in your title, in whichever case you wrote it. Set note to null or an empty string to drop the reassurance box — leaving note out entirely keeps the default one, which is a different statement.
  • Plain text only. Every line is rendered as text, never as markup, so nothing from your config can become an element on your page — which also means a phrase cannot be emphasised mid-sentence.
  • Leave dialog out and every link navigates straight through, with nothing extra drawn on your page.
  • It answers to theme like everything else: dialog.radius, dialog.background and dialog.overlay, with the button taking the button group. That button is the call to action under the thread reproduced — same size, weight, padding, corner and hover — with one exception: the typeface is inherited from your page, because cesta's is a webfont this package will not pull into a document it does not serve. The overlay is the one default that does not follow your palette — it is a black wash either way, because dimming means darkening whichever way the page is painted.

Arriving signed in

Every link out of this widget leads to something the reader has to be signed in to do — write a comment, give a star, reward a post. A reader who already has a Cesta.cz session arrives able to do it; one whose SPM account was signed in somewhere else arrives anonymous and has to sign in once they are there.

shieldRedirect closes that gap. Departures go through SPM's sign-in gateway, which hands the session over and passes the reader on to the address they asked for:

Comments.configure({
  shortId: "A4r5z",
  shieldRedirect: true,
});
  • It changes the address and nothing else. The same links lead to the same discussion, the dialog asks what it asked, and the UTM parameters still travel — inside returnUrl, where cesta reads them on arrival.
  • The gateway is https://secure.spmmedia.cz, and shieldBaseUrl names a different one — which a staging deployment is the reason to do. Give the origin and nothing more: the path and the query are this widget's to build, so the gateway's contract cannot be spelled wrong from a config. An invalid value falls back to the default rather than throwing, as cestaBaseUrl does.
  • It is off by default, because a reader already signed in on Cesta.cz gains nothing from the bounce.
  • A modified click inside either frame does not. Those anchors are written by cesta, which serves both documents and never reads your config. A plain click is handed to your page and does go through the gateway; ctrl and the middle button open the address cesta wrote, as they always have.

The config

Both entry points take the same type:

| Field | Required | Default | Notes | | --- | --- | --- | --- | | shortId | yes | — | cesta's own id for the article — what the embed route reads directly. Addressing an article by the partner's own id instead needs a backend endpoint that resolves it, which does not exist yet (#3414). | | cestaBaseUrl | no | https://www.cesta.cz | Absolute http(s) origin the embed route is served from. A relative or otherwise invalid value falls back to the default rather than throwing. The apex and its www. host count as the same site, so either spelling works. | | theme | no | see below | Colour tokens. | | dialog | no | off | The confirmation shown before a reader leaves for Cesta.cz — opt-in. true turns it on with the default wording; an object turns it on and rewrites its lines, or sets showOnce to ask only until the reader has left once. See Leaving for Cesta.cz. | | refresh | no | on, every 30 s | How the thread keeps itself current. false turns it off; an object adjusts it. See Refreshing. | | lazyLoad | no | on, 250 px ahead | Whether the thread waits to load until it nears the viewport. false loads it with the page; an object names the distance. See Lazy loading. | | utmSource | no | the page's hostname | Who the traffic is coming from. Worth setting only where the hostname is not the name the analytics should read. See Attribution. | | shieldRedirect | no | false | Whether departures go through SPM's sign-in gateway rather than straight to cesta, so a reader with an SPM account arrives able to write. See Arriving signed in. | | shieldBaseUrl | no | https://secure.spmmedia.cz | Absolute http(s) origin of that gateway — the origin alone, since the path and the query are the widget's to build. Read only when shieldRedirect is on; an invalid value falls back to the default. |

shortId is required by the type, so TypeScript callers cannot omit it. Comments.configure() has no compiler behind it: a JS caller who omits it gets exactly one console.error and no iframe, never a URL with undefined in it.

Theme

Page-level colours sit at the top; anything belonging to one component is grouped under it. The split is what a token scopes to, not what type it is.

| Token | Kind | Default | | --- | --- | --- | | colorAccent | colour | #ffb700 | | colorText | colour | #1d2126 | | colorBackground | colour | #ffffff | | colorBorder | colour | colorText at 15% | | colorLink | colour | colorAccent | | card.radius | length | 18px | | card.hover | colour | colorText at 5%, over whatever it covers | | card.interaction.color | colour | colorText at 60% over colorBackgroundinteraction.color once you set that | | card.interaction.radius | length | interaction.radius | | interaction.color | colour | colorText | | interaction.radius | length | button.radius | | input.radius | length | button.radius | | input.background | colour | colorText at 10% over colorBackground | | input.hover | colour | colorText at 15% over colorBackground | | button.color | colour | colorText | | button.background | colour | colorAccent | | button.radius | length | 10px | | dialog.radius | length | 26px | | dialog.background | colour | colorBackground | | dialog.overlay | colour | black at 55% |

Colours take a hex or an rgb()/rgba() value; lengths take a number with px, rem, em or % (or a bare 0). Each token is checked against its own kind, so a colour where a length belongs is refused and vice versa — calc() and unitless numbers are refused too. A value that does not pass falls back to that token's default and leaves its valid siblings alone.

Most defaults track another token rather than being values of their own: colorBorder is a 15% shade of colorText, card.interaction.color a 60% one, card.hover a 5% one, the input group a 10% fill answering the pointer at 15%, interaction.color is colorText itself, card.interaction.radius follows interaction.radius as that follows button.radius, colorLink follows colorAccent, and the button's colours follow the palette (colorAccent behind a colorText label). So a partner who themes the text light for a dark page gets legible dividers, legible badges, a visible hover, a field that still reads as one and a readable button without having to work any of them out. Set any to override it.

interaction.color paints the rating, comment-count and reward badges in the root article's row — their icons, their counts, and the emphasis they take under the pointer or keyboard focus. It is plain colorText by default: that row sits among your own content, not on one of cesta's cards. card.interaction.color paints those badges inside the thread, on every comment card and in the thread's header, and the engagement a card carries in the corner opposite them — the fourth number Cesta.cz's own comment cards show. Its default is the same neutral dimmed shade cesta's own discussion UI gives them, which only accents a badge when a signed-in reader's own vote or reward put it in a selected state — something a read-only embed can never know. Naming interaction.color carries into the cards too, so one colour themes every badge; set card.interaction.color to give the cards their own.

colorLink paints every link the page renders as text, all of them alike: the Cesta.cz note in the header, the way to the rest of the discussion at the foot of the thread, and any link a commenter wrote into their own comment. Each is plain at rest and underlines in its own colour under the pointer. It follows colorAccent by default, which is where those links already were — set it to move them off the accent without moving anything else.

Three anchors are deliberately not links in that sense and take their colour elsewhere: an author's name and a card's title, which are the content rather than links written into prose; and the badges and the button, which read as controls and answer to card.interaction.color and the button group.

colorAccent reaches less of a card than colorText, colorBackground and colorBorder do. Beyond standing in as the default for colorLink and button.background, what it paints is:

  • the underline of the author's name, while the pointer is on it;
  • the ring around the author's avatar, while the pointer is on it or it holds keyboard focus.

card.hover is the wash a card takes under the pointer. It is an overlay painted over the card rather than a background swapped underneath it, so a colour with no transparency in it covers the comment instead of tinting it.

input dresses the composer at the top of the thread — the field-looking link that opens Cesta.cz. It is a link, not a form control (see below), so hover is what answers the pointer where a real field would take focus.

The corners come in two groups: the surfaces and the controls. card.radius shapes the comment cards themselves, not only the page chrome, and dialog.radius shapes the panel drawn over your page — each its own length, neither following anything. Every control reaches button.radius instead: input.radius rounds the composer, interaction.radius rounds the badges in the root article's row, and card.interaction.radius rounds the badges and the report link on each card by following interaction.radius in turn. So rounding your controls is one token, and it leaves the cards alone — a pill among square badges, or square badges on a soft card, is expressible; it just has to be asked for.

Everything else on a card — the timestamp, the focus ring, the dividers — is themed from colorText, colorBackground and colorBorder.

Comments.configure({
  shortId: "A4r5z",
  theme: {
    colorText: "#fdf6ec",
    colorBackground: "#1d2126",
    colorBorder: "#4a4640",
    card: { radius: "4px" },
    input: { background: "#2a2f35", hover: "#343a41" },
    button: { background: "#46eaa2", color: "#1d2126" },
  },
});

There is no font token: the embed page renders in Cesta.cz's own type, which is not partner-configurable.

The theme travels to the page as a message, not as part of the URL, so every article has exactly one embed URL however it is themed. The page validates every token it receives — a value that is not a plain colour falls back to its own default and leaves its valid siblings alone.

A theme can be changed under a mounted frame. Name a new one — theme on a later render in React, Comments.configure() again in JS — and every thread and badge row already on the page repaints: each frame applies the new tokens where it stands, with no reload, no flash and no jump back to skeleton height, and the leaving-for-Cesta.cz dialog follows. A partner's own day/night switch is therefore a re-render or one configure() call, and nothing else.

A frame's first paint carries the theme it was wired with, not the one named on the render that created the element. The theme reaches a document before that document can be messaged by riding the frame's name, and Chromium and Safari fix a frame's name when the element is inserted — so a frame already on the page goes back onto it, in place, under the name it is wired with. A theme named once the frame has started loading arrives as the repaint above rather than in the first paint.

The theme is the only part of the config a mounted frame repaints for. The article and the origin decide which document a frame holds, so naming a new one is a different thread rather than the same one repainted: in React the hook re-points its frames itself, no key needed; in JS it is the next marker to be picked up, since configure() re-themes what is already mounted and leaves the rest of what it was mounted with alone.

Everything else — the dialog's wording, the refresh schedule, lazy loading — belongs to the instance that was mounted with it and changing it later is a no-op until there is a new one (in React, a key change).

The widget does no colour-scheme detection of its own. If a partner page has a dark mode, detecting it and choosing the tokens is the partner's job.

Refreshing

Both frames refresh themselves on the schedule below, each fetching its own document — the thread its comments, the interaction row its counts. Only the thread says so out loud; the row has no chrome to say it in.

The thread refreshes itself every 30 seconds, and says when it will next do so: the header reads Obnovení za 12s • 29 příspěvků. While something is holding the refresh back it reads Obnovení pozastaveno • 29 příspěvků instead — a clock that has stopped without saying so reads as a broken one.

Until the page learns whether a refresh is coming, the same slot reads Načítám… • 29 příspěvků: the words give way to the countdown once the schedule arrives, or to the count alone where refreshing (or its countdown) is turned off — so the line has its final shape from the first paint instead of growing half a second in.

Comments.configure({
  shortId: "A4r5z",
  refresh: { seconds: 60, preventHoverRefresh: true, countdown: false },
});

| Field | Default | Notes | | --- | --- | --- | | seconds | 30 | Raised to 30 if lower — see below. | | preventHoverRefresh | false | Holds the refresh back while a pointer is moving over the thread, until it stops. | | countdown | true | Whether the header states the time remaining. |

refresh: false turns it off. So does a seconds the widget cannot make sense of — zero, negative, Infinity, NaN, or anything that is not a number. That is the opposite of how the other fields degrade, and deliberately: passed to a timer, every one of those values means immediately, forever, and refreshing when you meant not to is worse than not refreshing when you meant to.

30 seconds is a floor, not a preference. The embedded page is served with s-maxage=30, so a request made sooner cannot return anything the last one did not already have.

A refresh is always held while the reader is not there to see it — the tab hidden, or the browser window sitting behind another application — whatever else is configured. That much is not the partner's to opt out of. The window's state is the one thing the page cannot see for itself, so the parent watches it and says; everything else it observes where it happens.

The other hold is preventHoverRefresh, which is off. Turned on, a pointer over the thread holds the refresh until it leaves — resting counts, because a reader reading does not move the mouse. Leaving releases it at once; a hold whose ending is never reported gives up on its own after two minutes.

A refresh replaces the thread where it stands rather than reloading the frame: the fonts stay put, nothing flashes, and the frame's height moves only by what the thread gained. A thread that arrives from a newer build of the page than the one the frame holds reloads the frame once instead: the styles and scripts it needs come only with a new document. Neither adds anything to the counts below — what is counted is the embed on your page, and nothing inside the frame counts at all.

Lazy loading

By default the thread's document waits until its frame comes within 250 pixels of the viewport, so a page whose discussion sits below the fold costs the reader nothing until they scroll toward it. lazyLoad names that distance, or turns the wait off:

Comments.configure({
  shortId: "A4r5z",
  lazyLoad: { edge: 500 },
});

| Field | Default | Notes | | --- | --- | --- | | edge | 250 | How many pixels ahead of the viewport the frame starts loading. |

lazyLoad: true is { edge: 250 } written shorter, and lazyLoad: false is the only value that loads the thread with the page. An edge the widget cannot make sense of — zero, negative, NaN, or anything that is not a number — falls back to that default rather than turning the deferral off, the opposite of how refresh reads the same nonsense: a partner who asked for the load to wait is not served by loading now, and a guessed distance still loads the frame one scroll later.

Waiting costs no measurement: the embed is counted where it is mounted, not where it loads, so a thread the reader never scrolls to is still counted as shown on your page — and counted once as seen if they do reach it.

Only the thread's own document waits. The interaction row loads with the page regardless, since it is what a reader clicks to reach the thread in the first place.

A browser with no IntersectionObserver loads the frame at once — too early costs nothing a reader notices, never loading at all costs the thread.

Attribution

Every link the widget opens carries UTM parameters, because nothing else can tell cesta where the reader came from: each anchor is rel="noreferrer" and the parent opens with it too, so no referrer arrives.

| Parameter | Value | Set by | | --- | --- | --- | | utm_medium | comments-embed | the frame — constant for every link | | utm_content | root-actions, discussion or comment | whoever renders the link | | utm_source | the partner, defaulting to the page's hostname | the parent page, when the link is opened |

utm_content is the surface a link sits on: root-actions is the interaction row beside the article, discussion the frame's own chrome (composer, call to action, the way to the rest of the thread), comment a card's badges and its permalink. It exists because the other parameters cannot separate them — the root article's rating badge and a comment's send the same reader to the same page for the same act.

Only utm_source is configurable. The other two describe this widget rather than the partner, and a partner rewriting them would be describing someone else's traffic.

The split is also why the parameters survive a middle-click: utm_medium and utm_content are in the served markup, so a click the parent never sees still carries them. What such a click loses is utm_source — an unknown partner in the report, never the wrong one.

Reader-written links inside a comment's own text are never decorated. They point wherever the commenter pointed them, and appending our parameters to a stranger's URL is not ours to do.

What the page can and cannot do

Both wrappers create the iframe themselves and set

sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"

with no allow-same-origin, so the embedded page runs in an opaque origin: it cannot read the partner page's cookies or storage, and it holds no session of its own. Everything in it is read-only — every interaction element is a link out to Cesta.cz, opened in a new tab. There is no voting, no commenting and no login inside the embed.

The interaction row is a second frame on the same terms, and read-only in the same sense: it writes nothing and holds no session. Its rating and reward badges are links out to Cesta.cz like everything above. Its reaction count is the one element that does something other than navigate — it asks the page around it to scroll to the thread — and it is still an anchor to Cesta.cz underneath, so a modified click, or a page with no thread on it, gets the link. Scrolling is the whole of the exception: nothing in this package is a control that can change anything.

The thread shows the first few comments, not all of them; when there are more than it showed, the foot of the thread offers a link to the rest alongside the call to action. Where the whole discussion is small, the replies under each of those comments are shown too, as deep as the thread stays a single line — the rule Cesta.cz's own discussion follows. A comment's whole card is a link: clicking anywhere on it that is not already a link opens that comment on Cesta.cz. Dragging across a comment's text selects it instead, so a reader can still quote what they are reading — the click that ends such a drag is not treated as opening the card. The card also takes keyboard focus, and Enter or Space on a focused card opens it the same way a click does; a focusable element inside the card keeps its own keys.

A long comment is cut to a few lines with Zobrazit více under it, the way Cesta.cz cuts one. Opening it asks Cesta.cz for nothing — the whole text was in the page already — and it stays open across the refreshes below. That control is the one element the page builds for itself rather than being served: it is still not a way to write anything, and without JavaScript the comment simply stays cut, with the card's own link to Cesta.cz still on it.

Ten kinds of message cross the boundary, none of them comment data. A page says it is ready, reports its height so the wrapper can size the iframe, hands over the url of a link the reader clicked so the confirmation can be drawn where it belongs, reports the two clicks that ask the wrapper for nothing — a comment opened where it stands, and a link a commenter wrote — and, from the interaction row alone, asks for the page to be scrolled to the thread. The wrapper sends the theme, how often to refresh, whether the window it sits in is the one being worked in, and — to the counting document below, which sees none of this for itself — that an embed was mounted, that one reached the reader, and what became of each click.

Every url that crosses is checked against your configured cesta.cz origin before anything opens or scrolls: nothing authenticates a postMessage sender, so a message naming another host does nothing at all. The comments themselves never cross — each page is served already rendered.

Each page is served with everything it needs already in it — no stylesheet request, no script file. The thread's makes three kinds of request, and because the document's origin is opaque, all are cross-origin even the ones that go back to Cesta.cz: its webfonts, whose faces are fetched from Cesta.cz itself (the stylesheet declaring them is inlined, the .woff2 files are not), and the images on its cards — author avatars, and the cover of any article published into the thread as a reply — loaded from the CDN Cesta.cz's own images are served from, a different host from Cesta.cz itself. The page's own content-security policy names one origin for each and nothing else, and the font host additionally has to answer with Access-Control-Allow-Origin, because a font request from an opaque origin is discarded without it. Those requests mean the CDN sees the reader's IP, user-agent and read-timing for a page they never directly visited; the partner's own page and its own visitors are not exposed by them, since the sandboxed iframe's opaque origin keeps the referrer as the embed document's own URL. A thread of plain comments by authors who never uploaded an avatar makes no such request at all: the initials are drawn from the name.

The third is the document itself, fetched again from Cesta.cz on the refresh schedule for what it carries: the request the frame made to load, answered from the same cache, carrying nothing that one did not. refresh: false turns it off along with the refresh.

The interaction row's document makes the first and the third of those and neither of the others: it is set in the same webfonts, it refreshes its counts on the same schedule, and it renders no images.

Counting the embed

Neither of those documents counts anything. What does is a third one, which the wrapper puts at the end of your <body> itself: a hidden, sandboxed frame at /embed/v1/prispevek/{shortId}/analytika, 0×0 and out of the tab order, carrying no markup, no styles and nothing a reader could see. One per embedded article, however many threads and rows the page holds, and it is taken off the page again with the last of them.

An article Cesta.cz does not publish is not counted at all: the document is not served for it, so a shortId that is a typo or names something retired adds nothing anywhere rather than a page nobody can open.

It sends one-way beacons to Cesta.cz's own analytics endpoint: that the page was shown, that an embed was mounted on it, that a reader reached the thread — once, when it comes into the viewport — and one for each click a reader makes in either document. All of them name the thread's own address, /embed/v1/prispevek/{shortId}, rather than this document's. Each says which article and which site: the shortId, and the hostname of the page embedding it read from the referrer. None carries a reader identity and none can — the sandboxed page has no cookies and no storage, so there is nothing to identify anyone with, and each request discards its own response.

A click beacon says what the reader was reaching for — to rate, to comment, to reward, to report, to open a comment further, to follow a link out of one, or simply a way to Cesta.cz — which surface it was clicked on, and how it ended: taken, or stopped at the leaving dialog. Nothing of the destination beyond that. A link a commenter pasted is counted as a link followed out of a comment and never as the address behind it, which is theirs and not ours to report.

Counting the embed rather than the thread's document is what makes the numbers survive lazyLoad: a thread that never loads, because nobody scrolled that far, was still an embed on a page a reader was shown. A page carrying only an interaction row mounts this document too, and sends the first beacon and whatever its own badges are clicked — nothing a row does is a thread being mounted or seen.

Being requests from an iframe on your page, they are subject to your own consent regime like any other. There is no way to turn them off in the config today; if you need one, say so and it becomes one.

Development

npm run build       # tsc (lib + embed) + three Vite passes: lib, IIFE, embed
npm test            # jest + jsdom
npm run size-limit  # tripwire over the shipped artifacts
npm run preview     # the integration example page, on :4321

integration.example.html is a stand-in partner page for the plain-JS surface — the one thing no test here can do, since it needs a real browser embedding a real served thread from a different origin. It exercises the marker scan, late insertion, teardown and re-theming in place. npm run preview serves it; the thread itself comes from a cesta client running separately, which the page's own header explains. Not shipped.

Build outputs: dist/ (the . and ./react library entries), dist/iife/comments.js (the parent script the cesta route serves at /embed/v1/comments.js) and dist/embed/ (the ./embed entry the cesta route imports to render the thread server-side, plus the compiled stylesheet it inlines).

Releases

Releasing is a manual decision, not a consequence of merging: run the Publish cesta-comments workflow and pick the release type. rc and beta cut a prerelease onto the matching dist-tag; patch, minor and major publish to latest, which every partner installs by default and which the workflow therefore refuses to cut from anywhere but the default branch. The dry_run box runs the whole verification and packs the tarball without touching the registry.

The version lives in package.json and the workflow is what moves it — bumping it by hand puts the repo and the registry out of step. The release commit and its tag come back to the branch the run started from.

Licence

CC BY-ND 4.0. Use it and redistribute it as part of a site you operate; do not distribute modified versions. Bundling and minification are technical processing under the licence's Section 2(a)(4), not modification — a normal build pipeline is fine. Anything beyond that: [email protected].