# allw.mn — for agents

A static site whose content is Markdown. **Every page carries its post's
Markdown as visible text**, with the image lines rendered as real `<img>` — so
the whole post reads and indexes with scripting off. `assets/app.js` rebuilds
that exact Markdown from the page and formats it in place. There is no server,
no database, and no HTML you need to parse to get the content.

## The shape of the content

```
content/posts/<id>/<lang>.md      an article
content/pages/<slug>/<lang>.md    a standing page
```

- **`<id>`** is the post's identity and its URL. It is the AllWomenStalk CMS id
  for imported posts. Slugs are metadata only — a translation can carry its own
  slug without moving the page.
- **`<lang>`** is a language code. `en.md` is the default language.

**Public location is a seven-character code.** `content/posts/<id>/` is the
internal identity (CMS id, importer key). Navigation, cards, sitemap, feeds,
JSON-LD and share controls emit `/<code>/` from `content/shortlinks.json`.
Legacy `/`, `/<id>/`, `/es/<id>/`, `/c/<slug>/` and standing slugs remain
compatibility aliases of the same HTML — they are not primary URLs and they
are not rewritten inside editorial Markdown.

Every generated page kind has its own base code (post, standing page, language
index, category index). A translation is a different code for the same
`(domainId, pageKind, pageId)` with another `lang`, not a child path.

| Page | Primary | Compatibility alias |
| --- | --- | --- |
| Index | `/<code>/` | `/`, `/es/` |
| Post | `/<code>/` | `/<id>/`, `/es/<id>/` |
| Category | `/<code>/` | `/c/food/`, `/es/c/comida/` |
| Standing page | `/<code>/` | `/about/`, `/es/about/` |

Internal ids still share a namespace with language codes, `c/`, generated
files, and public codes. `assertUsableInternalId()` refuses an id that would
shadow those. Category **keys** stay English; translated labels still form the
alias slug (`/es/c/comida/`). Two labels that slugify the same fail the build.

Primary paths come from the bound registry (`publicCodePath()` in
`tools/page-registry.js`). Alias paths use `legacyAliasPath()`. Do not assemble
either by hand. Changing titles, slugs, category labels, sort, or default
language must not change an already assigned code.

**Domain ownership is not a redirect allowlist.** `site.json` has a stable
`domainId`, `canonicalHost`, and optional aliases. Campaign destinations are
checked against `allowedRedirectHosts` (allw.mn and allwomenstalk.com today).
Relative internal targets resolve against the selected site domain.

**The allocator Worker** (`workers/shortcode-registry/`) is the uniqueness
authority per `domainId`. Pages bind it as `REGISTRY` and project records into
this project's `SHORTCODES` KV (never `LINKS`). Ordinary `pnpm build` is
offline: it reads the committed export and cannot allocate. Change
`CODE_LENGTH` only together with a rebuild and the reserved-name tests.

## Reading the site

```bash
curl https://allw.mn/content/index.json                # rich index (search); url is the code
curl https://allw.mn/content/discover.json             # every page kind, paginated
curl https://allw.mn/<code>.md                         # document Markdown at the public code
curl https://allw.mn/<code>.json                       # identity, domain, representations, settings
curl https://allw.mn/content/posts/<id>/<lang>.md      # authored source (still available)
curl https://allw.mn/llms.txt
curl https://allw.mn/feed.json                         # JSON Feed 1.1 (item id stays the alias)
```

`content/index.json` is the search entry (`indexVersion` 2). Each post has
internal `id`, public `url`/`code`, `pageMarkdown` (`/<code>.md`), `json`,
and `translations` only for languages that exist. Do not reconstruct a URL
from the CMS id. Prefer `pageMarkdown` or `json` over guessing paths.

`/<code>.json` names `domainId`, `pageKind`, `pageId`, `lang`, `baseCode`,
representations, supported settings, `updated`, and `contentRevision`. Index
and category `.md` files are generated link lists, not editorial files.

A **code is not a credential** and is not a measured token-reduction scheme.

## Adding a translation

This is the whole procedure:

```bash
node tools/translate-scaffold.js --lang es --id 5113c848f55da6baf4f84093
# → writes content/posts/5113c848f55da6baf4f84093/es.md, pre-filled with the
#   English text and the frontmatter a translation owns
```

Translate `title`, `summary` and the body in place, then `pnpm build`. Add the
language to `site.json`'s `languages` map so it gets a display name.

Drop `--id` to scaffold every post at once. Existing files are never
overwritten, so rerunning only fills gaps.

A translation's frontmatter is deliberately short:

```markdown
---
id: "5113c848f55da6baf4f84093"
lang: "es"
title: "7 cosas espontáneas y divertidas para hacer de adulta"
slug: "cosas-espontaneas-divertidas-para-hacer-de-adulta"
summary: "Una frase, texto plano, sin sintaxis Markdown."
---

El cuerpo, en Markdown. Los encabezados `##` forman el índice.
```

`date`, `updated`, `author`, `category`, `tags`, `image` and `source` are
**inherited from the default language at build time** — leave them out so a
translation can never disagree with the original about them.

## Writing a new post

`content/posts/<id>/en.md`, where `<id>` is any unique string. Full frontmatter:
`id`, `lang`, `title`, `slug`, `date`, `updated`, `author`, `category`, `tags`,
`image`, `summary`, `source`. Then `pnpm build`.

Frontmatter is a deliberate YAML subset that `parseFrontmatter` in
`assets/md.js` enforces: `key: value` per line, arrays as `[a, b, c]`, values
may be quoted. No nesting, no multi-line strings.

## Importing from the AllWomenStalk CMS

`tools/import-cms.js` pulls posts from the CMS agent layer at
`../admin/aws-admin/agentic` (see its `AGENTS.md`) and writes them here as
`content/posts/<cms id>/en.md`. It uses two **read-only** actions,
`content.search_posts` and `content.get_content_item`, and mutates nothing.

```bash
pnpm import                          # 6 most recent posts
node tools/import-cms.js --limit 20
node tools/import-cms.js --query "activated charcoal"
node tools/import-cms.js --slug some-existing-slug
AWS_ADMIN_ROOT=/path/to/aws-admin pnpm import   # if the CMS lives elsewhere
```

Re-importing overwrites that post's `en.md`. Translations are untouched, so a
re-import can silently leave them stale — check `updated` if that matters.

## What survives with JavaScript off

This is the site's main constraint. Everything below must keep working:

| Feature | How |
| --- | --- |
| The full post text | Written into the page as Markdown text |
| Post images | Emitted as real `<img>` in the flow |
| The post list | Written into every index page as links |
| Category filtering | Every category is a pre-built page; the chips are links |
| Language switching | `hreflang` links in the header; a `<details>` pill in the post rail |
| Thumbnail shape (wide/square/tall) | Three radios plus `:has()` rules in `src/site.css` |
| Category rail on every page | Links to the same pre-built category pages |
| Language switching | A `<details>` menu of real links in the header |
| Sorting by date or popularity | Each card states its rank in both orders; radios pick which one feeds `order` |
| Turning thumbnails off | A fourth radio in the same group; `:has()` hides `.card-frame` |
| Category chips staying on one line | The row scrolls; a checkbox unfolds it |
| Standing pages | Same reading view as a post |
| Metadata | `<title>`, description, canonical, OG, JSON-LD |

These need scripting, and all are additive: **free-text search** (the input
stays `hidden` until `app.js` unhides it), the **table of contents** (its
anchors point at ids that only exist once the Markdown has been formatted), the
cover's **"show original aspect"** toggle, the **section-number counters**, the
**inline poll**, the subscription form's **confirmation**, and the human-gated
article tools.
Inline article images always keep their natural aspect. If you add a feature,
put it on the static side unless it truly cannot live there.

## What the build produces

`pnpm build` reads `content/posts/**` and writes:

All paths below are inside `dist/`.

| Output | What it is |
| --- | --- |
| `<code>/`, `<code>.md`, `<code>.json` | Primary page (every kind), Markdown, metadata |
| `index.html`, `<lang>/index.html`, `<id>/`, `c/<slug>/`, `<slug>/` | Compatibility aliases (same HTML as the code) |
| `content/index.json` | Rich index for search (`url` is the code) |
| `content/discover.json` | Compact catalog of every page kind |
| `feed.json`, `<lang>/feed.json` | JSON Feed 1.1 |
| `sitemap.xml` | With `xhtml:link` hreflang alternates |
| `robots.txt`, `llms.txt` | |
| `assets/` | CSS, page modules, deferred article-tool assets, and the logo |
| `content/posts/<id>/<lang>.md` | The Markdown, copied so it can be served |
| `content/pages/<slug>/<lang>.md` | The same, for the standing pages |
| `_headers` | `text/markdown` + CORS for `.md`, immutable caching for assets |
| `_redirects` | Only if a locale sets `origin` — host rewrites for per-language subdomains |

## Layout

| Path | What it is |
| --- | --- |
| `content/posts/<id>/<lang>.md` | The content. The only files that matter. |
| `content/pages/<slug>/<lang>.md` | Standing pages: about, contact, advertising, agents. |
| `content/index.json` | Generated rich index — `url` is the public code. |
| `content/shortlinks.json` | Committed registry export. Offline builds bind this; they do not allocate. |
| `site.json` | Title, URL, `base`, `defaultLang`, `domainId`, `canonicalHost`, redirect allowlist. |
| `locales/<code>.json` | One language: display name, UI strings, category labels. |
| `src/shell.html` | Page template. `{{TOKEN}}` placeholders, filled by `build.js`. |
| `src/site.css` | Tailwind entry. Typography plugin lives here. |
| `assets/md.js` | The Markdown renderer, ~7KB, no dependencies. |
| `assets/app.js` | Formats inline Markdown; search, shape preference, cover toggle, human monitor. |
| `assets/ai-loader.js` | Loads the standalone article-tool partial, CSS, and controller only after a human pass. |
| `assets/ai-demo.js` | Local article actions and per-section question controls; no model call. |
| `assets/logo.svg` | The AllWomenStalk wordmark, in the header. |
| `build.js` | Generates every page and machine-readable file. |
| `tools/build-css.js` | Compiles `src/site.css` via PostCSS + Tailwind. |
| `tools/import-cms.js` | CMS → Markdown importer. |
| `tools/translate-scaffold.js` | Creates `content/posts/<id>/<lang>.md` to translate. |
| `tools/serve.js` | Local static server (`pnpm dev`). |
| `tools/md.test.js` | Renderer tests. |
| `tools/roundtrip.test.js` | Checks every built page rebuilds its `.md` exactly. |
| `tools/smoke.js` | Loads the built site in headless Chrome and checks the JS behaviour. |
| `tools/verify-live.js` | HTTP checks against a deployed origin (`pnpm verify`). |
| `tools/deploy.js` | Preflights credentials, then KV → project → build → upload. |
| `functions/` | Pages Functions: code resolver (static base pages first, then variants/campaigns) and `/api/links`. |
| `workers/shortcode-registry/` | Separate allocator Worker (Durable Object per domain). Deploy before Pages. |
| `wrangler.toml` | Pages project, `SHORTCODES`, `REGISTRY` service binding. Never `LINKS`. |

**Everything generated goes to `dist/`, and nothing else does.** The repo root
is source only. `dist/` is gitignored, rebuilt from scratch by `pnpm build`
(which cleans it first), and is the only directory that gets deployed — so
never hand-edit anything inside it, and never expect an edit there to survive.

`build.js` copies `assets/*.js`, `assets/logo.svg` and every
`content/posts/<id>/<lang>.md` into `dist/`, because the pages link to the
Markdown and agents fetch it directly. `tools/build-css.js` writes
`dist/assets/site.css` before `build.js` runs.

## Standing pages

`content/pages/<slug>/<lang>.md` is a page that is not an article: About,
Contact, Advertising, and `/agents/`, which describes in prose what an
automated client may do here and why. They are the same directory-per-document
shape as a post, render through the same reading view, and are served as
Markdown at the same kind of path — the only differences are that they carry no
cover, no byline and no poll, and that they are listed in `content/index.json`
under `pages` rather than `posts`.

Frontmatter: `slug`, `lang`, `title`, `nav` (the short label used in the rail),
`order` (sort position in the rail), `updated`, `audience`, `summary`. A
translation inherits `order`, `updated` and `audience` from the default
language.

**A page slug is a top-level alias**, so it goes through the same
`assertUsableInternalId()` as a post id and must not be code-shaped. `contact` is seven
characters — it is in `RESERVED` in `tools/shortlink-core.js` for exactly that
reason, along with the other page slugs. Add a new slug there before adding the
page, or the build will refuse it.

## Layout

The page runs the full width of the viewport. The reading column is capped
(`--content-max` for a list, `--measure` for an article), and the category rail
takes the elastic column beside it, so a wider screen buys air and a roomier
rail rather than longer lines. `--gutter` scales with the viewport. All four
live on `:root` in `src/site.css`; nothing else decides page width.

Below 1024px the rail folds into a checkbox disclosure labelled "Browse" —
still no JavaScript.

## The index controls

Four controls sit above the list, and all of them are CSS:

- **Sort** — every card carries `--r-newest` and `--r-popular` (its rank in
  each order, computed in `build.js`), and a radio decides which one feeds
  `order`. `postCard()` in `assets/app.js` restates both when a search narrows
  the list, or the reordering would be wrong for the filtered set.
- **Thumbnail shape** — as before, plus a fourth position that hides
  `.card-frame` entirely and enlarges the titles.
- **Category chips** — the row scrolls on one line and a checkbox unfolds it to
  the wrapped view.
- **Search** — the only one that needs scripting.

`popularity` is placeholder data. A post may state its own in frontmatter;
anything without one gets a stable score derived from its id, so the order is
deterministic across builds rather than reshuffling every time. Replace
`seededPopularity()` in `build.js` when there is real analytics to read.

## The poll and the subscription block

Both are demos with no server behind them, and both are stated as such in the
interface.

The **subscription block** is static markup on the index, the category pages
and every post; `app.js` only intercepts the submit and shows the confirmation.

The **inline poll** is mounted by `app.js` into the article in the same pass
that formats the Markdown, so it is present before the article's first paint
and never moves the text. `site.json`'s `poll.position` (`top`, `inside`,
`end`) says where; the questions live in `locales/<code>.json` under `poll`, so
they translate like everything else. A vote is remembered in `localStorage` and
the percentages are derived from the option text — nothing is sent anywhere.

## Numbered section headings

Most imported posts are written as `## 2. Something`. `numberHeadings()` in
`app.js` lifts that leading number out of the heading text into a `.h-num`
counter beside it, and the contents list drops it too. The heading's `id` is
left alone, because it is an anchor somebody may have linked to.

## Interface strings and category labels

Each language is one file, `locales/<code>.json`, holding its display `name`,
its `ui` strings and its `categories` labels. See `locales/README.md`.

**A language exists because its locale file exists** — nothing else enumerates
them, and `site.json` only names the `defaultLang`. `build.js` resolves
`{{T:key}}` tokens in `src/shell.html` against the page's language, falling
back to the default language, so a half-finished locale degrades to English
rather than to blanks. `pnpm build` prints which keys each locale is still
missing. The few strings the browser writes itself (the result count, category
badges after a search) are passed through `window.__PAGE__`.

A category's stored value stays the English key, because it is the filter key
and the link between languages. Its **URL segment is derived from the
translated label**, so `/c/food/` is `/es/c/comida/`.

Adding a language is therefore: write `locales/<code>.json`, then add
`content/posts/<id>/<code>.md` for whichever posts should exist in it.

## Canonical URLs

A post in the default language is the same text as the article it was imported
from, so its `rel=canonical` points at `source` — this site does not claim the
original's content, and pages that point elsewhere are left out of
`sitemap.xml`.

A **translation is not a duplicate** of the English original, so it is always
self-canonical. Pointing a translation at the English source would ask search
engines to drop it entirely. `hreflang` alternates tie the set together.
`canonicalFor()` in `build.js` is the one place this is decided, and each
entry in `content/index.json` carries its resolved `canonical`.

## Thumbnail shape and article images

The reader picks a thumbnail shape (wide / square / tall) from the control next
to the post count. It is three radio inputs; `#index-view:has(#ratio-…:checked)`
rules in `src/site.css` set `.card-frame`'s `aspect-ratio`, so it works with
scripting off. With scripting, `app.js` also mirrors the choice onto
`<html data-shape="…">`, remembers it in `localStorage` under
`allwmn:thumb-shape`, and applies it to the post cover.

Only the post cover has an aspect toggle. It starts in the selected crop and
`initCoverToggle()` drops `.card-frame`'s crop so the cover returns to its
natural aspect. Images inside the Markdown are never wrapped or cropped.

## Public codes, variants, and campaigns

Full operator reference: [`SHORTURL.md`](SHORTURL.md). Release and rollback:
[`DEPLOY.md`](DEPLOY.md).

**Base pages** are static 200s at `/<code>/` (and `/<code>.md`, `/<code>.json`).
They do not depend on KV or the allocator. Unknown codes 404; retired codes
410; resolver/KV outages 503 for non-static records. Host ownership comes from
`Host`, never `X-Forwarded-Host`. A saved variant with `data-shared-view` wins
over `localStorage` so a shared URL stays reproducible without client storage.

**Variants** are saved, allowlisted view settings (language, list sort/shape,
post cover, `viewVersion`). They reference a base code plus a normalized
parameter object. Empty/default settings resolve to the target language's base
code. Unimplemented layouts are rejected.

```bash
export SHORTLINK_TOKEN=…      # Pages secret; not a proof of page identity

node tools/shortlink.js --base <code> --cover original --domain allwmn
node tools/shortlink.js --base <code> --lang es --cover original --dry-run
node tools/shortlink.js --inspect <code> --domain allwmn
node tools/shortlink.js --settings --base <code>
```

**Campaigns** remain a separate mutable `redirect` kind (302, never 301):

```bash
node tools/shortlink.js --target /<id>/ --sources instagram,pinterest \
                        --utm-medium social --utm-campaign spring26 --dry-run
```

Creation needs the bearer token and an explicit configured `domainId`. Request
Host is not permission. Inspection is public. Codes are not authentication.

Offline export before every production-shaped build:

```bash
node tools/shortlinks.js --dry-run
node tools/shortlinks.js
pnpm build
```

Allocator and Pages are two deploys. Provisioning and production cutover are
SC-10. Local edge: `pnpm dev:edge` then `pnpm verify http://127.0.0.1:4180`.

## The scale ceiling

This build pre-renders every page and writes every index as one file. That is
the right shape for thousands of pages and the wrong shape past ~50,000, so the
limit is worth stating plainly before someone imports a large archive.

`files ≈ 2 × posts × languages` — one `index.html` and one `.md` copy per
post-language. Against Cloudflare's 100,000-file cap (20,000 on the free plan):

| Posts × languages | Pages | Files | Verdict |
| --- | --- | --- | --- |
| 16,000 × 3 | 50k | 100k | at the cap |
| 100,000 × 5 | 500k | 1M | 10× over |
| 100,000 × 30 | 3M | 6M | 60× over |

The file cap is not what breaks first. **`content/index.json` holds every post
in every language and `assets/app.js` fetches it in the browser to run search**
— roughly 1.8KB per entry, so 90MB at 50k pages and 5.4GB at 3M. Search stops
working long before the deploy stops fitting. `sitemap.xml`, `feed.json` and
`llms.txt` are single files with the same problem, and sitemaps additionally cap
at 50,000 URLs each. A full `pnpm build` is also single-threaded and cleans
first, against a 20-minute build timeout.

Going past this means rendering on demand rather than ahead of time: Markdown in
R2 (no object limit), a Worker that wraps it in the shell per request with the
result held in the Cache API, and a queryable index (D1) behind paginated list,
feed and sitemap endpoints instead of one JSON file. `stub()` and the string
builders around it are pure functions of a post object — they port to a Worker
unchanged. Only `readPosts()` and the `write*()` functions are tied to the
filesystem.

**Base page codes are unaffected by any of this.** They are static files. Campaign
and variant records live in KV and scale independently of how pages are served.

## Constraints worth knowing

- **The Markdown appears twice per post**: as the `.md` file, and inside
  `<div id="md-source">` on the page. `build.js` writes both from one source.
  The page splits the body at image lines — text goes in `<pre>`, each image
  becomes `<img data-md="![alt](url)">` — and `readInlineSource()` in `app.js`
  concatenates those back into the original, byte for byte.
  `tools/roundtrip.test.js` enforces that. If you change `inlineSource()` in
  `build.js`, change `readInlineSource()` with it and run `pnpm test`.
- **Run `pnpm test` after touching `assets/app.js`.** The static tests cannot
  see a runtime error: if `app.js` throws, the page still shows its Markdown
  and still looks fine to `curl`. `tools/smoke.js` loads the built site in
  headless Chrome and is the only thing that catches it.
- `postListItem()` in `build.js` and `postCard()` in `assets/app.js` produce
  the same list markup. Change one and change the other, or a search will
  repaint the list in a different shape.
- `assets/md.js` escapes everything before parsing, so raw HTML inside a
  Markdown file is displayed as text, never executed. Only `http(s):`,
  `mailto:` and site-relative link targets survive; anything else is dropped.
  `build.js` applies the same rule when it emits an `<img>`.
- The renderer covers headings, paragraphs, lists (nested by indent),
  blockquotes, fenced code, tables, hr, images, links, bold/italic/code/strike
  and bare-URL autolinks. It is not CommonMark-complete. If you need a
  construct it does not support, add it to `md.js` and a case to
  `tools/md.test.js` — do not switch the content to HTML.
- Tailwind only keeps classes it can see in `src/shell.html`, `assets/*.js` and
  `build.js` (the `@source` lines in `src/site.css`). Article styling comes
  from the `prose` utilities on `#post-body`, because the formatted article
  does not exist until runtime.
- Everything is relative to `site.json`'s `base`, so the site works from a
  subdirectory.
- The package manager is pnpm. CSS is built by `tools/build-css.js` (PostCSS +
  `@tailwindcss/postcss` + `cssnano`), not by `@tailwindcss/cli` — the CLI
  brings `@parcel/watcher` and a native postinstall build this project does
  not need.
