///|
pub(all) enum SitemapScheme {
SitemapHttp
SitemapHttps
SitemapRelative
SitemapUnknown
} derive(Eq, Debug)
///|
pub(all) enum SitemapFormat {
SitemapXml
SitemapXmlGzip
SitemapIndex
SitemapText
SitemapUnknownFormat
} derive(Eq, Debug)
///|
pub(all) enum SitemapIssueKind {
SitemapMissingHost
SitemapInsecureHttp
SitemapUnsupportedScheme
SitemapMissingPath
SitemapUnexpectedExtension
SitemapDuplicate
SitemapContainsQuery
SitemapContainsFragment
} derive(Eq, Debug)
///|
pub(all) struct SitemapIssue {
kind : SitemapIssueKind
sitemap : String
message : String
} derive(Eq, Debug)
///|
pub(all) struct SitemapEntry {
raw : String
scheme : SitemapScheme
host : String
path : String
normalized_path : String
format : SitemapFormat
secure : Bool
absolute : Bool
query : String?
fragment : String?
depth : Int
issue_count : Int
issues : Array[SitemapIssue]
} derive(Eq, Debug)
///|
pub(all) struct SitemapSummary {
entries : Array[SitemapEntry]
total : Int
https_count : Int
http_count : Int
relative_count : Int
xml_count : Int
gzip_count : Int
index_count : Int
issue_count : Int
duplicate_count : Int
unique_hosts : Array[String]
notes : Array[String]
} derive(Eq, Debug)
///|
pub fn SitemapScheme::label(self : SitemapScheme) -> String {
match self {
SitemapHttp => "http"
SitemapHttps => "https"
SitemapRelative => "relative"
SitemapUnknown => "unknown"
}
}
///|
pub fn SitemapFormat::label(self : SitemapFormat) -> String {
match self {
SitemapXml => "xml"
SitemapXmlGzip => "xml-gzip"
SitemapIndex => "sitemap-index"
SitemapText => "text"
SitemapUnknownFormat => "unknown"
}
}
///|
pub fn SitemapIssueKind::label(self : SitemapIssueKind) -> String {
match self {
SitemapMissingHost => "missing-host"
SitemapInsecureHttp => "insecure-http"
SitemapUnsupportedScheme => "unsupported-scheme"
SitemapMissingPath => "missing-path"
SitemapUnexpectedExtension => "unexpected-extension"
SitemapDuplicate => "duplicate"
SitemapContainsQuery => "contains-query"
SitemapContainsFragment => "contains-fragment"
}
}
///|
pub fn parse_sitemap_url(raw : StringView) -> SitemapEntry {
let text = raw.trim().to_owned()
let ref_parts = split_sitemap_reference(text)
let path_snapshot = normalize_target(ref_parts.path)
let format = classify_sitemap_format(path_snapshot.normalized)
let issues = validate_sitemap_parts(text, ref_parts, path_snapshot, format)
{
raw: text,
scheme: ref_parts.scheme,
host: ref_parts.host,
path: ref_parts.path,
normalized_path: path_snapshot.normalized,
format,
secure: ref_parts.scheme == SitemapHttps,
absolute: ref_parts.scheme == SitemapHttp ||
ref_parts.scheme == SitemapHttps,
query: ref_parts.query,
fragment: ref_parts.fragment,
depth: path_depth(path_snapshot.normalized),
issue_count: issues.length(),
issues,
}
}
///|
pub fn parse_sitemaps(raw_sitemaps : Array[String]) -> Array[SitemapEntry] {
let entries : Array[SitemapEntry] = []
for sitemap in raw_sitemaps {
entries.push(parse_sitemap_url(sitemap))
}
mark_duplicate_sitemaps(entries)
}
///|
pub fn RobotsPolicy::sitemap_entries(
self : RobotsPolicy,
) -> Array[SitemapEntry] {
parse_sitemaps(self.sitemaps)
}
///|
pub fn RobotsPolicy::sitemap_summary(self : RobotsPolicy) -> SitemapSummary {
summarize_sitemaps(self.sitemaps)
}
///|
pub fn summarize_sitemaps(raw_sitemaps : Array[String]) -> SitemapSummary {
let entries = parse_sitemaps(raw_sitemaps)
let hosts : Array[String] = []
let notes : Array[String] = []
let mut https_count = 0
let mut http_count = 0
let mut relative_count = 0
let mut xml_count = 0
let mut gzip_count = 0
let mut index_count = 0
let mut issue_count = 0
let mut duplicate_count = 0
for entry in entries {
match entry.scheme {
SitemapHttps => https_count = https_count + 1
SitemapHttp => http_count = http_count + 1
SitemapRelative => relative_count = relative_count + 1
SitemapUnknown => ()
}
match entry.format {
SitemapXml => xml_count = xml_count + 1
SitemapXmlGzip => gzip_count = gzip_count + 1
SitemapIndex => index_count = index_count + 1
_ => ()
}
issue_count = issue_count + entry.issue_count
if entry.has_issue(SitemapDuplicate) {
duplicate_count = duplicate_count + 1
}
if !entry.host.is_empty() {
add_sitemap_host(hosts, entry.host)
}
}
if entries.is_empty() {
notes.push("no sitemap directive was found")
}
if http_count > 0 {
notes.push("some sitemap URLs use plain HTTP")
}
if relative_count > 0 {
notes.push("relative sitemap paths rely on the site host")
}
if duplicate_count > 0 {
notes.push("duplicate sitemap URLs were found")
}
if issue_count == 0 && !entries.is_empty() {
notes.push("all sitemap directives passed local structural checks")
}
{
entries,
total: entries.length(),
https_count,
http_count,
relative_count,
xml_count,
gzip_count,
index_count,
issue_count,
duplicate_count,
unique_hosts: hosts,
notes,
}
}
///|
pub fn SitemapEntry::has_issue(
self : SitemapEntry,
kind : SitemapIssueKind,
) -> Bool {
self.issues.any(issue => issue.kind == kind)
}
///|
pub fn SitemapEntry::is_xml_like(self : SitemapEntry) -> Bool {
self.format == SitemapXml ||
self.format == SitemapXmlGzip ||
self.format == SitemapIndex
}
///|
pub fn SitemapEntry::is_cross_host(
self : SitemapEntry,
host : StringView,
) -> Bool {
!self.host.is_empty() && self.host != host.to_owned()
}
///|
pub fn SitemapEntry::to_line(self : SitemapEntry) -> String {
self.scheme.label() +
" " +
self.host +
self.normalized_path +
" format=" +
self.format.label() +
" issues=" +
self.issue_count.to_string()
}
///|
pub fn SitemapEntry::to_markdown_row(self : SitemapEntry) -> String {
"| " +
self.raw.replace_all(old="|", new="\\|") +
" | " +
self.scheme.label() +
" | " +
self.host.replace_all(old="|", new="\\|") +
" | " +
self.normalized_path.replace_all(old="|", new="\\|") +
" | " +
self.format.label() +
" | " +
self.issue_count.to_string() +
" |"
}
///|
pub fn SitemapEntry::issues_text(self : SitemapEntry) -> String {
if self.issues.is_empty() {
"none"
} else {
let labels : Array[String] = []
for issue in self.issues {
labels.push(issue.kind.label())
}
labels.join(",")
}
}
///|
pub fn SitemapIssue::to_line(self : SitemapIssue) -> String {
self.kind.label() + ": " + self.sitemap + " - " + self.message
}
///|
pub fn SitemapSummary::to_line(self : SitemapSummary) -> String {
"sitemaps=" +
self.total.to_string() +
" https=" +
self.https_count.to_string() +
" http=" +
self.http_count.to_string() +
" issues=" +
self.issue_count.to_string()
}
///|
pub fn SitemapSummary::to_markdown(self : SitemapSummary) -> String {
let lines : Array[String] = []
lines.push("# RoboPolicy Sitemap Summary")
lines.push("")
lines.push("- Total: " + self.total.to_string())
lines.push("- HTTPS: " + self.https_count.to_string())
lines.push("- HTTP: " + self.http_count.to_string())
lines.push("- Relative: " + self.relative_count.to_string())
lines.push("- XML: " + self.xml_count.to_string())
lines.push("- XML gzip: " + self.gzip_count.to_string())
lines.push("- Sitemap index: " + self.index_count.to_string())
lines.push("- Issues: " + self.issue_count.to_string())
lines.push("- Duplicates: " + self.duplicate_count.to_string())
lines.push("- Hosts: " + join_or_none(self.unique_hosts))
lines.push("")
lines.push("| URL | Scheme | Host | Path | Format | Issues |")
lines.push("| --- | --- | --- | --- | --- | --- |")
for entry in self.entries {
lines.push(entry.to_markdown_row())
}
lines.push("")
if self.notes.is_empty() {
lines.push("No sitemap notes.")
} else {
lines.push("Notes:")
for note in self.notes {
lines.push("- " + note)
}
}
lines.join("\n")
}
///|
pub fn SitemapSummary::issue_lines(self : SitemapSummary) -> Array[String] {
let lines : Array[String] = []
for entry in self.entries {
for issue in entry.issues {
lines.push(issue.to_line())
}
}
lines
}
///|
pub fn SitemapSummary::has_only_secure_absolute_urls(
self : SitemapSummary,
) -> Bool {
self.total > 0 && self.total == self.https_count && self.issue_count == 0
}
///|
pub fn SitemapSummary::has_cross_host_entries(
self : SitemapSummary,
expected_host : StringView,
) -> Bool {
self.entries.any(entry => entry.is_cross_host(expected_host))
}
///|
pub fn sitemap_host_report(entries : Array[SitemapEntry]) -> String {
let hosts : Array[String] = []
for entry in entries {
if !entry.host.is_empty() {
add_sitemap_host(hosts, entry.host)
}
}
if hosts.is_empty() {
"no-hosts"
} else {
hosts.join(",")
}
}
///|
pub fn sitemap_issue_report(entries : Array[SitemapEntry]) -> String {
let lines : Array[String] = []
for entry in entries {
if entry.issues.is_empty() {
lines.push(entry.raw + ": ok")
} else {
lines.push(entry.raw + ": " + entry.issues_text())
}
}
lines.join("\n")
}
///|
struct SitemapReferenceParts {
scheme : SitemapScheme
host : String
path : String
query : String?
fragment : String?
} derive(Eq, Debug)
///|
fn split_sitemap_reference(raw : String) -> SitemapReferenceParts {
if raw.has_prefix("https://") {
split_absolute_sitemap(raw, SitemapHttps, "https://")
} else if raw.has_prefix("http://") {
split_absolute_sitemap(raw, SitemapHttp, "http://")
} else if raw.has_prefix("/") {
let parts = split_sitemap_query_fragment(raw)
{
scheme: SitemapRelative,
host: "",
path: parts.path,
query: parts.query,
fragment: parts.fragment,
}
} else {
let parts = split_sitemap_query_fragment(raw)
{
scheme: SitemapUnknown,
host: "",
path: parts.path,
query: parts.query,
fragment: parts.fragment,
}
}
}
///|
fn split_absolute_sitemap(
raw : String,
scheme : SitemapScheme,
prefix : String,
) -> SitemapReferenceParts {
let after_scheme = match raw.strip_prefix(prefix) {
Some(rest) => rest
None => raw
}
match after_scheme.split_once("/") {
Some((host, path)) => {
let parts = split_sitemap_query_fragment("/" + path.to_owned())
{
scheme,
host: host.to_owned(),
path: parts.path,
query: parts.query,
fragment: parts.fragment,
}
}
None =>
{
scheme,
host: after_scheme.to_owned(),
path: "/",
query: None,
fragment: None,
}
}
}
///|
fn split_sitemap_query_fragment(raw : String) -> QueryFragmentParts {
let mut path = raw
let mut query : String? = None
let mut fragment : String? = None
let mut had_query = false
let mut had_fragment = false
match path.split_once("#") {
Some((left, right)) => {
had_fragment = true
fragment = Some(right.to_owned())
path = left.to_owned()
}
None => ()
}
match path.split_once("?") {
Some((left, right)) => {
had_query = true
query = Some(right.to_owned())
path = left.to_owned()
}
None => ()
}
{ path, query, fragment, had_query, had_fragment }
}
///|
fn classify_sitemap_format(path : String) -> SitemapFormat {
let lower = path.to_lower()
if lower.has_suffix(".xml.gz") {
SitemapXmlGzip
} else if lower.contains("sitemap_index") || lower.contains("sitemap-index") {
SitemapIndex
} else if lower.has_suffix(".xml") {
SitemapXml
} else if lower.has_suffix(".txt") {
SitemapText
} else {
SitemapUnknownFormat
}
}
///|
fn validate_sitemap_parts(
raw : String,
parts : SitemapReferenceParts,
snapshot : PathSnapshot,
format : SitemapFormat,
) -> Array[SitemapIssue] {
let issues : Array[SitemapIssue] = []
if parts.scheme == SitemapUnknown {
issues.push(
sitemap_issue(
SitemapUnsupportedScheme,
raw,
"sitemap URL should be absolute HTTP(S) or root-relative",
),
)
}
if parts.scheme == SitemapHttp {
issues.push(
sitemap_issue(
SitemapInsecureHttp,
raw,
"HTTPS sitemap URLs are preferred",
),
)
}
if (parts.scheme == SitemapHttp || parts.scheme == SitemapHttps) &&
parts.host.is_empty() {
issues.push(
sitemap_issue(SitemapMissingHost, raw, "absolute sitemap URL has no host"),
)
}
if snapshot.normalized == "/" {
issues.push(
sitemap_issue(
SitemapMissingPath,
raw,
"sitemap URL should point to an explicit sitemap resource",
),
)
}
if format == SitemapUnknownFormat {
issues.push(
sitemap_issue(
SitemapUnexpectedExtension,
raw,
"sitemap extension is not recognized",
),
)
}
if parts.query is Some(_) {
issues.push(
sitemap_issue(
SitemapContainsQuery,
raw,
"query strings reduce sitemap cacheability",
),
)
}
if parts.fragment is Some(_) {
issues.push(
sitemap_issue(
SitemapContainsFragment,
raw,
"fragments are not useful for sitemap URLs",
),
)
}
issues
}
///|
fn mark_duplicate_sitemaps(
entries : Array[SitemapEntry],
) -> Array[SitemapEntry] {
let result : Array[SitemapEntry] = []
let seen : Array[String] = []
for entry in entries {
let key = sitemap_key(entry)
if seen.contains(key) {
result.push(
add_sitemap_issue(
entry,
SitemapDuplicate,
"same sitemap URL appears more than once",
),
)
} else {
seen.push(key)
result.push(entry)
}
}
result
}
///|
fn add_sitemap_issue(
entry : SitemapEntry,
kind : SitemapIssueKind,
message : String,
) -> SitemapEntry {
let issues = entry.issues
issues.push(sitemap_issue(kind, entry.raw, message))
{
raw: entry.raw,
scheme: entry.scheme,
host: entry.host,
path: entry.path,
normalized_path: entry.normalized_path,
format: entry.format,
secure: entry.secure,
absolute: entry.absolute,
query: entry.query,
fragment: entry.fragment,
depth: entry.depth,
issue_count: issues.length(),
issues,
}
}
///|
fn sitemap_key(entry : SitemapEntry) -> String {
entry.scheme.label() + "://" + entry.host.to_lower() + entry.normalized_path
}
///|
fn sitemap_issue(
kind : SitemapIssueKind,
sitemap : String,
message : String,
) -> SitemapIssue {
{ kind, sitemap, message }
}
///|
fn add_sitemap_host(hosts : Array[String], host : String) -> Unit {
let lowered = host.to_lower()
if !hosts.contains(lowered) {
hosts.push(lowered)
}
}
///|
fn join_or_none(items : Array[String]) -> String {
if items.is_empty() {
"none"
} else {
items.join(",")
}
}