Files
sm-seo-plugin/classes/normalize.go
Jakub Zych 4cb0fbfebb 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
2026-10-27 11:41:45 +01:00

156 lines
3.9 KiB
Go

package classes
import (
"net/url"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
)
// NormalizeOptions are a site's address rules (D-20b, settings tab
// Addresses): Host is keep, www or bare; Slash is keep, add or remove;
// Lowercase lowercases the ASCII letters of the path. The defaults (keep,
// keep, off) change nothing but scheme, double slashes and index files
// (UI-SPEC Q-7).
type NormalizeOptions struct {
Host string
Slash string
Lowercase bool
}
// NormalizeOptionsOf reads the address rules of the settings.
func NormalizeOptionsOf(s models.Settings) NormalizeOptions {
return NormalizeOptions{Host: s.URLHost, Slash: s.URLSlash, Lowercase: s.URLLowercase}
}
// indexFiles are the directory index names collapsed into their directory.
var indexFiles = []string{"/index.html", "/index.htm", "/index.php"}
// Normalize returns u in the site's address form and whether anything
// changed (D-20b). It applies every rule at once, so any other form reaches
// the final one in one hop:
// - an absolute URL gets https, and its host gains or loses "www." per
// opts.Host (keep leaves it);
// - repeated slashes collapse and a trailing /index.html (.htm, .php)
// collapses into its directory;
// - with opts.Lowercase the ASCII letters of the decoded path are
// lowercased; other letters (Ł) and the percent-encoding are kept, so
// %C5%81 and %c5%81 stay the same address (RESEARCH Pitfall 13);
// - opts.Slash add appends a trailing slash to a path whose last segment
// has no file extension, remove drops it; the root / never changes.
//
// The query is kept as it is and a fragment is dropped. u is not modified.
func Normalize(u *url.URL, opts NormalizeOptions) (*url.URL, bool) {
if u == nil {
return nil, false
}
out := *u
out.Fragment, out.RawFragment = "", ""
changed := false
if out.Host != "" {
if out.Scheme != "https" {
out.Scheme, changed = "https", true
}
host := strings.ToLower(out.Host)
switch opts.Host {
case models.HostWWW:
if !strings.HasPrefix(host, "www.") {
host = "www." + host
}
case models.HostBare:
host = strings.TrimPrefix(host, "www.")
}
if host != strings.ToLower(out.Host) {
changed = true
}
out.Host = host
}
p := out.Path
if p == "" {
p = "/"
}
clean := collapseSlashes(p)
for _, index := range indexFiles {
if strings.HasSuffix(strings.ToLower(clean), index) {
clean = clean[:len(clean)-len(index)]
if clean == "" {
clean = "/"
}
break
}
}
if opts.Lowercase {
clean = lowerASCII(clean)
}
if clean != "/" {
switch opts.Slash {
case models.SlashAdd:
if !strings.HasSuffix(clean, "/") && !strings.Contains(lastSegment(clean), ".") {
clean += "/"
}
case models.SlashDrop:
clean = strings.TrimRight(clean, "/")
if clean == "" {
clean = "/"
}
}
}
if clean != p {
changed = true
}
out.Path, out.RawPath = clean, ""
return &out, changed
}
// collapseSlashes replaces every run of slashes with one.
func collapseSlashes(p string) string {
if !strings.Contains(p, "//") {
return p
}
var b strings.Builder
prev := false
for i := 0; i < len(p); i++ {
if p[i] == '/' {
if prev {
continue
}
prev = true
} else {
prev = false
}
b.WriteByte(p[i])
}
return b.String()
}
// lowerASCII lowercases the ASCII letters of s only.
func lowerASCII(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + ('a' - 'A')
}
}
return string(b)
}
// lastSegment is the part of p after its last slash.
func lastSegment(p string) string {
if i := strings.LastIndex(p, "/"); i >= 0 {
return p[i+1:]
}
return p
}
// sameSite reports whether host is the site URL's host or its www twin.
func sameSite(site, host string) bool {
su, err := url.Parse(site)
if err != nil || su.Host == "" || host == "" {
return false
}
bare := func(h string) string { return strings.TrimPrefix(strings.ToLower(h), "www.") }
return bare(su.Host) == bare(host)
}