suma 0.2.6 → 0.4.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +7 -1
  3. data/.rubocop_todo.yml +170 -13
  4. data/CLAUDE.md +37 -11
  5. data/Gemfile +3 -3
  6. data/README.adoc +127 -13
  7. data/exe/suma +1 -1
  8. data/lib/suma/cli/build.rb +0 -5
  9. data/lib/suma/cli/check_svg_quality.rb +82 -97
  10. data/lib/suma/cli/compare.rb +0 -1
  11. data/lib/suma/cli/convert_jsdai.rb +0 -2
  12. data/lib/suma/cli/core.rb +119 -0
  13. data/lib/suma/cli/export.rb +37 -22
  14. data/lib/suma/cli/extract_terms.rb +5 -8
  15. data/lib/suma/cli/generate_register.rb +34 -0
  16. data/lib/suma/cli/generate_schemas.rb +0 -2
  17. data/lib/suma/cli/reformat.rb +39 -64
  18. data/lib/suma/cli/validate.rb +14 -7
  19. data/lib/suma/cli/validate_links.rb +11 -157
  20. data/lib/suma/cli.rb +12 -141
  21. data/lib/suma/collection_config.rb +0 -2
  22. data/lib/suma/collection_manifest.rb +7 -110
  23. data/lib/suma/eengine/wrapper.rb +0 -1
  24. data/lib/suma/eengine.rb +8 -0
  25. data/lib/suma/express_reformatter.rb +94 -0
  26. data/lib/suma/express_schema.rb +0 -1
  27. data/lib/suma/jsdai/figure.rb +0 -3
  28. data/lib/suma/jsdai.rb +5 -2
  29. data/lib/suma/link_validation.rb +144 -0
  30. data/lib/suma/link_validator.rb +17 -8
  31. data/lib/suma/manifest_traverser.rb +92 -0
  32. data/lib/suma/processor.rb +5 -8
  33. data/lib/suma/register_manifest_generator.rb +163 -0
  34. data/lib/suma/schema_category.rb +83 -0
  35. data/lib/suma/schema_collection.rb +30 -34
  36. data/lib/suma/schema_comparer.rb +4 -3
  37. data/lib/suma/schema_compiler.rb +86 -0
  38. data/lib/suma/schema_discovery.rb +75 -0
  39. data/lib/suma/schema_exporter.rb +14 -42
  40. data/lib/suma/schema_manifest_generator.rb +14 -6
  41. data/lib/suma/schema_naming.rb +111 -0
  42. data/lib/suma/schema_template/document.rb +141 -0
  43. data/lib/suma/schema_template/plain.rb +46 -0
  44. data/lib/suma/schema_template.rb +19 -0
  45. data/lib/suma/svg_quality/batch_report.rb +0 -2
  46. data/lib/suma/svg_quality/formatters.rb +12 -0
  47. data/lib/suma/svg_quality/scanner.rb +61 -0
  48. data/lib/suma/svg_quality.rb +4 -1
  49. data/lib/suma/term_classification.rb +78 -0
  50. data/lib/suma/term_extractor.rb +125 -81
  51. data/lib/suma/urn.rb +61 -0
  52. data/lib/suma/version.rb +1 -1
  53. data/lib/suma.rb +35 -3
  54. data/suma.gemspec +1 -1
  55. metadata +28 -6
  56. data/lib/suma/schema_attachment.rb +0 -103
  57. data/lib/suma/schema_document.rb +0 -118
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # Value object mapping an ExpressSchema::Type to its register/export category.
5
+ #
6
+ # Single source of truth for category identity across the codebase:
7
+ # directory layout (SchemaExporter), display prefix (SchemaNaming), and
8
+ # register.yaml sections (RegisterManifestGenerator) all derive from this.
9
+ #
10
+ # Each category carries: +id+ (slug used in register.yaml section id and
11
+ # export subdirectory), +label+ (human-readable section heading),
12
+ # +prefix+ (prefix used when forming display names, e.g. "Resource: ..."),
13
+ # +types+ (Array of ExpressSchema::Type symbols that belong to it),
14
+ # and +directory+ (subdirectory name; "." for the root output path).
15
+ class SchemaCategory
16
+ attr_reader :id, :label, :prefix, :types, :directory
17
+
18
+ def initialize(id:, label:, prefix:, types:, directory:)
19
+ @id = id
20
+ @label = label
21
+ @prefix = prefix
22
+ @types = types
23
+ @directory = directory
24
+ end
25
+
26
+ def member?(type)
27
+ types.include?(type)
28
+ end
29
+
30
+ RESOURCES = new(
31
+ id: "resources",
32
+ label: "Resources",
33
+ prefix: "Resource",
34
+ types: [ExpressSchema::Type::RESOURCE].freeze,
35
+ directory: "resources",
36
+ )
37
+ MODULES = new(
38
+ id: "modules",
39
+ label: "Application Modules",
40
+ prefix: "Module",
41
+ types: [ExpressSchema::Type::MODULE_ARM, ExpressSchema::Type::MODULE_MIM].freeze,
42
+ directory: "modules",
43
+ )
44
+ BUSINESS_OBJECT_MODELS = new(
45
+ id: "business_object_models",
46
+ label: "Business Object Models",
47
+ prefix: "Business Object Model",
48
+ types: [ExpressSchema::Type::BUSINESS_OBJECT_MODEL].freeze,
49
+ directory: "business_object_models",
50
+ )
51
+ CORE_MODEL = new(
52
+ id: "core_model",
53
+ label: "Core Model",
54
+ prefix: "Core Model",
55
+ types: [ExpressSchema::Type::CORE_MODEL].freeze,
56
+ directory: "core_model",
57
+ )
58
+ OTHER = new(
59
+ id: "other",
60
+ label: "Other Schemas",
61
+ prefix: "Schema",
62
+ types: [ExpressSchema::Type::STANDALONE].freeze,
63
+ directory: ".",
64
+ )
65
+
66
+ ALL = [
67
+ RESOURCES,
68
+ MODULES,
69
+ BUSINESS_OBJECT_MODELS,
70
+ CORE_MODEL,
71
+ OTHER,
72
+ ].freeze
73
+
74
+ def self.for_type(type)
75
+ ALL.find { |category| category.member?(type) } || OTHER
76
+ end
77
+
78
+ def self.for_schema(id:, path:)
79
+ type = ExpressSchema::Type.classify(id: id, path: path)
80
+ for_type(type)
81
+ end
82
+ end
83
+ end
@@ -1,11 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "express_schema"
4
- require_relative "schema_attachment"
5
- require_relative "schema_document"
6
- require_relative "schema_exporter"
7
3
  require "expressir"
8
- require_relative "utils"
9
4
 
10
5
  module Suma
11
6
  class SchemaCollection
@@ -30,36 +25,14 @@ module Suma
30
25
  @schema_name_to_docs[schema_name]
31
26
  end
32
27
 
33
- def process_schemas(schemas, klass)
34
- schemas.each do |config_schema|
35
- process_schema(config_schema, klass)
36
- end
37
- end
38
-
39
- def process_schema(config_schema, klass)
40
- s = ExpressSchema.new(
41
- id: config_schema.id, path: config_schema.path.to_s,
42
- output_path: @output_path_schemas.to_s
43
- )
44
-
45
- doc = klass.new(
46
- schema: s, output_path: @output_path_docs.join(s.id),
47
- )
48
-
49
- @docs[s.id] = doc
50
- @schemas[s.id] = s
51
- @schema_name_to_docs[s.id] = doc
52
- end
53
-
54
28
  def finalize
55
- process_schemas(@config.schemas, SchemaAttachment)
56
-
57
- manifest_entry = @manifest.lookup_schemas_only
29
+ process_schemas(@config.schemas, SchemaTemplate::Plain)
58
30
 
59
- manifest_entry.each do |entry|
31
+ schemas_only_entries = ManifestTraverser.new(@manifest).find_schemas_only
32
+ schemas_only_entries.each do |entry|
60
33
  next unless entry.schema_config
61
34
 
62
- process_schemas(entry.schema_config.schemas, SchemaDocument)
35
+ process_schemas(entry.schema_config.schemas, SchemaTemplate::Document)
63
36
  end
64
37
  end
65
38
 
@@ -67,15 +40,38 @@ module Suma
67
40
  finalize
68
41
 
69
42
  exporter = SchemaExporter.new(
70
- schemas: @config.schemas,
43
+ schemas: schemas.values,
71
44
  output_path: @output_path_schemas,
72
45
  options: { annotations: false },
73
46
  )
74
47
  exporter.export
75
48
 
76
- docs.each_pair do |_schema_id, entry|
77
- entry.compile
49
+ docs.each_pair do |_schema_id, compiler|
50
+ compiler.compile
78
51
  end
79
52
  end
53
+
54
+ private
55
+
56
+ def process_schemas(schemas, template_class)
57
+ schemas.each { |s| process_schema(s, template_class) }
58
+ end
59
+
60
+ def process_schema(config_schema, template_class)
61
+ express = ExpressSchema.new(
62
+ id: config_schema.id, path: config_schema.path.to_s,
63
+ output_path: @output_path_schemas.to_s
64
+ )
65
+
66
+ compiler = SchemaCompiler.new(
67
+ schema: express,
68
+ output_path: @output_path_docs.join(express.id),
69
+ template: template_class.new(express.id),
70
+ )
71
+
72
+ @docs[express.id] = compiler
73
+ @schemas[express.id] = express
74
+ @schema_name_to_docs[express.id] = compiler
75
+ end
80
76
  end
81
77
  end
@@ -2,8 +2,6 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "tmpdir"
5
- require_relative "eengine/wrapper"
6
- require_relative "eengine_converter"
7
5
 
8
6
  module Suma
9
7
  class SchemaComparer
@@ -37,7 +35,10 @@ module Suma
37
35
  return nil
38
36
  end
39
37
 
40
- raise Suma::CompilationError, "XML output not found" unless result[:xml_path]
38
+ unless result[:xml_path]
39
+ raise Suma::CompilationError,
40
+ "XML output not found"
41
+ end
41
42
 
42
43
  convert_to_change_yaml(result[:xml_path], out_dir)
43
44
  ensure
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Suma
7
+ # Orchestrates the compilation of a single EXPRESS schema into HTML/XML
8
+ # via Metanorma.
9
+ #
10
+ # SchemaCompiler owns all file I/O and the Metanorma::Compile invocation
11
+ # for one schema; the rendered AsciiDoc body is supplied by a SchemaTemplate
12
+ # that the caller injects. This split means templates can be tested as pure
13
+ # functions and the compiler can be tested with a real adoc fixture, without
14
+ # either depending on the other.
15
+ class SchemaCompiler
16
+ attr_reader :schema, :id, :output_path, :template
17
+
18
+ def initialize(schema:, output_path:, template:)
19
+ @schema = schema
20
+ @id = schema.id
21
+ @output_path = output_path
22
+ @template = template
23
+ end
24
+
25
+ def compile
26
+ save_config
27
+ save_adoc
28
+ invoke_metanorma
29
+ self
30
+ end
31
+
32
+ def output_xml_path
33
+ filename_adoc("xml")
34
+ end
35
+
36
+ def extensions
37
+ template.extensions
38
+ end
39
+
40
+ private
41
+
42
+ def filename_adoc(ext = "adoc")
43
+ File.join(output_path, "doc_#{id}.#{ext}")
44
+ end
45
+
46
+ def filename_config
47
+ File.join(output_path, "schema_#{id}.yaml")
48
+ end
49
+
50
+ def save_adoc
51
+ log_relative "Save EXPRESS adoc", filename_adoc
52
+
53
+ FileUtils.mkdir_p(File.dirname(filename_adoc))
54
+
55
+ config_relative = Pathname.new(filename_config)
56
+ .relative_path_from(Pathname.new(File.dirname(filename_adoc)))
57
+
58
+ File.write(filename_adoc, template.render(config_relative))
59
+ end
60
+
61
+ def save_config
62
+ log_relative "Save schema config", filename_config
63
+
64
+ FileUtils.mkdir_p(File.dirname(filename_config))
65
+ config = Expressir::SchemaManifest.new
66
+ config.schemas << Expressir::SchemaManifestEntry.new(id: id,
67
+ path: schema.path)
68
+ config.save_to_path(filename_config)
69
+ end
70
+
71
+ def invoke_metanorma
72
+ log_relative "Compiling schema (id: #{id})", filename_adoc
73
+ Metanorma::Compile.new.compile(
74
+ filename_adoc,
75
+ agree_to_terms: true,
76
+ install_fonts: false,
77
+ )
78
+ log_relative "Compiling schema (id: #{id}) ... done!", filename_adoc
79
+ end
80
+
81
+ def log_relative(prefix, path)
82
+ relative = Pathname.new(path).relative_path_from(Dir.pwd)
83
+ Utils.log "#{prefix}: #{relative}"
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # Service for schema-config I/O on a single CollectionManifest node.
5
+ #
6
+ # SchemaDiscovery owns two concerns that previously lived on
7
+ # CollectionManifest:
8
+ #
9
+ # 1. Loading the `schemas.yaml` that sits next to a `collection.yml`
10
+ # into the manifest's `schema_config` slot.
11
+ # 2. Building the doc CollectionManifest sub-tree that hosts the
12
+ # compiled XML output for each schema in `schema_config`.
13
+ #
14
+ # It does not walk the manifest tree — that is ManifestTraverser's job.
15
+ # The split keeps schema I/O in one place and tree traversal in another,
16
+ # so each is independently testable.
17
+ class SchemaDiscovery
18
+ attr_reader :manifest
19
+
20
+ def initialize(manifest)
21
+ @manifest = manifest
22
+ end
23
+
24
+ # If the manifest's file is a `collection.yml` and a `schemas.yaml`
25
+ # sits alongside it, parse it into an Expressir::SchemaManifest and
26
+ # store it on the manifest. Otherwise leave `schema_config` untouched.
27
+ def load_config
28
+ return unless manifest.file
29
+ return unless File.basename(manifest.file) == "collection.yml"
30
+
31
+ schemas_yaml_path = File.join(File.dirname(manifest.file), "schemas.yaml")
32
+ return unless File.exist?(schemas_yaml_path)
33
+
34
+ manifest.schema_config = Expressir::SchemaManifest.from_file(schemas_yaml_path)
35
+ end
36
+
37
+ # Build a CollectionManifest sub-tree that hosts the compiled XML for
38
+ # every schema in `manifest.schema_config`. The wrapper is named with
39
+ # a trailing underscore so it does not collide with the parent id.
40
+ def build_added_manifest(schema_output_path)
41
+ doc = CollectionConfig.from_file(manifest.file)
42
+ doc_id = doc.bibdata.id
43
+
44
+ added = CollectionManifest.new(
45
+ title: "Collection",
46
+ type: "collection",
47
+ identifier: "#{manifest.identifier}_",
48
+ )
49
+
50
+ added.entry = [
51
+ CollectionManifest.new(
52
+ title: doc_id,
53
+ type: "document",
54
+ entry: build_doc_entries(schema_output_path),
55
+ ),
56
+ ]
57
+
58
+ added
59
+ end
60
+
61
+ # Build one CollectionManifest per schema in schema_config, naming
62
+ # each output file as `doc_<basename>.xml` under
63
+ # `<schema_output_path>/<schema_id>/`.
64
+ def build_doc_entries(schema_output_path)
65
+ manifest.schema_config.schemas.map do |schema|
66
+ xml_basename = "#{File.basename(schema.path, '.exp')}.xml"
67
+ CollectionManifest.new(
68
+ identifier: schema.id,
69
+ title: schema.id,
70
+ file: File.join(schema_output_path, schema.id, "doc_#{xml_basename}"),
71
+ )
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,22 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "express_schema"
4
- require_relative "utils"
5
3
  require "fileutils"
6
4
 
7
5
  module Suma
8
- # SchemaExporter exports EXPRESS schemas from a manifest
9
- # with configurable options for annotations and ZIP packaging
6
+ # Exports EXPRESS schemas to a directory, with optional ZIP packaging.
7
+ #
8
+ # Pure sink: the exporter accepts already-loaded +Suma::ExpressSchema+
9
+ # instances and writes their content to disk. Construction of those
10
+ # instances (with the right +output_path+ and +is_standalone_file+
11
+ # flags) is the caller's responsibility — the exporter does not
12
+ # reach across the seam to inspect manifest entries or classify
13
+ # schema types itself.
14
+ #
15
+ # This is a deep module: a small interface (one +export+ method, one
16
+ # option hash) backed by save_exp + zip packaging. The CLI and
17
+ # SchemaCollection adapters construct ExpressSchema instances; the
18
+ # exporter never inspects their shape.
10
19
  class SchemaExporter
11
- CATEGORY_MAP = {
12
- ExpressSchema::Type::RESOURCE => "resources",
13
- ExpressSchema::Type::MODULE_ARM => "modules",
14
- ExpressSchema::Type::MODULE_MIM => "modules",
15
- ExpressSchema::Type::BUSINESS_OBJECT_MODEL => "business_object_models",
16
- ExpressSchema::Type::CORE_MODEL => "core_model",
17
- ExpressSchema::Type::STANDALONE => ".",
18
- }.freeze
19
-
20
20
  attr_reader :schemas, :output_path, :options
21
21
 
22
22
  def initialize(schemas:, output_path:, options: {})
@@ -46,38 +46,10 @@ module Suma
46
46
 
47
47
  def export_to_directory(schemas)
48
48
  schemas.each do |schema|
49
- export_single_schema(schema)
50
- end
51
- end
52
-
53
- def export_single_schema(schema)
54
- is_standalone = !schema.is_a?(Expressir::SchemaManifestEntry)
55
- schema_output_path = determine_output_path(schema, is_standalone)
56
-
57
- express_schema = ExpressSchema.new(
58
- id: schema.id,
59
- path: schema.path.to_s,
60
- output_path: schema_output_path,
61
- is_standalone_file: is_standalone,
62
- )
63
-
64
- express_schema.save_exp(with_annotations: options[:annotations])
65
- end
66
-
67
- def determine_output_path(schema, is_standalone)
68
- if is_standalone
69
- output_path.to_s
70
- else
71
- category = categorize_schema(schema)
72
- output_path.join(category).to_s
49
+ schema.save_exp(with_annotations: options[:annotations])
73
50
  end
74
51
  end
75
52
 
76
- def categorize_schema(schema)
77
- type = ExpressSchema::Type.classify(id: schema.id, path: schema.path)
78
- CATEGORY_MAP.fetch(type, "standalone")
79
- end
80
-
81
53
  # rubocop:disable Metrics/MethodLength
82
54
  def create_zip_archive
83
55
  require "zip"
@@ -2,13 +2,13 @@
2
2
 
3
3
  require "yaml"
4
4
  require "pathname"
5
- require_relative "utils"
6
5
 
7
6
  module Suma
8
7
  class SchemaManifestGenerator
9
8
  YAML_FILE_EXTENSIONS = [".yaml", ".yml"].freeze
10
9
 
11
- def initialize(metanorma_manifest_file, schema_manifest_file, exclude_paths: nil)
10
+ def initialize(metanorma_manifest_file, schema_manifest_file,
11
+ exclude_paths: nil)
12
12
  @metanorma_manifest_file = File.expand_path(metanorma_manifest_file)
13
13
  @schema_manifest_file = schema_manifest_file
14
14
  @exclude_paths = exclude_paths
@@ -27,9 +27,15 @@ module Suma
27
27
  private
28
28
 
29
29
  def validate_inputs
30
- raise Errno::ENOENT, "Specified file `#{@metanorma_manifest_file}` not found." unless File.exist?(@metanorma_manifest_file)
30
+ unless File.exist?(@metanorma_manifest_file)
31
+ raise Errno::ENOENT,
32
+ "Specified file `#{@metanorma_manifest_file}` not found."
33
+ end
31
34
 
32
- raise ArgumentError, "Specified path `#{@metanorma_manifest_file}` is not a file." unless File.file?(@metanorma_manifest_file)
35
+ unless File.file?(@metanorma_manifest_file)
36
+ raise ArgumentError,
37
+ "Specified path `#{@metanorma_manifest_file}` is not a file."
38
+ end
33
39
 
34
40
  [@metanorma_manifest_file, @schema_manifest_file].each do |file|
35
41
  unless YAML_FILE_EXTENSIONS.include?(File.extname(file))
@@ -53,7 +59,8 @@ module Suma
53
59
  all_schemas = { "schemas" => {} }
54
60
 
55
61
  manifest_files.each do |file|
56
- schemas_file_path = File.expand_path(file.gsub("collection.yml", "schemas.yaml"))
62
+ schemas_file_path = File.expand_path(file.gsub("collection.yml",
63
+ "schemas.yaml"))
57
64
 
58
65
  unless File.exist?(schemas_file_path)
59
66
  Utils.log "Schemas file not found: #{schemas_file_path}"
@@ -83,7 +90,8 @@ module Suma
83
90
  schema_manifest_path = File.expand_path(@schema_manifest_file, Dir.pwd)
84
91
 
85
92
  schemas_data["schemas"].each do |key, value|
86
- path_in_schema = File.expand_path(value["path"], File.dirname(schemas_file_path))
93
+ path_in_schema = File.expand_path(value["path"],
94
+ File.dirname(schemas_file_path))
87
95
 
88
96
  fixed_path = Pathname.new(path_in_schema).relative_path_from(
89
97
  Pathname.new(File.dirname(schema_manifest_path)),
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # Converts EXPRESS schema identifiers into human-readable display names.
5
+ #
6
+ # Naming is model-driven: the schema type (resource/module) and suffix
7
+ # determine how the identifier is formatted. Acronyms and numeric
8
+ # prefixes are preserved to match ISO 10303 conventions.
9
+ #
10
+ # @example
11
+ # SchemaNaming.display_name("topology_schema")
12
+ # # => "Topology"
13
+ # SchemaNaming.display_name("Activity_method_assignment_mim")
14
+ # # => "Activity Method Assignment (MIM)"
15
+ # SchemaNaming.display_name("aic_advanced_brep")
16
+ # # => "AIC Advanced Brep"
17
+ module SchemaNaming
18
+ # Acronyms preserved as uppercase during title-casing.
19
+ # Source: ISO 10303 naming conventions.
20
+ ACRONYMS = %w[
21
+ aic aec apu bom csg edraw id ifc pdf pld
22
+ xml xpdl 2d 3d
23
+ ].freeze
24
+
25
+ # Lowercase function words (ISO title-case convention).
26
+ LOWERCASE_WORDS = %w[a an and as for in of on or the to].freeze
27
+
28
+ # Suffixes stripped from the schema name before title-casing,
29
+ # mapped to the parenthesised label appended to the display name.
30
+ # A +nil+ label means the suffix is stripped silently.
31
+ SUFFIXES = {
32
+ "_arm" => "ARM",
33
+ "_mim" => "MIM",
34
+ "_bom" => "BOM",
35
+ "_schema" => nil,
36
+ }.freeze
37
+
38
+ class << self
39
+ # Produce a human-readable display name from a schema identifier.
40
+ #
41
+ # @param schema_id [String] the EXPRESS schema identifier
42
+ # @return [String] human-readable name
43
+ def display_name(schema_id)
44
+ base, label = decompose(schema_id)
45
+ title_cased = title_case(base)
46
+ label ? "#{title_cased} (#{label})" : title_cased
47
+ end
48
+
49
+ # Produce a prefixed display name with the schema category.
50
+ #
51
+ # @param schema_id [String] the EXPRESS schema identifier
52
+ # @param path [String, Pathname] the schema file path (for classification)
53
+ # @return [String] e.g. "Resource: Topology" or "Module: Activity (ARM)"
54
+ def prefixed_name(schema_id, path: nil)
55
+ type = ExpressSchema::Type.classify(id: schema_id, path: path)
56
+ prefix = category_prefix(type)
57
+ "#{prefix}: #{display_name(schema_id)}"
58
+ end
59
+
60
+ # Determine the category prefix for a schema type.
61
+ #
62
+ # @param type [Symbol] one of ExpressSchema::Type constants
63
+ # @return [String]
64
+ def category_prefix(type)
65
+ SchemaCategory.for_type(type).prefix
66
+ end
67
+
68
+ private
69
+
70
+ # Split a schema ID into (base_without_suffix, suffix_label_or_nil).
71
+ #
72
+ # @return [(String, String, nil)]
73
+ def decompose(schema_id)
74
+ SUFFIXES.each do |suffix, label|
75
+ if schema_id.end_with?(suffix)
76
+ return [schema_id.delete_suffix(suffix),
77
+ label]
78
+ end
79
+ end
80
+ [schema_id, nil]
81
+ end
82
+
83
+ # Title-case a snake_case identifier, preserving acronyms.
84
+ # The +tr+ collapses runs of underscores (leading, trailing, and
85
+ # consecutive) into spaces, which +split+ then treats as a single
86
+ # separator.
87
+ #
88
+ # @param name [String] snake_case identifier
89
+ # @return [String] title-cased name
90
+ def title_case(name)
91
+ words = name.tr("_", " ").split
92
+ words.each_with_index.map do |word, i|
93
+ capitalize_word(word, first: i.zero?)
94
+ end.join(" ")
95
+ end
96
+
97
+ # Capitalise a single word, preserving acronyms and
98
+ # lowercasing function words (except when first in the name).
99
+ #
100
+ # @param word [String]
101
+ # @param first [Boolean] whether this is the first word
102
+ # @return [String]
103
+ def capitalize_word(word, first: false)
104
+ return word.upcase if ACRONYMS.include?(word.downcase)
105
+ return word.downcase if LOWERCASE_WORDS.include?(word.downcase) && !first
106
+
107
+ word.capitalize
108
+ end
109
+ end
110
+ end
111
+ end