ea 0.2.5 → 0.3.1

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.refactor/01-ea-model-domain-layer.md +64 -0
  3. data/TODO.refactor/02-ea-model-qea-source-adapter.md +58 -0
  4. data/TODO.refactor/03-ea-model-xmi-source-adapter.md +33 -0
  5. data/TODO.refactor/04-ea-model-annotations-tagged-values.md +34 -0
  6. data/TODO.refactor/05-ea-model-diagrams-coordinates.md +37 -0
  7. data/TODO.refactor/06-spa-projection-layer.md +45 -0
  8. data/TODO.refactor/07-spa-sharded-output-strategy.md +28 -0
  9. data/TODO.refactor/08-spa-build-time-search-index.md +24 -0
  10. data/TODO.refactor/09-spa-frontend-data-contract.md +25 -0
  11. data/TODO.refactor/10-spa-cli-command-rework.md +34 -0
  12. data/TODO.refactor/11-spa-perf-benchmark.md +23 -0
  13. data/TODO.refactor/12-lutaml-uml-static-site-deprecation.md +28 -0
  14. data/lib/ea/cli/app.rb +13 -7
  15. data/lib/ea/cli/command/spa.rb +33 -48
  16. data/lib/ea/cli/command/svg.rb +68 -0
  17. data/lib/ea/cli/command.rb +1 -0
  18. data/lib/ea/model/annotation.rb +29 -0
  19. data/lib/ea/model/association.rb +40 -0
  20. data/lib/ea/model/base.rb +27 -0
  21. data/lib/ea/model/bounds.rb +22 -0
  22. data/lib/ea/model/classifier.rb +41 -0
  23. data/lib/ea/model/data_type.rb +9 -0
  24. data/lib/ea/model/dependency.rb +19 -0
  25. data/lib/ea/model/diagram.rb +28 -0
  26. data/lib/ea/model/diagram_connector.rb +30 -0
  27. data/lib/ea/model/diagram_element.rb +23 -0
  28. data/lib/ea/model/document.rb +107 -0
  29. data/lib/ea/model/enumeration.rb +15 -0
  30. data/lib/ea/model/enumeration_literal.rb +16 -0
  31. data/lib/ea/model/generalization.rb +19 -0
  32. data/lib/ea/model/interface.rb +9 -0
  33. data/lib/ea/model/klass.rb +16 -0
  34. data/lib/ea/model/metadata.rb +31 -0
  35. data/lib/ea/model/operation.rb +33 -0
  36. data/lib/ea/model/package.rb +36 -0
  37. data/lib/ea/model/parameter.rb +24 -0
  38. data/lib/ea/model/point.rb +16 -0
  39. data/lib/ea/model/polymorphic_maps.rb +30 -0
  40. data/lib/ea/model/primitive_type.rb +9 -0
  41. data/lib/ea/model/property.rb +49 -0
  42. data/lib/ea/model/realization.rb +19 -0
  43. data/lib/ea/model/relationship.rb +21 -0
  44. data/lib/ea/model/stereotype.rb +24 -0
  45. data/lib/ea/model/tagged_value.rb +20 -0
  46. data/lib/ea/model/waypoint.rb +19 -0
  47. data/lib/ea/model.rb +48 -0
  48. data/lib/ea/sources/qea/adapter.rb +63 -0
  49. data/lib/ea/sources/qea/annotation_builder.rb +36 -0
  50. data/lib/ea/sources/qea/classifier_builder.rb +119 -0
  51. data/lib/ea/sources/qea/connector_relationship_map.rb +42 -0
  52. data/lib/ea/sources/qea/diagram_builder.rb +214 -0
  53. data/lib/ea/sources/qea/diagram_style_parser.rb +43 -0
  54. data/lib/ea/sources/qea/id_normalizer.rb +24 -0
  55. data/lib/ea/sources/qea/metadata_builder.rb +42 -0
  56. data/lib/ea/sources/qea/object_classifier_map.rb +36 -0
  57. data/lib/ea/sources/qea/operation_builder.rb +79 -0
  58. data/lib/ea/sources/qea/package_builder.rb +44 -0
  59. data/lib/ea/sources/qea/property_builder.rb +86 -0
  60. data/lib/ea/sources/qea/relationship_builder.rb +113 -0
  61. data/lib/ea/sources/qea/stereotype_builder.rb +44 -0
  62. data/lib/ea/sources/qea/tagged_value_builder.rb +39 -0
  63. data/lib/ea/sources/qea.rb +31 -0
  64. data/lib/ea/sources/xmi/adapter.rb +62 -0
  65. data/lib/ea/sources/xmi/annotation_builder.rb +49 -0
  66. data/lib/ea/sources/xmi/classifier_builder.rb +89 -0
  67. data/lib/ea/sources/xmi/diagram_builder.rb +82 -0
  68. data/lib/ea/sources/xmi/id_normalizer.rb +27 -0
  69. data/lib/ea/sources/xmi/metadata_builder.rb +32 -0
  70. data/lib/ea/sources/xmi/operation_builder.rb +59 -0
  71. data/lib/ea/sources/xmi/package_builder.rb +73 -0
  72. data/lib/ea/sources/xmi/property_builder.rb +77 -0
  73. data/lib/ea/sources/xmi/relationship_builder.rb +142 -0
  74. data/lib/ea/sources/xmi/relationship_type_map.rb +34 -0
  75. data/lib/ea/sources/xmi/type_classifier_map.rb +33 -0
  76. data/lib/ea/sources/xmi.rb +29 -0
  77. data/lib/ea/sources.rb +15 -0
  78. data/lib/ea/spa/configuration.rb +110 -0
  79. data/lib/ea/spa/generator.rb +45 -0
  80. data/lib/ea/spa/lazy_ref.rb +18 -0
  81. data/lib/ea/spa/output/sharded_multi_file_strategy.rb +71 -0
  82. data/lib/ea/spa/output/single_file_strategy.rb +56 -0
  83. data/lib/ea/spa/output/strategy.rb +36 -0
  84. data/lib/ea/spa/output.rb +11 -0
  85. data/lib/ea/spa/package_tree.rb +15 -0
  86. data/lib/ea/spa/package_tree_node.rb +26 -0
  87. data/lib/ea/spa/projector.rb +199 -0
  88. data/lib/ea/spa/search_entry.rb +28 -0
  89. data/lib/ea/spa/search_index.rb +18 -0
  90. data/lib/ea/spa/shard.rb +19 -0
  91. data/lib/ea/spa/skeleton.rb +22 -0
  92. data/lib/ea/spa/skeleton_entry.rb +27 -0
  93. data/lib/ea/spa.rb +31 -0
  94. data/lib/ea/svg/bounds_calculator.rb +74 -0
  95. data/lib/ea/svg/connector_path.rb +138 -0
  96. data/lib/ea/svg/element_box.rb +256 -0
  97. data/lib/ea/svg/renderer.rb +77 -0
  98. data/lib/ea/svg/style_resolver.rb +72 -0
  99. data/lib/ea/svg.rb +21 -0
  100. data/lib/ea/version.rb +1 -1
  101. data/lib/ea.rb +4 -0
  102. metadata +97 -2
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ # Translates XMI relationship elements (uml:Association,
7
+ # uml:Generalization, uml:Realization, uml:Dependency) into
8
+ # the appropriate Ea::Model::Relationship subclass.
9
+ class RelationshipBuilder
10
+ attr_reader :root
11
+
12
+ def initialize(root)
13
+ @root = root
14
+ end
15
+
16
+ def build_all
17
+ relationships = []
18
+ walk(root.model) do |element|
19
+ next unless RelationshipTypeMap.relationship_type?(element.type)
20
+
21
+ rel = build_one(element)
22
+ relationships << rel if rel
23
+ end
24
+ relationships
25
+ end
26
+
27
+ def build_one(element)
28
+ klass = RelationshipTypeMap.class_for(element.type)
29
+ args = common_args(element)
30
+ args.merge!(specific_args_for(klass, element))
31
+ klass.new(**args)
32
+ end
33
+
34
+ private
35
+
36
+ def walk(element, &block)
37
+ yield element if block
38
+ element.packaged_element.each { |inner| walk(inner, &block) }
39
+ end
40
+
41
+ def common_args(element)
42
+ id = IdNormalizer.from_xmi_id(element.id)
43
+ {
44
+ id: id,
45
+ name: element.name || element.type&.split(":")&.last&.downcase,
46
+ qualified_name: element.name,
47
+ annotations: AnnotationBuilder.from_element(element, id)
48
+ }
49
+ end
50
+
51
+ def specific_args_for(klass, element)
52
+ case klass.name
53
+ when "Ea::Model::Association" then association_args(element)
54
+ when "Ea::Model::Generalization" then generalization_args(element)
55
+ when "Ea::Model::Realization" then realization_args(element)
56
+ when "Ea::Model::Dependency" then dependency_args(element)
57
+ else {}
58
+ end
59
+ end
60
+
61
+ def association_args(element)
62
+ ends = element.member_ends.to_a
63
+ source_id = ends.first&.idref
64
+ target_id = ends.last&.idref
65
+ owned_ends = element.owned_end.to_a
66
+ source_end = owned_ends.first
67
+ target_end = owned_ends.last
68
+ {
69
+ source_id: source_id,
70
+ target_id: target_id,
71
+ source_role_name: source_end&.name,
72
+ target_role_name: target_end&.name,
73
+ source_multiplicity_lower: parse_lower(source_end),
74
+ source_multiplicity_upper: parse_upper(source_end),
75
+ target_multiplicity_lower: parse_lower(target_end),
76
+ target_multiplicity_upper: parse_upper(target_end),
77
+ source_aggregation: source_end&.aggregation || "none",
78
+ target_aggregation: target_end&.aggregation || "none",
79
+ source_navigable: navigable?(source_end),
80
+ target_navigable: navigable?(target_end)
81
+ }
82
+ end
83
+
84
+ def generalization_args(element)
85
+ # PackagedElement#generalization is a collection of
86
+ # AssociationGeneralization rows. The "specific" (child)
87
+ # is the owning element; "general" (parent) is the
88
+ # referenced id on each generalization row.
89
+ gen = element.generalization.first
90
+ {
91
+ specific_id: IdNormalizer.from_xmi_id(element.id),
92
+ general_id: gen&.general
93
+ }
94
+ end
95
+
96
+ def realization_args(element)
97
+ {
98
+ realizing_id: element.client,
99
+ contract_id: element.supplier
100
+ }
101
+ end
102
+
103
+ def dependency_args(element)
104
+ {
105
+ client_id: element.client,
106
+ supplier_id: element.supplier
107
+ }
108
+ end
109
+
110
+ def parse_lower(end_element)
111
+ return 1 unless end_element
112
+
113
+ value = end_element.lower_value
114
+ return 1 unless value
115
+
116
+ Integer(value.value) rescue 1
117
+ end
118
+
119
+ def parse_upper(end_element)
120
+ return 1 unless end_element
121
+
122
+ value = end_element.upper_value
123
+ return 1 unless value
124
+
125
+ raw = value.value
126
+ return -1 if raw.to_s == "*"
127
+
128
+ Integer(raw) rescue 1
129
+ end
130
+
131
+ def navigable?(end_element)
132
+ return true unless end_element
133
+
134
+ # XMI represents non-navigable ends with aggregation=none
135
+ # and no role name; treat presence of role name or any
136
+ # aggregation as navigable. Default: navigable.
137
+ true
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ # Maps XMI's `xmi:type` discriminator for relationship-shaped
7
+ # packaged elements (uml:Association, uml:Generalization,
8
+ # etc.) to the concrete Ea::Model::Relationship subclass.
9
+ module RelationshipTypeMap
10
+ module_function
11
+
12
+ LOOKUP = {
13
+ "uml:Association" => "Ea::Model::Association",
14
+ "uml:Generalization" => "Ea::Model::Generalization",
15
+ "uml:Realization" => "Ea::Model::Realization",
16
+ "uml:Dependency" => "Ea::Model::Dependency",
17
+ "uml:Usage" => "Ea::Model::Dependency",
18
+ "uml:Abstraction" => "Ea::Model::Dependency",
19
+ }.freeze
20
+
21
+ def class_for(xmi_type)
22
+ name = LOOKUP[xmi_type]
23
+ return nil unless name
24
+
25
+ Object.const_get(name)
26
+ end
27
+
28
+ def relationship_type?(xmi_type)
29
+ LOOKUP.key?(xmi_type)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ # Maps XMI's `xmi:type` discriminator on packaged elements to
7
+ # the concrete Ea::Model::Classifier subclass. New classifier
8
+ # kinds = one entry here, nothing else changes (OCP).
9
+ module TypeClassifierMap
10
+ module_function
11
+
12
+ LOOKUP = {
13
+ "uml:Class" => "Ea::Model::Klass",
14
+ "uml:DataType" => "Ea::Model::DataType",
15
+ "uml:PrimitiveType" => "Ea::Model::PrimitiveType",
16
+ "uml:Enumeration" => "Ea::Model::Enumeration",
17
+ "uml:Interface" => "Ea::Model::Interface",
18
+ }.freeze
19
+
20
+ def class_for(xmi_type)
21
+ name = LOOKUP[xmi_type]
22
+ return nil unless name
23
+
24
+ Object.const_get(name)
25
+ end
26
+
27
+ def classifier_type?(xmi_type)
28
+ LOOKUP.key?(xmi_type)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ # XMI source adapter. Mirrors Ea::Sources::Qea: produces an
6
+ # Ea::Model::Document from a parsed Xmi::Sparx::Root by walking
7
+ # the UML model tree once and translating each element into the
8
+ # canonical model.
9
+ #
10
+ # Both adapters emit structurally identical Ea::Model instances
11
+ # — source format becomes irrelevant once you're in the model.
12
+ # That's the "single way of doing things": one pipeline, two
13
+ # interchangeable sources.
14
+ module Xmi
15
+ autoload :IdNormalizer, "ea/sources/xmi/id_normalizer"
16
+ autoload :TypeClassifierMap, "ea/sources/xmi/type_classifier_map"
17
+ autoload :RelationshipTypeMap, "ea/sources/xmi/relationship_type_map"
18
+ autoload :MetadataBuilder, "ea/sources/xmi/metadata_builder"
19
+ autoload :PackageBuilder, "ea/sources/xmi/package_builder"
20
+ autoload :ClassifierBuilder, "ea/sources/xmi/classifier_builder"
21
+ autoload :PropertyBuilder, "ea/sources/xmi/property_builder"
22
+ autoload :OperationBuilder, "ea/sources/xmi/operation_builder"
23
+ autoload :RelationshipBuilder, "ea/sources/xmi/relationship_builder"
24
+ autoload :AnnotationBuilder, "ea/sources/xmi/annotation_builder"
25
+ autoload :DiagramBuilder, "ea/sources/xmi/diagram_builder"
26
+ autoload :Adapter, "ea/sources/xmi/adapter"
27
+ end
28
+ end
29
+ end
data/lib/ea/sources.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ # Source adapters: translate format-specific parses (QEA SQLite,
5
+ # Sparx XMI) into the canonical Ea::Model::Document.
6
+ #
7
+ # Each adapter is a driving port in the hexagonal sense — its
8
+ # only job is to take a parsed source and emit model instances.
9
+ # Adapters do not know about each other or about consumer
10
+ # adapters (Ea::Spa, etc.).
11
+ module Sources
12
+ autoload :Qea, "ea/sources/qea"
13
+ autoload :Xmi, "ea/sources/xmi"
14
+ end
15
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ea
6
+ module Spa
7
+ # SPA-side configuration. A YAML file with the shape:
8
+ #
9
+ # metadata:
10
+ # title: ...
11
+ # description: ...
12
+ # version: ...
13
+ # author: ...
14
+ # license: ...
15
+ # homepage: ...
16
+ # repository: ...
17
+ # ui:
18
+ # title: ...
19
+ # description: ...
20
+ #
21
+ # Values under `metadata` override fields on the model's
22
+ # Ea::Model::Metadata. Values under `ui` (and other sections
23
+ # like `appearance`) are surfaced to the SPA view via the
24
+ # skeleton's metadata hash so the frontend can use them.
25
+ class Configuration
26
+ attr_reader :path, :data
27
+
28
+ def self.load(path)
29
+ return nil if path.nil? || path.empty?
30
+ unless File.exist?(path)
31
+ raise ArgumentError, "SPA config not found: #{path}"
32
+ end
33
+
34
+ new(path, YAML.safe_load(File.read(path)) || {})
35
+ end
36
+
37
+ def initialize(path, data)
38
+ @path = path
39
+ @data = data.is_a?(Hash) ? data : {}
40
+ end
41
+
42
+ def metadata_override
43
+ (@data["metadata"] || {}).transform_keys(&:to_sym)
44
+ end
45
+
46
+ def ui
47
+ @data["ui"] || {}
48
+ end
49
+
50
+ def appearance
51
+ @data["appearance"] || {}
52
+ end
53
+
54
+ # Diagram rendering control. Default: true (preserve historical
55
+ # behavior). Set `diagrams.enabled: false` in the YAML to skip
56
+ # diagram shards entirely — useful when the renderer output is
57
+ # not yet production-quality and you'd rather hide diagrams
58
+ # than ship broken visuals.
59
+ def render_diagrams?
60
+ diagrams_cfg = @data["diagrams"].is_a?(Hash) ? @data["diagrams"] : {}
61
+ enabled = diagrams_cfg.key?("enabled") ? diagrams_cfg["enabled"] : true
62
+ enabled != false
63
+ end
64
+
65
+ # Apply overrides to a model Metadata instance, returning a new
66
+ # one. The original is left untouched.
67
+ def apply_to_metadata(model_metadata)
68
+ return model_metadata unless model_metadata
69
+
70
+ merged = model_metadata_to_hash(model_metadata)
71
+ .merge(stringify_keys(metadata_override))
72
+ hash_to_metadata(merged)
73
+ end
74
+
75
+ # Surface the relevant config (ui, appearance, diagrams) into
76
+ # a hash the SPA skeleton embeds in its metadata payload so
77
+ # the frontend can hide its diagram UI when disabled.
78
+ def view_extras
79
+ extras = { "ui" => ui, "appearance" => appearance }
80
+ extras["diagrams"] = { "enabled" => render_diagrams? }
81
+ extras.reject { |_, v| v.nil? || v.empty? }
82
+ end
83
+
84
+ private
85
+
86
+ def model_metadata_to_hash(meta)
87
+ JSON.parse(meta.to_json)
88
+ end
89
+
90
+ def hash_to_metadata(hash)
91
+ Ea::Model::Metadata.new(
92
+ id: hash["id"] || "metadata",
93
+ title: hash["title"],
94
+ version: hash["version"],
95
+ author: hash["author"],
96
+ company: hash["company"],
97
+ created_date: hash["createdDate"],
98
+ modified_date: hash["modifiedDate"],
99
+ source_format: hash["sourceFormat"],
100
+ source_tool: hash["sourceTool"],
101
+ source_path: hash["sourcePath"]
102
+ )
103
+ end
104
+
105
+ def stringify_keys(hash)
106
+ hash.transform_keys(&:to_s)
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ # Orchestrator: takes an Ea::Model::Document and an output mode,
6
+ # dispatches to the matching Output::Strategy. Single source of
7
+ # truth for which modes exist; new modes = one entry in
8
+ # OUTPUT_STRATEGIES.
9
+ class Generator
10
+ OUTPUT_STRATEGIES = {
11
+ single_file: Output::SingleFileStrategy,
12
+ sharded: Output::ShardedMultiFileStrategy
13
+ }.freeze
14
+
15
+ DEFAULT_MODE = :single_file
16
+
17
+ attr_reader :document, :mode, :output_path, :shard_url_for, :configuration
18
+
19
+ def initialize(document, output:, mode: nil, shard_url_for: nil,
20
+ configuration: nil)
21
+ @document = document
22
+ @output_path = output
23
+ @mode = (mode || DEFAULT_MODE).to_sym
24
+ @shard_url_for = shard_url_for
25
+ @configuration = configuration
26
+ end
27
+
28
+ def generate
29
+ strategy_class.new(output_path).render(projector)
30
+ end
31
+
32
+ def projector
33
+ @projector ||= Projector.new(document,
34
+ shard_url_for: shard_url_for,
35
+ configuration: configuration)
36
+ end
37
+
38
+ def strategy_class
39
+ OUTPUT_STRATEGIES[mode] ||
40
+ raise(ArgumentError, "Unknown SPA mode: #{mode.inspect}. " \
41
+ "Use :single_file or :sharded.")
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ # A lazy reference: id + URL to where the full shard lives. Used
6
+ # in the skeleton so the frontend can fetch detail on demand
7
+ # rather than upfront.
8
+ class LazyRef < Lutaml::Model::Serializable
9
+ attribute :id, :string
10
+ attribute :url, :string
11
+
12
+ json do
13
+ map "id", to: :id
14
+ map "url", to: :url
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ea
6
+ module Spa
7
+ module Output
8
+ # Sharded layout: directory with skeleton.json, search.json,
9
+ # and one JSON file per entity under data/. The frontend
10
+ # loads skeleton + search upfront and fetches shards on
11
+ # demand. This is the layout for large models.
12
+ class ShardedMultiFileStrategy < Strategy
13
+ def render(projector)
14
+ FileUtils.mkdir_p(output_path)
15
+ FileUtils.mkdir_p(File.join(output_path, "data"))
16
+
17
+ skeleton = projector.skeleton
18
+ write_json(File.join(output_path, "skeleton.json"), skeleton)
19
+ write_json(File.join(output_path, "search.json"),
20
+ projector.search_index)
21
+ write_shards(projector)
22
+ write_index_html(skeleton)
23
+
24
+ output_path
25
+ end
26
+
27
+ private
28
+
29
+ def write_shards(projector)
30
+ projector.each_shard.each do |shard|
31
+ path = File.join(output_path, "data", pluralize(shard.kind),
32
+ "#{shard.id}.json")
33
+ write_json(path, shard)
34
+ end
35
+ end
36
+
37
+ def pluralize(kind)
38
+ case kind
39
+ when "class" then "classes"
40
+ when "property" then "properties"
41
+ else "#{kind}s"
42
+ end
43
+ end
44
+
45
+ def write_index_html(skeleton)
46
+ title = skeleton.view_extras&.dig("ui", "title") ||
47
+ skeleton.metadata&.fetch("title", nil) ||
48
+ "Ea::Spa"
49
+ File.write(File.join(output_path, "index.html"), <<~HTML)
50
+ <!DOCTYPE html>
51
+ <html lang="en">
52
+ <head>
53
+ <meta charset="UTF-8">
54
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
55
+ <title>#{title}</title>
56
+ </head>
57
+ <body>
58
+ <div id="app"></div>
59
+ <script>
60
+ window.__SPA_SKELETON_URL__ = 'skeleton.json';
61
+ window.__SPA_SEARCH_URL__ = 'search.json';
62
+ window.__SPA_SHARD_BASE__ = 'data/';
63
+ </script>
64
+ </body>
65
+ </html>
66
+ HTML
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ea
6
+ module Spa
7
+ module Output
8
+ # Embeds skeleton + search + every shard in one HTML file
9
+ # (data inlined as a JSON blob). Suitable for small models
10
+ # only; not appropriate when shard count or total payload is
11
+ # large.
12
+ class SingleFileStrategy < Strategy
13
+ def render(projector)
14
+ FileUtils.mkdir_p(File.dirname(output_path))
15
+
16
+ skeleton = projector.skeleton
17
+ payload = {
18
+ metadata: skeleton.metadata,
19
+ packageTree: skeleton.package_tree,
20
+ skeletonEntries: skeleton.entries,
21
+ searchIndex: projector.search_index,
22
+ shards: projector.each_shard.to_a
23
+ }
24
+ payload[:viewExtras] = skeleton.view_extras unless skeleton.view_extras.nil? || skeleton.view_extras.empty?
25
+
26
+ File.write(output_path, build_html(payload))
27
+ output_path
28
+ end
29
+
30
+ private
31
+
32
+ def build_html(payload)
33
+ title = payload.dig(:viewExtras, "ui", "title") ||
34
+ payload[:metadata]&.fetch("title", nil) ||
35
+ "Ea::Spa"
36
+ <<~HTML
37
+ <!DOCTYPE html>
38
+ <html lang="en">
39
+ <head>
40
+ <meta charset="UTF-8">
41
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
42
+ <title>#{title}</title>
43
+ </head>
44
+ <body>
45
+ <div id="app"></div>
46
+ <script>
47
+ window.__SPA_DATA__ = #{payload.to_json};
48
+ </script>
49
+ </body>
50
+ </html>
51
+ HTML
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ module Output
6
+ # Abstract output strategy. Concrete subclasses project the
7
+ # same SPA artifacts (skeleton, search index, shards) to disk
8
+ # in different layouts.
9
+ #
10
+ # Adding a new layout = new subclass + registration in
11
+ # Generator::OUTPUT_STRATEGIES. No edit to Projector or
12
+ # existing strategies.
13
+ class Strategy
14
+ SIZE_THRESHOLD_BYTES = 2 * 1024 * 1024 # 2 MB
15
+
16
+ attr_reader :output_path
17
+
18
+ def initialize(output_path)
19
+ @output_path = output_path
20
+ end
21
+
22
+ def render(_projector)
23
+ raise NotImplementedError,
24
+ "#{self.class} must implement #render"
25
+ end
26
+
27
+ private
28
+
29
+ def write_json(path, data)
30
+ FileUtils.mkdir_p(File.dirname(path))
31
+ File.write(path, data.to_json)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ module Output
6
+ autoload :Strategy, "ea/spa/output/strategy"
7
+ autoload :SingleFileStrategy, "ea/spa/output/single_file_strategy"
8
+ autoload :ShardedMultiFileStrategy, "ea/spa/output/sharded_multi_file_strategy"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ class PackageTree < Lutaml::Model::Serializable
6
+ attribute :root_ids, :string, collection: true, initialize_empty: true
7
+ attribute :nodes, PackageTreeNode, collection: true, initialize_empty: true
8
+
9
+ json do
10
+ map "rootIds", to: :root_ids, render_empty: true
11
+ map "nodes", to: :nodes, render_empty: true
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Spa
5
+ # One node in the SPA's package navigation tree. References
6
+ # sub-packages and classifiers by id — the frontend resolves
7
+ # them via the skeleton index.
8
+ class PackageTreeNode < Lutaml::Model::Serializable
9
+ attribute :id, :string
10
+ attribute :name, :string
11
+ attribute :parent_id, :string
12
+ attribute :child_ids, :string, collection: true, initialize_empty: true
13
+ attribute :classifier_ids, :string, collection: true, initialize_empty: true
14
+ attribute :diagram_ids, :string, collection: true, initialize_empty: true
15
+
16
+ json do
17
+ map "id", to: :id
18
+ map "name", to: :name
19
+ map "parentId", to: :parent_id
20
+ map "childIds", to: :child_ids, render_empty: true
21
+ map "classifierIds", to: :classifier_ids, render_empty: true
22
+ map "diagramIds", to: :diagram_ids, render_empty: true
23
+ end
24
+ end
25
+ end
26
+ end