Dynamic segments
[id] captures one segment, [...path] captures the rest.
Two forms of dynamic segment exist, and both are read through the request context rather than through a positional argument list.
[name] — exactly one segment
// routes/posts/[id].chpl matching /posts/42
const id = ctx.pathParam("id"); // "42"
const numeric = ctx.paramInt("id", 0); // 42, or 0 if unparsableA [name] segment matches exactly one non-empty path segment. It never matches across a /.
[...name] — one or more segments
A catch-all captures the remainder of the path as a single string with the separators intact, and must be the last segment of the pattern.
// routes/docs/[...path].chpl matching /docs/guide/routing
const slug = ctx.pathParam("path"); // "guide/routing"A catch-all does not match its own parent: /docs/[...path] does not answer /docs. Add routes/docs/index.chpl if that path needs an answer — which is exactly how this wiki redirects /docs to the introduction.
Validate what you capture
The runtime percent-decodes and normalises the request target before routing: . and .. are resolved against a virtual root, any residual escape is a 400, and control bytes, spaces and NUL in a decoded segment are rejected. Normalisation runs on the decoded path, so %2e%2e%2f cannot slip past a check for ../.
That is one guard, not a licence to skip your own. A captured parameter is still request data, and anything you do with it — a file lookup, a map key, a redirect target — deserves its own check:
proc page(ctx: Context, ref meta: PageMeta): string {
const slug = ctx.pathParam("path");
if !isSafeSlugPath(slug) then return notFound(meta);
var doc: Document;
if !ContentStore.find("/docs/" + slug, doc) then return notFound(meta);
// ...
}Resolving a parameter against an in-memory registry rather than against the filesystem removes the class of bug entirely: a map lookup cannot escape a directory.
Both forms together
routes/posts/new.chpl -> /posts/new
routes/posts/[id].chpl -> /posts/[id]
routes/posts/[...rest].chpl -> /posts/[...rest]A request for /posts/new matches the literal route, not the parameter — see match order.