Skip to content

Give each content type a preview URL that the preview token fills in - #3130

Merged
pournasserian merged 2 commits into
mainfrom
3099-preview-url-template
Nov 15, 2026
Merged

pournasserian merged 2 commits into
mainfrom
3099-preview-url-template

Conversation

@pournasserian

@pournasserian pournasserian commented Nov 15, 2026 •

Copy link
Copy Markdown
Member

Closes #3099

A new core settings area, preview, holds an absolute http or https URL template for each content type. It is set per site with instance defaults, for example https://www.example.com/{locale}/articles/{slug}?preview={token}. POST .../preview-token now also answers url and publishedUrl. url is the template filled with the draft's values and the new token. publishedUrl is the same template, without the token's parameter, filled from the published row of the locale that gave the draft. Both are null when the type has no template. Saving a template that is not an absolute http(s) URL, or that names a type or field the schema does not have, is refused. Spec 4.7, 4.10 and 4.13, the admin draft (4.12 and AQ15), the host configuration page and the OpenAPI document describe what was built.

Dependencies

None.

Decisions

  • publishedUrl, not a {status} placeholder (AQ15, now settled in the admin draft). The published page takes no token, and the admin cannot fill a template itself because it does not read the area. So a {status} placeholder would still need a second answer.
  • Locale. {locale} is the site's code (en, never the EN a request may send) of the locale whose row gave the draft. That is the token's locale, or, for a token without a locale, the first locale of the request's chain in which the entry has a row, which is how a read by id is served. publishedUrl is filled from that locale's published row only, read with no fallback, so it is null when that locale was never published. An entry with only English rows, read in French, gets its English pages.
  • {token} is required, once, as the whole value of one query parameter (name={token}). Because of that, the published URL can drop the parameter cleanly, and the token never sits in the path or the host. A front end whose preview goes through a separate route would need a second template. That is left for later, and adding it breaks nothing.
  • Only the path, query and fragment take values. The scheme is http or https. The host and port have no placeholder and no user info. The path does not start with //, which a front end that re-emits the path as a relative redirect would read as another host. Each % in the template must start a complete escape (two hex digits), so %{slug} and .%{slug} are refused and no value can complete an escape. A template holds only URL characters (no spaces, no backslash) and is at most 2,048 characters. Each value is escaped with Uri.EscapeDataString.
  • Placeholders. {id}, {locale} (localized types only), {token}, and a field of the type that holds one value: a core text, slug, select, email, url, integer, decimal, boolean, date or dateTime field that is not hidden and not multiple. Plugin field types, relations, rich text, components, blocks and JSON are refused. {token} always means the token, even if the type has a field named token.
  • Field read access. The values come from the caller's own Management reads, which drop fields the caller may not read. The token reads as the same principal, so the URL shows nothing the token could not.
  • Missing values. If any placeholder has no value, the URL is null rather than filled with an empty string. This covers a field the caller may not read, an empty field, a value that is not a single scalar, and a value of . or ...
  • Stale templates. If the schema changes after a template is saved, the token is still minted and its URL is null. The mint is not refused, and nothing is logged.
  • Validation is in the service. A new internal ISettingsCheck runs in SettingsService on write and on import, including a dry run, after the class's own attributes. PreviewSettingsCheck checks the templates against the schema. Codes: FCMS3031 for a template that is not a valid URL or puts {token} in the wrong place, and FCMS3032 for an unknown type, field or placeholder. Both are 422 on the path templates.{type}, and both are the next free codes in the preview-token block.
  • Access. PreviewSettings is internal to core. It is listed by GET /settings with plugin: null, and reading or writing it needs settings.preview.manage. Minting reads the area through ISettings<T>, so an editor who may mint gets the URL.
  • No PublicAPI, migration, role seeding or log event id changes.

Acceptance

  • With a template for article, the preview-token answer holds the URL with the entry's values and the token, escaped
  • For a type without a template, the answer has url: null
  • Saving a template that is not an absolute http or https URL, or that names a field the type does not have, is refused
  • Spec 4.7 (preview tokens as built) and 4.10 describe the area and the field

Tests

  • PreviewUrlTemplateTests (new, unit): escaping, no value can change the origin or add a segment, the template's own escapes are kept, the published URL drops the token's parameter, scalar formats, null for missing, empty, dot-only or non-scalar values, every refused form (FCMS3031, including %{slug}, .%{slug}, %zz and a path starting with //), every refused placeholder (FCMS3032), and the area check for unknown types.
  • PreviewUrlTests (new, integration on SQLite):
    • the draft URL and the published URL before and after publishing and after a draft edit;
    • the locale: EN filled as en, a French read served by English rows giving the English pages, and a French draft whose publishedUrl stays null until French is published;
    • url: null with no template and for a type that has none, and a site template that replaces the defaults;
    • an editor who gets the URL but gets 403 on the area, and preview listed in GET /settings;
    • templates refused on both routes with nothing stored;
    • a field the minter may not read, an empty field, and a stored template the schema no longer fits.
  • TransferTests.ASettingsDocumentThatTheChecksOfItsAreaRefuseIsReportedByADryRun (data): a dry-run import of a bad preview document reports the item as FCMS3031 and writes nothing.
  • OpenApiDocumentTests.ThePreviewTokenRouteHasItsBodies covers the two new properties.
  • Ran: dotnet build -c Release -warnaserror, the SDK, core and architecture tests, the new and changed classes, PreviewTokenTests, PreviewTokenTargetTests, the settings, site and transfer endpoint tests, the data SettingsTests and TransferTests on SQLite, PluginContributionTests, and markdownlint.

@pournasserian pournasserian added type:feature New capability area:api Delivery and Management APIs, OpenAPI, errors and caching labels Nov 15, 2026
@pournasserian
pournasserian enabled auto-merge (squash) November 15, 2026 16:59
@pournasserian
pournasserian merged commit f5d06ce into main Nov 15, 2026
14 checks passed
@pournasserian
pournasserian deleted the 3099-preview-url-template branch November 15, 2026 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:api Delivery and Management APIs, OpenAPI, errors and caching type:feature New capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Give each content type a preview URL that the preview token fills in

1 participant