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:
155
classes/normalize.go
Normal file
155
classes/normalize.go
Normal file
@@ -0,0 +1,155 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user