feat: URL normalisation, strict URLs and the one-hop redirect decision

- Normalize: https, www or bare host, double slashes and index files,
  trailing slash add/remove, optional ASCII lowercasing (defaults change
  nothing but scheme, slashes and index files)
- strict URLs through signpost MatchPath: a path that names a record
  without being its address 301s to the record's address
- resolve runs normalisation, redirects, strict URLs, then SEO; the
  location is the final address, so a visitor makes one hop
- P1 warns when a record's canonical URL redirects
This commit is contained in:
2026-10-27 11:41:45 +01:00
parent 327925c43e
commit 4cb0fbfebb
6 changed files with 539 additions and 55 deletions

View File

@@ -13,11 +13,14 @@ import (
var log = diary.For("seo")
// Resolve serves GET /_seo/api/v1/resolve?url=<path or absolute URL>: the
// effective SEO of a public URL for the frontend's <head>. It answers 200
// with the resolve answer (action ok or not_found, the status the page
// should answer with) and 422 when url is missing or not a path or an
// http(s) URL. Absolute URLs come from the configured site URL, never from
// the request's Host.
// redirect decision and the effective SEO of a public URL for the
// frontend (D-20a, D-103). It answers 200 with the resolve answer: action
// redirect with status (301 or 302) and an absolute location, reached in
// one hop through normalisation, redirects and strict URLs; gone with 410;
// ok with the page's SEO; or not_found with 404, the status the page
// should answer with. It answers 422 when url is missing or not a path or
// an http(s) URL. Absolute URLs come from the configured site URL, never
// from the request's Host. The frontend enforces the decision (Phase 33).
func Resolve(service func() *classes.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
svc := service()