Contributing
Publish posts and pages on misomoon.com
The marketing site (apps/landing) reads its blog posts, extra pages, and authors from
a D1 database. You write them in Markdown and send them to the site's content API. The
comparison pages under /alternatives, the features page, and the pricing page are
code, in apps/landing/src/lib/alternatives.ts, apps/landing/src/lib/features.ts, and
apps/landing/src/lib/pricing.ts. The plans' prices and limits come from
packages/core/src/plans.ts, the list the API enforces, so change them there. The legal
pages, /privacy, /terms, /dpa, and /security, render the privacy
policy, the terms, the data processing agreement, and
the security page from docs/ when the site is built. A legal page that still contains a **Draft.** note is marked noindex
and left out of the sitemap. None of these pages use this API.
A post appears at /blog/<slug>. A page appears at /<slug>, and a page slug can have
up to three segments, such as /guides/setup. Each author has a page at
/authors/<id>. The site's nav, footer, and sitemap link to /blog only once a post is
published; until then /blog still opens, empty.
Set up the database and the token
The site needs two things on Cloudflare: the CONTENT D1 database and the
CONTENT_TOKEN secret. Configuration lists them.
Deploy the site from
apps/landing:pnpm run deployWrite
pnpm run deploy, becausepnpm deployis a different pnpm command. The script builds the site, runswrangler deploy, and applies the migrations inapps/landing/migrationsto the remote database. On the first deploy,wrangler deploycreates themisomoon-contentdatabase, becausewrangler.jsoncgives nodatabase_id. Until the migrations finish, content pages answer503.Make a token, and keep a copy of it. Wrangler doesn't show a secret again.
openssl rand -base64 32Set the token as the secret that allows writes, and paste it when Wrangler asks:
wrangler secret put CONTENT_TOKEN
To work locally, run pnpm db:migrate once in apps/landing. It creates the tables in
the local database that vp dev uses. Then put a token in apps/landing/.dev.vars,
which git ignores:
CONTENT_TOKEN=local-dev-tokenWrite a post
A post is a Markdown file with a front matter block. The file name is the slug, so
count-without-cookies.md becomes /blog/count-without-cookies.
---
title: How misomoon counts a visitor without a cookie
description: misomoon hashes the address, the user agent, and a salt that changes every day. Here is what it keeps and what it deletes.
author: jane-doe
tags: privacy, how-to
status: published
image: https://misomoon.com/blog/salt.png
imageAlt: A diagram of the daily salt, the hash, and the visit it counts
---
## The daily salt
misomoon hashes the address...An author is a file with kind: author. The file name is the author id, and the text
below the block is the bio:
---
kind: author
name: Jane Doe
avatar: https://misomoon.com/authors/jane.jpg
sameAs: https://github.com/jane, https://bsky.app/profile/jane.dev
---
Jane builds misomoon's ingest and query planner.A page is a file with kind: page. Set slug when the page has more than one segment:
---
kind: page
slug: guides/setup
title: Set up misomoon in ten minutes
description: Deploy misomoon to your Cloudflare account, add your first site, and see your first visit in the dashboard.
status: published
---
Deploy the Worker, add a site, and paste the snippet into your page's `<head>`.Publish the files
Run the content script from the repo root. File paths are relative to the folder you
run it in. Set CONTENT_URL to the site and
CONTENT_TOKEN to its token:
CONTENT_URL=https://misomoon.com CONTENT_TOKEN=... \
pnpm --filter @misomoon/landing content publish posts/*.md authors/*.mdThe script sends authors first, so a post can name an author from the same run. It
prints created or updated for each file, and the warnings the API returns. A file
that fails prints failed with the reason, and the script exits with code 1. Without
CONTENT_URL, it writes to http://localhost:5174.
To delete an entry or an author:
pnpm --filter @misomoon/landing content delete post count-without-cookiesPublishing the same file again replaces the entry. The publish date stays the date of
the first publish. For a typo fix that shouldn't move the "Updated" date, add
minor: true to the block.
Front matter keys
The keys are the fields of the API's JSON body. A list is comma-separated in front matter and an array in JSON.
| Key | Entries | Required | Rules |
|---|---|---|---|
kind | All | No | post, page, or author. Front matter only. Defaults to post. |
slug | Posts, pages | No | Front matter only. Defaults to the file name. Lowercase letters, digits, and dashes, at most 120 characters. A post slug has no /. A page slug has up to three segments, and its first segment can't be one of the site's own routes: api, blog, authors, alternatives, features, pricing, privacy, terms, dpa, security, demo, sitemap.xml, sitemap-docs.xml, robots.txt, llms.txt, llms-full.txt, feed.xml, .well-known, or _app. |
title | Posts, pages | Yes | 1 to 140 characters. Over 60 gives a warning, because search results cut long titles. |
description | Posts, pages | Yes | 1 to 320 characters. Outside 70 to 160 gives a warning. It is the meta description and the lede under the title. |
status | Posts, pages | No | draft or published. Defaults to draft. Only a client with the token reads drafts. |
author | Posts, pages | For a published post | An author id. The author must exist. |
tags | Posts, pages | No | Up to 10. Lowercase letters, digits, and dashes, at most 40 characters each. |
image | Posts, pages | No | An https URL or a path on the site. Use at least 1200 pixels wide. A post without one gives a warning. |
imageAlt | Posts, pages | With image | Text that describes the image. |
canonical | Posts, pages | No | An https URL, for a post first published somewhere else. The sitemap then leaves the entry out. |
noindex | Posts, pages | No | true keeps the entry out of search results and the sitemap. |
publishedAt | Posts, pages | No | An ISO 8601 date, such as 2026-10-25T09:00:00Z. Defaults to the time of the first publish. |
minor | Posts, pages | No | true keeps the "Updated" date. Not stored. |
name | Authors | Yes | 1 to 80 characters. |
bio | Authors | No | Up to 600 characters. In a file, the text below the block. |
avatar | Authors | No | An https URL or a path on the site. |
sameAs | Authors | No | Up to 10 https URLs of the author's profiles. Search engines read them to tell who the author is. |
A post or page needs a body: Markdown, 1 to 200,000 characters, with tables and fenced
code. A post or page file with nothing after its front matter fails with
400 {"error":"body: Markdown, 1 to 200,000 characters"}. The site renders the body
when you publish:
- Raw HTML prints as text.
- A link keeps its
hrefonly forhttps:,http:,mailto:, a path on the site, or a#heading. An image takes the same URLs, exceptmailto:. - A
#heading becomes anh2, because the title is the page's onlyh1. Every heading gets an id, and three or more##and###headings add an "On this page" list beside the text.
Content API
The API runs on the marketing site, not on the API Worker. Reads are public and allow
any origin. Writes need Authorization: Bearer <CONTENT_TOKEN>. A request body is JSON
with Content-Type: application/json.
| Method and path | What it does |
|---|---|
GET /api/entries | Lists entries, newest first: { items, next }. Query parameters: kind (post or page, default post), tag, author, limit (1 to 100, default 20), cursor (the next of the previous page), and drafts=1 (needs the token). |
GET /api/entries/:kind/:slug | One entry with its rendered html, body, and toc: { entry }. With the token, it also returns a draft. |
PUT /api/entries/:kind/:slug | Creates or replaces an entry. Answers 201 for a new entry and 200 for a replaced one, with { entry, warnings }. |
DELETE /api/entries/:kind/:slug | Deletes the entry and its tags. Answers 204. |
GET /api/authors | Lists authors: { items }. |
GET /api/authors/:id | One author: { author }. |
PUT /api/authors/:id | Creates or replaces an author. Answers 201 or 200, with { author }. |
DELETE /api/authors/:id | Deletes an author that no entry names. Answers 204. |
GET /api/tags | Tags of published posts, most used first: { items: [{ tag, count }] }. |
curl -X PUT https://misomoon.com/api/entries/post/count-without-cookies \
-H "Authorization: Bearer $CONTENT_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title":"How misomoon counts a visitor without a cookie","description":"...","body":"## The daily salt\n\n...","author":"jane-doe","status":"published"}'The site moves updatedAt only when the title, the description, the body, or the image
changes, and not when minor is true. A change of tags alone keeps the date. The
sitemap prints updatedAt as lastmod, and the post page prints it as "Updated".
Errors
An error answers { "error": "<field>: <what is wrong>" } with one of these statuses:
| Status | When |
|---|---|
400 | A field breaks a rule in Front matter keys, the body isn't valid JSON, or the post names an author that doesn't exist. |
401 | The token is missing or wrong, or drafts=1 was sent without it. |
404 | No entry or author has that slug or id, or kind isn't post or page. |
409 | DELETE /api/authors/:id for an author that an entry still names. Move or delete those entries first. |
415 | A write without Content-Type: application/json. |
503 | The Worker has no CONTENT binding, the token secret isn't set (for writes), or D1 didn't answer. |
When D1 doesn't answer, blog and author pages show an error page with status 503.
Pages built from code, such as the home page and the comparisons, keep working. The
sitemap lists only the pages built from code, and /feed.xml answers 503 with
Retry-After: 60, so feed readers try again instead of dropping the posts.
Caching
Pages read the database through the cache of the Cloudflare data center that serves the
request. A read stays in that cache for 60 seconds (CONTENT_TTL in
apps/landing/src/lib/server/api.ts). A write clears the keys it changes in the data
center that took the write. Other data centers show the old version for up to 60
seconds. An author change clears the author page at once, and the posts that print the
author's name within the same 60 seconds.
Browsers and shared caches keep a content page for 60 seconds, and can serve it for 10 minutes more while they fetch a new copy. API reads are public for 60 seconds. A read that can return drafts isn't cached.