indexmap 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/README.md +287 -35
  4. data/UPGRADE.md +361 -0
  5. data/docs/architecture.md +55 -0
  6. data/docs/deployment.md +223 -0
  7. data/docs/domain-terms.md +27 -0
  8. data/docs/dynamic-sitemaps.md +302 -0
  9. data/docs/search-engine-ping.md +368 -0
  10. data/docs/storage.md +94 -0
  11. data/lib/generators/indexmap/install_generator.rb +1 -1
  12. data/lib/generators/indexmap/templates/initializer.rb.tt +18 -18
  13. data/lib/indexmap/artifact.rb +9 -0
  14. data/lib/indexmap/configuration.rb +12 -1
  15. data/lib/indexmap/creator.rb +45 -42
  16. data/lib/indexmap/index_now_configuration.rb +3 -1
  17. data/lib/indexmap/output.rb +19 -5
  18. data/lib/indexmap/parser.rb +112 -52
  19. data/lib/indexmap/pinger/base.rb +6 -5
  20. data/lib/indexmap/pinger/google.rb +12 -8
  21. data/lib/indexmap/pinger/index_now.rb +97 -52
  22. data/lib/indexmap/publication.rb +89 -0
  23. data/lib/indexmap/robots.rb +67 -0
  24. data/lib/indexmap/robots_controller.rb +13 -0
  25. data/lib/indexmap/sitemaps_controller.rb +57 -0
  26. data/lib/indexmap/storage/active_storage.rb +132 -1
  27. data/lib/indexmap/storage/filesystem.rb +182 -11
  28. data/lib/indexmap/storage/memory.rb +1 -1
  29. data/lib/indexmap/task_runner.rb +21 -28
  30. data/lib/indexmap/validator/duplicates.rb +83 -0
  31. data/lib/indexmap/validator.rb +171 -59
  32. data/lib/indexmap/version.rb +1 -1
  33. data/lib/indexmap/writer.rb +89 -34
  34. data/lib/indexmap.rb +14 -1
  35. data/lib/tasks/indexmap_tasks.rake +21 -2
  36. data/test/indexmap/correctness_test.rb +153 -0
  37. data/test/indexmap/install_generator_test.rb +36 -4
  38. data/test/indexmap/parser_test.rb +77 -0
  39. data/test/indexmap/pinger/google_test.rb +22 -0
  40. data/test/indexmap/pinger/index_now_test.rb +168 -0
  41. data/test/indexmap/publication_test.rb +280 -0
  42. data/test/indexmap/robots_test.rb +73 -0
  43. data/test/indexmap/sitemaps_controller_test.rb +50 -0
  44. data/test/indexmap/storage_test.rb +27 -0
  45. data/test/indexmap/task_runner_test.rb +24 -0
  46. data/test/indexmap/validator_test.rb +14 -0
  47. data/test/indexmap/writer_test.rb +42 -0
  48. data/test/integration/queue_checks.rb +244 -0
  49. data/test/release_task_test.rb +153 -0
  50. metadata +22 -5
  51. data/lib/indexmap/storage/file.rb +0 -11
@@ -4,85 +4,202 @@ require "nokogiri"
4
4
  require "date"
5
5
  require "time"
6
6
  require "uri"
7
+ require "tmpdir"
8
+ require "stringio"
9
+ require "json"
7
10
 
8
11
  module Indexmap
9
12
  class Validator
10
- def initialize(configuration: Indexmap.configuration, filename: nil)
13
+ MAX_ENTRIES = 50_000
14
+ MAX_BYTES = 52_428_800
15
+
16
+ attr_reader :entries
17
+
18
+ def initialize(configuration: Indexmap.configuration, filename: nil, storage: nil)
11
19
  @configuration = configuration
20
+ @storage = storage
12
21
  @filename = filename
13
22
  end
14
23
 
15
- def validate!
24
+ def validate!(collect: true)
16
25
  sitemap_filename = filename || configuration.index_filename
17
26
  raise ValidationError, "Missing sitemap file: #{sitemap_filename}" unless storage.exist?(sitemap_filename)
18
27
 
19
- validate_sitemap_file!(sitemap_filename)
20
- entries = Parser.new(source: sitemap_filename, storage: storage, index_filename: configuration.index_filename).entries
21
- validate_presence!(entries)
22
- validate_duplicates!(entries)
23
- validate_parameterized_urls!(entries)
24
- validate_fragment_urls!(entries)
25
- validate_absolute_http_urls!(entries)
26
- validate_same_host_urls!(entries)
27
- validate_lastmods!(entries)
28
+ @entries = []
29
+ count = 0
30
+ Dir.mktmpdir("indexmap-validation") do |directory|
31
+ duplicates = Duplicates.new(directory: directory)
32
+ validate_sitemap_files!(sitemap_filename) do |entry|
33
+ validate_parameterized_urls!([entry])
34
+ validate_fragment_urls!([entry])
35
+ validate_absolute_http_urls!([entry])
36
+ validate_same_host_urls!([entry])
37
+ validate_lastmods!([entry])
38
+ yield entry if block_given?
39
+ duplicates.add(entry.loc)
40
+ @entries << entry if collect
41
+ count += 1
42
+ end
43
+ duplicates.validate!
44
+ end
45
+ raise ValidationError, "Sitemap has no URLs" if count == 0
46
+
28
47
  true
29
48
  end
30
49
 
50
+ def each_entry
51
+ return enum_for(:each_entry) unless block_given?
52
+
53
+ Dir.mktmpdir("indexmap-entries") do |directory|
54
+ path = File.join(directory, "entries.jsonl")
55
+ File.open(path, "wb") do |file|
56
+ validate!(collect: false) { |entry| file.puts(JSON.generate(entry.to_h)) }
57
+ end
58
+ File.foreach(path) do |line|
59
+ yield Parser::Entry.new(**JSON.parse(line, symbolize_names: true))
60
+ end
61
+ end
62
+ end
63
+
31
64
  private
32
65
 
33
66
  attr_reader :configuration, :filename
34
67
 
35
68
  def storage
36
- configuration.storage
69
+ @storage ||= configuration.storage
37
70
  end
38
71
 
39
- def validate_sitemap_file!(sitemap_filename)
40
- document = read_xml_document(sitemap_filename)
41
- root_name = document.root&.name
42
-
43
- case root_name
44
- when "urlset"
45
- validate_urlset_document!(document, sitemap_filename)
46
- when "sitemapindex"
47
- validate_sitemap_index_document!(document, sitemap_filename)
48
- else
49
- raise ValidationError, "Invalid sitemap root element in #{sitemap_filename}: #{root_name || "none"}"
72
+ def validate_sitemap_files!(sitemap_filename)
73
+ pending = [[sitemap_filename, false]]
74
+ active = Set.new
75
+ visited = Set.new
76
+ until pending.empty?
77
+ name, leaving = pending.pop
78
+ if leaving
79
+ active.delete(name)
80
+ next
81
+ end
82
+ raise ValidationError, "Sitemap cycle detected: #{name}" if active.include?(name)
83
+ next if visited.include?(name)
84
+
85
+ active.add(name)
86
+ visited.add(name)
87
+ pending << [name, true]
88
+ children = read_sitemap(name) { |entry| yield entry }
89
+ children.reverse_each { |child| pending << [child, false] }
50
90
  end
51
91
  end
52
92
 
53
- def read_xml_document(filename)
54
- document = Nokogiri::XML(storage.read(filename)) { |config| config.strict }
55
- document.remove_namespaces!
56
- document
93
+ def read_sitemap(filename)
94
+ children = []
95
+ root = nil
96
+ count = 0
97
+ open_sitemap(filename) do |io|
98
+ reader = Nokogiri::XML::Reader.from_io(io, nil, nil, Nokogiri::XML::ParseOptions::NONET)
99
+ reader.each do |node|
100
+ if node.node_type == Nokogiri::XML::Reader::TYPE_DOCUMENT_TYPE
101
+ raise ValidationError, "Sitemap DTDs are not allowed: #{filename}"
102
+ end
103
+ next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT
104
+
105
+ if node.depth == 0
106
+ root = node.local_name
107
+ unless %w[urlset sitemapindex].include?(root)
108
+ raise ValidationError, "Invalid sitemap root element in #{filename}: #{root}"
109
+ end
110
+ unless node.namespace_uri == "http://www.sitemaps.org/schemas/sitemap/0.9"
111
+ raise ValidationError, "Invalid sitemap namespace: #{filename}"
112
+ end
113
+ elsif node.depth == 1 && node.local_name == ((root == "urlset") ? "url" : "sitemap")
114
+ count += 1
115
+ raise ValidationError, "Sitemap exceeds 50,000 entries: #{filename}" if count > MAX_ENTRIES
116
+
117
+ fields = read_fields(reader)
118
+ location = location_for(fields, filename: filename)
119
+ if root == "urlset"
120
+ lastmod = fields.fetch("lastmod").to_s.strip
121
+ yield Parser::Entry.new(loc: location, lastmod: lastmod.empty? ? nil : lastmod, source_sitemap: filename)
122
+ else
123
+ child = local_child_filename(location, parent_filename: filename)
124
+ children << child
125
+ end
126
+ end
127
+ end
128
+ end
129
+ if count == 0
130
+ message = (root == "sitemapindex") ? "Sitemap index has no child sitemap URLs" : "Sitemap has no URLs"
131
+ raise ValidationError, "#{message}: #{filename}"
132
+ end
133
+ children.each { |child| raise ValidationError, "Missing child sitemap file: #{child}" unless storage.exist?(child) }
134
+ if children.uniq.size != children.size
135
+ raise ValidationError, "Duplicate child sitemap URLs detected: #{filename}"
136
+ end
137
+ children
57
138
  rescue Nokogiri::XML::SyntaxError => error
58
139
  raise ValidationError, "Invalid sitemap XML in #{filename}: #{error.message.lines.first.strip}"
59
140
  end
60
141
 
61
- def validate_urlset_document!(document, sitemap_filename)
62
- return if document.xpath("/urlset/url/loc").any?
142
+ def open_sitemap(filename, &block)
143
+ if storage.respond_to?(:open) && storage.respond_to?(:metadata)
144
+ if storage.metadata(filename).fetch(:bytes) > MAX_BYTES
145
+ raise ValidationError, "Sitemap exceeds 52,428,800 bytes: #{filename}"
146
+ end
147
+ storage.open(filename, &block)
148
+ else
149
+ body = storage.read(filename)
150
+ raise ValidationError, "Sitemap exceeds 52,428,800 bytes: #{filename}" if body.bytesize > MAX_BYTES
63
151
 
64
- raise ValidationError, "Sitemap has no URLs: #{sitemap_filename}"
152
+ yield StringIO.new(body)
153
+ end
65
154
  end
66
155
 
67
- def validate_sitemap_index_document!(document, sitemap_filename)
68
- child_locations = document.xpath("/sitemapindex/sitemap/loc").map { |node| node.text.to_s.strip }.reject(&:empty?)
69
- raise ValidationError, "Sitemap index has no child sitemap URLs: #{sitemap_filename}" if child_locations.empty?
70
-
71
- duplicate_children = child_locations.group_by(&:itself).select { |_loc, values| values.size > 1 }.keys
72
- unless duplicate_children.empty?
73
- raise ValidationError, "Duplicate child sitemap URLs detected: #{duplicate_children.first(5).join(", ")}"
156
+ def read_fields(reader)
157
+ fields = {"loc" => nil, "lastmod" => nil}
158
+ return fields if reader.self_closing?
159
+
160
+ text = nil
161
+ while reader.read
162
+ type = reader.node_type
163
+ if type == Nokogiri::XML::Reader::TYPE_END_ELEMENT
164
+ break if reader.depth == 1
165
+
166
+ text = nil if reader.depth == 2
167
+ elsif type == Nokogiri::XML::Reader::TYPE_ELEMENT && reader.depth == 2
168
+ name = reader.local_name
169
+ if fields.key?(name)
170
+ raise ValidationError, "Repeated sitemap field: #{name}" if fields[name]
171
+
172
+ fields[name] = +""
173
+ text = reader.self_closing? ? nil : fields[name]
174
+ else
175
+ text = nil
176
+ end
177
+ elsif text && [Nokogiri::XML::Reader::TYPE_TEXT, Nokogiri::XML::Reader::TYPE_CDATA,
178
+ Nokogiri::XML::Reader::TYPE_WHITESPACE, Nokogiri::XML::Reader::TYPE_SIGNIFICANT_WHITESPACE].include?(type)
179
+ text << reader.value
180
+ end
74
181
  end
182
+ fields
183
+ end
75
184
 
76
- child_locations.each do |location|
77
- child_filename = local_child_filename(location, parent_filename: sitemap_filename)
78
- raise ValidationError, "Missing child sitemap file: #{child_filename}" unless storage.exist?(child_filename)
79
-
80
- validate_sitemap_file!(child_filename)
185
+ def location_for(fields, filename:)
186
+ location = fields.fetch("loc").to_s.strip
187
+ if location.empty?
188
+ raise ValidationError, "Sitemap entry must have one nonempty loc: #{filename}"
81
189
  end
190
+ raise ValidationError, "Sitemap loc must be shorter than 2,048 characters: #{filename}" if location.length >= 2048
191
+
192
+ location
82
193
  end
83
194
 
84
195
  def local_child_filename(location, parent_filename:)
85
196
  uri = URI.parse(location)
197
+ base_uri = URI.parse(configuration.base_url)
198
+ if uri.query || uri.fragment || uri.userinfo || (uri.host &&
199
+ [uri.scheme, uri.host, uri.port] != [base_uri.scheme, base_uri.host, base_uri.port]) ||
200
+ (uri.scheme && !%w[http https].include?(uri.scheme))
201
+ raise ValidationError, "Invalid child sitemap URL: #{location}"
202
+ end
86
203
  if uri.absolute? || location.start_with?("/")
87
204
  normalize_local_filename(uri.path)
88
205
  else
@@ -90,29 +207,18 @@ module Indexmap
90
207
  normalize_local_filename((parent_directory == ".") ? location : File.join(parent_directory, location))
91
208
  end
92
209
  rescue URI::InvalidURIError
93
- normalize_local_filename(location)
210
+ raise ValidationError, "Invalid child sitemap URL: #{location}"
94
211
  end
95
212
 
96
213
  def normalize_local_filename(filename)
97
214
  normalized = Pathname(filename.to_s).cleanpath.to_s.sub(%r{\A/+}, "")
98
- return if normalized.empty? || normalized == ".." || normalized.start_with?("../")
215
+ if normalized.empty? || normalized == ".." || normalized.start_with?("../")
216
+ raise ValidationError, "Invalid child sitemap filename: #{filename}"
217
+ end
99
218
 
100
219
  normalized
101
220
  end
102
221
 
103
- def validate_presence!(entries)
104
- return unless entries.empty?
105
-
106
- raise ValidationError, "Sitemap has no URLs"
107
- end
108
-
109
- def validate_duplicates!(entries)
110
- duplicates = entries.map(&:loc).group_by(&:itself).select { |_url, values| values.size > 1 }.keys
111
- return if duplicates.empty?
112
-
113
- raise ValidationError, "Duplicate sitemap URLs detected: #{duplicates.first(5).join(", ")}"
114
- end
115
-
116
222
  def validate_parameterized_urls!(entries)
117
223
  param_urls = entries.map(&:loc).select { |url| url&.include?("?") }
118
224
  return if param_urls.empty?
@@ -130,7 +236,7 @@ module Indexmap
130
236
  def validate_absolute_http_urls!(entries)
131
237
  invalid_urls = entries.map(&:loc).reject do |url|
132
238
  uri = parse_uri(url)
133
- uri&.absolute? && %w[http https].include?(uri.scheme)
239
+ uri&.absolute? && %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo
134
240
  end
135
241
  return if invalid_urls.empty?
136
242
 
@@ -154,7 +260,11 @@ module Indexmap
154
260
  invalid_entries = entries.select do |entry|
155
261
  next false if entry.lastmod.nil?
156
262
 
157
- Date.iso8601(entry.lastmod)
263
+ if entry.lastmod.match?(/\A\d{4}-\d{2}-\d{2}\z/)
264
+ Date.iso8601(entry.lastmod)
265
+ else
266
+ Time.iso8601(entry.lastmod)
267
+ end
158
268
  false
159
269
  rescue ArgumentError
160
270
  true
@@ -171,3 +281,5 @@ module Indexmap
171
281
  end
172
282
  end
173
283
  end
284
+
285
+ require_relative "validator/duplicates"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Indexmap
4
- VERSION = "0.8.2"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -2,34 +2,68 @@
2
2
 
3
3
  module Indexmap
4
4
  class Writer
5
+ attr_reader :counts
6
+
5
7
  VALID_FORMATS = %i[index single_file].freeze
6
8
 
7
- def initialize(base_url:, sections: nil, entries: nil, index_filename: "sitemap.xml", format: :index)
8
- @entries = normalize_entries(entries)
9
+ def initialize(base_url:, sections: nil, entries: nil, index_filename: "sitemap.xml", format: :index, max_urls: 50_000, max_bytes: 52_428_800, generation: nil)
10
+ @entries = entries || []
9
11
  @format = normalize_format(format)
10
12
  @sections = normalize_sections(sections)
11
13
  @base_url = base_url
12
14
  @index_filename = index_filename
15
+ @max_urls = Integer(max_urls.to_s, 10)
16
+ @max_bytes = Integer(max_bytes.to_s, 10)
17
+ @generation = generation
18
+ @counts = {}
19
+ unless (1..Validator::MAX_ENTRIES).cover?(@max_urls) && (1..Validator::MAX_BYTES).cover?(@max_bytes)
20
+ raise ConfigurationError, "Invalid sitemap partition limits"
21
+ end
13
22
  end
14
23
 
15
24
  def write
16
- return [write_file(index_filename, urlset_xml(entries))] if single_file?
25
+ each_file.to_a
26
+ end
17
27
 
18
- paths = sections.map do |section|
19
- write_file(section.filename, urlset_xml(section.entries))
20
- end
28
+ def each_file
29
+ return enum_for(:each_file) unless block_given?
21
30
 
22
- paths + [write_file(index_filename, index_xml(sections))]
31
+ if single_file?
32
+ body, = urlset_xml(entries)
33
+ else
34
+ index_entries = []
35
+ sections.each_with_index do |section, section_index|
36
+ part = 0
37
+ chunks(section.entries) do |body, lastmod, count|
38
+ part += 1
39
+ filename = if @generation
40
+ Publication.new(root: index_filename).filename(generation: @generation, section: section_index + 1, part: part)
41
+ elsif part == 1
42
+ section.filename
43
+ else
44
+ section.filename.sub(/\.xml\z/, "-#{Kernel.format("%03d", part)}.xml")
45
+ end
46
+ raise ConfigurationError, "Section filenames must end in .xml" if part > 1 && filename == section.filename
47
+
48
+ @counts[filename] = count
49
+ index_entries << [filename, lastmod]
50
+ raise ValidationError, "Sitemap exceeds 50,000 entries: #{index_filename}" if index_entries.size > Validator::MAX_ENTRIES
51
+
52
+ yield write_file(filename, body)
53
+ end
54
+ end
55
+ body = index_xml(index_entries)
56
+ raise ValidationError, "Sitemap exceeds 52,428,800 bytes: #{index_filename}" if body.bytesize > @max_bytes
57
+
58
+ @counts[index_filename] = index_entries.size
59
+ end
60
+ yield write_file(index_filename, body)
23
61
  end
24
62
 
25
63
  private
26
64
 
27
65
  attr_reader :base_url, :entries, :format, :index_filename, :sections
28
66
 
29
- def normalize_entries(raw_entries)
30
- Array(raw_entries).map { |entry| normalize_entry(entry) }
31
- end
32
-
33
67
  def normalize_format(value)
34
68
  normalized = value.nil? ? :index : value.to_sym
35
69
  return normalized if VALID_FORMATS.include?(normalized)
@@ -53,25 +87,52 @@ module Indexmap
53
87
  end
54
88
 
55
89
  def write_file(filename, body)
56
- Storage::File.new(filename: filename, body: body, content_type: "application/xml")
90
+ Artifact.new(filename: filename, body: body, content_type: "application/xml")
91
+ end
92
+
93
+ HEADER = %(<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n)
94
+ FOOTER = "</urlset>\n"
95
+
96
+ def chunks(source)
97
+ body = +HEADER
98
+ latest = nil
99
+ count = 0
100
+ source.each do |value|
101
+ entry = normalize_entry(value)
102
+ xml = " <url>\n <loc>#{escape(entry.loc)}</loc>\n"
103
+ timestamp = nil
104
+ if entry.lastmod
105
+ date = parsed_lastmod(entry.lastmod)
106
+ xml << " <lastmod>#{escape(date.iso8601)}</lastmod>\n"
107
+ timestamp = comparable_lastmod(date)
108
+ end
109
+ xml << " </url>\n"
110
+ if HEADER.bytesize + xml.bytesize + FOOTER.bytesize > @max_bytes
111
+ raise ValidationError, "Sitemap entry exceeds byte limit"
112
+ end
113
+ if count > 0 && (count == @max_urls || body.bytesize + xml.bytesize + FOOTER.bytesize > @max_bytes)
114
+ yield body << FOOTER, latest, count
115
+ body = +HEADER
116
+ latest = nil
117
+ count = 0
118
+ end
119
+ body << xml
120
+ latest = timestamp if timestamp && (latest.nil? || timestamp > latest)
121
+ count += 1
122
+ end
123
+ yield body << FOOTER, latest, count if count > 0
57
124
  end
58
125
 
59
- def urlset_xml(entries)
60
- lines = [
61
- %(<?xml version="1.0" encoding="UTF-8"?>),
62
- %(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">)
63
- ]
126
+ def urlset_xml(source)
127
+ result = nil
128
+ chunks(source) do |body, lastmod, count|
129
+ raise ValidationError, "Single-file sitemap exceeds partition limits; use index format" if result
64
130
 
65
- entries.each do |entry|
66
- sitemap_entry = normalize_entry(entry)
67
- lines << " <url>"
68
- lines << " <loc>#{escape(sitemap_entry.loc)}</loc>"
69
- lines << " <lastmod>#{format_lastmod(sitemap_entry.lastmod)}</lastmod>" if sitemap_entry.lastmod
70
- lines << " </url>"
131
+ @counts[index_filename] = count
132
+ result = [body, lastmod]
71
133
  end
72
-
73
- lines << "</urlset>"
74
- lines.join("\n") + "\n"
134
+ @counts[index_filename] ||= 0
135
+ result || [HEADER + FOOTER, nil]
75
136
  end
76
137
 
77
138
  def index_xml(sitemap_sections)
@@ -80,10 +141,9 @@ module Indexmap
80
141
  %(<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">)
81
142
  ]
82
143
 
83
- sitemap_sections.each do |section|
144
+ sitemap_sections.each do |filename, lastmod|
84
145
  lines << " <sitemap>"
85
- lines << " <loc>#{escape(index_loc(section.filename))}</loc>"
86
- lastmod = section_lastmod(section)
146
+ lines << " <loc>#{escape(index_loc(filename))}</loc>"
87
147
  lines << " <lastmod>#{format_lastmod(lastmod)}</lastmod>" if lastmod
88
148
  lines << " </sitemap>"
89
149
  end
@@ -102,11 +162,6 @@ module Indexmap
102
162
  File.join(base_url.sub(%r{/\z}, ""), filename)
103
163
  end
104
164
 
105
- def section_lastmod(section)
106
- timestamps = Array(section.entries).map { |entry| comparable_lastmod(normalize_entry(entry).lastmod) }.compact
107
- timestamps.max
108
- end
109
-
110
165
  def format_lastmod(value)
111
166
  timestamp = parsed_lastmod(value)
112
167
 
data/lib/indexmap.rb CHANGED
@@ -8,11 +8,13 @@ require "time"
8
8
  require_relative "indexmap/version"
9
9
  require_relative "indexmap/google_configuration"
10
10
  require_relative "indexmap/index_now_configuration"
11
- require_relative "indexmap/storage/file"
11
+ require_relative "indexmap/robots"
12
+ require_relative "indexmap/artifact"
12
13
  require_relative "indexmap/storage/memory"
13
14
  require_relative "indexmap/storage/filesystem"
14
15
  require_relative "indexmap/storage/active_storage"
15
16
  require_relative "indexmap/configuration"
17
+ require_relative "indexmap/publication"
16
18
  require_relative "indexmap/creator"
17
19
  require_relative "indexmap/entry"
18
20
  require_relative "indexmap/output"
@@ -33,6 +35,17 @@ module Indexmap
33
35
 
34
36
  class ConfigurationError < Error; end
35
37
  class ValidationError < Error; end
38
+ class SupersededPublication < Error; end
39
+
40
+ class PublicationCleanupError < Error
41
+ attr_reader :failures
42
+
43
+ def initialize(failures)
44
+ @failures = failures
45
+ artifacts = failures.map { |failure| failure.fetch(:artifact) }.join(", ")
46
+ super("Failed to clean up publication artifacts: #{artifacts}")
47
+ end
48
+ end
36
49
 
37
50
  class << self
38
51
  def configuration
@@ -5,7 +5,7 @@ namespace :indexmap do
5
5
  runner = Indexmap::TaskRunner.new
6
6
  create_result = runner.create
7
7
 
8
- puts "Created, formatted, and validated #{file_count(create_result[:files])}."
8
+ puts "Created and validated #{file_count(create_result[:files])}."
9
9
  puts "Files created:"
10
10
  create_result[:sitemaps].each do |sitemap|
11
11
  puts " - #{sitemap[:location]} (#{link_count(sitemap[:link_count])})"
@@ -13,6 +13,13 @@ namespace :indexmap do
13
13
  puts "IndexNow key file: #{create_result[:index_now_key_filename]}" if create_result[:index_now_key_filename]
14
14
  end
15
15
 
16
+ desc "Prune expired immutable generations (RETENTION_HOURS defaults to 48)"
17
+ task prune: :environment do
18
+ hours = Integer(ENV.fetch("RETENTION_HOURS", "48"), 10)
19
+ deleted = Indexmap::TaskRunner.new.prune(retention: hours * 3600)
20
+ puts "Pruned #{deleted.size} sitemap artifacts."
21
+ end
22
+
16
23
  desc "Format sitemap files for better readability"
17
24
  task format: :environment do
18
25
  runner = Indexmap::TaskRunner.new
@@ -56,9 +63,21 @@ namespace :indexmap do
56
63
  end
57
64
 
58
65
  namespace :index_now do
66
+ desc "Preview exact IndexNow selection as JSON Lines without submitting"
67
+ task plan: :environment do
68
+ Indexmap::Pinger::IndexNow.new.plan.each { |row| puts JSON.generate(row) }
69
+ end
70
+
59
71
  desc "Ping IndexNow. ENV: SINCE=2026-04-18T10:30:00Z or INDEXNOW_RECENT_HOURS=24"
60
72
  task ping: :environment do
61
- result = Indexmap::Pinger::IndexNow.new.ping
73
+ report = ENV["INDEXNOW_REPORT"] == "1"
74
+ result = Indexmap::Pinger::IndexNow.new.ping do |event|
75
+ puts JSON.generate(event) if report
76
+ end
77
+ if report
78
+ puts JSON.generate(result.merge(event: :summary))
79
+ next
80
+ end
62
81
 
63
82
  case result[:status]
64
83
  when :submitted