Changelog
Source:NEWS.md
sitemapr 0.0.0.9000
First development release. sitemapr is a deterministic toolkit for reading and validating XML, text, and index sitemaps against the Sitemap Protocol 0.9 and related W3C and RFC standards.
Reading
-
read_sitemap()reads a sitemap from a URL or a local file (.xml,.txt,.gz,.tar.gz) into one tidy tibble row per URL, withlastmod,changefreq,priority, and list-columns for the image, video, news, and hreflang-alternate extensions. - XML
urlset/sitemapindexand one-URL-per-line text sitemaps are supported, with transparent gzip decompression and bounded, safe local.tar.gzextraction. - A top-level sitemap index is expanded recursively — cycle-safe, depth- and count-capped — so every reachable child sitemap’s rows carry provenance.
-
max_activeopts into bounded-concurrency index expansion inread_sitemap(),audit_sitemap(), andsitemap_tree(): up to that many child sitemaps are fetched at once while the per-host pace is respected. It is a scheduling optimization only — the rows, findings, tree, and budget-truncation point are byte-identical to the sequential default (ADR-008). - XML parsing is XXE-safe: external entities are never expanded.
Validation
-
validate_sitemap()returns a stable findings tibble — one row per issue with acode,severity, and thelayerthat produced it. The same source and mode always yield a row-for-row identical result. - Schema validation (Layer C) against bundled, clean-room XSD profiles for the core protocol and the image, video, news, pagemap, and xhtml-hreflang extensions. Wrapper XSDs for arbitrary namespace combinations are synthesized and cached on demand.
- Protocol validation (Layer D):
<loc>URL rules with IRI identity,<loc>equivalence and RFC-3986/3987 encoding conformance, count and field-value rules, hreflang token policy, extension field rules, per-line text-sitemap rules, and unsupported-input/encoding diagnostics. - Whole-sitemap hreflang cluster findings surface across the corpus: a missing self-referencing alternate, a non-reciprocal return link, and inconsistent language annotations for the same target URL.
-
mode = "non-strict"downgrades schema violations to warnings and drops strict-only findings. - RSS/Atom feeds are detected and reported as an unsupported-feed finding rather than misparsed.
Page and robots checking (Layer E)
-
validate_sitemap()andvalidate_sitemaps()gainedcheck_robots, an opt-in check of whether the advertised URLs are crawlable under the target host’srobots.txt.robots_user_agentselects the group to match against ("*", the catch-all, by default; or a token such as"Googlebot"). Threerobots-layer codes ship with it:ROBOTS_DISALLOWED,ROBOTS_SITEMAP_DISALLOWED, andROBOTS_INDETERMINATEfor the case where the fetch could not establish an answer either way. -
validate_sitemap_robots()andvalidate_sitemaps_robots()run that check under an explicit robots context instead of a bare user-agent string.robots_context()carries the three independent robots axes — the matcher product token, the status-policy ruleset, and the matcher backend — androbots_context_preset()selects one engine’s set of all three at once (robots_context_presets()enumerates them). The context’s expanded values are retained on the object and surfaced again in arobots_contextlist-column of the result, so a caller can read back exactly which engine decided each finding. The robots axes are independent ofsitemap_ruleset(ADR-009 §1): selecting a Bing sitemap ruleset does not select Bing robots semantics, and vice versa.validate_sitemap(robots_user_agent =)is unchanged and remains the string shorthand for the Google defaults. - Both engine-aware axes can be selected in a single call:
validate_sitemap_ruleset(x, sitemap_ruleset, robots_context =)validates under one engine’s sitemap rules while deciding robots under another’s, and runs the pipeline once instead of the two passes a caller previously needed. Independence is unchanged — neither axis derives the other, so a"bing"sitemap ruleset paired with a Yandex robots context is honoured on both sides — and each axis still governs its own columns, so a baseline call carrying a robots context returns exactly whatvalidate_sitemap_robots()returns.validate_sitemap_robots()remains the shorthand for the robots axis alone. Supplying bothrobots_contextand a non-defaultrobots_user_agentis an error rather than a silent precedence rule: they select the same axis. - Two limits are worth knowing, both properties of the installed
robotstxtrrather than of sitemapr: a matcher backend it reports ascapability_unavailabledecides nothing, so every URL comes backROBOTS_INDETERMINATErather than a guessed allow; and a backend bounded to its own vendor profiles accepts only its own tokens without publishing the set, which is why the presets carry a known-good token per engine. -
inspect_pagesopts into per-URL page inspection: a deduplicated, deterministically sampled, budgeted set of the advertised pages is fetched and each is checked for its transport outcome, its canonical, itsnoindexsignals, and its hreflang reciprocity. Elevenpage-layer codes ship with it —PAGE_FETCH_FAILED,PAGE_SSRF_BLOCKED,PAGE_STATUS_ERROR,PAGE_STATUS_REDIRECT,PAGE_REDIRECT_CHAIN,PAGE_CANONICAL_MISMATCH,PAGE_CANONICAL_MISSING,PAGE_CANONICAL_RELATIVE,PAGE_META_ROBOTS_NOINDEX,PAGE_XROBOTSTAG_NOINDEX, andPAGE_HREFLANG_MISMATCH. -
page_modechooses"sample"(the default, inspectingpage_samplededuplicated URLs picked by a stable hash order so re-runs pick the same set) or"full".page_budgetcaps the run’s pages, requests, aggregate bytes, per-page body size, and wall time;page_user_agentsets the UA the inspector sends. Page inspection fetches sequentially andmax_activedoes not apply to it. That is deliberate, not an unfinished half of the concurrency work: its byte and time caps cannot be reserved before dispatch, so a concurrent run would either sample a different set of pages — changing the findings — or issue requests it then discards, against the very site the budgets exist to be polite to. Recorded as an amendment to ADR-008. -
Network expansion is never implicit. Both checks default to
FALSE, and with them off the result is byte-identical to a call without them — the pinned ten-column findings surface and no extra attributes. - A run with
inspect_pages = TRUEcarries apage_coverageattribute (attr(x, "page_coverage")): a versioned, batch-wide report of what was actually covered (eligible,deduplicated,selected,attempted,completed,partial, and which caps bit), so a sampled or budget-capped run can never be misread as a clean bill of health for the whole corpus.
Discovery
-
sitemap_tree()discovers a site’s sitemaps from a root URL, returning a discovery tree (one row per candidate, markedacceptedorrejected). - Candidates come from robots.txt
Sitemap:directives (ADR-006), explicit seed entry points, and an ordered catalog of generic and CMS-oriented guessed paths; results are deduped and capped.sitemap_tree_from_bytes()classifies an already-fetched document. -
probe_url()inspects a single URL without resolving it — it reports what the URL is (a sitemap, an index, a feed,robots.txt, an HTML page, or an error state) and never expands an index or fetches a child; for an index it counts the direct<sitemap>children by parsing locally. It is the diagnostic counterpart toread_sitemap(): probe to diagnose, read to resolve. A fetch or parse failure is reported in the returnedsitemapr_proberecord rather than raised, so only invalid input errors.
Resource bounds
- The traversal-wide aggregate budgets in
index_limits()are now finite by default:max_total_sitemapsis 50 000 (wasInf) andmax_total_urlsis 25 000 000 (wasInf). Onlymax_depthand the per-indexmax_childrenwere bounded before, so a pathological index graph could drive an unbounded traversal in an embedding caller. Both ceilings sit above what a traversal of protocol-legal sitemaps reaches in practice, and reaching one stops the traversal and returns the accumulated partial result with anINDEX_TOTAL_SITEMAPS_EXCEEDED/INDEX_TOTAL_URLS_EXCEEDEDfinding. PassInfexplicitly, or setoptions(sitemapr.max_total_sitemaps = Inf), to keep the previous unbounded behavior. - Each finding code now contributes at most 100 individual rows to an assembled report; the remainder are accounted for in a single report-scoped
REPORT_TRUNCATEDrow naming each capped code and its omitted count. A blanketDisallow: /over a 50 000-URL sitemap previously produced 50 000 near-identicalROBOTS_DISALLOWEDrows. Configure withoptions(sitemapr.max_findings_per_code = );Infopts out. - Robots findings are built in one vectorized pass instead of one tibble per URL, which dominated the cost of a high-cardinality robots result.
Per-engine rulesets
-
validate_sitemap_ruleset()andvalidate_sitemaps_ruleset()validate a sitemap under a named search engine’s rules instead of the sitemaps.org baseline, via asitemap_rulesetargument.sitemap_rulesets()enumerates the selectable values ("sitemaps.org","google","bing","yandex"). Selecting an engine is always explicit — nothing falls through to an overlay, and the default stays"sitemaps.org"(ADR-009). -
validate_sitemap_ruleset(context =)now rejects anything that is not aruleset_context(), matching the checkvalidate_sitemap_robots()has always applied to its own context. Previously arobots_context()passed there was accepted in silence and then discarded, so the call appeared to succeed while validating under nothing the caller had asked for. With both contexts now reachable from one signature, that confusion had to be closed. - The result gains four additive columns under an engine ruleset:
ruleset,ruleset_revision,context, andprovenance. A baseline call returns exactly the pinned ten-column schema v1, byte-for-byte unchanged, so existing callers see nothing new. -
provenancerecords how each rule’s authority was established and, crucially, whether the finding may be a hard verdict:documented,inherited_protocolandapplication_choiceare executable, whileinferred,documentation_gap,documentation_conflictandadvisoryare diagnostic and never produce an engine-specific validity failure. One fact, one tag. -
ruleset_context()andruleset_context_for_child()build the independent context axes a finding is evaluated under (submission_channel,discovery_provenance,property_scope, and structuredauthority_evidence). Context is per source: a sitemap-index child inherits nothing implicitly. -
gsc_submission()androbots_cross_submission()are construction-time presets overruleset_context()for the two common cases, each bundling the axes its channel implies: a verified Search Console submission (submission_channel = "search_console_api"plus the verified property), and a sitemap both discovered through and trusted via a target host’srobots.txt(which keeps the discovery and authority senses distinct and sets noproperty_scope, that trust being blanket rather than property-bound). Every axis stays independently overridable viaruleset_context(). -
Breaking: the
subject_refgrammar changed. A ref now keeps the document’s own scheme (https://example.com/s.xml, notsitemap://example.com/s.xml), every URL or path inside a fragment is percent-encoded, and#index-childcarries a one-based ordinal alongside the child URL (#index-child:3:https%3A%2F%2F…). The old form collapsedhttpandhttpsdocuments onto one ref, could not be parsed back apart when a child URL contained a#, and could not tell two duplicate children apart. Refs are composed, never parsed, by this package; a consumer holding persisted reports in the old form has to migrate them, and the two forms are distinguishable because only the old one beginssitemap://. -
ENCODING_NOT_UTF8(error) reports a sitemap that is not the UTF-8 the protocol requires, andENCODING_BOM_DETECTED(info) reports a leading byte-order mark of any kind. The UTF-8 check reads the highest-priority encoding signal — a non-UTF-8 BOM, then a non-UTF-8 XML-declaration label, then the HTTP charset, then bytes that do not decode — rather than testing the bytes alone, which cannot see a UTF-16 document at all. A UTF-16 sitemap is read correctly and now also reported as non-conformant; reading a document is not the same as approving it. -
ruleset_revision()returns a ruleset’s published revision string, so a cross-repo consumer can pin against a known version of the rules. -
sitemap_contract()publishes the rest of what ADR-009 §7 requires: the findings-contract generation this build speaks, the dated revision offindings-registry.csv, and the supported sibling-version ranges forsitemap-validatorandrobotstxtr. Thesibling_versionsmap has the same shape the two sibling repos already publish, so all three are read the same way. Editing the registry without advancing its published revision fails the verify gate, so the revision cannot go stale. - Three engine-specific finding codes ship with the surface, all Yandex and all emitted only under that overlay:
PROTOCOL_URL_DECODED_TOO_LONG(the decoded whole-URL length limit, distinct from the 2 048-character rawPROTOCOL_URL_TOO_LONG),PROTOCOL_TAG_DATA_LIMIT_EXCEEDED(the per-tag byte guard, distinct from the whole-filePROTOCOL_SIZE_EXCEEDED), andENGINE_UNSUPPORTED_SITEMAP_FORMAT(a format sitemapr parses but the selected engine does not accept). Every other code applies under every ruleset by inheritance.
Reporting
-
report_sitemap()now renders four per-finding columns it previously computed and dropped: the producer’sremediation_hint(as a “Fix” line — the robots-by-noindextrap synthesis exists to produce these), thecontextpayload (as a collapsible block), and theruleset/ruleset_revisionandprovenanceof a finding produced under an engine overlay (as badges). Executable and diagnostic provenance are visually distinct, so adocumentation_gaporadvisoryfinding cannot read as a hard verdict (ADR-009). Baseline runs carry none of the additive columns and render exactly as before. - A new Checks section enumerates the checks the run performed with their outcome — passed, reported an issue, or not exercised by this run. A clean report previously said only “No issues found.”, which cannot distinguish “checked and found nothing” from “never checked at all”. The enumeration is driven by the findings registry rather than a hand-kept list, so it cannot drift from the shipped codes, and two rules keep it from overstating: only
activecodes are eligible, so a code this port does not implement can never be reported as passed; and a layer counts as run only on positive evidence of its execution. Anything unproven is reported as not exercised — the table deliberately understates rather than advertise a check that may not have run. - A new Recommendations section makes the report prescriptive rather than purely diagnostic: absent
lastmod, a majority-stalelastmodcorpus,priorityandchangefreqthe major engines ignore, and the 50 000-URL / 50 MB / 50 000-child bounds as a document approaches them. Every input was already computed for another section, so nothing re-parses or re-fetches. Each recommendation carries exactly one provenance tag for the fact it rests on, and where sitemapr chose the trigger point rather than citing a source, the rendered prose says so instead of presenting a product decision as documented authority. - The findings tibble may carry a
layers_runattribute (attr(x, "layers_run")) — a run manifest naming layers a call exercised whose execution the rows cannot otherwise evidence, which is what lets the Checks section tell “ran and found nothing” from “never ran”. It records"robots"forcheck_robots = TRUE, since a run where every URL is allowed emits no findings at all, and"schema"when XSD validation ran on the parsed document — so a gzip-compressed sitemap no longer reports schema validation as unexercised when it did run. It is advisory and not part of the row contract, and is present only when there was something to record.
Network safety
- SSRF guard blocks requests to private, loopback, link-local, and cloud-metadata addresses, including decoding of NAT64, IPv4-translated, and IPv4-compatible IPv6 embeddings.
- The guard also decodes the three IPv6 transition mechanisms — 6to4 (
2002::/16), Teredo (2001::/32, whose embedded IPv4 is XOR-obfuscated), and ISATAP (a*:5efemarker under any prefix) — and classifies the IPv4 address each one wraps. All three pack that address outside the low 32 bits, so the previous decoders missed them and, for example,2002:a9fe:a9fe::(6to4-wrapped169.254.169.254) was allowed. A wrapped public address still passes: these prefixes are globally reachable, so only what they carry decides the outcome. - An IPv6 literal that cannot be expanded to exactly 8 hextets (
fe80:::1,::12345) is now refused with the reasonmalformed-addressinstead of reaching the default allow. Such literals never survived URL parsing, so no reachable fetch changes; the guard simply no longer depends on the parser rejecting them first. - The guard classifies the IPv6 unspecified and loopback addresses on the expanded address rather than the literal string, so every spelling of those 128 bits is treated alike:
0::1,::0:1,0:0:0:0:0:0:0:1and::0.0.0.1are all recognised as loopback, and the matching forms of::as unspecified. Previously only the exact literals::1and::matched, and every other spelling was classified as neither special nor embedded IPv4 and reached the default allow. Fetches were not affected, becauserurlcanonicalises such literals before the guard sees them; the guard is now correct on its own rather than relying on that (SITE-vovtwvuh). - The guard’s IPv6 link-local and AWS cloud-metadata rules now match on the expanded address too, completing the change above. Because a hextet may be written with leading zeros, matching the literal string mis-decided both rules:
fd00:0ec2::254was allowed through while the identicalfd00:ec2::254was blocked, and addresses such asfe8::were reported as link-local despite lying far outsidefe80::/10. Both are now decided by value —fe80::/10andfd00:ec2::/32— so every spelling agrees, matching the ADR-003 §1 matrix (SITE-mhfmtdxa).
Request customization
-
request_policy()configures the HTTP requests sitemapr issues on every hop and is accepted by every reading, validation, and discovery entry point via thepolicyargument. Configure custom headers, authentication (request_auth_basic()/request_auth_bearer()), a proxy (request_proxy()), TLS options, bounded retry with backoff (request_retry()), and host-aware throttling (request_throttle()). - Safety controls are never overridable: the per-hop SSRF guard, redirect control, and non-2xx error policy are re-asserted after all caller customization, so a policy can add headers or auth but cannot re-enable redirect following or defeat the SSRF re-check.
Requirements
- The declared R requirement is
R (>= 4.1), corrected fromR (>= 4.0.0). The old floor was never installable:httr2, a hard dependency, itself declaresR (>= 4.1), so resolution failed on R 4.0 before any sitemapr code ran. sitemapr’s own sources use no post-4.0 syntax — the floor comes entirely from the dependency chain, and no supported configuration is lost. -
rurlnow resolves from CRAN. The declared floor isrurl (>= 3.0.1), corrected fromrurl (>= 2.1.0)— a version that exists on no repository and no tag — and theRemotes:entry pointing at GitLab has been dropped (SITE-ieajhiee).
Documentation
- The documentation URL is now
https://sitemapr-eca867.gitlab.io/, corrected fromhttps://bart-turczynski.gitlab.io/sitemapr/— a host that never served this project, because its GitLab Pages site uses a unique domain rather than the namespace path.DESCRIPTION,inst/CITATION,CITATION.cff,.zenodo.json,codemeta.json,.bestpractices.json, the README, the introduction vignette and_pkgdown.ymlall name the same address; the repository URL stays first inDESCRIPTIONbecausedefault_user_agent()reads that entry as the crawler contact URL (SITE-rysgulhf). - A
pagesjob in.gitlab-ci.ymlbuilds the pkgdown site on the default branch and deploys it, so the declared documentation URL is published rather than merely advertised (SITE-rysgulhf). - The
BugReports:field inDESCRIPTIONnow readshttps://gitlab.com/bart-turczynski/sitemapr/-/issues, corrected from the/-/work_itemsform.tools:::.check_package_CRAN_incoming()accepts a gitlab.com bug tracker only when its path ends in/-/issues, and the/-/work_itemsform is what got a sibling package archived at the CRAN incoming pretest. GitLab answers/-/issueswith 404 to a signed-out, non-browser client and redirects a browser to the work-items view, so the address a reader clicks still resolves;cran-comments.mdexplains the resulting URL note. The check reads no file butDESCRIPTION, so the two human-facing metadata files that name the tracker —codemeta.jsonand.bestpractices.json— deliberately keep the/-/work_itemsaddress, which returns 200 (SITE-wpxlhijx).
Internal
-
.gitlab-ci.ymlgained acheckjob that runsRscript tools/verify.R— the exact chain the pre-push hook runs — so a green pipeline now verifies the package, not only the citation metadata and the docs site (SITE-dzikrmnh). - The OSS Index dependency audit in
tests/testthat/test-security.Rscopes to hard dependencies (Depends+Imports) instead of theSuggeststree, and allow-lists by ID the twocurladvisories the narrower scope still reports.curlis a genuine hard dependency here, reached throughhttr2, and both CVE-2026-18924 and CVE-2026-3783 name libcurl ranges covering CRAN’s currentcurl8.0.0 — there is no version to upgrade to, so the gate had been blocking every push since 2026-09-06. Each allow-list row carries a written reason and a review date, andhelper-security.Renforces three rules: an advisory that is reported and not allow-listed fails, an allow-listed advisory that is no longer reported fails, and drift past a review date or a package version warns (SITE-epezowxs).