Operator-controlled URLs for runtime content types

Runtime-defined content types ship public URLs the operator controls, not a framework convention. A patch to T104 Inc 2 removes a Go 1.22 mux conflict and decouples admin routes from the public URL surface.

Amendment A154 · Smeldr core v1.41.1 · smeldr/mcp v1.21.1

When T104 Inc 2 shipped dynamic content types (A153), the framework registered a GET /{seg1}/{seg2} catch-all to route requests to whichever dynamic type matched. Clean in theory. But Go 1.22's net/http mux changed the rules: wildcard patterns now conflict with literal routes registered by compiled modules. A Smeldr application that wired both smeldr.Content(&Post{}, smeldr.At("/posts"), ...) and app.ServeDynamicContent() panicked at startup before serving a single request.

The fix is the right design anyway: the framework should not guess where a type lives. The operator sets it.

URLPrefix — the operator's declaration

ContentTypeSchema now has a URLPrefix field. When you define a content type, you say where it should live:

{
  "type_name": "recipe",
  "label": "Recipe",
  "url_prefix": "/recipes",
  "fields": [
    {"name": "Title",       "type": "string",  "required": true, "role": "title"},
    {"name": "Ingredients", "type": "string",  "format": "markdown"},
    {"name": "Steps",       "type": "string",  "format": "markdown"}
  ]
}

Smeldr registers exactly two routes at that prefix:

GET /recipes        -> published list (JSON)
GET /recipes/{slug} -> single published item (JSON)

Leave url_prefix empty and the type gets no public HTTP surface at all. It is still fully accessible via admin routes, MCP tools, and CLI.

Via MCP, the define_content_type tool accepts url_prefix as an optional parameter:

define_content_type
  type_name:  "recipe"
  label:      "Recipe"
  url_prefix: "/recipes"
  fields:     [...]

The route is registered immediately when the call succeeds. No restart required.

Admin routes use type_name, not prefix

The original A153 admin routes were keyed on URL prefix: /_content/{prefix}. That created a coupling: moving a type from /recipes to /food would break the admin surface. A154 decouples them:

OperationRoute
Define typePOST /_content/types
List itemsGET /_content/{type}
Get itemGET /_content/{type}/{id}
Create draftPOST /_content/{type}
Update fieldsPATCH /_content/{type}/{id}
Set statusPOST /_content/{type}/{id}/status

{type} is the type_name: recipe, not /recipes. The admin namespace is now stable regardless of what URL the type serves on.

ContentList block: type_name, not prefix

The content_list block type renders a live feed of any registered content type inside a block-composed page. Its ContentType field used to store the URL prefix (/recipes). It now stores the type_name (recipe) instead.

The renderer calls registry.Lookup(typeName), a direct name lookup, rather than LookupByPrefix(prefix). The result is the same at runtime, but the block is no longer tied to where the type is hosted. A type can move to a new URL without touching any content_list blocks that reference it.

Existing content_list blocks with a prefix value need updating: change the ContentType field from /recipes to recipe.

Sitemap stays current automatically

When set_content_status publishes or archives a dynamic content item, a goroutine rebuilds the sitemap fragment for that type in the background. No manual refresh, no cron job. The sitemap reflects the live Published set after every status change.

This only fires for types with a url_prefix. Admin-only types (empty prefix) are not included in sitemaps, which is the right default.

Migration note

Existing deployments that ran A153 (core v1.41.0) need the url_prefix column added to smeldr_content_type_schemas. MigrateURLPrefixColumn(db) handles this idempotently at startup, called by ServeDynamicContent() before Handler() returns. No manual step is required.