metanorma-release 0.2.23 → 0.2.25

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +21 -319
  3. data/README.adoc +306 -91
  4. data/lib/metanorma/release/aggregation_pipeline.rb +65 -40
  5. data/lib/metanorma/release/asset_processor.rb +0 -2
  6. data/lib/metanorma/release/cache_store.rb +1 -0
  7. data/lib/metanorma/release/change_detector.rb +3 -2
  8. data/lib/metanorma/release/cli.rb +26 -2
  9. data/lib/metanorma/release/commands/aggregate.rb +0 -16
  10. data/lib/metanorma/release/commands/package.rb +9 -17
  11. data/lib/metanorma/release/commands/release_command.rb +19 -14
  12. data/lib/metanorma/release/config.rb +16 -7
  13. data/lib/metanorma/release/dependency_validation.rb +19 -0
  14. data/lib/metanorma/release/document_flattener.rb +173 -0
  15. data/lib/metanorma/release/index.rb +1 -1
  16. data/lib/metanorma/release/interfaces.rb +12 -0
  17. data/lib/metanorma/release/platform/github/manifest_reader.rb +3 -1
  18. data/lib/metanorma/release/platform/github/release_fetcher.rb +4 -10
  19. data/lib/metanorma/release/platform/github/topic_discoverer.rb +1 -1
  20. data/lib/metanorma/release/platform/github.rb +0 -4
  21. data/lib/metanorma/release/platform/local/fetcher.rb +5 -17
  22. data/lib/metanorma/release/platform/null/manifest_reader.rb +15 -0
  23. data/lib/metanorma/release/platform/null/publisher.rb +1 -1
  24. data/lib/metanorma/release/platform/null.rb +2 -0
  25. data/lib/metanorma/release/platform/static_discoverer.rb +19 -0
  26. data/lib/metanorma/release/platform.rb +1 -0
  27. data/lib/metanorma/release/platform_factory.rb +6 -21
  28. data/lib/metanorma/release/publication.rb +23 -161
  29. data/lib/metanorma/release/publication_serializer.rb +59 -0
  30. data/lib/metanorma/release/release_pipeline.rb +7 -15
  31. data/lib/metanorma/release/rxl_extractor.rb +106 -0
  32. data/lib/metanorma/release/site.rb +4 -154
  33. data/lib/metanorma/release/slug_strategy.rb +30 -15
  34. data/lib/metanorma/release/version.rb +1 -1
  35. data/lib/metanorma/release.rb +36 -19
  36. metadata +8 -2
@@ -5,6 +5,8 @@ module Metanorma
5
5
  module Platform
6
6
  module Null
7
7
  autoload :Publisher, "metanorma/release/platform/null/publisher"
8
+ autoload :ManifestReader,
9
+ "metanorma/release/platform/null/manifest_reader"
8
10
  end
9
11
  end
10
12
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module Platform
6
+ class StaticDiscoverer
7
+ include RepoDiscoverer
8
+
9
+ def initialize(repos:)
10
+ @repos = repos
11
+ end
12
+
13
+ def discover
14
+ @repos
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -6,6 +6,7 @@ module Metanorma
6
6
  autoload :GitHub, "metanorma/release/platform/github"
7
7
  autoload :Local, "metanorma/release/platform/local"
8
8
  autoload :Null, "metanorma/release/platform/null"
9
+ autoload :StaticDiscoverer, "metanorma/release/platform/static_discoverer"
9
10
  end
10
11
  end
11
12
  end
@@ -24,14 +24,17 @@ module Metanorma
24
24
 
25
25
  discoverer = if opts[:repos]
26
26
  repos = opts[:repos].map { |r| RepoRef.from_string(r) }
27
- StaticDiscoverer.new(repos: repos)
27
+ Platform::StaticDiscoverer.new(repos: repos)
28
28
  else
29
29
  Platform::GitHub::TopicDiscoverer.new(
30
30
  client: client, organizations: opts[:organizations], topic: opts[:topic],
31
31
  )
32
32
  end
33
33
 
34
- download_cache = opts[:cache_dir] ? File.join(opts[:cache_dir], "downloads") : nil
34
+ download_cache = if opts[:cache_dir]
35
+ File.join(opts[:cache_dir],
36
+ "downloads")
37
+ end
35
38
 
36
39
  {
37
40
  discoverer: discoverer,
@@ -58,7 +61,7 @@ module Metanorma
58
61
  if source.start_with?("local:")
59
62
  adapters = AGGREGATION_REGISTRY["local"].call(options,
60
63
  options[:token])
61
- adapters[:manifest_reader] = NullManifestReader.new
64
+ adapters[:manifest_reader] = Platform::Null::ManifestReader.new
62
65
  return adapters
63
66
  end
64
67
 
@@ -78,24 +81,6 @@ module Metanorma
78
81
  def self.register_aggregation(name, factory)
79
82
  AGGREGATION_REGISTRY[name] = factory
80
83
  end
81
-
82
- class StaticDiscoverer
83
- include RepoDiscoverer
84
-
85
- def initialize(repos:)
86
- @repos = repos
87
- end
88
-
89
- def discover
90
- @repos
91
- end
92
- end
93
-
94
- class NullManifestReader
95
- include Metanorma::Release::ManifestReader
96
-
97
- def read(_repo) = nil
98
- end
99
84
  end
100
85
  end
101
86
  end
@@ -1,12 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- begin
4
- require "relaton/bib"
5
- rescue LoadError
6
- raise LoadError,
7
- "The relaton-bib gem is required. Add `gem 'relaton-bib'` to your Gemfile."
8
- end
9
-
10
3
  require "json"
11
4
 
12
5
  module Metanorma
@@ -67,6 +60,11 @@ module Metanorma
67
60
  class Publication
68
61
  METADATA_VERSION = 1
69
62
 
63
+ DRAFT_STAGES = %w[
64
+ 20 30 40 50
65
+ working-draft committee-draft draft-standard final-draft
66
+ ].freeze
67
+
70
68
  attr_reader :identifier, :slug, :title, :edition, :stage, :doctype,
71
69
  :revdate, :files, :channels, :source
72
70
 
@@ -94,14 +92,23 @@ module Metanorma
94
92
  files.any? ? File.dirname(files.first.path) : "."
95
93
  end
96
94
 
97
- def content_hash
98
- ContentHash.of_directory(base_dir, base: slug)
95
+ def content_hash(from_directory: nil)
96
+ dir = if from_directory && base_dir == "."
97
+ from_directory
98
+ else
99
+ base_dir
100
+ end
101
+ ContentHash.of_directory(dir, base: slug)
99
102
  end
100
103
 
101
104
  def file?(format)
102
105
  formats.include?(format)
103
106
  end
104
107
 
108
+ def draft?
109
+ DRAFT_STAGES.include?(stage.to_s)
110
+ end
111
+
105
112
  def eql?(other)
106
113
  other.is_a?(self.class) && identifier == other.identifier &&
107
114
  edition == other.edition && stage == other.stage
@@ -154,40 +161,26 @@ module Metanorma
154
161
  end
155
162
 
156
163
  def to_release_body
157
- "<!-- mn-release-metadata\n#{JSON.generate(metadata_hash)}\n-->"
164
+ PublicationSerializer.to_release_body(self)
158
165
  end
159
166
 
160
167
  def to_json(*_args)
161
- JSON.generate(metadata_hash)
162
- end
163
-
164
- def self.from_json(json_string)
165
- data = JSON.parse(json_string)
166
- raise ArgumentError, "Missing required field: id" unless data["id"]
167
- unless data["title"]
168
- raise ArgumentError,
169
- "Missing required field: title"
170
- end
171
-
172
- from_metadata_hash(data)
168
+ PublicationSerializer.to_json(self)
173
169
  end
174
170
 
175
171
  def self.from_release_body(body)
176
- return nil if body.nil? || body.empty?
177
-
178
- match = body.match(/<!--\s*mn-release-metadata\s*\n(.*?)\n-->/m)
179
- return nil unless match
172
+ PublicationSerializer.from_release_body(body)
173
+ end
180
174
 
181
- from_json(match[1])
182
- rescue JSON::ParserError
183
- nil
175
+ def self.from_json(json_string)
176
+ PublicationSerializer.from_json(json_string)
184
177
  end
185
178
 
186
179
  def self.from_metadata_hash(data)
187
180
  ident = data["identifier"] || data["id"]
188
181
  new(
189
182
  identifier: ident,
190
- slug: slug_from_identifier(ident),
183
+ slug: SlugStrategy.slug_from_identifier(ident),
191
184
  title: data["title"],
192
185
  edition: data["edition"],
193
186
  stage: data["stage"],
@@ -199,137 +192,6 @@ module Metanorma
199
192
  metadata_formats: data["formats"],
200
193
  )
201
194
  end
202
-
203
- private
204
-
205
- def metadata_hash
206
- {
207
- "version" => METADATA_VERSION,
208
- "id" => slug,
209
- "identifier" => identifier,
210
- "title" => title,
211
- "edition" => edition,
212
- "stage" => stage,
213
- "doctype" => doctype,
214
- "revdate" => revdate,
215
- "formats" => formats,
216
- "channels" => channels,
217
- "publisher" => Publication.publisher_from_identifier(identifier),
218
- }
219
- end
220
-
221
- def self.slug_from_identifier(identifier)
222
- identifier.to_s.strip
223
- .gsub(/\s+/, "-")
224
- .gsub(/:+/, "-")
225
- .downcase
226
- .gsub(/--+/, "-")
227
- .gsub(/[-.]+$/, "")
228
- end
229
-
230
- def self.publisher_from_identifier(identifier)
231
- return nil if identifier.nil? || identifier.strip.empty?
232
-
233
- identifier.strip.split(/[\s-]/).first&.downcase
234
- end
235
-
236
- # -- RXL extraction --
237
-
238
- def self.discover(output_dir)
239
- Dir.glob(File.join(output_dir, "**", "*.rxl")).filter_map do |path|
240
- from_rxl(path)
241
- rescue StandardError => e
242
- warn "Warning: Skipping #{path}: #{e.message}"
243
- nil
244
- end
245
- end
246
-
247
- def self.from_rxl(rxl_path)
248
- unless File.exist?(rxl_path)
249
- raise ArgumentError,
250
- "RXL file not found: #{rxl_path}"
251
- end
252
-
253
- content = File.read(rxl_path)
254
- bib = Relaton::Bib::Item.from_xml(content)
255
- build_from_bib(bib, rxl_path)
256
- rescue StandardError => e
257
- warn "Warning: Failed to parse RXL #{rxl_path}: #{e.message}"
258
- fallback_from_rxl(rxl_path)
259
- end
260
-
261
- class << self
262
- private
263
-
264
- def build_from_bib(bib, rxl_path)
265
- identifier = bib.docidentifier&.first&.content || ""
266
- slug = slug_from_identifier(identifier)
267
- output_dir = File.dirname(rxl_path)
268
- base_name = File.basename(rxl_path, ".rxl")
269
-
270
- new(
271
- identifier: identifier, slug: slug,
272
- title: bib.title&.first&.content || "",
273
- edition: extract_edition(bib),
274
- stage: extract_stage(bib),
275
- doctype: extract_doctype(bib),
276
- revdate: extract_revdate(bib),
277
- files: discover_files(output_dir, base_name),
278
- channels: [], source: nil
279
- )
280
- end
281
-
282
- def extract_edition(bib)
283
- ed = bib.edition
284
- return "1" unless ed
285
-
286
- ed.respond_to?(:content) ? ed.content.to_s : ed.to_s
287
- end
288
-
289
- def extract_stage(bib)
290
- stage = bib.status&.stage
291
- return "" unless stage
292
-
293
- stage.respond_to?(:content) ? stage.content.to_s : stage.to_s
294
- end
295
-
296
- def extract_doctype(bib)
297
- doctype = bib.ext&.doctype
298
- return "" unless doctype
299
-
300
- doctype.respond_to?(:content) ? doctype.content.to_s : doctype.to_s
301
- end
302
-
303
- def extract_revdate(bib)
304
- date = bib.date&.find { |d| d.type == "published" } || bib.date&.first
305
- return nil unless date
306
-
307
- val = date.at
308
- val ? val.to_s : nil
309
- rescue StandardError
310
- nil
311
- end
312
-
313
- def discover_files(output_dir, base_name)
314
- Dir.glob(File.join(output_dir, "#{base_name}.*")).filter_map do |path|
315
- next if File.directory?(path)
316
-
317
- name = File.basename(path)
318
- ext = File.extname(name).delete_prefix(".")
319
- PublicationFile.new(format: ext, name: name, path: name)
320
- end
321
- end
322
-
323
- def fallback_from_rxl(rxl_path)
324
- base_name = File.basename(rxl_path, ".rxl")
325
- slug = slug_from_identifier(base_name)
326
- new(
327
- identifier: base_name, slug: slug, title: "",
328
- edition: "0", stage: "", doctype: "",
329
- revdate: nil, files: [], channels: [], source: nil
330
- )
331
- end
332
- end
333
195
  end
334
196
  end
335
197
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ module PublicationSerializer
6
+ METADATA_COMMENT_PATTERN = /<!--\s*mn-release-metadata\s*\n(.*?)\n-->/m
7
+
8
+ def self.to_release_body(publication)
9
+ "<!-- mn-release-metadata\n#{JSON.generate(metadata_hash(publication))}\n-->"
10
+ end
11
+
12
+ def self.from_release_body(body)
13
+ return nil if body.nil? || body.empty?
14
+
15
+ match = body.match(METADATA_COMMENT_PATTERN)
16
+ return nil unless match
17
+
18
+ from_json(match[1])
19
+ rescue JSON::ParserError
20
+ nil
21
+ end
22
+
23
+ def self.to_json(publication)
24
+ JSON.generate(metadata_hash(publication))
25
+ end
26
+
27
+ def self.from_json(json_string)
28
+ data = JSON.parse(json_string)
29
+ raise ArgumentError, "Missing required field: id" unless data["id"]
30
+ unless data["title"]
31
+ raise ArgumentError,
32
+ "Missing required field: title"
33
+ end
34
+
35
+ Publication.from_metadata_hash(data)
36
+ end
37
+
38
+ class << self
39
+ private
40
+
41
+ def metadata_hash(publication)
42
+ {
43
+ "version" => Publication::METADATA_VERSION,
44
+ "id" => publication.slug,
45
+ "identifier" => publication.identifier,
46
+ "title" => publication.title,
47
+ "edition" => publication.edition,
48
+ "stage" => publication.stage,
49
+ "doctype" => publication.doctype,
50
+ "revdate" => publication.revdate,
51
+ "formats" => publication.formats,
52
+ "channels" => publication.channels,
53
+ "publisher" => SlugStrategy.publisher_from_identifier(publication.identifier),
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -15,6 +15,8 @@ module Metanorma
15
15
  :manifest, :channel_override, :config,
16
16
  keyword_init: true
17
17
  ) do
18
+ include DependencyValidation
19
+
18
20
  def initialize(**kwargs)
19
21
  super
20
22
  validate_types!
@@ -23,9 +25,9 @@ module Metanorma
23
25
  private
24
26
 
25
27
  def validate_types!
26
- unless extractor.respond_to?(:discover)
28
+ unless extractor.is_a?(Class) && extractor.singleton_class.ancestors.include?(Extractor)
27
29
  raise ArgumentError,
28
- "extractor must respond to #discover, got #{extractor.class}"
30
+ "extractor must extend Extractor, got #{extractor}"
29
31
  end
30
32
 
31
33
  validate_interface!(change_detector, ChangeDetector,
@@ -33,21 +35,11 @@ module Metanorma
33
35
  validate_interface!(packager, Packager, "packager")
34
36
  validate_interface!(publisher, Publisher, "publisher")
35
37
  end
36
-
37
- def validate_interface!(obj, mod, name)
38
- return if obj.is_a?(mod) || begin
39
- obj.class.ancestors.include?(mod)
40
- rescue StandardError
41
- false
42
- end
43
-
44
- raise ArgumentError, "#{name} must include #{mod}, got #{obj.class}"
45
- end
46
38
  end
47
39
 
48
40
  Config = Struct.new(
49
- :output_dir, :manifest_path, :force, :force_replace_patterns,
50
- :concurrency, :default_visibility,
41
+ :output_dir, :force, :force_replace_patterns,
42
+ :concurrency,
51
43
  keyword_init: true
52
44
  )
53
45
 
@@ -72,7 +64,7 @@ module Metanorma
72
64
 
73
65
  def phase_one(publications, config)
74
66
  publications.map do |pub|
75
- publisher = Publication.publisher_from_identifier(pub.identifier)
67
+ publisher = SlugStrategy.publisher_from_identifier(pub.identifier)
76
68
  strategy = @deps.slug_registry.resolve(publisher)
77
69
  tag_info = strategy.compute_tag(pub)
78
70
  canonical_base = strategy.compute_asset_name(pub).sub(/\.zip$/, "")
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Metanorma
4
+ module Release
5
+ class RxlExtractor
6
+ extend Extractor
7
+
8
+ def self.discover(output_dir)
9
+ require "relaton/bib"
10
+ Dir.glob(File.join(output_dir, "**", "*.rxl")).filter_map do |path|
11
+ from_rxl(path)
12
+ rescue StandardError => e
13
+ Metanorma::Release.logger.warn "Skipping #{path}: #{e.message}"
14
+ nil
15
+ end
16
+ end
17
+
18
+ def self.from_rxl(rxl_path)
19
+ unless File.exist?(rxl_path)
20
+ raise ArgumentError,
21
+ "RXL file not found: #{rxl_path}"
22
+ end
23
+
24
+ content = File.read(rxl_path)
25
+ bib = Relaton::Bib::Item.from_xml(content)
26
+ build_from_bib(bib, rxl_path)
27
+ rescue StandardError => e
28
+ Metanorma::Release.logger.warn "Failed to parse RXL #{rxl_path}: #{e.message}"
29
+ fallback_from_rxl(rxl_path)
30
+ end
31
+
32
+ class << self
33
+ private
34
+
35
+ def build_from_bib(bib, rxl_path)
36
+ identifier = bib.docidentifier&.first&.content || ""
37
+ slug = SlugStrategy.slug_from_identifier(identifier)
38
+ output_dir = File.dirname(rxl_path)
39
+ base_name = File.basename(rxl_path, ".rxl")
40
+
41
+ Publication.new(
42
+ identifier: identifier, slug: slug,
43
+ title: bib.title&.first&.content || "",
44
+ edition: extract_edition(bib),
45
+ stage: extract_stage(bib),
46
+ doctype: extract_doctype(bib),
47
+ revdate: extract_revdate(bib),
48
+ files: discover_files(output_dir, base_name),
49
+ channels: [], source: nil
50
+ )
51
+ end
52
+
53
+ def extract_edition(bib)
54
+ ed = bib.edition
55
+ return "1" unless ed
56
+
57
+ ed.is_a?(String) ? ed : ed.content.to_s
58
+ end
59
+
60
+ def extract_stage(bib)
61
+ stage = bib.status&.stage
62
+ return "" unless stage
63
+
64
+ stage.is_a?(String) ? stage : stage.content.to_s
65
+ end
66
+
67
+ def extract_doctype(bib)
68
+ doctype = bib.ext&.doctype
69
+ return "" unless doctype
70
+
71
+ doctype.is_a?(String) ? doctype : doctype.content.to_s
72
+ end
73
+
74
+ def extract_revdate(bib)
75
+ date = bib.date&.find { |d| d.type == "published" } || bib.date&.first
76
+ return nil unless date
77
+
78
+ val = date.at
79
+ val&.to_s
80
+ rescue StandardError
81
+ nil
82
+ end
83
+
84
+ def discover_files(output_dir, base_name)
85
+ Dir.glob(File.join(output_dir, "#{base_name}.*")).filter_map do |path|
86
+ next if File.directory?(path)
87
+
88
+ name = File.basename(path)
89
+ ext = File.extname(name).delete_prefix(".")
90
+ PublicationFile.new(format: ext, name: name, path: name)
91
+ end
92
+ end
93
+
94
+ def fallback_from_rxl(rxl_path)
95
+ base_name = File.basename(rxl_path, ".rxl")
96
+ slug = SlugStrategy.slug_from_identifier(base_name)
97
+ Publication.new(
98
+ identifier: base_name, slug: slug, title: "",
99
+ edition: "0", stage: "", doctype: "",
100
+ revdate: nil, files: [], channels: [], source: nil
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end