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) }