ea 0.2.5 → 0.3.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.
- 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 +130 -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 +96 -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 +194 -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 +46 -0
- data/lib/ea/svg/element_box.rb +71 -0
- data/lib/ea/svg/renderer.rb +70 -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,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,96 @@
|
|
|
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
|
+
# Apply overrides to a model Metadata instance, returning a new
|
|
55
|
+
# one. The original is left untouched.
|
|
56
|
+
def apply_to_metadata(model_metadata)
|
|
57
|
+
return model_metadata unless model_metadata
|
|
58
|
+
|
|
59
|
+
merged = model_metadata_to_hash(model_metadata)
|
|
60
|
+
.merge(stringify_keys(metadata_override))
|
|
61
|
+
hash_to_metadata(merged)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Surface the relevant config (ui, appearance) into a hash the
|
|
65
|
+
# SPA skeleton embeds in its metadata payload.
|
|
66
|
+
def view_extras
|
|
67
|
+
{ "ui" => ui, "appearance" => appearance }.reject { |_, v| v.nil? || v.empty? }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def model_metadata_to_hash(meta)
|
|
73
|
+
JSON.parse(meta.to_json)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def hash_to_metadata(hash)
|
|
77
|
+
Ea::Model::Metadata.new(
|
|
78
|
+
id: hash["id"] || "metadata",
|
|
79
|
+
title: hash["title"],
|
|
80
|
+
version: hash["version"],
|
|
81
|
+
author: hash["author"],
|
|
82
|
+
company: hash["company"],
|
|
83
|
+
created_date: hash["createdDate"],
|
|
84
|
+
modified_date: hash["modifiedDate"],
|
|
85
|
+
source_format: hash["sourceFormat"],
|
|
86
|
+
source_tool: hash["sourceTool"],
|
|
87
|
+
source_path: hash["sourcePath"]
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def stringify_keys(hash)
|
|
92
|
+
hash.transform_keys(&:to_s)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
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
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Spa
|
|
5
|
+
# One-way transform: Ea::Model::Document → SPA view artifacts.
|
|
6
|
+
#
|
|
7
|
+
# The projector walks the model once and emits:
|
|
8
|
+
# - Skeleton (with package tree + per-classifier entries)
|
|
9
|
+
# - SearchIndex (flat searchable rows)
|
|
10
|
+
# - Shards (one per entity, generated lazily via #shard_for)
|
|
11
|
+
#
|
|
12
|
+
# Sharding strategy is configurable via the `shard_url_for`
|
|
13
|
+
# proc — by default it produces "data/<kind>s/<id>.json" paths
|
|
14
|
+
# matching the sharded output strategy.
|
|
15
|
+
class Projector
|
|
16
|
+
attr_reader :document, :shard_url_for, :configuration
|
|
17
|
+
|
|
18
|
+
def initialize(document, shard_url_for: nil, configuration: nil)
|
|
19
|
+
@document = document
|
|
20
|
+
@shard_url_for = shard_url_for || default_shard_url
|
|
21
|
+
@configuration = configuration
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def skeleton
|
|
25
|
+
Skeleton.new(
|
|
26
|
+
metadata: metadata_hash,
|
|
27
|
+
package_tree: build_package_tree,
|
|
28
|
+
entries: build_entries,
|
|
29
|
+
view_extras: view_extras
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def search_index
|
|
34
|
+
SearchIndex.new(entries: build_search_entries)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def shard_for(model_element)
|
|
38
|
+
Shard.new(
|
|
39
|
+
id: model_element.id,
|
|
40
|
+
kind: kind_of(model_element),
|
|
41
|
+
payload: payload_for(model_element)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Enumerate every (id, kind, shard) triple the SPA can address.
|
|
46
|
+
def each_shard
|
|
47
|
+
return enum_for(:each_shard) unless block_given?
|
|
48
|
+
|
|
49
|
+
document.classifiers.each { |c| yield shard_for(c) }
|
|
50
|
+
document.packages.each { |p| yield shard_for(p) }
|
|
51
|
+
document.diagrams.each { |d| yield shard_for(d) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def default_shard_url
|
|
57
|
+
lambda do |element|
|
|
58
|
+
kind = kind_of(element)
|
|
59
|
+
"data/#{pluralize(kind)}/#{element.id}.json"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def pluralize(kind)
|
|
64
|
+
case kind
|
|
65
|
+
when "class" then "classes"
|
|
66
|
+
when "property" then "properties"
|
|
67
|
+
else "#{kind}s"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def metadata_hash
|
|
72
|
+
base = document.metadata
|
|
73
|
+
merged = configuration ? configuration.apply_to_metadata(base) : base
|
|
74
|
+
JSON.parse(merged.to_json)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def view_extras
|
|
78
|
+
configuration&.view_extras || {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_package_tree
|
|
82
|
+
nodes = document.packages.map do |pkg|
|
|
83
|
+
PackageTreeNode.new(
|
|
84
|
+
id: pkg.id,
|
|
85
|
+
name: pkg.name,
|
|
86
|
+
parent_id: pkg.parent_id,
|
|
87
|
+
child_ids: pkg.sub_package_ids,
|
|
88
|
+
classifier_ids: classifiers_in_package_ids(pkg.id),
|
|
89
|
+
diagram_ids: pkg.diagram_ids
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
PackageTree.new(
|
|
93
|
+
root_ids: document.root_packages.map(&:id),
|
|
94
|
+
nodes: nodes
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def classifiers_in_package_ids(package_id)
|
|
99
|
+
document.classifiers_in_package(package_id).map(&:id)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_entries
|
|
103
|
+
document.classifiers.map do |c|
|
|
104
|
+
SkeletonEntry.new(
|
|
105
|
+
id: c.id,
|
|
106
|
+
name: c.name,
|
|
107
|
+
kind: c.model_kind,
|
|
108
|
+
package_id: c.package_id,
|
|
109
|
+
qualified_name: c.qualified_name,
|
|
110
|
+
shard_url: shard_url_for.call(c)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_search_entries
|
|
116
|
+
entries = []
|
|
117
|
+
document.classifiers.each do |c|
|
|
118
|
+
entries << search_entry_for_classifier(c)
|
|
119
|
+
c.properties.each { |p| entries << search_entry_for_property(p, c) }
|
|
120
|
+
end
|
|
121
|
+
document.packages.each { |p| entries << search_entry_for_package(p) }
|
|
122
|
+
entries
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def search_entry_for_classifier(c)
|
|
126
|
+
SearchEntry.new(
|
|
127
|
+
id: c.id,
|
|
128
|
+
kind: c.model_kind,
|
|
129
|
+
name: c.name,
|
|
130
|
+
qualified_name: c.qualified_name,
|
|
131
|
+
package: package_qualified_name(c.package_id),
|
|
132
|
+
content: classifier_search_content(c),
|
|
133
|
+
boost: 1.5
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def search_entry_for_property(prop, owner)
|
|
138
|
+
SearchEntry.new(
|
|
139
|
+
id: prop.id,
|
|
140
|
+
kind: "property",
|
|
141
|
+
name: prop.name,
|
|
142
|
+
qualified_name: "#{owner.qualified_name}::#{prop.name}",
|
|
143
|
+
package: package_qualified_name(owner.package_id),
|
|
144
|
+
content: [prop.name, prop.type_name, prop.annotations.map(&:body)].flatten.compact.join(" "),
|
|
145
|
+
boost: 1.0
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def search_entry_for_package(pkg)
|
|
150
|
+
SearchEntry.new(
|
|
151
|
+
id: pkg.id,
|
|
152
|
+
kind: "package",
|
|
153
|
+
name: pkg.name,
|
|
154
|
+
qualified_name: pkg.qualified_name,
|
|
155
|
+
package: pkg.parent_id ? package_qualified_name(pkg.parent_id) : "",
|
|
156
|
+
content: [pkg.name, pkg.annotations.map(&:body)].flatten.compact.join(" "),
|
|
157
|
+
boost: 1.2
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def classifier_search_content(c)
|
|
162
|
+
parts = [c.name, c.qualified_name, c.stereotype_refs].compact
|
|
163
|
+
parts << c.properties.map(&:name)
|
|
164
|
+
parts << c.operations.map(&:name)
|
|
165
|
+
parts << c.annotations.map(&:body)
|
|
166
|
+
parts.flatten.compact.join(" ").gsub(/\s+/, " ").strip
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def package_qualified_name(package_id)
|
|
170
|
+
return "" if package_id.nil?
|
|
171
|
+
|
|
172
|
+
pkg = document.index_by_id[package_id]
|
|
173
|
+
pkg&.qualified_name || ""
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def kind_of(element)
|
|
177
|
+
case element
|
|
178
|
+
when Ea::Model::Klass then "class"
|
|
179
|
+
when Ea::Model::Enumeration then "enumeration"
|
|
180
|
+
when Ea::Model::DataType then "data_type"
|
|
181
|
+
when Ea::Model::PrimitiveType then "primitive_type"
|
|
182
|
+
when Ea::Model::Interface then "interface"
|
|
183
|
+
when Ea::Model::Package then "package"
|
|
184
|
+
when Ea::Model::Diagram then "diagram"
|
|
185
|
+
else "element"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def payload_for(element)
|
|
190
|
+
JSON.parse(element.to_json)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ea
|
|
4
|
+
module Spa
|
|
5
|
+
# One searchable row. MiniSearch-compatible shape: id, name,
|
|
6
|
+
# qualifiedName, type, package, content — all searchable, with
|
|
7
|
+
# `boost` weighting source-type importance.
|
|
8
|
+
class SearchEntry < Lutaml::Model::Serializable
|
|
9
|
+
attribute :id, :string
|
|
10
|
+
attribute :kind, :string # class|enumeration|package|...
|
|
11
|
+
attribute :name, :string
|
|
12
|
+
attribute :qualified_name, :string
|
|
13
|
+
attribute :package, :string
|
|
14
|
+
attribute :content, :string # flattened searchable text
|
|
15
|
+
attribute :boost, :float, default: -> { 1.0 }
|
|
16
|
+
|
|
17
|
+
json do
|
|
18
|
+
map "id", to: :id
|
|
19
|
+
map "kind", to: :kind
|
|
20
|
+
map "name", to: :name
|
|
21
|
+
map "qualifiedName", to: :qualified_name
|
|
22
|
+
map "package", to: :package
|
|
23
|
+
map "content", to: :content
|
|
24
|
+
map "boost", to: :boost, render_default: true
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|