Skip to main content

Slug strategy

How URLs are built from slugs, where collision detection happens, and what changes when you edit them.

The canonical implementation lives in src/core/slug.ts with comprehensive tests in slug.test.ts.

URL structure

Every public URL has the shape /<path> where <path> is computed from a small set of rules:

EntityURL path
Home/index.html (always exists, even when home is a static page)
Single post WITH category/<category-slug>/<post-slug>.html
Single post WITHOUT category/<post-slug>.html
Page (top-level)/<page-slug>.html
Category archive/<category-slug>/index.html
Author page/author/<author-slug>.html
404/404.html
Tag pages(none — tags don't get archive pages in v1)

Tag pages are intentionally absent. Tags are organisational metadata for filtering / listings; they don't have their own URL hierarchy.

Slug rules

function isValidSlug(slug: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
}

Slugs MUST be:

  • Lower-case — Flexweg is case-sensitive on file paths. Mixed-case slugs cause real 404s.
  • ASCII — accents and non-ASCII chars are stripped by slugify. cafécafe.
  • Letters + digits only, separated by single dashes — no underscores, no consecutive dashes, no leading or trailing dashes.

The admin auto-generates valid slugs from titles via slugify. User-edited slugs are validated inline; invalid slugs disable the Save button.

Collision detection

Two posts can't share an URL. Two categories can't share an URL. A post and a category CAN have the same slug — their URLs differ.

detectPathCollision(path, posts, pages, terms, excludeId?) checks the candidate URL path against every existing entity:

const collision = detectPathCollision("news/launch.html", posts, pages, terms);
// → { entityType: "post", entityId: "abc123" } | null

The check is on the final path, not the raw slug. So:

  • A post with slug news (URL: /news.html)
  • A category with slug news (URL: /news/index.html)

These don't collide — the URLs are different. Both stay valid.

Same with:

  • Two posts in different categories: /news/launch.html and /blog/launch.html. The slugs match (launch), the URLs differ. No collision.

Auto-deduplication

When a new post / page / category is created, findAvailableSlug ensures uniqueness:

findAvailableSlug("launch", posts, terms, "post");
// → "launch" if free, else "launch-2", else "launch-3", …

Used silently on auto-slug from title. User-typed slugs trigger inline collision warnings instead — admins should pick their own resolution rather than getting silently renamed.

What changes when you edit a slug

When you edit a post's slug:

  1. The next publish moves the file from old path → new path
  2. The publisher's stale-path cleanup deletes the old file
  3. Cascade regenerations update the home page + category archive (which list the post by URL)
  4. menu.json is re-uploaded (might reference the post)
  5. Sitemap / RSS / search index regenerate via lifecycle actions

External links to the old URL break — there's no automatic redirect (Flexweg is a static host).

When a category slug changes

Wider blast radius:

  1. Every post in the category has its URL change (since the URL includes the category slug)
  2. Every post's stale path needs cleaning up
  3. Listings (home, the category itself, sitemap) all regenerate

The admin doesn't auto-publish every post in a category when the category slug changes — that would be expensive on large sites. Instead, run Themes → Regenerate site → All HTML pages after the rename.

Special characters in slugs

Input titleslugify output
Hello, World!hello-world
My First Postmy-first-post
Café & Barcafe-bar
Über cooluber-cool
日本語`` (empty — stripped to nothing — falls back to a default slug)

Languages with non-Latin scripts (CJK, Arabic, Hebrew, etc.) need manual slug entry — the auto-slugifier returns empty. Pick something like post-1 or transliterate to ASCII.

Reserved slugs

The publisher reserves a handful of paths for system files:

Reserved pathUsed by
index.htmlHome
404.html404 page
menu.jsonDynamic menu data
posts.jsonPosts widget data (when theme uses it)
search-index.jsonSearch index (flexweg-search)
search.jsSearch runtime
rss.xml + rss.xml.xslRSS feeds (flexweg-rss)
robots.txtSitemap pointer (flexweg-sitemaps)
theme-assets/Theme CSS + JS
media/Uploaded images
archives/Archive pages (flexweg-archives)
sitemaps/Sitemap files (flexweg-sitemaps)
favicon/Favicon files (flexweg-favicon)

Don't create posts / pages / categories with slugs that would collide with these. The admin doesn't formally enforce it, but you'd get into a confusing state.

Slug uniqueness gotchas

Slug shared between post and page

Allowed — different URLs. /<post>.html for a post; /<page>.html for a page. The collision detector's excludeId parameter handles edits-without-renames.

Slug equal to a reserved name

Allowed but inadvisable. slug: "index" would produce /<category>/index.html for posts in a category — clashing with the category archive. The admin doesn't catch this; avoid.

Trailing dashes

Stripped by slugify. my post -my-post.

Empty slug

Rejected. Auto-generation falls back to untitled-<timestamp> for posts with empty titles.

Slug editing best practices

  1. Set the slug carefully on first publish. Once external links exist, changing the slug breaks them.
  2. Prefer auto-slug from title. Hand-editing is for cases where the auto-slug doesn't work (long titles, non-ASCII titles).
  3. Avoid changing slugs after publish. If you must, do it as soon as possible — the longer the URL has been live, the more backlinks point at it.
  4. Categories once renamed cascade widely. Plan category names carefully.

Continue