The project has two pipeline layers that transform requests and content: request middleware (Astro onRequest) for URL normalization, and build-time rehype plugins for markdown link resolution. This page documents both.
Who this is for
- Implementors and editors linking between content files using relative markdown paths
- Developers adding middleware functions, extending the link rewriting plugin, or troubleshooting URL behavior
Request Middleware
The project uses Astro middleware (src/middleware.ts — CORE-OWNED) for request preprocessing.
Trailing-Slash Redirect
When platformConfig.trailingSlash is true, all non-extension, non-API paths without a trailing slash receive a 301 redirect to the same path with a trailing slash appended.
- File extensions are detected via regex
/\.\w{1,10}$/(skips .js, .css, .png, etc.). - API routes (
/api/) are always skipped. - Query parameters are preserved in redirects.
Controlled by the trailingSlash boolean in build-safe site configuration (src/site/config/platform.config.ts — SITE-OWNED).
Adding New Middleware
Additional middleware functions can be chained using Astro’s sequence() helper. Each function receives context and next, returning a Response.
// src/middleware.ts — CORE-OWNED
import { sequence } from "astro:middleware";
export const onRequest = sequence(trailingSlashMiddleware, newMiddleware);
Note: Keep middleware functions small and focused. Each function should handle a single concern.
Internal Link Rewriting
The rehype-resolve-internal-links plugin rewrites file-relative markdown links targeting .md or .mdx files into their canonical route URLs during build-time markdown compilation. This lets authors use natural markdown inter-document linking (e.g., [see this](./other-post.md)) without worrying about how content files map to generated routes.
The plugin runs in the rehype pipeline (HAST tree) and is registered in astro.config.mjs before the rehype-external-links plugin.
What gets rewritten
The plugin rewrites links that meet all of these criteria:
- The href targets a
.mdor.mdxfile - The href uses file-relative resolution: explicit
./or../prefixes, or a bare filename likeother-post.md - The target file exists in one of the configured content collection directories
What does not get rewritten
- External links (
https://...,mailto:...) — handled byrehype-external-links - Root-relative links (
/articles/post/) — already in route form - Absolute filesystem paths (
/home/.../file.md) - Non-markdown targets (
./image.png,./data.json) - Unresolvable links — if the target
.mdfile does not exist in any configured collection, the link is left unchanged. The PLN-4broken-linklinter (DOC-00082) catches these during validation. - Links in
.astrotemplates — only markdown/MDX content is processed by rehype
How resolution works
At build time, the plugin scans all configured collection directories and builds a resolution map from absolute file paths to canonical route URLs. For each content entry:
- If the entry has a
linkedRoutefrontmatter field, the plugin uses that value as the canonical URL. This applies to metadata stubs for static.astroroutes in thepagesandtheme-docscollections. - Otherwise, the plugin calls
normalizeEntrySlug()and prepends the collection’s route prefix. Frontmatterslugoverrides are respected (supported onpagesandarticles). - For the
pagescollection, slugs whose first segment matches a reserved prefix (articles,docs,tests,api) are excluded — those routes belong to other collections.
All rewritten URLs include a trailing slash when platformConfig.trailingSlash enables trailing slash behavior.
Fragments (#heading) and query strings (?param=value) are preserved on the rewritten URL.
Draft entries are included in the resolution map so that inter-draft links work during development.
Covered collections
The plugin ships with four default collection mappings:
| Collection | Content directory | Route prefix | Example route |
|---|---|---|---|
pages | src/content/pages | (none) | /about/ |
articles | src/content/articles | /articles | /articles/my-post/ |
theme-docs | src/core/content/theme-docs | /theme-docs | /theme-docs/platform/overview/ |
site-docs | src/content/site-docs | /site-docs | /site-docs/getting-started/ |
Adding a collection
Sites can register additional collections by importing the core defaults and appending their own mappings in astro.config.mjs:
import {
rehypeResolveInternalLinks,
coreCollections,
} from "./src/core/lib/rehype/rehype-resolve-internal-links.ts";
// In the markdown config:
rehypePlugins: [
[
rehypeResolveInternalLinks,
{
collections: [
...coreCollections,
{
name: "guides",
base: "src/content/guides",
routePrefix: "/guides",
},
],
},
],
// ... other plugins
];
Each collection mapping requires three fields:
name— collection name (used for diagnostics)base— content directory relative to the project rootroutePrefix— route prefix prepended to the slug (use""for collections that route to/{slug}/with no prefix)
Relationship to the broken-link linter
The broken-link static rule (DOC-00082) validates internal links in source content before build. It operates on the raw authored markdown, not on rendered HTML, so the two systems do not interact at runtime. The linter catches broken links; the plugin rewrites valid ones.
If a .md link targets a file that does not exist, the plugin leaves it unchanged and the linter reports it during npm run lint:static-rules.