Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0d27f98
feat(content): add delivery definitions and validation
aXenDeveloper Aug 8, 2026
2b19206
feat(content): add canonical URL and sitemap helpers
aXenDeveloper Aug 8, 2026
b57d8c8
feat(content): add slug history persistence
aXenDeveloper Aug 8, 2026
5bdb9a4
feat(content): add the delivery service and redirect resolver
aXenDeveloper Aug 8, 2026
28fb474
feat(content): integrate redirects with editorial mutations
aXenDeveloper Aug 8, 2026
940561c
feat(content): add the delivery events
aXenDeveloper Aug 8, 2026
dfff439
feat(content): integrate delivery cache invalidation
aXenDeveloper Aug 8, 2026
9d22bdf
feat(content): add the delivery routes and error contracts
aXenDeveloper Aug 8, 2026
b7c8e1f
feat(content): add the Next.js delivery helpers
aXenDeveloper Aug 8, 2026
b329b42
feat(admin): show content delivery metadata
aXenDeveloper Aug 8, 2026
7dc8dca
feat(example): add the delivery reference fixtures
aXenDeveloper Aug 8, 2026
224f088
test(content): cover delivery, redirects and the cache boundary
aXenDeveloper Aug 8, 2026
117667d
test(content): add the Stage 8 PostgreSQL suite
aXenDeveloper Aug 8, 2026
0681413
docs(content): document Content Delivery and SEO
aXenDeveloper Aug 8, 2026
9df590c
fix(example): satisfy the plugin's typed lint in the delivery suite
aXenDeveloper Aug 8, 2026
f3b822d
fix(content): resolve two delivery correctness bugs found in review
aXenDeveloper Aug 8, 2026
7fc01f9
style(example): satisfy the plugin lint in the new delivery assertions
aXenDeveloper Aug 8, 2026
e122fda
fix(content): make delivery paths a globally unique namespace
aXenDeveloper Aug 8, 2026
b71fd58
fix(content): require editorial for delivery.redirects
aXenDeveloper Aug 8, 2026
483b830
fix(content): expire the sitemap whenever its lastModified moves
aXenDeveloper Aug 8, 2026
3810af0
fix(content): carry the delivery tags across the revalidation bridge
aXenDeveloper Aug 8, 2026
943edf5
test(content): cover the sitemap lastModified invariant
aXenDeveloper Aug 8, 2026
d07fb1d
docs(content): correct the sitemap cache and Editorial requirements
aXenDeveloper Aug 8, 2026
491abdb
test(content): cover the restore path in the sitemap audit
aXenDeveloper Aug 8, 2026
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
144 changes: 144 additions & 0 deletions apps/docs/content/docs/dev/content-engine/caching.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,150 @@ Nothing falls back to Polish, whatever the fallback setting is - so a Polish edi
never throws away the English cache. See
[Localized public API](/docs/dev/content-engine/localized-public-api#caching).

## Delivery tags

A content type with [`delivery`](/docs/dev/content-engine/content-delivery) produces
three more scopes, in the same namespace and with the locale in the same position:

```ts
import {
contentDeliveryRedirectTag,
contentDeliverySitemapTag,
contentDeliveryTag,
} from "@vitnode/core/content";

contentDeliveryTag("example.article", 42);
// "content:example.article:delivery:42"

contentDeliveryTag("example.article", 42, "pl");
// "content:example.article:delivery:pl:42"

contentDeliveryRedirectTag("example.article", "stary-slug", "pl");
// "content:example.article:redirect:pl:stary-slug"

contentDeliverySitemapTag("example.article", "pl");
// "content:example.article:sitemap:pl"
```

Each answers a different question a page asked, which is why they are separate from
the three above rather than folded into them:

| Scope | Keyed by | Holds |
| ---------- | ------------- | -------------------------------------------------- |
| `delivery` | the record | canonical path, alternates, SEO metadata |
| `redirect` | the **slug** | "does this address still resolve here" |
| `sitemap` | the locale | one locale's file, and the index that lists them |

A `generateMetadata` that renders only metadata is tagged `delivery` alone, so an
unrelated field of the record changing does not throw it away. A redirect lookup is
tagged by the **old** address, because that is what a request for a moved page arrives
with.

### What expires them

`contentInvalidationTags` takes an optional `delivery` block and derives everything
from the data it already has - the affected locales and every slug the record answered
to across the mutation:

```ts
contentInvalidationTags({
contentTypeId,
delivery: { sitemap: true },
id,
isPublic,
slugs: [previousSlug, currentSlug],
wasPublic,
});
```

```ts
contentInvalidationTags({
contentTypeId,
delivery: { sitemap: { contentChanged: true, indexChanged: false } },
id,
isPublic,
slugs: [previousSlug, currentSlug],
wasPublic,
});
```

The sitemap is **two** decisions rather than one, and the reason is `<lastmod>`. A
sitemap entry carries a `lastModified` derived from `updatedAt`, so a plain title edit
on a published record changes the *bytes* of its sitemap file even though the set of
URLs in it is identical:

- **`contentChanged`** expires the sitemap **file** of each locale the mutation
reached. True for any real mutation of a record that is or was publicly reachable.
- **`indexChanged`** expires the locale-less tag, which for a localized content type is
the *index* of its per-locale files. True only when public reachability flipped,
because an index lists files and counts URLs.

| Mutation | delivery | redirect (old + new) | sitemap file | sitemap index |
| ----------------------------------- | -------- | -------------------- | ------------ | ------------- |
| Title / SEO edit (still published) | ✅ | ✅ | ✅ | ❌ |
| Slug change (published) | ✅ | ✅ | ✅ | ❌ |
| Publish / unpublish | ✅ | ✅ | ✅ | ✅ |
| Delete (was published) | ✅ | ✅ | ✅ | ✅ |
| Restore that moves a slug | ✅ | ✅ | ✅ | ❌ |
| Translation publish / unpublish | ✅ | ✅ | ✅ | ✅ |
| Translation create / delete | ✅ | ✅ | ✅ | ✅ |
| No-op edit | ❌ | ❌ | ❌ | ❌ |
| Draft edited into another draft | ❌ | ❌ | ❌ | ❌ |

The first row is the one worth reading twice. **A real update to a published
representation expires that locale's sitemap file, because its `lastModified` changes -
even when the canonical URL stays the same.** Anything else would leave a cached
sitemap serving a timestamp that is no longer true.

The last two rows are the other half of the same rule: the engine issues no `UPDATE`
for an update that changed nothing, so `updatedAt` does not move and the cached file is
still byte-correct. A draft is in no sitemap either way.

<Callout type="info" title="A nonlocalized content type has one file and no index">
Its locale-less tag *is* its sitemap file, so `contentChanged` is what expires it.
The locale-less tag means "the index" only for a localized content type, whose files
are the per-locale ones.
</Callout>

### Which locale's sitemap

Per locale, reusing the Stage 5 fan-out above rather than a second rule:

```text
PL translation edit → sitemap:pl
EN translation edit → sitemap:en
shared field edit → sitemap:en AND sitemap:pl (the base `updatedAt` is in both)
```

A shared edit reaches every locale because a localized entry's `lastModified` is
`max(base.updatedAt, translation.updatedAt)` - so a new base timestamp becomes the
effective value for every published translation.

One conservative case is worth naming: with `fallback: "default"`, an edit to the
**default** locale's translation also reaches every locale that has no translation of
its own, because that is where their public pages come from. Those locales contribute
no sitemap URL at all - a sitemap never lists a fallback - so expiring their files is a
cache miss rather than a necessity. Following the Stage 5 fan-out is deliberate: one
locale-propagation rule, not two.

<Callout type="warn" title="Delivery is opt-in at this layer too">
Omit `delivery` from the input - which is what every content type without the block
does - and `contentInvalidationTags` returns exactly the strings it always returned,
byte for byte. Nothing existing has to be re-tagged, and no warm cache is thrown
away for a feature the content type does not use. A test asserts the exact lists.
</Callout>

### Background mutations

A [scheduled](/docs/dev/content-engine/scheduling) publish reaches the web app through
the same revalidation bridge, with the delivery tags included - there is no second
cross-origin invalidation system, and the same all-origins-must-accept rule applies.

The bridge's request schema declares `delivery` explicitly, because an object schema
strips what it does not name: a body that carried delivery tags and arrived without
them would leave a stale sitemap behind every background transition. It stays optional,
so an API that has not been redeployed keeps working.

## Where the Next imports live

Exactly one place: `@vitnode/core/content/next`.
Expand Down
176 changes: 176 additions & 0 deletions apps/docs/content/docs/dev/content-engine/canonical-urls.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
---
title: Canonical URLs
description: One helper builds every content URL, it is relative on purpose, and the locale is normalized so one page has one cache key.
icon: Link
---

A canonical URL is the one address a page admits to living at. Everything else -
redirects, `hreflang`, sitemaps, cache tags - is defined in terms of it, so the
engine builds it in exactly one place.

```ts
import { contentDeliveryPath } from "@vitnode/core/content";

contentDeliveryPath({ definition: articleContentType, slug: "my-article" });
// "/articles/my-article"

contentDeliveryPath({
definition: advancedArticleContentType,
locale: "pl",
slug: "moj-artykul",
});
// "/pl/articles/moj-artykul"
```

## How a path is built

```text
nonlocalized /{publicApi.path}/{slug}
localized /{locale}/{publicApi.path}/{slug}
```

Nothing is configurable here, and that is the point: a resolver has to be able to
parse back what the builder produced, and a per-content-type URL template would
make that a guess. `publicApi.path` is reused rather than duplicated into
`delivery`, so the public API route and the public page cannot disagree about the
prefix.

## It is relative

`contentDeliveryPath` never returns an origin, because a content type definition
lives in source control and gets deployed to a preview domain, a staging domain and
production - so an origin baked into it would be wrong in two of the three places.

Supply one when you need an absolute URL:

```ts
import { contentDeliveryUrl } from "@vitnode/core/content";

contentDeliveryUrl({
origin: "https://example.com",
path: "/pl/articles/moj-artykul",
});
// "https://example.com/pl/articles/moj-artykul"
```

`https://example.com` and `https://example.com/` produce the same URL - it resolves
rather than concatenates - and a malformed origin comes back `null` rather than a
link with two schemes in it.

The delivery service takes the same argument:

```ts
await delivery.findById(42, { locale: "pl", origin: "https://example.com" });
// { canonicalPath: "/pl/articles/…", canonicalUrl: "https://example.com/pl/articles/…", … }
```

`canonicalUrl` is **absent** rather than `null` when no origin was given, so a
consumer never has to tell "no origin was supplied" from "the URL could not be
built".

<Callout type="info" title="Sitemaps are the exception">
The sitemap protocol only accepts absolute URLs, so `contentSitemapXml` requires
an origin rather than taking one. See [Sitemaps](/docs/dev/content-engine/sitemaps).
</Callout>

## The locale is normalized

```ts
contentDeliveryPath({ definition, locale: "PL", slug: "witaj" });
contentDeliveryPath({ definition, locale: "pl", slug: "witaj" });
contentDeliveryPath({ definition, locale: " pl ", slug: "witaj" });
// all three: "/pl/articles/witaj"
```

Same `normalizeContentLocale` the rest of the engine uses. It matters because a
path is also a cache key: three spellings of one locale producing three paths would
produce three cache entries for one page, and expiring one of them would leave the
other two stale forever.

Slugs are percent-encoded on the way in. A generated slug is already URL-safe -
[`slugify`](/docs/dev/content-engine/slug-field) guarantees it - but a row written
straight into the database is not, and a *path* is what this function promises.

## Nulls are deliberate

`contentDeliveryPath` returns `null` rather than a best effort in three cases:

- **An empty slug.** A canonical URL that points at the list page is worse than no
canonical URL at all.
- **An empty `publicApi.path`.** The content type has no public API.
- **A localized content type with no locale.** A localized record has one URL per
language and no locale-less one, so guessing would hand a reader the wrong
language under a URL that claims otherwise.

## Parsing a path back

```ts
import { parseContentDeliveryPath } from "@vitnode/core/content";

parseContentDeliveryPath(articleContentType, "/articles/my-article");
// { locale: null, slug: "my-article" }

parseContentDeliveryPath(advancedArticleContentType, "/pl/articles/moj-artykul");
// { locale: "pl", slug: "moj-artykul" }
```

The inverse of the builder, and deliberately strict: it accepts exactly the shape
that function produces and refuses everything else. An extra segment, a different
public prefix, a traversal or a malformed escape is `null` rather than a best guess
- a resolver that guessed would answer one content type's URL with another's
record.

A query string and a fragment are stripped first, because a browser sends them and
they are not part of the identity of a page.

`delivery.resolvePath()` is this plus the lookup, and it is what a catch-all route
should call:

```ts
const resolution = await delivery.resolvePath("/pl/articles/stary-slug");
```

## The canonical URL is the *served* locale

This is the rule most likely to be got wrong, and it comes straight out of
[Stage 5 fallback](/docs/dev/content-engine/localized-public-api):

```text
requestedLocale = pl
PL translation missing
fallback EN translation exists
```

A public `findById()` may return the English copy. The canonical URL of that
response is the **English** one:

```ts
{
requestedLocale: "pl",
locale: "en",
isFallback: true,
canonicalPath: "/en/articles/article",
}
```

`/pl/articles/article` would be a self-declared canonical that answers 404 - the
Polish translation does not exist, so nothing serves that URL. Reporting the served
locale is what lets a page render `<link rel="canonical">` correctly *and* show a
"not translated yet" notice.

`findBySlug` and `resolveSlug` remain strict-locale: a URL belongs to the language
it was published under, so they never fall back at all.

## Registry helpers

```ts
import { listDeliveryContentTypes } from "@vitnode/core/content";

const delivered = listDeliveryContentTypes(core.contentModels);
```

Every delivery-enabled content type of an installation, sorted by id so two
processes building the same sitemap index produce the same document. It is what
lets a site-level `/sitemap.xml` enumerate `blog.article`, `docs.page` and
`shop.category` without hardcoding a single plugin name - installing a plugin adds
its content types and removing it takes them out again.
Loading
Loading