Getting URL for alternative template type (e.g., markdown) with query parameters

,

I’m trying to generate URLs for alternative template types (like .md instead of the default HTML) while preserving query parameters, and I’m wondering if there’s a cleaner way than manually constructing the URL.

Current approach

I’m building the URL manually like this:

$page->url() . '.md' . kirby()->request()->params()->toString(true)

This works, but I’m curious if there’s a more elegant method similar to how you can pass params to url():

$page->url(['params' => kirby()->request()->params()])

Question

Is there a built-in Kirby method to get a page URL with a different template type (like .md) that properly handles query parameters? I don’t see this documented anywhere — is there a recommended approach for linking to alternative template types?

Use case

I have a posts.md.php template that outputs markdown, and I want to link to it from the regular HTML posts.twig template while preserving any tag filters that might be active.

(As a sidenote, when query parameters are present, the resulting URL format is http://localhost:8000/posts.md/tag:Hello — which is a bit awkward format, in my opinion — not a blocker, just noting it.)

Any suggestions or workarounds would be appreciated!

I’m not aware of a method that returns the url for a content representation, but you could create a method in a page model (e.g. $page->represenationUrl()), which also allows you to pass parameters.

If you want to use query format (i.e. https://mydomain.tld?tag=hello) instead of the params, you can use the query option instead of the paramsoption:

$page->url(['query' =>kirby()->request()->query()]);

Thanks for the suggestion! I’ve actually created a custom urlExtended() method as a page extension that handles this use case. Here’s what I’ve implemented:

What it does:

  • Adds support for a type parameter to generate URLs with content type extensions (e.g., .md)
  • Supports both params (path-based) and query (query string) simultaneously
  • Normalizes query and params to be interchangeable (handles Kirby Params/Query objects automatically)

Example usage:

// Regular page with type extension and query params
$page->urlExtended(['type' => 'md', 'query' => kirby()->request()->params()]);
// Produces: "http://localhost:8000/snippets.md?tag=CSS"

// With both params and query (both are used)
$page->urlExtended([
    'type' => 'md',
    'params' => kirby()->request()->params(),
    'query' => kirby()->request()->params()
]);
// Produces: "http://localhost:8000/snippets.md/tag:CSS?tag=CSS"

// Homepage example
site()->homePage()->urlExtended([
    'type' => 'md',
    'params' => kirby()->request()->params(),
    'query' => kirby()->request()->params()
]);
// Produces: "http://localhost:8000/home.md/tag:CSS?tag=CSS"

Why this would be nice to have in core:

  1. Content type representation support: Kirby already supports different content types (.md.php, .json.php, etc.), but there’s no built-in way to dynamically construct URLs to the same page with a different content type. Having this in core would make it much easier to provide alternative representations of content (markdown exports, JSON APIs, etc.).

  2. Interchangeable query/params: Currently, when using query with Kirby’s Params object, you need to call .toArray() first:

    $page->url(['query' => kirby()->request()->params()->toArray()]);
    

    It would be a nice quality-of-life improvement if url() could automatically handle Params/Query objects for the query option, similar to how it handles them for params.

The implementation uses Kirby’s Params and Query classes internally, so it follows the same patterns and formatting as core Kirby functionality. It would be great to see this kind of functionality built into url() directly!

Here’s the code for my extension:

<?php

use Kirby\Http\Params;
use Kirby\Http\Query;

Kirby::plugin('lemmon/extensions', [
    'pageMethods' => [
        /**
         * Extended URL method that adds content type representation support.
         * Supports both params (path-based, Kirby-style) and query (query string).
         * Both can be provided simultaneously and will be used together.
         *
         * @param array $options Options array (must be array for extended functionality)
         * @return string The URL with optional type extension
         */
        'urlExtended' => function (array $options = []) {
            // Extract extended parameters from options
            $type = $options['type'] ?? null;
            $query = $options['query'] ?? null;
            $params = $options['params'] ?? null;
            $fragment = $options['fragment'] ?? null;

            // Remove extended parameters from options
            unset($options['type'], $options['query'], $options['params'], $options['fragment']);

            // Get base URL from Kirby's url() method (without params, query, fragment)
            $baseUrl = $this->url($options);
            $extendedPath = '';

            // Homepage edge case: homepage URL is typically '/' or empty
            // If we need to add type/params, we need a path to extend
            if ($this->isHomePage() && ($type !== null || $params !== null)) {
                $slug = $this->slug();
                // Only add slug if it exists and is not empty
                if ($slug !== '' && $slug !== '/') {
                    $extendedPath = '/' . $slug;
                } else {
                    // For homepage with empty slug, use root path
                    $extendedPath = '/';
                }
            }

            // Append type extension to path BEFORE params (e.g., /snippets.md or /.md for homepage)
            if ($type !== null && $type !== '') {
                $extendedPath .= '.' . $type;
            }

            // Normalize and format params for path (Kirby-style: /key:value)
            if ($params !== null) {
                // Convert to Params object if needed
                if (is_object($params) && $params instanceof Params) {
                    $paramsObj = $params;
                } elseif (is_object($params) && method_exists($params, 'toArray')) {
                    $paramsObj = new Params($params->toArray());
                } elseif (is_array($params)) {
                    $paramsObj = new Params($params);
                } else {
                    $paramsObj = new Params((array) $params);
                }

                // Format params as path string (e.g., /tag:CSS) and append to path
                // Params::toString(true) includes leading slash, so it appends correctly
                if ($paramsObj->isNotEmpty()) {
                    $extendedPath .= $paramsObj->toString(true);
                }
            }

            // Normalize and format query for query string
            $queryString = '';
            if ($query !== null) {
                // Convert to Query object if needed
                if (is_object($query) && $query instanceof Query) {
                    $queryObj = $query;
                } elseif (is_object($query) && method_exists($query, 'toArray')) {
                    $queryObj = new Query($query->toArray());
                } elseif (is_array($query)) {
                    $queryObj = new Query($query);
                } else {
                    $queryObj = new Query((array) $query);
                }

                // Format query as query string (e.g., ?tag=CSS)
                if ($queryObj->isNotEmpty()) {
                    $queryString = $queryObj->toString(true);
                }
            }

            // Handle fragment
            $fragmentString = '';
            if ($fragment !== null) {
                $fragmentString = '#' . ltrim($fragment, '#');
            }

            // Reconstruct URL: base + extended path + query + fragment
            // Normalize to avoid double slashes (e.g., if $baseUrl ends with '/' and $extendedPath starts with '/')
            if ($extendedPath !== '' && str_ends_with($baseUrl, '/') && str_starts_with($extendedPath, '/')) {
                $baseUrl = rtrim($baseUrl, '/');
            }

            return $baseUrl . $extendedPath . $queryString . $fragmentString;
        },
    ],
]);

Hm, I don’t quite understand why I would want both the param and the query syntax here for the same value? Or, if I leave out the params prop, why I would want to convert params to query syntax?

Totally fair point — having the same value twice as both params and query is not something you’d normally want in a real URL. That example was intentionally a bit silly just to show that the inputs can be treated interchangeably by the helper.

In practice, the “params ↔︎ query” part is mainly useful as a conversion/compat step in edge cases, e.g.:

  • you receive Kirby-style params (/tag:CSS) but need to redirect to a query-based URL (?tag=CSS) because the target endpoint (or external service) doesn’t understand params
  • Kirby pagination defaults to params in some setups, but you may prefer query strings for consistency/SEO/tooling
  • you want to keep existing param-style links working but canonicalize/redirect to query-based ones

That said, this is a secondary feature in the thread — the main thing I’m after is the content-type aware URL generation.

Kirby can already render alternate representations for the same page via content representations (e.g. page.md.php, page.json.php etc.), and Page::url() already supports language switching — but there’s no equivalent way to say “give me the URL to this page in representation X”.

So the core request is basically something like:

$page->url(['type' => 'md'])
// or
$page->url(['representation' => 'md'])

…and it would produce the correct URL with the extension (and then still allow the usual params, query, fragment handling).

My helper just demonstrates that it’s possible and (I hope) ergonomic. The params/query normalization is just a nice-to-have to make it easier to pass Params/Query objects around without thinking too much.

But again — the “why” here is mostly: I want to generate URLs for alternate representations in a clean, official way, similar to how language URLs work today.