hecks 3.2.0 → 3.2.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/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
- data/lib/hecks/cli/check_era.rb +3 -39
- data/lib/hecks/doors/mcp_door.rb +16 -2
- data/lib/hecks/doors/mcp_door_scope.rb +43 -4
- data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
- data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
- data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
- data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
- data/lib/hecks/projections/deploy/box/settings.rb +23 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
- data/lib/hecks/projections/deploy/box.rb +13 -2
- data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
- data/lib/hecks/projections/site/edge/checks.rb +31 -5
- data/lib/hecks/projections/site/edge.rb +25 -11
- data/lib/hecks/projections/site/routes_ts.rb +50 -27
- data/lib/hecks/projections/site/site_cdn.rb +7 -4
- data/lib/hecks/projections/site/table/checks.rb +53 -7
- data/lib/hecks/projections/site/table/link_builder.rb +64 -0
- data/lib/hecks/projections/site/table/row_builder.rb +21 -6
- data/lib/hecks/projections/site/table.rb +16 -5
- data/lib/hecks/rust_build/kernel_input.rb +18 -6
- data/lib/hecks/rust_build/project_rust.rb +6 -132
- data/lib/hecks/site/bluebook/site.bluebook +27 -13
- data/lib/hecks/storehouse.rb +35 -1
- data/lib/hecks/tools/comment_style.rb +4 -2
- data/lib/hecks/tools/rust_comment_style.rb +1 -1
- data/lib/hecks/tools/site_routes.rb +69 -28
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -1
- data/lib/hecks.rb +1 -0
- data/rust/build/src/lineage_pass.rs +1 -1
- data/rust/build/src/pipeline.rs +1 -1
- data/rust/codegen/src/attr.rs +1 -1
- data/rust/codegen/src/bridging.rs +1 -1
- data/rust/codegen/src/commands.rs +1 -1
- data/rust/codegen/src/constraints.rs +1 -1
- data/rust/codegen/src/dependency_planning.rs +1 -1
- data/rust/codegen/src/domain_generator.rs +2 -2
- data/rust/codegen/src/exemplar.rs +1 -1
- data/rust/codegen/src/expr_emitter.rs +1 -1
- data/rust/codegen/src/fielded.rs +1 -1
- data/rust/codegen/src/json_codec.rs +1 -1
- data/rust/codegen/src/main.rs +3 -4
- data/rust/codegen/src/mutations.rs +1 -1
- data/rust/codegen/src/naming.rs +1 -1
- data/rust/codegen/src/ports.rs +1 -1
- data/rust/codegen/src/queries.rs +3 -3
- data/rust/codegen/src/reactions.rs +1 -1
- data/rust/codegen/src/read_models.rs +2 -2
- data/rust/codegen/src/reference_specs.rs +1 -1
- data/rust/codegen/src/registry.rs +4 -4
- data/rust/codegen/src/shared.rs +1 -1
- data/rust/codegen/src/skip_reason.rs +1 -1
- data/rust/codegen/src/types.rs +4 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/needs.rs +94 -27
- data/rust/src/exemplar/commands.rs +1 -1
- data/rust/src/exemplar/constraints.rs +1 -1
- data/rust/src/exemplar/fielded.rs +1 -1
- data/rust/src/exemplar/json.rs +1 -1
- data/rust/src/exemplar/mod.rs +1 -1
- data/rust/src/exemplar/mutations.rs +1 -1
- data/rust/src/exemplar/queries.rs +1 -1
- data/rust/src/exemplar/reactions.rs +1 -1
- data/rust/src/exemplar/read_models.rs +1 -1
- data/rust/src/exemplar/registry.rs +1 -1
- data/rust/src/exemplar/types.rs +1 -1
- data/rust/src/kernel/cli.rs +2 -2
- data/rust/src/kernel/read_model.rs +1 -1
- data/rust/src/lib.rs +1 -1
- metadata +3 -23
- data/rust/project/bridging.rb +0 -257
- data/rust/project/commands.rb +0 -877
- data/rust/project/constraints.rb +0 -86
- data/rust/project/dependency_planning.rb +0 -178
- data/rust/project/domain_generator.rb +0 -983
- data/rust/project/exemplar.rb +0 -148
- data/rust/project/expr_emitter.rb +0 -72
- data/rust/project/fielded.rb +0 -141
- data/rust/project/json_codec.rb +0 -641
- data/rust/project/mutations.rb +0 -695
- data/rust/project/naming.rb +0 -223
- data/rust/project/ports.rb +0 -65
- data/rust/project/queries.rb +0 -493
- data/rust/project/reactions.rb +0 -317
- data/rust/project/read_models.rb +0 -690
- data/rust/project/reference_specs.rb +0 -27
- data/rust/project/registry.rb +0 -396
- data/rust/project/skip_reason.rb +0 -29
- data/rust/project/types.rb +0 -225
- data/rust/project/write_if_changed.rb +0 -7
- data/rust/project.rb +0 -30
- data/rust/project_rust_pipeline.rb +0 -391
|
@@ -28,7 +28,9 @@ module Hecks
|
|
|
28
28
|
# @param rows [Array<Table::Row>] the route table's rows
|
|
29
29
|
# @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
|
|
30
30
|
# @param problems [Array<String>] collects each problem found, one line each
|
|
31
|
-
|
|
31
|
+
# @param template_named [Boolean] whether the caller names the template itself
|
|
32
|
+
def initialize(edge, rows, vocabulary, problems, template_named: false)
|
|
33
|
+
@template_named = template_named
|
|
32
34
|
@edge = edge
|
|
33
35
|
@rows = rows
|
|
34
36
|
@vocabulary = vocabulary
|
|
@@ -48,15 +50,21 @@ module Hecks
|
|
|
48
50
|
|
|
49
51
|
def check_setting
|
|
50
52
|
setting = @edge.setting
|
|
51
|
-
return problem("Edge", "has no row
|
|
53
|
+
return problem("Edge", "has no row; declare one member with template:") unless setting
|
|
54
|
+
|
|
55
|
+
check_template(setting.template) if setting.template || !@template_named
|
|
52
56
|
|
|
53
|
-
template = setting.template.to_s
|
|
54
|
-
problem("Edge", "template #{template.inspect} must be a path inside the project") if
|
|
55
|
-
template.empty? || template.start_with?("/") || template.split("/").include?("..")
|
|
56
57
|
problem("Edge", "has secret_header but no secret_value") if setting.secret_header && !setting.secret_value
|
|
57
58
|
problem("Edge", "has secret_value but no secret_header") if setting.secret_value && !setting.secret_header
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
def check_template(template)
|
|
62
|
+
template = template.to_s
|
|
63
|
+
problem("Edge", "has no template; declare template: or name the file to the tool") if template.empty?
|
|
64
|
+
problem("Edge", "template #{template.inspect} must be a path inside the project") if
|
|
65
|
+
template.start_with?("/") || template.split("/").include?("..")
|
|
66
|
+
end
|
|
67
|
+
|
|
60
68
|
def check_policies
|
|
61
69
|
@edge.policies.each do |policy|
|
|
62
70
|
label = "EdgePolicy #{policy.cache_class}#{" on #{policy.origin}" if policy.origin}"
|
|
@@ -86,12 +94,30 @@ module Hecks
|
|
|
86
94
|
end
|
|
87
95
|
|
|
88
96
|
def check_rules
|
|
97
|
+
return check_no_alb unless @edge.alb?
|
|
98
|
+
|
|
89
99
|
problem("EdgeRule", "rows need an Edge row with listener:") if @edge.rules.any? && !@edge.setting&.listener
|
|
90
100
|
@edge.rules.each { |rule| check_rule(rule) }
|
|
91
101
|
repeated(@edge.rules.map(&:rule), "EdgeRule", &:itself)
|
|
92
102
|
repeated(@edge.rules.map(&:priority), "EdgeRule priority", &:to_s)
|
|
93
103
|
end
|
|
94
104
|
|
|
105
|
+
# With `alb: false` nothing routes by listener rule, so a rule row, or a route that names
|
|
106
|
+
# one, describes a load balancer the project says it does not have. Every route that is
|
|
107
|
+
# served by a server behind the edge still needs the origin that reaches it.
|
|
108
|
+
def check_no_alb
|
|
109
|
+
problem("EdgeRule", "rows describe a load balancer, and the Edge row says alb: false") if @edge.rules.any?
|
|
110
|
+
@rows.select(&:alb_rule).each do |row|
|
|
111
|
+
problem(row.path, "names alb_rule #{row.alb_rule}, and the Edge row says alb: false")
|
|
112
|
+
end
|
|
113
|
+
@rows.each do |row|
|
|
114
|
+
next if row.cdn || !%w[cms domain].include?(row.origin) || @edge.upstream(row.origin)
|
|
115
|
+
|
|
116
|
+
problem(row.path, "is served from #{row.origin}, which no EdgeOrigin maps, and with alb: false " \
|
|
117
|
+
"nothing else reaches it")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
95
121
|
def check_rule(rule)
|
|
96
122
|
label = "EdgeRule #{rule.rule}"
|
|
97
123
|
member_of(label, "origin", rule.origin, :origin)
|
|
@@ -19,7 +19,7 @@ module Hecks
|
|
|
19
19
|
# `EdgePolicy`, `EdgeOrigin` and `EdgeRule`. `docs/site-routes.md` lists their fields. A
|
|
20
20
|
# project that declares none of the four has no edge to project.
|
|
21
21
|
class Edge
|
|
22
|
-
Setting = Struct.new(:template, :listener, :secret_header, :secret_value, keyword_init: true)
|
|
22
|
+
Setting = Struct.new(:template, :listener, :secret_header, :secret_value, :alb, keyword_init: true)
|
|
23
23
|
Policy = Struct.new(:cache_class, :origin, :cache, :origin_request, :response_headers, keyword_init: true)
|
|
24
24
|
Upstream = Struct.new(:origin, :id, :target_group, keyword_init: true)
|
|
25
25
|
Rule = Struct.new(:rule, :priority, :origin, keyword_init: true)
|
|
@@ -29,15 +29,15 @@ module Hecks
|
|
|
29
29
|
|
|
30
30
|
# The fields each kind of row may carry, with the Ruby class each takes.
|
|
31
31
|
FIELDS = {
|
|
32
|
-
setting: { template: String, listener: String, secret_header: String, secret_value: String },
|
|
32
|
+
setting: { template: String, listener: String, secret_header: String, secret_value: String, alb: :bool },
|
|
33
33
|
policy: { cache_class: String, origin: String, cache: String, origin_request: String,
|
|
34
34
|
response_headers: String },
|
|
35
35
|
upstream: { origin: String, id: String, target_group: String },
|
|
36
36
|
rule: { rule: String, priority: Integer, origin: String }
|
|
37
37
|
}.freeze
|
|
38
38
|
|
|
39
|
-
# The fields a row of each kind must carry.
|
|
40
|
-
REQUIRED = { setting: [
|
|
39
|
+
# The fields a row of each kind must carry. The template may instead be named to the tool.
|
|
40
|
+
REQUIRED = { setting: [], policy: %i[cache_class cache], upstream: %i[origin id],
|
|
41
41
|
rule: %i[rule priority origin] }.freeze
|
|
42
42
|
|
|
43
43
|
STRUCTS = { setting: Setting, policy: Policy, upstream: Upstream, rule: Rule }.freeze
|
|
@@ -62,15 +62,17 @@ module Hecks
|
|
|
62
62
|
# @param chapter [Bluebook::Chapter] the chapter that declares the route table
|
|
63
63
|
# @param table [Table] the checked route table
|
|
64
64
|
# @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
|
|
65
|
+
# @param template [String, nil] the template the caller names, which stands in for the
|
|
66
|
+
# `Edge` row's own `template:`
|
|
65
67
|
# @return [Edge, nil] the checked edge, or nil when the chapter declares none
|
|
66
68
|
# @raise [Table::Invalid] when a row is refused or the edge contradicts the routes
|
|
67
|
-
def self.read(chapter, table:, vocabulary:)
|
|
69
|
+
def self.read(chapter, table:, vocabulary:, template: nil)
|
|
68
70
|
declared = OBJECTS.transform_values { |name| Table.rows_of(chapter, name) }
|
|
69
71
|
return nil if declared.values.all?(&:empty?)
|
|
70
72
|
|
|
71
73
|
problems = []
|
|
72
74
|
records = declared.to_h { |kind, members| [kind, build(kind, members, problems)] }
|
|
73
|
-
new(records, table: table, vocabulary: vocabulary, problems: problems)
|
|
75
|
+
new(records, table: table, vocabulary: vocabulary, problems: problems, template: template)
|
|
74
76
|
end
|
|
75
77
|
|
|
76
78
|
# @param kind [Symbol] a key of `OBJECTS`
|
|
@@ -86,27 +88,35 @@ module Hecks
|
|
|
86
88
|
end
|
|
87
89
|
(REQUIRED.fetch(kind) - member.keys).each { |field| problems << "#{label} needs #{field}" }
|
|
88
90
|
typed = member.slice(*FIELDS.fetch(kind).keys).select do |field, value|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
typed?(value, FIELDS.fetch(kind).fetch(field)) ||
|
|
92
|
+
(problems << "#{label} has #{field} #{value.inspect}; " \
|
|
93
|
+
"#{field} is #{type_name(FIELDS.fetch(kind).fetch(field))}")
|
|
91
94
|
end
|
|
92
95
|
STRUCTS.fetch(kind).new(**typed)
|
|
93
96
|
end
|
|
94
97
|
end
|
|
95
98
|
private_class_method :build
|
|
96
99
|
|
|
100
|
+
def self.typed?(value, type) = type == :bool ? [true, false].include?(value) : value.is_a?(type)
|
|
101
|
+
private_class_method :typed?
|
|
102
|
+
|
|
103
|
+
def self.type_name(type) = type == :bool ? "true or false" : "a #{type}"
|
|
104
|
+
private_class_method :type_name
|
|
105
|
+
|
|
97
106
|
# @param records [Hash{Symbol => Array<Struct>}] the rows of each kind
|
|
98
107
|
# @param table [Table] the checked route table
|
|
99
108
|
# @param vocabulary [Hash{Symbol => Array<String>}] the closed sets
|
|
100
109
|
# @param problems [Array<String>] the problems already found in reading the rows
|
|
110
|
+
# @param template [String, nil] the template the caller names, if any
|
|
101
111
|
# @raise [Table::Invalid] naming every problem when there is one
|
|
102
|
-
def initialize(records, table:, vocabulary:, problems:)
|
|
112
|
+
def initialize(records, table:, vocabulary:, problems:, template: nil)
|
|
103
113
|
@setting = records.fetch(:setting).first
|
|
104
114
|
@policies = records.fetch(:policy)
|
|
105
115
|
@upstreams = records.fetch(:upstream)
|
|
106
116
|
@rules = records.fetch(:rule)
|
|
107
|
-
Checks.new(self, table.rows, vocabulary, problems).call
|
|
117
|
+
Checks.new(self, table.rows, vocabulary, problems, template_named: !template.nil?).call
|
|
108
118
|
if problems.empty?
|
|
109
|
-
@listener_rules = Rules.new(self, table.rows, problems).call
|
|
119
|
+
@listener_rules = alb? ? Rules.new(self, table.rows, problems).call : []
|
|
110
120
|
built = Behaviours.new(self, table.rows, problems).call
|
|
111
121
|
@default_behaviour = built.fetch(:default)
|
|
112
122
|
@behaviours = built.fetch(:list)
|
|
@@ -116,6 +126,10 @@ module Hecks
|
|
|
116
126
|
raise Table::Invalid, "the edge is refused:\n#{problems.map { |line| " - #{line}" }.join("\n")}"
|
|
117
127
|
end
|
|
118
128
|
|
|
129
|
+
# @return [Boolean] whether the project has a load balancer, and so listener rules; true
|
|
130
|
+
# unless the `Edge` row says `alb: false`
|
|
131
|
+
def alb? = setting.nil? || setting.alb != false
|
|
132
|
+
|
|
119
133
|
# @param origin [String] an origin of the Origin set
|
|
120
134
|
# @return [Upstream, nil] its CloudFront origin and target group
|
|
121
135
|
def upstream(origin) = upstreams.find { |candidate| candidate.origin == origin }
|
|
@@ -77,10 +77,12 @@ module Hecks
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def route_fields(row)
|
|
80
|
-
{ path: row.path, kind: row.kind, render: row.render, auth: row.auth, methods: row.verbs,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
fields = { path: row.path, kind: row.kind, render: row.render, auth: row.auth, methods: row.verbs,
|
|
81
|
+
cache: row.cache, origin: row.origin, source: row.source, indexable: row.indexable,
|
|
82
|
+
switch: blank_to_nil(row.switch), off: row.off, preview: row.preview,
|
|
83
|
+
label: blank_to_nil(row.label), seo: blank_to_nil(row.seo), redirectTo: row.redirect_to }
|
|
84
|
+
fields[:seoTitle] = row.seo_title if row.seo_title
|
|
85
|
+
fields
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
def switches(rows)
|
|
@@ -112,13 +114,16 @@ module Hecks
|
|
|
112
114
|
TS
|
|
113
115
|
end
|
|
114
116
|
|
|
117
|
+
# The navigation arrays hold every entry that has a slot, a switched-off page's too: an
|
|
118
|
+
# entry of an off page carries its `switch` and `on: false`, and the site drops it while
|
|
119
|
+
# `pageIsOn(switch)` is false. An entry of a page that is on carries neither.
|
|
115
120
|
def navigation(table)
|
|
116
|
-
|
|
121
|
+
items = table.rows + table.links
|
|
117
122
|
[nav_const("NAV_DESKTOP", "The desktop navigation: a page with no group is its own entry; a group holds its pages.",
|
|
118
|
-
desktop(
|
|
119
|
-
nav_const("NAV_MOBILE", "The mobile navigation, in its own order.", mobile(
|
|
120
|
-
nav_const("NAV_FOOTER", "The footer columns, left to right, each with its links.", footer(
|
|
121
|
-
nav_const("NAV_ADMIN", "The admin navigation: each admin page under its key.", admin(
|
|
123
|
+
desktop(items)),
|
|
124
|
+
nav_const("NAV_MOBILE", "The mobile navigation, in its own order.", mobile(items)),
|
|
125
|
+
nav_const("NAV_FOOTER", "The footer columns, left to right, each with its links.", footer(items)),
|
|
126
|
+
nav_const("NAV_ADMIN", "The admin navigation: each admin page under its key.", admin(items))].join("\n\n")
|
|
122
127
|
end
|
|
123
128
|
|
|
124
129
|
def nav_const(name, comment, entries)
|
|
@@ -126,32 +131,41 @@ module Hecks
|
|
|
126
131
|
"// #{comment}\nexport const #{name} = #{body.empty? ? '[]' : "[\n#{body.join("\n")}\n]"} as const;"
|
|
127
132
|
end
|
|
128
133
|
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
# One link: a row's or a link's path and label, the fragment a link names, and for a page
|
|
135
|
+
# that is off the switch that turns it on.
|
|
136
|
+
def entry(item, **before)
|
|
137
|
+
link = { **before, path: item.path, label: item.label }
|
|
138
|
+
link[:fragment] = item.fragment if item.respond_to?(:fragment) && item.fragment
|
|
139
|
+
link.merge!(switch: item.switch, on: false) if item.off
|
|
140
|
+
link
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def order_key(item, order) = [order, item.path, item.respond_to?(:fragment) ? item.fragment.to_s : ""]
|
|
144
|
+
|
|
145
|
+
def desktop(items)
|
|
131
146
|
entries = []
|
|
132
|
-
items.each do |
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
slot
|
|
136
|
-
slot ? slot[:items] << item : entries << { group: group, items: [item] }
|
|
147
|
+
items.reject { |item| item.nav_order.nil? }.sort_by { |item| order_key(item, item.nav_order) }.each do |item|
|
|
148
|
+
group = item.nav_group
|
|
149
|
+
slot = group && entries.find { |candidate| candidate[:group] == group }
|
|
150
|
+
slot ? slot[:items] << entry(item) : entries << { group: group, items: [entry(item)] }
|
|
137
151
|
end
|
|
138
152
|
entries
|
|
139
153
|
end
|
|
140
154
|
|
|
141
|
-
def mobile(
|
|
142
|
-
|
|
143
|
-
|
|
155
|
+
def mobile(items)
|
|
156
|
+
items.reject { |item| item.mobile_order.nil? }.sort_by { |item| order_key(item, item.mobile_order) }
|
|
157
|
+
.map { |item| item.mobile_heading ? entry(item, heading: item.mobile_heading) : entry(item) }
|
|
144
158
|
end
|
|
145
159
|
|
|
146
|
-
def footer(
|
|
147
|
-
columns =
|
|
160
|
+
def footer(items)
|
|
161
|
+
columns = items.select(&:footer_column).sort_by { |item| order_key(item, item.footer_order) }.group_by(&:footer_column)
|
|
148
162
|
columns.sort_by { |name, group| [group.first.footer_order, name] }
|
|
149
|
-
.map { |name, group| { column: name, items: group.map { |
|
|
163
|
+
.map { |name, group| { column: name, items: group.map { |item| entry(item) } } }
|
|
150
164
|
end
|
|
151
165
|
|
|
152
|
-
def admin(
|
|
153
|
-
|
|
154
|
-
|
|
166
|
+
def admin(items)
|
|
167
|
+
items.select(&:admin_key).sort_by { |item| [item.admin_order, item.admin_key] }
|
|
168
|
+
.map { |item| entry(item, key: item.admin_key) }
|
|
155
169
|
end
|
|
156
170
|
|
|
157
171
|
def search(rows)
|
|
@@ -216,13 +230,22 @@ module Hecks
|
|
|
216
230
|
const source = stripHtml(pattern)
|
|
217
231
|
.split("/")
|
|
218
232
|
.map((part) =>
|
|
219
|
-
part
|
|
233
|
+
part.startsWith(":")
|
|
234
|
+
? "[^/]+"
|
|
235
|
+
: part
|
|
236
|
+
.split("*")
|
|
237
|
+
.map((piece) => piece.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
238
|
+
.join(".*"),
|
|
220
239
|
)
|
|
221
240
|
.join("/");
|
|
222
241
|
return new RegExp("^" + source + "$");
|
|
223
242
|
}
|
|
224
243
|
|
|
225
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* Whether a pathname is one a route pattern describes, with or without `.html`. A `:name` is one
|
|
246
|
+
* segment; a `*` is any run of characters, `/` included, wherever it stands, as in a CDN's path
|
|
247
|
+
* pattern: `/pay/*` matches `/pay/7` and `/admin*` matches `/admin`, `/admin-inbox` and `/admin/x`.
|
|
248
|
+
*/
|
|
226
249
|
export function matchesPath(pattern: string, pathname: string): boolean {
|
|
227
250
|
return toRegExp(pattern).test(stripHtml(pathname));
|
|
228
251
|
}
|
|
@@ -11,9 +11,10 @@ module Hecks
|
|
|
11
11
|
# on it: the distribution's cache behaviours and the load balancer's listener rules.
|
|
12
12
|
#
|
|
13
13
|
# The blocks are returned by name, `"behaviors"` and `"listener_rules"`, as text from the
|
|
14
|
-
# left margin
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# left margin; a project whose `Edge` row says `alb: false` has no load balancer and gets
|
|
15
|
+
# `"behaviors"` alone. `Regions` puts each between the markers of the same name in the
|
|
16
|
+
# project's template, at the markers' indentation. The text is a pure function of the table
|
|
17
|
+
# and the edge rows.
|
|
17
18
|
module SiteCdn
|
|
18
19
|
extend Projector::Target
|
|
19
20
|
|
|
@@ -25,7 +26,7 @@ module Hecks
|
|
|
25
26
|
# @param options [Hash{Symbol => Object}] `:registry` (Runtime::Registry) the project booted
|
|
26
27
|
# into; or `:table` (Table) and `:edge` (Edge), already read
|
|
27
28
|
# @return [Hash{String => String}] each region's name to its text; empty when the chapter
|
|
28
|
-
# declares no edge
|
|
29
|
+
# declares no edge, and without `"listener_rules"` when the edge has no load balancer
|
|
29
30
|
# @raise [Table::Invalid] when the table or the edge is refused
|
|
30
31
|
def call(bluebook:, options: {})
|
|
31
32
|
registry = options[:registry]
|
|
@@ -33,6 +34,8 @@ module Hecks
|
|
|
33
34
|
edge = options[:edge] || Edge.read(bluebook, table: table, vocabulary: Table.vocabulary(registry))
|
|
34
35
|
return {} unless edge
|
|
35
36
|
|
|
37
|
+
return { "behaviors" => behaviors(edge) } unless edge.alb?
|
|
38
|
+
|
|
36
39
|
{ "behaviors" => behaviors(edge), "listener_rules" => listener_rules(edge) }
|
|
37
40
|
end
|
|
38
41
|
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../edge/pattern"
|
|
4
|
+
|
|
3
5
|
module Hecks
|
|
4
6
|
module Projections
|
|
5
7
|
module Site
|
|
6
8
|
class Table
|
|
7
9
|
# What no single row can show, and what a row contradicts in itself once its defaults are
|
|
8
|
-
# filled: a path declared twice, a switch the rows disagree on,
|
|
9
|
-
#
|
|
10
|
+
# filled: a path declared twice, a switch the rows disagree on, a navigation entry that
|
|
11
|
+
# cannot be linked to, an indexable route that is not public, a public route beneath a
|
|
12
|
+
# prefix that is not.
|
|
10
13
|
class Checks
|
|
11
14
|
SWITCH_ID = /\A[a-z][a-z0-9_]*\z/
|
|
15
|
+
FRAGMENT = /\A[A-Za-z0-9][\w:.-]*\z/
|
|
12
16
|
PUBLIC_MENUS = %w[desktop mobile footer].freeze
|
|
13
17
|
|
|
14
18
|
# The navigation slots a row sits in.
|
|
15
19
|
#
|
|
16
|
-
# @param row [Row] a row
|
|
20
|
+
# @param row [Row, Link] a row or link
|
|
17
21
|
# @return [Array<String>] some of `desktop`, `mobile`, `footer`, `admin`
|
|
18
22
|
def self.navigation(row)
|
|
19
23
|
{ "desktop" => row.nav_order, "mobile" => row.mobile_order, "footer" => row.footer_column,
|
|
@@ -22,8 +26,10 @@ module Hecks
|
|
|
22
26
|
|
|
23
27
|
# @param rows [Array<Row>] the table's rows, defaults filled in
|
|
24
28
|
# @param problems [Array<String>] collects each problem found, one line each
|
|
25
|
-
|
|
29
|
+
# @param links [Array<Link>] the table's extra navigation entries
|
|
30
|
+
def initialize(rows, problems, links: [])
|
|
26
31
|
@rows = rows
|
|
32
|
+
@links = links
|
|
27
33
|
@problems = problems
|
|
28
34
|
end
|
|
29
35
|
|
|
@@ -32,7 +38,8 @@ module Hecks
|
|
|
32
38
|
check_duplicate_paths
|
|
33
39
|
check_switches
|
|
34
40
|
@rows.each { |row| check_row(row) }
|
|
35
|
-
|
|
41
|
+
@links.each { |link| check_link(link) }
|
|
42
|
+
check_unique((@rows + @links).filter_map(&:admin_key), "admin key")
|
|
36
43
|
check_unique(@rows.map(&:source).grep(/\Aglobal:/), "source")
|
|
37
44
|
end
|
|
38
45
|
|
|
@@ -63,6 +70,7 @@ module Hecks
|
|
|
63
70
|
check_redirect(row, path)
|
|
64
71
|
check_navigation(row, path)
|
|
65
72
|
check_indexing(row, path)
|
|
73
|
+
check_beneath_prefix(row, path)
|
|
66
74
|
return unless row.preview == "draft" && row.source == "none"
|
|
67
75
|
|
|
68
76
|
problem(path, "previews as a draft but has source none; a draft preview needs a global: or collection: source")
|
|
@@ -90,13 +98,51 @@ module Hecks
|
|
|
90
98
|
slots = self.class.navigation(row)
|
|
91
99
|
return if slots.empty?
|
|
92
100
|
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
unless row.verbs.include?("GET")
|
|
102
|
+
problem(path, "sits in the navigation but answers #{row.verbs.join(',')}; a link is a GET")
|
|
103
|
+
end
|
|
95
104
|
problem(path, "sits in the navigation and has a parameter") if path.match?(/[:*]/)
|
|
96
105
|
problem(path, "sits in the navigation and has no label") if row.label.to_s.empty?
|
|
106
|
+
check_heading(row, path)
|
|
97
107
|
check_menu_audience(row, path, slots)
|
|
98
108
|
end
|
|
99
109
|
|
|
110
|
+
def check_heading(item, path)
|
|
111
|
+
return unless item.mobile_heading
|
|
112
|
+
return problem(path, "has a mobile_heading but no mobile_order") if item.mobile_order.nil?
|
|
113
|
+
|
|
114
|
+
problem(path, "has an empty mobile_heading") if item.mobile_heading.strip.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def check_link(link)
|
|
118
|
+
path = "NavLink #{link.path}"
|
|
119
|
+
slots = self.class.navigation(link)
|
|
120
|
+
problem(path, "sits in no navigation; give it nav_order, mobile_order, footer_column or admin_key") if slots.empty?
|
|
121
|
+
problem(path, "has a parameter; a link is to one page") if link.path.to_s.match?(/[:*]/)
|
|
122
|
+
problem(path, "has no label") if link.label.to_s.empty?
|
|
123
|
+
if link.fragment && !link.fragment.match?(FRAGMENT)
|
|
124
|
+
problem(path, "has fragment #{link.fragment.inspect}; a fragment is an element id, written without #")
|
|
125
|
+
end
|
|
126
|
+
check_heading(link, path)
|
|
127
|
+
check_menu_audience(link, path, slots)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# A public route under a prefix that is not public (the sign-in page beside the admin
|
|
131
|
+
# pages) inherits nothing from the prefix, so the row has to say what it is: its cache
|
|
132
|
+
# class is declared, not derived from its auth.
|
|
133
|
+
def check_beneath_prefix(row, path)
|
|
134
|
+
return if row.auth != "public" || row.explicit_cache || row.path.nil?
|
|
135
|
+
|
|
136
|
+
pattern = Edge::Pattern.edge(row.path)
|
|
137
|
+
prefix = @rows.find do |other|
|
|
138
|
+
other.auth != "public" && other.path && Edge::Pattern.strictly_covers?(Edge::Pattern.edge(other.path), pattern)
|
|
139
|
+
end
|
|
140
|
+
return unless prefix
|
|
141
|
+
|
|
142
|
+
problem(path, "is public but sits beneath #{prefix.path}, which is #{prefix.auth}; " \
|
|
143
|
+
"name its cache class (cache: \"no_store\" for a sign-in page) to say that is meant")
|
|
144
|
+
end
|
|
145
|
+
|
|
100
146
|
def check_menu_audience(row, path, slots)
|
|
101
147
|
public_slots = slots & PUBLIC_MENUS
|
|
102
148
|
if row.auth != "public" && public_slots.any?
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
module Site
|
|
6
|
+
class Table
|
|
7
|
+
# One extra navigation entry that points at a route the table declares, optionally at a
|
|
8
|
+
# fragment of its page. `off` and `switch` are copied from the route it points at.
|
|
9
|
+
Link = Struct.new(:path, :fragment, :label, :nav_group, :nav_order, :mobile_order, :mobile_heading,
|
|
10
|
+
:footer_column, :footer_order, :admin_key, :admin_order, :off, :switch, :auth,
|
|
11
|
+
keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# Turns a declared `NavLink` member into a `Link`: checks its fields and their types, and
|
|
14
|
+
# copies the facts it shares with the route it points at.
|
|
15
|
+
class LinkBuilder
|
|
16
|
+
# The fields a link may carry, with the Ruby class each takes.
|
|
17
|
+
FIELDS = { path: String, fragment: String, label: String, nav_group: String, nav_order: Integer,
|
|
18
|
+
mobile_order: Integer, mobile_heading: String, footer_column: String,
|
|
19
|
+
footer_order: Integer, admin_key: String, admin_order: Integer }.freeze
|
|
20
|
+
|
|
21
|
+
# @param rows [Array<Row>] the table's rows, which a link points at
|
|
22
|
+
# @param problems [Array<String>] collects each problem found, one line each
|
|
23
|
+
def initialize(rows, problems)
|
|
24
|
+
@rows = rows
|
|
25
|
+
@problems = problems
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param member [Hash{Symbol => Object}] the declared fields of one link
|
|
29
|
+
# @param index [Integer] the link's position, for a message about a link with no path
|
|
30
|
+
# @return [Link] the link; its problems are in `problems`
|
|
31
|
+
def call(member, index)
|
|
32
|
+
label = "NavLink #{member[:path] || "row #{index + 1}"}"
|
|
33
|
+
fields = checked_fields(member, label)
|
|
34
|
+
target = @rows.find { |row| row.path == fields[:path] && row.verbs.include?("GET") }
|
|
35
|
+
problem(label, "points at no route that answers GET; declare the route first") unless target
|
|
36
|
+
link = Link.new(**fields, off: target ? target.off : false, switch: target ? target.switch : "",
|
|
37
|
+
auth: target ? target.auth : "public")
|
|
38
|
+
link.footer_order ||= 0 if link.footer_column
|
|
39
|
+
link.admin_order ||= 0 if link.admin_key
|
|
40
|
+
link
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def checked_fields(member, label)
|
|
46
|
+
member.each_key do |key|
|
|
47
|
+
problem(label, "has no field #{key}; fields are #{FIELDS.keys.join(', ')}") unless FIELDS.key?(key)
|
|
48
|
+
end
|
|
49
|
+
known = member.slice(*FIELDS.keys)
|
|
50
|
+
known.each do |key, value|
|
|
51
|
+
next if value.is_a?(FIELDS.fetch(key))
|
|
52
|
+
|
|
53
|
+
problem(label, "has #{key} #{value.inspect}; #{key} is a #{FIELDS.fetch(key)}")
|
|
54
|
+
end
|
|
55
|
+
problem(label, "needs a path") unless known[:path]
|
|
56
|
+
known
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def problem(label, text) = @problems << "#{label} #{text}"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -8,7 +8,8 @@ module Hecks
|
|
|
8
8
|
Row = Struct.new(:path, :kind, :render, :auth, :verbs, :cache, :origin, :source,
|
|
9
9
|
:indexable, :switch, :off, :preview, :label, :seo, :redirect_to, :aliases,
|
|
10
10
|
:nav_group, :nav_order, :mobile_order, :footer_column, :footer_order,
|
|
11
|
-
:admin_key, :admin_order, :compress, :alb_rule, :cdn,
|
|
11
|
+
:admin_key, :admin_order, :compress, :alb_rule, :cdn,
|
|
12
|
+
:seo_title, :edge_verbs, :mobile_heading, :explicit_cache, keyword_init: true)
|
|
12
13
|
|
|
13
14
|
# Turns a declared member into a `Row`: checks its fields and their types, fills the
|
|
14
15
|
# defaults, and checks each closed-set value against the Site chapter's vocabulary.
|
|
@@ -19,7 +20,8 @@ module Hecks
|
|
|
19
20
|
off: :bool, preview: String, label: String, seo: String, redirect_to: String,
|
|
20
21
|
aliases: String, nav_group: String, nav_order: Integer, mobile_order: Integer,
|
|
21
22
|
footer_column: String, footer_order: Integer, admin_key: String,
|
|
22
|
-
admin_order: Integer, compress: :bool, alb_rule: String, cdn: :bool
|
|
23
|
+
admin_order: Integer, compress: :bool, alb_rule: String, cdn: :bool,
|
|
24
|
+
seo_title: String, edge_methods: String, mobile_heading: String }.freeze
|
|
23
25
|
|
|
24
26
|
# The value objects of the Site chapter's `Route` aggregate whose members are the closed
|
|
25
27
|
# sets, by the row field each constrains.
|
|
@@ -50,7 +52,9 @@ module Hecks
|
|
|
50
52
|
label = path || label
|
|
51
53
|
check_source(label, source)
|
|
52
54
|
verbs = fields.delete(:methods)
|
|
53
|
-
|
|
55
|
+
edge_verbs = fields.delete(:edge_methods)
|
|
56
|
+
row = Row.new(**fields, verbs: verbs, edge_verbs: edge_verbs, path: path, source: source,
|
|
57
|
+
explicit_cache: !fields[:cache].nil?,
|
|
54
58
|
kind: fields[:kind] || default_kind(source), auth: fields[:auth] || "public",
|
|
55
59
|
origin: fields[:origin] || default_origin(source))
|
|
56
60
|
fill_defaults(row)
|
|
@@ -94,6 +98,7 @@ module Hecks
|
|
|
94
98
|
row.cdn = true if row.cdn.nil?
|
|
95
99
|
row.render ||= row.kind == "page" ? "prerender" : "ssr"
|
|
96
100
|
row.verbs = list(row.verbs || (row.source.start_with?("command:") ? "POST" : "GET"))
|
|
101
|
+
row.edge_verbs = row.edge_verbs.nil? ? row.verbs : list(row.edge_verbs)
|
|
97
102
|
row.aliases = list(row.aliases)
|
|
98
103
|
row.cache ||= default_cache(row)
|
|
99
104
|
row.indexable = indexable_by_default?(row) if row.indexable.nil?
|
|
@@ -123,11 +128,21 @@ module Hecks
|
|
|
123
128
|
|
|
124
129
|
problem(label, "has #{field} #{row[field].inspect}; #{field} is one of #{@vocabulary.fetch(field).join(', ')}")
|
|
125
130
|
end
|
|
126
|
-
row
|
|
127
|
-
|
|
131
|
+
check_verbs(row, label)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def check_verbs(row, label)
|
|
135
|
+
{ "method" => row.verbs, "edge method" => row.edge_verbs }.each do |what, verbs|
|
|
136
|
+
verbs.each do |verb|
|
|
137
|
+
next if @vocabulary.fetch(:http_method).include?(verb)
|
|
128
138
|
|
|
129
|
-
|
|
139
|
+
problem(label, "has #{what} #{verb.inspect}; methods are #{@vocabulary.fetch(:http_method).join(', ')}")
|
|
140
|
+
end
|
|
130
141
|
end
|
|
142
|
+
return if (row.verbs - row.edge_verbs).empty?
|
|
143
|
+
|
|
144
|
+
problem(label, "has edge_methods #{row.edge_verbs.join(',')}, which leave out its methods " \
|
|
145
|
+
"#{(row.verbs - row.edge_verbs).join(',')}; the edge must allow every verb the route answers")
|
|
131
146
|
end
|
|
132
147
|
|
|
133
148
|
def check_source(label, source)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "table/row_builder"
|
|
4
|
+
require_relative "table/link_builder"
|
|
4
5
|
require_relative "table/checks"
|
|
5
6
|
|
|
6
7
|
module Hecks
|
|
@@ -21,9 +22,15 @@ module Hecks
|
|
|
21
22
|
# The name of the value object a client declares its rows in.
|
|
22
23
|
ROW_OBJECT = "Route"
|
|
23
24
|
|
|
25
|
+
# The name of the value object a client declares its extra navigation entries in.
|
|
26
|
+
LINK_OBJECT = "NavLink"
|
|
27
|
+
|
|
24
28
|
# @return [Array<Row>] the rows, in the order the client declared them
|
|
25
29
|
attr_reader :rows
|
|
26
30
|
|
|
31
|
+
# @return [Array<Link>] the extra navigation entries, in the order the client declared them
|
|
32
|
+
attr_reader :links
|
|
33
|
+
|
|
27
34
|
# Finds the one chapter of a project that declares the route table.
|
|
28
35
|
#
|
|
29
36
|
# @param registry [Runtime::Registry] the registry the project booted into
|
|
@@ -47,7 +54,8 @@ module Hecks
|
|
|
47
54
|
# @return [Table] the checked table
|
|
48
55
|
# @raise [Invalid] when the Site chapter is not attached or a row is refused
|
|
49
56
|
def self.read(chapter, registry:)
|
|
50
|
-
new(rows_of(chapter), vocabulary: vocabulary(registry), registry: registry
|
|
57
|
+
new(rows_of(chapter), vocabulary: vocabulary(registry), registry: registry,
|
|
58
|
+
links: rows_of(chapter, LINK_OBJECT))
|
|
51
59
|
end
|
|
52
60
|
|
|
53
61
|
# @param registry [Runtime::Registry] a registry holding the Site chapter
|
|
@@ -76,20 +84,23 @@ module Hecks
|
|
|
76
84
|
# @param vocabulary [Hash{Symbol => Array<String>}] the closed sets
|
|
77
85
|
# @param registry [Runtime::Registry, nil] where a `command:` or `query:` source is looked
|
|
78
86
|
# up; nil skips that check
|
|
87
|
+
# @param links [Array<Hash{Symbol => Object}>] the declared `NavLink` rows
|
|
79
88
|
# @raise [Invalid] when a row is refused
|
|
80
|
-
def initialize(members, vocabulary:, registry: nil)
|
|
89
|
+
def initialize(members, vocabulary:, registry: nil, links: [])
|
|
81
90
|
problems = []
|
|
82
91
|
builder = RowBuilder.new(vocabulary: vocabulary, registry: registry, problems: problems)
|
|
83
92
|
@rows = members.each_with_index.map { |member, index| builder.call(member, index) }
|
|
84
|
-
|
|
93
|
+
link_builder = LinkBuilder.new(@rows, problems)
|
|
94
|
+
@links = links.each_with_index.map { |member, index| link_builder.call(member, index) }
|
|
95
|
+
Checks.new(@rows, problems, links: @links).call
|
|
85
96
|
return if problems.empty?
|
|
86
97
|
|
|
87
98
|
raise Invalid, "the route table is refused:\n#{problems.map { |line| " - #{line}" }.join("\n")}"
|
|
88
99
|
end
|
|
89
100
|
|
|
90
|
-
# The navigation slots a row sits in.
|
|
101
|
+
# The navigation slots a row or link sits in.
|
|
91
102
|
#
|
|
92
|
-
# @param row [Row] a row
|
|
103
|
+
# @param row [Row, Link] a row or link
|
|
93
104
|
# @return [Array<String>] some of `desktop`, `mobile`, `footer`, `admin`
|
|
94
105
|
def navigation(row) = Checks.navigation(row)
|
|
95
106
|
end
|