Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/adr/0040-collection-permalinks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ADR-0040: Collection permalinks: a closed-token template per collection, one path function both ways
- Status: Accepted
- Date: 2026-10-22 (decided by Halil)
- Related: RFC-0038, RFC-0002, RFC-0003, RFC-0033, #50
- Sources: [Ghost routes](https://docs.ghost.org/themes/routing/) · [Hugo permalinks](https://gohugo.io/configuration/permalinks/) · [WordPress `register_post_type`](https://developer.wordpress.org/reference/functions/register_post_type/) and [old-slug redirects](https://developer.wordpress.org/reference/functions/wp_old_slug_redirect/) · [Eleventy permalinks](https://www.11ty.dev/docs/permalinks/)

## Context
An entry's URL is `/<collection>/<slug>`, built inline in about 15 places. A site that wants its posts at `/blog` has to rename the collection, which changes the key filters, templates and agents use (cloudhal.com, #50). WordPress, Ghost and Hugo all ended up with URL templates. None of them reliably redirects when a template changes, and date tokens move URLs when an editable date changes.

## Decision
1. **`Site.collections.<key>.permalink`**, optional, default `/<collection>/{slug}`. Version 1 accepts only fixed lower-case segments followed by `/{slug}`, enforced by the schema pattern.
2. **A closed token set**, widened only by changing the schema pattern. There is no template language, so every template is checked when it is written.
3. **One path function each way** in `@cloudhal/core-ts`: `entryPath(site, entry)` and `entryAt(site, path)`. Every inline `/${collection}/${slug}` moves to them.
4. **Redirects come from the change.** A v1 template change adds one prefix redirect in the same batch as the site update (`RedirectPlan`, as for taxonomy archives). Once there are tokens, the store compares each published entry's path before and after and writes one redirect per entry.
5. **Collisions fail at write time** (`path_conflict`) against other collections, taxonomy archives, pages and reserved paths.
6. **Later, already decided:** date tokens from a fixed first-publication date; per-locale templates as a map; entries at the root once page writes run the same collision check; an explicit `indexPath`; `{category}` only once an entry has one primary term.

## Consequences
- Positive:
- URLs become content: they are in snapshots, can be rolled back, and agents and Studio can change them behind approval.
- Moving a collection no longer changes its key.
- The path logic exists once.
- Negative:
- A larger site node.
- Every caller of the inline path moves in one PR.
- Templates and redirects have to stay in step on rollback (redirects are kept).
- Risks: an overlap check that misses a case would make two things answer one URL. That is why the check runs both ways (`entryAt`) and at write time.
35 changes: 35 additions & 0 deletions docs/adr/0041-site-archives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# ADR-0041: Site archives: a streaming `.cloudhal` tar of NDJSON parts and media, ids kept, graph only
- Status: Accepted
- Date: 2026-10-22 (decided by Halil)
- Related: RFC-0039, RFC-0004, RFC-0010, ADR-0002, #47
- Sources: [Sanity datasets](https://www.sanity.io/docs/cli-reference/dataset) · [Strapi data management](https://docs.strapi.io/cms/data-management/export) · [Contentful CLI export](https://github.com/contentful/contentful-cli/tree/main/docs/space/export) · [Ghost migration](https://docs.ghost.org/migration/ghost) · [WordPress export](https://wordpress.org/documentation/article/tools-export-screen/)

## Context
A site's content can only leave a project through a deployment (`push`, `pull`), so cloudhal.com kept its content as code (#47). The `SiteBundle` format and `importSite` already exist and are tested. The CMSs compared stream large exports (Sanity's tar.gz of NDJSON, Strapi's JSONL parts) and check assets. Strapi erases the target on import and encrypts with AES-128-ECB.

## Decision
1. **`cloudhal export` / `cloudhal import`** with a `.cloudhal` file: a gzip-compressed ustar holding `manifest.json`, `project.json`, `nodes|snapshots|blobs/NNNN.ndjson` parts of at most 8 MB, `media/<sha256>` and `ledger.ndjson`. Read in order, the parts are exactly a `cloudhal.site-bundle@1`.
2. **The manifest** names the archive format, the contracts' `schemaVersion`, the bundle format, the counts, and every file's SHA-256 and size. A newer format or schema version is refused; an older one is migrated forward.
3. **Ids are kept.** An imported site is the same site.
4. **Import modes:**
- bootstrap (default): adds what is missing and refuses differing nodes;
- `--replace`: first saves the current graph as a `before import` snapshot, so the import can be undone;
- `--dry-run`: shows the difference and writes nothing.

Never erase-then-import.
5. **Graph only.** No users, sessions, roles, agent keys, comments, readers, analytics, audit log, automations, secrets or webhook credentials. That is why there is no built-in encryption; people can pipe the file through `age` or `gpg`.
6. **Deterministic bytes:** a fixed order, zero timestamps and fixed modes, so an unchanged site exports identically.
7. **Untrusted input:** the layout is a closed list of paths (no traversal, links or devices); sizes are capped; rows go through the precompiled validators and gates, as for `push`.
8. **Later, already decided:** `init --from`, `--new-ids` for templates, partial export, `--from <admin-url>`, Studio download, and signed archives.

## Consequences
- Positive:
- Backups, demos and starters become files.
- Moving a site needs no deployment.
- Nothing in the format needs a second data model.
- Negative:
- A tar reader and writer of our own (small, no dependency).
- The format is versioned and must be read forever.
- Risks:
- A file is as trustworthy as its source until archives are signed.
- Large media sets make big files; `--no-media` and the parts limit keep memory flat, not size small.
2 changes: 2 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,5 @@ One file per decision, numbered, immutable once accepted (write a new ADR to sup
| [0037](0037-domain-strategy.md) | Domain strategy: `cloudhal.com` is marketing, a future hosted control plane and the playground; `cloudhal.dev` is docs, schemas, registry and the open-source home; customer sites never run on either (they deploy into the customer's own Cloudflare account), so no third domain or PSL entry |
| [0038](0038-public-playground.md) | Public playground: one SQLite Durable Object per visitor holding store, API and renderer; `@cloudhal/storage/do-sqlite` and SQL/layered/fetcher blob stores; a deny-list guard in the Worker and the API with a route-walk test and a fixed Editor principal; 60-minute TTL with a 2-hour cap; private, uncached session pages (RFC-0036) |
| [0039](0039-cloudhal-dev-docs-site.md) | cloudhal.dev: Starlight in `apps/docs` over the repo's `docs/`, generated reference, a link contract checked in CI (301s, permanent `/schemas/v1`, `/e/<code>` pages from one error-code registry), docs for agents (`.md` per page, `Accept: text/markdown`, `llms.txt`, a standalone read-only docs MCP at `cloudhal.dev/mcp`, none in `cloudhal mcp` (amended)); all ADRs and RFCs published (RFC-0037) |
| [0040](0040-collection-permalinks.md) | Collection permalinks: `Site.collections.<key>.permalink` (default `/<collection>/{slug}`, v1 a fixed prefix plus `{slug}`), a closed token set, `entryPath`/`entryAt` in core-ts, redirects derived from the change, collisions refused at write (RFC-0038) |
| [0041](0041-site-archives.md) | Site archives: `cloudhal export`/`import`, a deterministic `.cloudhal` tar of NDJSON parts and media with per-file SHA-256, ids kept, bootstrap/`--replace` (snapshot first)/`--dry-run`, graph only and no built-in encryption (RFC-0039) |
141 changes: 141 additions & 0 deletions docs/rfcs/0038-collection-permalinks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# RFC-0038: Collection permalinks

- Status: Accepted (2026-10-22, by Halil; ADR-0040). Open questions decided as recommended. Revised before acceptance after a comparison with WordPress, Ghost, Hugo, Webflow, Eleventy and the headless CMSs (see Prior art).
- Author: Halil (Madraka), drafted with Claude · Date: 2026-10-21
- Related:
- #50 (found while building cloudhal.com: the blog at `/blog` while the starter's collection is `posts`).
- RFC-0002 §7 (route order), RFC-0003 (menus, `collectionIndex`), RFC-0033 §4 (locale prefixes).
- Precedent: `Taxonomy.archive.basePath`, whose change emits prefix redirects through `RedirectPlan` (`packages/core-ts/src/taxonomy.ts`).
- Prior art:
- [Ghost routes](https://docs.ghost.org/themes/routing/) (`permalink: /blog/{slug}/`);
- [Hugo permalinks](https://gohugo.io/configuration/permalinks/) (per section and per language);
- [WordPress](https://developer.wordpress.org/reference/functions/register_post_type/) (`rewrite.slug`, [old-slug redirects](https://developer.wordpress.org/reference/functions/wp_old_slug_redirect/));
- [Eleventy](https://www.11ty.dev/docs/permalinks/) (fails on duplicate URLs);
- [Sanity's resolver](https://www.sanity.io/docs/visual-editing/presentation-resolver-api) (path → document).

## Summary

A content entry's URL is `/<collection>/<slug>`, so moving where a collection lives means renaming it, which changes the key every template, filter and agent uses. This RFC adds an optional permalink template per collection (`Site.collections.<key>.permalink`, default `/<collection>/{slug}`), computes entry paths in one place both ways (entry → path, path → entry), and redirects old URLs when the template changes. Version 1 accepts only a fixed prefix followed by `{slug}`. The field and its closed token set are designed so date tokens and per-locale templates can follow without renaming anything.

## Motivation

- **Who:** anyone whose URLs are not the collection's internal name: `/blog` for `posts`, `/news` for `articles`, `/docs/guides` for `guides`. Moving a site from WordPress or Ghost usually means keeping its URLs.
- **Today:** cloudhal.com renamed its `posts` collection to `blog`. That worked thanks to the store's automatic 301s, but it changed the key that themes, filters (`collection:posts`), skills and agents' prompts refer to. `collectionIndex` only changes menu links, not where entries live.
- **Code:** the path is built inline in about 15 places (`routing.ts` `entryPath`, `navigation.ts`, `ContentList.astro`, `locales.ts`, `preview.ts`, `surfaces/tree.ts`, `search-rows.ts`, `site-search.ts`, `automations.ts`, `store/entries.ts`, `api` assets and menus, `cli` analytics, `mcp-server` schedules). Any change to URLs means changing all of them, and they can drift.
- **What others learned:**
- Every server-rendering platform ends up offering a template with tokens, not just a prefix. WordPress, Ghost and Hugo all do.
- None of them reliably redirects when the template itself changes. Per-item old-URL tracking (WordPress old slugs, Hugo aliases) works better than hand-written rules (Ghost `redirects.yaml`, Webflow).
- Date tokens change URLs when an editable date changes.

Naming the field after a prefix (`basePath`) would force a rename the day tokens arrive.

## Proposal

### 1. Contract (`site.schema.json`)

```jsonc
// Site.properties
"collections": {
"type": "object",
"description": "Per-collection settings, keyed by collection (ContentEntry.collection). A collection without an entry here uses the defaults.",
"propertyNames": { "$ref": "common.schema.json#/$defs/Slug" },
"additionalProperties": { "$ref": "#/$defs/CollectionSettings" }
}

// $defs
"CollectionSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"permalink": {
"type": "string",
"description": "Where the collection's entries live, as a template. Version 1: one or more fixed lower-case path segments followed by /{slug}, e.g. /blog/{slug}. Default: /<collection>/{slug}.",
"pattern": "^(/[a-z0-9]+(?:-[a-z0-9]+)*)+/\\{slug\\}$"
}
}
}
```

Optional, no `schemaVersion` bump. Absent means `/<collection>/{slug}`, so every existing site keeps its URLs.

**The token set is closed.** Version 1 has only `{slug}`. Later versions widen the schema pattern; there is no expression language, so an agent cannot write a template the store cannot check.

### 2. One function each way (`@cloudhal/core-ts`)

```ts
/** `/blog/hello-world`: the collection's permalink with the entry's values filled in. */
export function entryPath(
site: Pick<Site, "collections">,
entry: Pick<ContentEntry, "collection" | "slug">,
): string;

/** The collection and slug a path matches, or undefined. The longest fixed prefix wins. */
export function entryAt(
site: Pick<Site, "collections">,
path: string,
): { collection: string; slug: string } | undefined;
```

- All the inline copies listed above call `entryPath`. The renderer's `resolvePath`, the overlap checks and the redirect planner use `entryAt`.
- Locale prefixes (RFC-0033 §4) still wrap the result, as they do today.

### 3. Changing a permalink

`updateSite` compares each collection's old and new template. When one changes, it adds a redirect to the same batch, as `updateTaxonomy` does for `archive.basePath`. While templates are a fixed prefix plus `{slug}`, one `RedirectPlan.move(oldPrefix, newPrefix, { match: "prefix", reason: "slug-change" })` covers every entry in the collection.

When tokens other than `{slug}` arrive, a prefix no longer describes the change. The store then compares `entryPath` before and after for each published entry of the collection and writes one redirect per entry, the model of WordPress old slugs and Hugo aliases. The MCP tool's preview shows that list before anything is written.

### 4. Invariants

1. A template never overlaps another collection's template, a taxonomy archive's `basePath`, a page path, or a reserved path (`checkReservedPath`, `pathsOverlap`, and `entryAt` for the reverse check). A conflicting `updateSite` fails with `path_conflict`, the way Eleventy refuses duplicate URLs.
2. The default is `/<collection>/{slug}`: a site without `collections` renders exactly as before.
3. The collection key is never derived from the URL. Filters, templates, MCP tools and `ContentEntry.collection` keep using the key.
4. Changing a template never breaks a published URL: the old URL answers with a 301 to the new one.
5. Snapshots carry the setting (it is part of the site node), so a rollback restores the old template. Its redirect stays, since redirects are not removed on rollback.
6. The token set is closed and every template is checked when it is written, never when a page is rendered.

### 5. Surfaces

- **Studio:** Site settings gets a "Collections" section next to General, Domains and Previews: one row per collection with its URL (an `Input` showing the fixed part before `/{slug}`, with a preview of a real entry's address). Saving goes through the existing site update.
- **MCP:** `get_site` returns the setting. No tool changes site settings today, so this adds `set_collection_permalink` (`site:write`). It moves public URLs, so it goes through approval like a publish, and its preview lists the old and new address of a few entries.
- **Starter:** the `collectionIndex` hook defaults to the template's fixed prefix, so a menu item for a collection links there without theme code.

## Later (decided, not in v1)

- **Date tokens** (`{year}`, `{month}`, `{day}`): taken from a stored first-publication date that never changes, not from an editable date, to avoid the date-change trap. Redirects per entry (§3).
- **Per-locale templates:** a map, `permalink: { en: "/blog/{slug}", tr: "/yazilar/{slug}" }`, as Hugo allows per language.
- **Entries at the root** (`/{slug}`): allowed only once the collision check of invariant 1 also runs when a page is created or moved.
- **The collection index page:** an explicit `indexPath` setting (Hugo's section page, WordPress `has_archive`), not implied by the template.
- **`{category}`:** only once an entry can name one primary term (Ghost's `primary_tag`). Without it, a post in two categories has two candidate URLs.

## Alternatives considered

| Option | Pros | Cons | Why not |
|---|---|---|---|
| Rename the collection (today) | No new field | Changes the key that filters, skills and agents use; rewrites every entry | The key is an identity, not a URL |
| A fixed `basePath` field (the first draft) | Simplest | Must be renamed or doubled when tokens arrive | `permalink` with v1 limited to `/<prefix>/{slug}` costs the same now |
| A free-form template language | Full freedom | Unchecked routes, collisions found only at render time, agents can write anything | A closed token set gives the useful templates and stays checkable |
| A `Collection` node kind | Room for labels, schemas, per-collection feeds | New node kind, store tables, sync, snapshots, MCP tools | Too big for one setting; `Site.collections` can move into a node later if collections grow |
| Templates in theme config (`cloudhal.json` / `theme.ts`) | No contract change | Not in snapshots or the graph; agents and Studio cannot change them; the API's paths would disagree with the renderer's | URLs are content, not theme |
| Hand-written redirect files (Ghost, Webflow) | Explicit | Forgotten when URLs move; drift | Redirects are derived from the change itself |

## Compatibility & migration

- Optional field: old sites, old snapshots and old bundles are valid and render the same.
- Rust mirror: generated from the schema (`pnpm gen`). Fixtures go under `fixtures/{valid,invalid}/site/`: a valid `/blog/{slug}`, and invalid templates with a trailing slash, an unknown token, or no `{slug}`.
- Rollout, one PR each:
1. The contract plus `entryPath`/`entryAt`, with every caller moved and no behaviour change.
2. `updateSite` redirects and overlap checks.
3. Studio, the MCP tool and descriptions.
4. The starter README.
- Rollback: removing the setting returns to `/<collection>/{slug}`; the redirect from step 2 keeps old links working.

## Security & policy impact

No new scopes: changing a template is a site update (`site:write`). In Studio a person makes it; through MCP the new tool waits for a person's approval, because it moves public URLs. Redirects are same-site paths only (`RedirectPlan`), never external URLs.

## Open questions (decided as recommended)

1. **Per-locale templates:** not in v1; one template for all locales, with locale prefixes as today. They come later as a map (see Later).
2. **`/` as a base:** not in v1; allowed later once page writes also run the collision check.
3. **The collection index page:** not part of this RFC; it stays a page the site owner creates, and `collectionIndex` links to it. An explicit `indexPath` comes later.
Loading
Loading