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

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

Every URL on the site is the same shape — **an optional language prefix, then
the page's own path**:

| Page | Default language | Another language |
| --- | --- | --- |
| Index | `/` | `/es/` |
| Post | `/<id>/` | `/es/<id>/` |
| Category | `/c/food/` | `/es/c/comida/` |

The language is always the **first** segment and never nested under the page,
so a translation is a sibling of the original rather than something beneath it.

Two consequences worth knowing:

- **A post id is a top-level path segment**, sharing the root namespace with
  the language codes, `c/`, and the files the build writes. `assertUsableId()`
  in `build.js` refuses an id that would shadow one of those, so a directory
  named `content/posts/es/` fails the build instead of quietly replacing the
  Spanish index.
- **Category slugs are translated**, derived from the locale's label —
  `fashion` becomes `/es/c/moda/` and `/fr/c/mode/`. The stored category is
  still the English key; only the URL segment and label change. Two categories
  whose labels slugify the same fail the build.

`postUrl()`, `categoryUrl()` and `langBase()` in `build.js` are the only places
that build paths; derive from them rather than assembling strings by hand.

**The root namespace is shared with the link shortener** in `functions/`, which
answers `/<code>` for a 7-character code. `build.js` imports its `isCode()` and
refuses a post id of that shape, so the two cannot collide — but if you change
`CODE_LENGTH`, rebuild and check nothing starts failing.

## Reading the site

```bash
curl https://allw.mn/content/index.json                # every post, every language
curl https://allw.mn/content/posts/<id>/<lang>.md      # one post, complete
curl https://allw.mn/llms.txt                          # the same, as prose
curl https://allw.mn/feed.json                         # JSON Feed 1.1 (per language)
```

`content/index.json` is the entry point. It carries `defaultLang`, a
`languages` list, and a `posts` array where each entry is one post in one
language: `id`, `lang`, `languages` (which languages this post exists in),
`title`, `slug`, `summary`, `date`, `updated`, `author`, `category`, `tags`,
`image`, `source`, `words`, `readingTime`, `sections` (the H2s), plus
`markdown` and `url` paths.

Fetch `markdown`. `url` returns the same text wrapped in a page.

## 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` |
| Metadata | `<title>`, description, canonical, OG, JSON-LD |

Four things 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, 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 |
| --- | --- |
| `index.html`, `<lang>/index.html` | Post list per language |
| `c/<slug>/`, `<lang>/c/<slug>/` | Filtered lists, slug per language |
| `<id>/`, `<lang>/<id>/` | The post, Markdown inline as text |
| `content/index.json` | The index above |
| `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 |
| `_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/index.json` | Generated index — read this first. |
| `site.json` | Title, URL, base path, `defaultLang`. |
| `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. |

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

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

## Short links

Full reference, including deploy steps and what is still unverified:
[`SHORTURL.md`](SHORTURL.md).

`allw.mn/<code>` redirects to a page on allwomenstalk.com or allw.mn with a
fixed set of tracking parameters attached. Codes are 7 random base62
characters, so a link is unguessable and reveals nothing about the post behind
it or how many links exist.

```bash
export SHORTLINK_TOKEN=…      # same value as the Pages secret

# one link per traffic source, same page, same campaign
node tools/shortlink.js --target /<id>/ --sources instagram,pinterest,newsletter \
                        --utm-medium social --utm-campaign spring26

# one specific link
node tools/shortlink.js --target /<id>/ --utm-source bio --note "IG profile"

# see the destinations without creating anything
node tools/shortlink.js --target /<id>/ --sources instagram --dry-run
```

Any `--utm-*` flag becomes a parameter, so a new UTM field needs no code change.

**A link is identified by its destination plus its parameters**, not by the
post. `dedupeKey()` hashes the two together in canonical form — parameters
sorted, empties dropped, a query already on the target merged in — and that
hash maps to a code in KV. So the same page with `utm_source=instagram` and the
same page with `utm_source=pinterest` are two different links with two
different codes, while re-running either command returns the code that already
exists. Creation is idempotent; put it in a loop without fear of duplicates.

### Why codes can live at the root

`/<code>` is one path segment, so `functions/[code].js` never sees `/c/food/`
or `/assets/*`. It **does** see every post, because `postUrl()` puts posts at
`/<id>/` — but a CMS id is 24 characters and a code is exactly 7, so a post is
rejected by the shape check before any KV read and costs only a regex. The
function redirects only when the segment is exactly 7 alphanumerics **and** is
not in `RESERVED`; otherwise it calls `next()` and the static page is served as
though the function were not there. `content` and `sitemap` are themselves
7 characters, which is why `RESERVED` exists and why
`tools/shortlink.test.js` fails the build if `build.js` ever emits a new
top-level name of that shape.

### Why it cannot become an open redirect

The destination is never read from the incoming request. It is validated
against `ALLOWED_HOSTS` when the link is written *and* again on the way out in
`destinationFor()`, so neither a crafted URL nor a tampered KV record can point
allw.mn at another domain. Creating a link needs the bearer token; following
one is public.

Redirects are **302**, never 301: a 301 is cached by the browser indefinitely
and a link you cannot repoint or retire is not one you can run a campaign with.

### Setup

```bash
npx wrangler kv namespace create LINKS           # put the id in wrangler.toml
npx wrangler kv namespace create LINKS --preview # and the preview id
npx wrangler pages secret put SHORTLINK_TOKEN
```

`CLICKS` (Analytics Engine) is optional — without it redirects work exactly the
same, just unrecorded. With it, each click stores the code, destination, UTM
source/medium/campaign, referrer, user agent and country.

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

**The short links are unaffected by any of this.** They live in KV, add no
files, and scale to millions of codes 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.
