antoinette 0.1.0 → 0.1.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/CHANGELOG.md +13 -0
- data/LICENSE.txt +1 -1
- data/README.md +118 -17
- data/lib/antoinette/cli/commands.rb +175 -0
- data/lib/antoinette/engine.rb +13 -0
- data/lib/antoinette/services/clear_script_tag.rb +30 -0
- data/lib/antoinette/services/compile_elm.rb +53 -0
- data/lib/antoinette/services/concat_bundle.rb +16 -0
- data/lib/antoinette/services/elm_app_usage_analyzer.rb +100 -0
- data/lib/antoinette/services/inject_script_tag.rb +30 -0
- data/lib/antoinette/services/partial_resolver.rb +68 -0
- data/lib/antoinette/services/weaver.rb +59 -0
- data/lib/antoinette/version.rb +3 -1
- data/lib/antoinette.rb +12 -3
- data/lib/generators/antoinette/install_generator.rb +88 -0
- data/lib/generators/antoinette/templates/BundleGraph.elm +344 -0
- data/lib/generators/antoinette/templates/Sankey.elm +364 -0
- data/lib/generators/antoinette/templates/graph_controller.rb +9 -0
- data/lib/generators/antoinette/templates/show.html.erb +11 -0
- data/lib/tasks/antoinette.rake +10 -0
- metadata +109 -14
- data/CODE_OF_CONDUCT.md +0 -74
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "haikunator"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Antoinette
|
|
7
|
+
class Weaver
|
|
8
|
+
Bundle = Struct.new(:name, :elm_apps, :templates)
|
|
9
|
+
|
|
10
|
+
def initialize(
|
|
11
|
+
elm_analyzer: ElmAppUsageAnalyzer.new,
|
|
12
|
+
partial_resolver: PartialResolver.new,
|
|
13
|
+
custom_view_paths: []
|
|
14
|
+
)
|
|
15
|
+
@elm_analyzer = elm_analyzer
|
|
16
|
+
@partial_resolver = partial_resolver
|
|
17
|
+
@custom_view_paths = custom_view_paths
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def bundles
|
|
21
|
+
@bundles ||= @elm_analyzer.layout_apps.then do |layout_apps|
|
|
22
|
+
@elm_analyzer.mappings.map do |apps, templates|
|
|
23
|
+
resolved_templates = resolve_templates_from_partials(templates)
|
|
24
|
+
merged_apps = (apps + layout_apps).uniq.sort
|
|
25
|
+
Bundle.new(
|
|
26
|
+
Haikunator.haikunate,
|
|
27
|
+
merged_apps,
|
|
28
|
+
resolved_templates.sort
|
|
29
|
+
)
|
|
30
|
+
end.sort_by { |bundle| -bundle.elm_apps.count }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate_json
|
|
35
|
+
output = {bundles: bundles.map do |bundle|
|
|
36
|
+
{
|
|
37
|
+
name: bundle.name,
|
|
38
|
+
elm_apps: bundle.elm_apps,
|
|
39
|
+
templates: bundle.templates
|
|
40
|
+
}
|
|
41
|
+
end}
|
|
42
|
+
output[:custom_view_paths] = @custom_view_paths if @custom_view_paths.any?
|
|
43
|
+
JSON.pretty_generate(output)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resolve_templates_from_partials(templates)
|
|
47
|
+
templates.flat_map do |template|
|
|
48
|
+
if template.start_with?("_") || template.include?("/_")
|
|
49
|
+
partial_path = template.sub(%r{^app/views/}, "")
|
|
50
|
+
@partial_resolver.resolve(partial_path).map do |parent|
|
|
51
|
+
"app/views/#{parent}"
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
template
|
|
55
|
+
end
|
|
56
|
+
end.uniq
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/antoinette/version.rb
CHANGED
data/lib/antoinette.rb
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "antoinette/version"
|
|
4
|
+
require_relative "antoinette/services/partial_resolver"
|
|
5
|
+
require_relative "antoinette/services/elm_app_usage_analyzer"
|
|
6
|
+
require_relative "antoinette/services/weaver"
|
|
7
|
+
require_relative "antoinette/services/compile_elm"
|
|
8
|
+
require_relative "antoinette/services/concat_bundle"
|
|
9
|
+
require_relative "antoinette/services/inject_script_tag"
|
|
10
|
+
require_relative "antoinette/services/clear_script_tag"
|
|
11
|
+
require_relative "antoinette/cli/commands"
|
|
12
|
+
require_relative "antoinette/engine" if defined?(Rails::Engine)
|
|
2
13
|
|
|
3
14
|
module Antoinette
|
|
4
|
-
class Error < StandardError; end
|
|
5
|
-
# Your code goes here...
|
|
6
15
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/base"
|
|
5
|
+
|
|
6
|
+
module Antoinette
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Install Antoinette into your Rails application"
|
|
12
|
+
|
|
13
|
+
def create_config_file
|
|
14
|
+
create_file "config/antoinette.json", <<~JSON
|
|
15
|
+
{
|
|
16
|
+
"elm_path": "elm",
|
|
17
|
+
"bundles": []
|
|
18
|
+
}
|
|
19
|
+
JSON
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_client_directory
|
|
23
|
+
empty_directory "app/client"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def copy_elm_files
|
|
27
|
+
copy_file "BundleGraph.elm", "app/client/BundleGraph.elm"
|
|
28
|
+
copy_file "Sankey.elm", "app/client/Sankey.elm"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def install_elm_dependencies
|
|
32
|
+
say "Installing Elm dependencies..."
|
|
33
|
+
run "elm install elm/browser"
|
|
34
|
+
run "elm install elm/html"
|
|
35
|
+
run "elm install elm/json"
|
|
36
|
+
run "elm install elm/svg"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def copy_controller
|
|
40
|
+
copy_file "graph_controller.rb", "app/controllers/antoinette/graph_controller.rb"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def copy_view
|
|
44
|
+
copy_file "show.html.erb", "app/views/antoinette/graph/show.html.erb"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_antoinette_binstub
|
|
48
|
+
create_file "bin/antoinette", <<~RUBY
|
|
49
|
+
#!/usr/bin/env ruby
|
|
50
|
+
require_relative "../config/environment"
|
|
51
|
+
Dry::CLI.new(Antoinette::CLI::Commands).call
|
|
52
|
+
RUBY
|
|
53
|
+
chmod "bin/antoinette", 0o755
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def create_assets_directory
|
|
57
|
+
empty_directory "app/assets/javascripts/antoinette"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update_gitignore
|
|
61
|
+
gitignore_path = Rails.root.join(".gitignore")
|
|
62
|
+
ignore_line = "app/assets/javascripts/antoinette/"
|
|
63
|
+
|
|
64
|
+
if File.exist?(gitignore_path) && !File.read(gitignore_path).include?(ignore_line)
|
|
65
|
+
append_to_file ".gitignore", "#{ignore_line}\n"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def add_routes
|
|
70
|
+
route_content = <<~RUBY
|
|
71
|
+
get "/antoinette", to: "antoinette/graph#show"
|
|
72
|
+
RUBY
|
|
73
|
+
|
|
74
|
+
route route_content
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def show_post_install_message
|
|
78
|
+
say ""
|
|
79
|
+
say "Antoinette has been installed!", :green
|
|
80
|
+
say ""
|
|
81
|
+
say "Next steps:"
|
|
82
|
+
say " 1. Run `bin/antoinette config` to generate bundle configuration"
|
|
83
|
+
say " 2. Run `bin/antoinette build` to compile Elm bundles"
|
|
84
|
+
say ""
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
module BundleGraph exposing (main)
|
|
2
|
+
|
|
3
|
+
import Browser
|
|
4
|
+
import Dict
|
|
5
|
+
import Html exposing (Html, button, div, h1, text)
|
|
6
|
+
import Html.Attributes exposing (disabled, style)
|
|
7
|
+
import Html.Events exposing (onClick)
|
|
8
|
+
import Json.Decode as Decode exposing (Decoder)
|
|
9
|
+
import Sankey exposing (layout, render)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
type alias Model =
|
|
13
|
+
{ bundles : List Bundle
|
|
14
|
+
, selectedNodeId : Maybe String
|
|
15
|
+
, lockedNodeIds : List String
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
type alias Bundle =
|
|
20
|
+
{ name : String
|
|
21
|
+
, elmApps : List String
|
|
22
|
+
, templates : List String
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
type alias Flags =
|
|
27
|
+
{ bundles : List Bundle
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
type alias LabelNode =
|
|
32
|
+
{ id : String
|
|
33
|
+
, label : String
|
|
34
|
+
, column : Int
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
type Msg
|
|
39
|
+
= SelectNode String
|
|
40
|
+
| DeselectNode
|
|
41
|
+
| ToggleLockNode String
|
|
42
|
+
| ClearSelection
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
main : Program Decode.Value Model Msg
|
|
46
|
+
main =
|
|
47
|
+
Browser.element
|
|
48
|
+
{ init = init
|
|
49
|
+
, update = update
|
|
50
|
+
, view = view
|
|
51
|
+
, subscriptions = \_ -> Sub.none
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
init : Decode.Value -> ( Model, Cmd Msg )
|
|
56
|
+
init flags =
|
|
57
|
+
case Decode.decodeValue flagsDecoder flags of
|
|
58
|
+
Ok decoded ->
|
|
59
|
+
( { bundles = decoded.bundles
|
|
60
|
+
, selectedNodeId = Nothing
|
|
61
|
+
, lockedNodeIds = []
|
|
62
|
+
}
|
|
63
|
+
, Cmd.none
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
Err _ ->
|
|
67
|
+
( { bundles = []
|
|
68
|
+
, selectedNodeId = Nothing
|
|
69
|
+
, lockedNodeIds = []
|
|
70
|
+
}
|
|
71
|
+
, Cmd.none
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
76
|
+
update msg model =
|
|
77
|
+
case msg of
|
|
78
|
+
SelectNode appId ->
|
|
79
|
+
( { model | selectedNodeId = Just appId }, Cmd.none )
|
|
80
|
+
|
|
81
|
+
DeselectNode ->
|
|
82
|
+
case model.selectedNodeId of
|
|
83
|
+
Just nodeId ->
|
|
84
|
+
if List.member nodeId model.lockedNodeIds then
|
|
85
|
+
( model, Cmd.none )
|
|
86
|
+
|
|
87
|
+
else
|
|
88
|
+
( { model | selectedNodeId = Nothing }, Cmd.none )
|
|
89
|
+
|
|
90
|
+
Nothing ->
|
|
91
|
+
( model, Cmd.none )
|
|
92
|
+
|
|
93
|
+
ToggleLockNode nodeId ->
|
|
94
|
+
if List.member nodeId model.lockedNodeIds then
|
|
95
|
+
( { model
|
|
96
|
+
| lockedNodeIds = List.filter (\id -> id /= nodeId) model.lockedNodeIds
|
|
97
|
+
}
|
|
98
|
+
, Cmd.none
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
else
|
|
102
|
+
( { model | lockedNodeIds = nodeId :: model.lockedNodeIds }
|
|
103
|
+
, Cmd.none
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
ClearSelection ->
|
|
107
|
+
( { model | selectedNodeId = Nothing, lockedNodeIds = [] }, Cmd.none )
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
view : Model -> Html Msg
|
|
111
|
+
view model =
|
|
112
|
+
let
|
|
113
|
+
nodes =
|
|
114
|
+
buildNodes model.bundles
|
|
115
|
+
|
|
116
|
+
columnWidths =
|
|
117
|
+
buildColumnWidths nodes
|
|
118
|
+
|
|
119
|
+
highlightedNodeIds =
|
|
120
|
+
case model.selectedNodeId of
|
|
121
|
+
Just id ->
|
|
122
|
+
id :: model.lockedNodeIds
|
|
123
|
+
|
|
124
|
+
Nothing ->
|
|
125
|
+
model.lockedNodeIds
|
|
126
|
+
|
|
127
|
+
sankeyModel =
|
|
128
|
+
{ svgWidth = 1100
|
|
129
|
+
, svgHeight = totalHeight model.bundles
|
|
130
|
+
, columnWidths = columnWidths
|
|
131
|
+
, nodePadding = 8
|
|
132
|
+
, columnSpacing = 350
|
|
133
|
+
, edgeColor = "#60A5FA"
|
|
134
|
+
, edgeOpacity = 0.3
|
|
135
|
+
, fontSize = 11
|
|
136
|
+
, highlightedNodeIds = highlightedNodeIds
|
|
137
|
+
, lockedNodeIds = model.lockedNodeIds
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
events =
|
|
141
|
+
{ onSelectNode = SelectNode
|
|
142
|
+
, onDeselectNode = DeselectNode
|
|
143
|
+
, onLockNode = ToggleLockNode
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
diagram =
|
|
147
|
+
layout sankeyModel nodes (buildEdges model.bundles)
|
|
148
|
+
|
|
149
|
+
hasSelection =
|
|
150
|
+
not (List.isEmpty model.lockedNodeIds)
|
|
151
|
+
|
|
152
|
+
buttonStyles =
|
|
153
|
+
if hasSelection then
|
|
154
|
+
[ style "color" "#6b7280"
|
|
155
|
+
, style "cursor" "pointer"
|
|
156
|
+
, style "border" "1px solid #374151"
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
else
|
|
160
|
+
[ style "color" "#9ca3af"
|
|
161
|
+
, style "cursor" "not-allowed"
|
|
162
|
+
, style "border" "1px solid #d1d5db"
|
|
163
|
+
]
|
|
164
|
+
in
|
|
165
|
+
div []
|
|
166
|
+
[ div [ style "display" "flex", style "align-items" "center", style "gap" "16px" ]
|
|
167
|
+
[ h1
|
|
168
|
+
[ style "font-family" "system-ui, sans-serif"
|
|
169
|
+
, style "margin-bottom" "10px"
|
|
170
|
+
, style "color" "#374151"
|
|
171
|
+
]
|
|
172
|
+
[ text "Antoinette Bundle Graph" ]
|
|
173
|
+
, button
|
|
174
|
+
([ onClick ClearSelection
|
|
175
|
+
, style "font-size" "12px"
|
|
176
|
+
, style "margin-bottom" "8px"
|
|
177
|
+
, style "border-radius" "4px"
|
|
178
|
+
, style "padding" "4px 8px"
|
|
179
|
+
, style "background" "white"
|
|
180
|
+
, disabled (not hasSelection)
|
|
181
|
+
]
|
|
182
|
+
++ buttonStyles
|
|
183
|
+
)
|
|
184
|
+
[ text "Clear Selection" ]
|
|
185
|
+
]
|
|
186
|
+
, render sankeyModel events diagram
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
totalHeight : List Bundle -> Float
|
|
191
|
+
totalHeight bundles =
|
|
192
|
+
let
|
|
193
|
+
elmApps =
|
|
194
|
+
uniqueElmApps bundles
|
|
195
|
+
|
|
196
|
+
templates =
|
|
197
|
+
uniqueTemplates bundles
|
|
198
|
+
|
|
199
|
+
maxItems =
|
|
200
|
+
max (List.length elmApps) (max (List.length bundles) (List.length templates))
|
|
201
|
+
|
|
202
|
+
rowHeight =
|
|
203
|
+
27
|
|
204
|
+
in
|
|
205
|
+
toFloat (maxItems * rowHeight + 100)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
buildColumnWidths : List LabelNode -> Dict.Dict Int Float
|
|
209
|
+
buildColumnWidths nodes =
|
|
210
|
+
let
|
|
211
|
+
padding =
|
|
212
|
+
2
|
|
213
|
+
|
|
214
|
+
labelWidth node =
|
|
215
|
+
let
|
|
216
|
+
perCharacter =
|
|
217
|
+
case node.column of
|
|
218
|
+
2 ->
|
|
219
|
+
6
|
|
220
|
+
|
|
221
|
+
_ ->
|
|
222
|
+
7
|
|
223
|
+
in
|
|
224
|
+
toFloat (padding + String.length node.label * perCharacter)
|
|
225
|
+
|
|
226
|
+
updateMax node dict =
|
|
227
|
+
let
|
|
228
|
+
currentMax =
|
|
229
|
+
Dict.get node.column dict |> Maybe.withDefault 0
|
|
230
|
+
|
|
231
|
+
nodeWidth =
|
|
232
|
+
labelWidth node
|
|
233
|
+
in
|
|
234
|
+
Dict.insert node.column (max currentMax nodeWidth) dict
|
|
235
|
+
in
|
|
236
|
+
List.foldl updateMax Dict.empty nodes
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
buildNodes : List Bundle -> List LabelNode
|
|
240
|
+
buildNodes bundles =
|
|
241
|
+
let
|
|
242
|
+
elmAppNodes =
|
|
243
|
+
uniqueElmApps bundles
|
|
244
|
+
|> List.map
|
|
245
|
+
(\app ->
|
|
246
|
+
{ id = "app:" ++ app
|
|
247
|
+
, label = app
|
|
248
|
+
, column = 0
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
bundleNodes =
|
|
253
|
+
bundles
|
|
254
|
+
|> List.map
|
|
255
|
+
(\bundle ->
|
|
256
|
+
{ id = "bundle:" ++ bundle.name
|
|
257
|
+
, label = bundle.name
|
|
258
|
+
, column = 1
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
templateNodes =
|
|
263
|
+
uniqueTemplates bundles
|
|
264
|
+
|> List.map
|
|
265
|
+
(\template ->
|
|
266
|
+
{ id = "template:" ++ template
|
|
267
|
+
, label = template
|
|
268
|
+
, column = 2
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
in
|
|
272
|
+
elmAppNodes ++ bundleNodes ++ templateNodes
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
buildEdges : List Bundle -> List { fromId : String, toId : String }
|
|
276
|
+
buildEdges bundles =
|
|
277
|
+
let
|
|
278
|
+
appToBundleEdges =
|
|
279
|
+
bundles
|
|
280
|
+
|> List.concatMap
|
|
281
|
+
(\bundle ->
|
|
282
|
+
bundle.elmApps
|
|
283
|
+
|> List.map
|
|
284
|
+
(\app ->
|
|
285
|
+
{ fromId = "app:" ++ app
|
|
286
|
+
, toId = "bundle:" ++ bundle.name
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
bundleToTemplateEdges =
|
|
292
|
+
bundles
|
|
293
|
+
|> List.concatMap
|
|
294
|
+
(\bundle ->
|
|
295
|
+
bundle.templates
|
|
296
|
+
|> List.map
|
|
297
|
+
(\template ->
|
|
298
|
+
{ fromId = "bundle:" ++ bundle.name
|
|
299
|
+
, toId = "template:" ++ template
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
in
|
|
304
|
+
appToBundleEdges ++ bundleToTemplateEdges
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
uniqueElmApps : List Bundle -> List String
|
|
308
|
+
uniqueElmApps bundles =
|
|
309
|
+
bundles
|
|
310
|
+
|> List.concatMap .elmApps
|
|
311
|
+
|> List.sort
|
|
312
|
+
|> unique
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
uniqueTemplates : List Bundle -> List String
|
|
316
|
+
uniqueTemplates bundles =
|
|
317
|
+
bundles
|
|
318
|
+
|> List.concatMap .templates
|
|
319
|
+
|> List.sort
|
|
320
|
+
|> unique
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
unique : List comparable -> List comparable
|
|
324
|
+
unique list =
|
|
325
|
+
case list of
|
|
326
|
+
[] ->
|
|
327
|
+
[]
|
|
328
|
+
|
|
329
|
+
first :: rest ->
|
|
330
|
+
first :: unique (List.filter (\x -> x /= first) rest)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
flagsDecoder : Decoder Flags
|
|
334
|
+
flagsDecoder =
|
|
335
|
+
Decode.map Flags
|
|
336
|
+
(Decode.field "bundles" (Decode.list bundleDecoder))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
bundleDecoder : Decoder Bundle
|
|
340
|
+
bundleDecoder =
|
|
341
|
+
Decode.map3 Bundle
|
|
342
|
+
(Decode.field "name" Decode.string)
|
|
343
|
+
(Decode.field "elm_apps" (Decode.list Decode.string))
|
|
344
|
+
(Decode.field "templates" (Decode.list Decode.string))
|