- 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
156 lines
3.9 KiB
Go
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)
|
|
}
|