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.
- checksums.yaml +4 -4
- data/TODO.refactor/01-ea-model-domain-layer.md +64 -0
- data/TODO.refactor/02-ea-model-qea-source-adapter.md +58 -0
- data/TODO.refactor/03-ea-model-xmi-source-adapter.md +33 -0
- data/TODO.refactor/04-ea-model-annotations-tagged-values.md +34 -0
- data/TODO.refactor/05-ea-model-diagrams-coordinates.md +37 -0
- data/TODO.refactor/06-spa-projection-layer.md +45 -0
- data/TODO.refactor/07-spa-sharded-output-strategy.md +28 -0
- data/TODO.refactor/08-spa-build-time-search-index.md +24 -0
- data/TODO.refactor/09-spa-frontend-data-contract.md +25 -0
- data/TODO.refactor/10-spa-cli-command-rework.md +34 -0
- data/TODO.refactor/11-spa-perf-benchmark.md +23 -0
- data/TODO.refactor/12-lutaml-uml-static-site-deprecation.md +28 -0
- data/lib/ea/cli/app.rb +13 -7
- data/lib/ea/cli/command/spa.rb +33 -48
- data/lib/ea/cli/command/svg.rb +68 -0
- data/lib/ea/cli/command.rb +1 -0
- data/lib/ea/model/annotation.rb +29 -0
- data/lib/ea/model/association.rb +40 -0
- data/lib/ea/model/base.rb +27 -0
- data/lib/ea/model/bounds.rb +22 -0
- data/lib/ea/model/classifier.rb +41 -0
- data/lib/ea/model/data_type.rb +9 -0
- data/lib/ea/model/dependency.rb +19 -0
- data/lib/ea/model/diagram.rb +28 -0
- data/lib/ea/model/diagram_connector.rb +30 -0
- data/lib/ea/model/diagram_element.rb +23 -0
- data/lib/ea/model/document.rb +107 -0
- data/lib/ea/model/enumeration.rb +15 -0
- data/lib/ea/model/enumeration_literal.rb +16 -0
- data/lib/ea/model/generalization.rb +19 -0
- data/lib/ea/model/interface.rb +9 -0
- data/lib/ea/model/klass.rb +16 -0
- data/lib/ea/model/metadata.rb +31 -0
- data/lib/ea/model/operation.rb +33 -0
- data/lib/ea/model/package.rb +36 -0
- data/lib/ea/model/parameter.rb +24 -0
- data/lib/ea/model/point.rb +16 -0
- data/lib/ea/model/polymorphic_maps.rb +30 -0
- data/lib/ea/model/primitive_type.rb +9 -0
- data/lib/ea/model/property.rb +49 -0
- data/lib/ea/model/realization.rb +19 -0
- data/lib/ea/model/relationship.rb +21 -0
- data/lib/ea/model/stereotype.rb +24 -0
- data/lib/ea/model/tagged_value.rb +20 -0
- data/lib/ea/model/waypoint.rb +19 -0
- data/lib/ea/model.rb +48 -0
- data/lib/ea/sources/qea/adapter.rb +63 -0
- data/lib/ea/sources/qea/annotation_builder.rb +36 -0
- data/lib/ea/sources/qea/classifier_builder.rb +119 -0
- data/lib/ea/sources/qea/connector_relationship_map.rb +42 -0
- data/lib/ea/sources/qea/diagram_builder.rb +214 -0
- data/lib/ea/sources/qea/diagram_style_parser.rb +43 -0
- data/lib/ea/sources/qea/id_normalizer.rb +24 -0
- data/lib/ea/sources/qea/metadata_builder.rb +42 -0
- data/lib/ea/sources/qea/object_classifier_map.rb +36 -0
- data/lib/ea/sources/qea/operation_builder.rb +79 -0
- data/lib/ea/sources/qea/package_builder.rb +44 -0
- data/lib/ea/sources/qea/property_builder.rb +86 -0
- data/lib/ea/sources/qea/relationship_builder.rb +113 -0
- data/lib/ea/sources/qea/stereotype_builder.rb +44 -0
- data/lib/ea/sources/qea/tagged_value_builder.rb +39 -0
- data/lib/ea/sources/qea.rb +31 -0
- data/lib/ea/sources/xmi/adapter.rb +62 -0
- data/lib/ea/sources/xmi/annotation_builder.rb +49 -0
- data/lib/ea/sources/xmi/classifier_builder.rb +89 -0
- data/lib/ea/sources/xmi/diagram_builder.rb +82 -0
- data/lib/ea/sources/xmi/id_normalizer.rb +27 -0
- data/lib/ea/sources/xmi/metadata_builder.rb +32 -0
- data/lib/ea/sources/xmi/operation_builder.rb +59 -0
- data/lib/ea/sources/xmi/package_builder.rb +73 -0
- data/lib/ea/sources/xmi/property_builder.rb +77 -0
- data/lib/ea/sources/xmi/relationship_builder.rb +142 -0
- data/lib/ea/sources/xmi/relationship_type_map.rb +34 -0
- data/lib/ea/sources/xmi/type_classifier_map.rb +33 -0
- data/lib/ea/sources/xmi.rb +29 -0
- data/lib/ea/sources.rb +15 -0
- data/lib/ea/spa/configuration.rb +110 -0
- data/lib/ea/spa/generator.rb +45 -0
- data/lib/ea/spa/lazy_ref.rb +18 -0
- data/lib/ea/spa/output/sharded_multi_file_strategy.rb +71 -0
- data/lib/ea/spa/output/single_file_strategy.rb +56 -0
- data/lib/ea/spa/output/strategy.rb +36 -0
- data/lib/ea/spa/output.rb +11 -0
- data/lib/ea/spa/package_tree.rb +15 -0
- data/lib/ea/spa/package_tree_node.rb +26 -0
- data/lib/ea/spa/projector.rb +199 -0
- data/lib/ea/spa/search_entry.rb +28 -0
- data/lib/ea/spa/search_index.rb +18 -0
- data/lib/ea/spa/shard.rb +19 -0
- data/lib/ea/spa/skeleton.rb +22 -0
- data/lib/ea/spa/skeleton_entry.rb +27 -0
- data/lib/ea/spa.rb +31 -0
- data/lib/ea/svg/bounds_calculator.rb +74 -0
- data/lib/ea/svg/connector_path.rb +138 -0
- data/lib/ea/svg/element_box.rb +256 -0
- data/lib/ea/svg/renderer.rb +77 -0
- data/lib/ea/svg/style_resolver.rb +72 -0
- data/lib/ea/svg.rb +21 -0
- data/lib/ea/version.rb +1 -1
- data/lib/ea.rb +4 -0
- 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
|