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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  3. data/lib/hecks/cli/check_era.rb +3 -39
  4. data/lib/hecks/doors/mcp_door.rb +16 -2
  5. data/lib/hecks/doors/mcp_door_scope.rb +43 -4
  6. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  7. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  8. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  9. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  10. data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
  11. data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
  12. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  13. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  14. data/lib/hecks/projections/deploy/box/settings.rb +23 -2
  15. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
  16. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  17. data/lib/hecks/projections/deploy/box.rb +13 -2
  18. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  19. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  20. data/lib/hecks/projections/site/edge.rb +25 -11
  21. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  22. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  23. data/lib/hecks/projections/site/table/checks.rb +53 -7
  24. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  25. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  26. data/lib/hecks/projections/site/table.rb +16 -5
  27. data/lib/hecks/rust_build/kernel_input.rb +18 -6
  28. data/lib/hecks/rust_build/project_rust.rb +6 -132
  29. data/lib/hecks/site/bluebook/site.bluebook +27 -13
  30. data/lib/hecks/storehouse.rb +35 -1
  31. data/lib/hecks/tools/comment_style.rb +4 -2
  32. data/lib/hecks/tools/rust_comment_style.rb +1 -1
  33. data/lib/hecks/tools/site_routes.rb +69 -28
  34. data/lib/hecks/version.rb +1 -1
  35. data/lib/hecks/vocabulary.rb +1 -1
  36. data/lib/hecks.rb +1 -0
  37. data/rust/build/src/lineage_pass.rs +1 -1
  38. data/rust/build/src/pipeline.rs +1 -1
  39. data/rust/codegen/src/attr.rs +1 -1
  40. data/rust/codegen/src/bridging.rs +1 -1
  41. data/rust/codegen/src/commands.rs +1 -1
  42. data/rust/codegen/src/constraints.rs +1 -1
  43. data/rust/codegen/src/dependency_planning.rs +1 -1
  44. data/rust/codegen/src/domain_generator.rs +2 -2
  45. data/rust/codegen/src/exemplar.rs +1 -1
  46. data/rust/codegen/src/expr_emitter.rs +1 -1
  47. data/rust/codegen/src/fielded.rs +1 -1
  48. data/rust/codegen/src/json_codec.rs +1 -1
  49. data/rust/codegen/src/main.rs +3 -4
  50. data/rust/codegen/src/mutations.rs +1 -1
  51. data/rust/codegen/src/naming.rs +1 -1
  52. data/rust/codegen/src/ports.rs +1 -1
  53. data/rust/codegen/src/queries.rs +3 -3
  54. data/rust/codegen/src/reactions.rs +1 -1
  55. data/rust/codegen/src/read_models.rs +2 -2
  56. data/rust/codegen/src/reference_specs.rs +1 -1
  57. data/rust/codegen/src/registry.rs +4 -4
  58. data/rust/codegen/src/shared.rs +1 -1
  59. data/rust/codegen/src/skip_reason.rs +1 -1
  60. data/rust/codegen/src/types.rs +4 -4
  61. data/rust/host/HECKS_RELEASE +1 -1
  62. data/rust/host/src/needs.rs +94 -27
  63. data/rust/src/exemplar/commands.rs +1 -1
  64. data/rust/src/exemplar/constraints.rs +1 -1
  65. data/rust/src/exemplar/fielded.rs +1 -1
  66. data/rust/src/exemplar/json.rs +1 -1
  67. data/rust/src/exemplar/mod.rs +1 -1
  68. data/rust/src/exemplar/mutations.rs +1 -1
  69. data/rust/src/exemplar/queries.rs +1 -1
  70. data/rust/src/exemplar/reactions.rs +1 -1
  71. data/rust/src/exemplar/read_models.rs +1 -1
  72. data/rust/src/exemplar/registry.rs +1 -1
  73. data/rust/src/exemplar/types.rs +1 -1
  74. data/rust/src/kernel/cli.rs +2 -2
  75. data/rust/src/kernel/read_model.rs +1 -1
  76. data/rust/src/lib.rs +1 -1
  77. metadata +3 -23
  78. data/rust/project/bridging.rb +0 -257
  79. data/rust/project/commands.rb +0 -877
  80. data/rust/project/constraints.rb +0 -86
  81. data/rust/project/dependency_planning.rb +0 -178
  82. data/rust/project/domain_generator.rb +0 -983
  83. data/rust/project/exemplar.rb +0 -148
  84. data/rust/project/expr_emitter.rb +0 -72
  85. data/rust/project/fielded.rb +0 -141
  86. data/rust/project/json_codec.rb +0 -641
  87. data/rust/project/mutations.rb +0 -695
  88. data/rust/project/naming.rb +0 -223
  89. data/rust/project/ports.rb +0 -65
  90. data/rust/project/queries.rb +0 -493
  91. data/rust/project/reactions.rb +0 -317
  92. data/rust/project/read_models.rb +0 -690
  93. data/rust/project/reference_specs.rb +0 -27
  94. data/rust/project/registry.rb +0 -396
  95. data/rust/project/skip_reason.rb +0 -29
  96. data/rust/project/types.rb +0 -225
  97. data/rust/project/write_if_changed.rb +0 -7
  98. data/rust/project.rb +0 -30
  99. 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
- def initialize(edge, rows, vocabulary, problems)
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 naming the template; declare one member with template:") unless setting
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: [:template], policy: %i[cache_class cache], upstream: %i[origin id],
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
- value.is_a?(FIELDS.fetch(kind).fetch(field)) || (problems << "#{label} has #{field} #{value.inspect}; " \
90
- "#{field} is a #{FIELDS.fetch(kind).fetch(field)}")
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
- 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 }
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
- rows = live(table.rows)
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(rows)),
119
- nav_const("NAV_MOBILE", "The mobile navigation, in its own order.", mobile(rows)),
120
- nav_const("NAV_FOOTER", "The footer columns, left to right, each with its links.", footer(rows)),
121
- nav_const("NAV_ADMIN", "The admin navigation: each admin page under its key.", admin(rows))].join("\n\n")
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
- def desktop(rows)
130
- items = rows.reject { |row| row.nav_order.nil? }.sort_by { |row| [row.nav_order, row.path] }
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 |row|
133
- item = { path: row.path, label: row.label }
134
- group = row.nav_group
135
- slot = group && entries.find { |entry| entry[:group] == group }
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(rows)
142
- rows.reject { |row| row.mobile_order.nil? }.sort_by { |row| [row.mobile_order, row.path] }
143
- .map { |row| { path: row.path, label: row.label } }
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(rows)
147
- columns = rows.select(&:footer_column).sort_by { |row| [row.footer_order, row.path] }.group_by(&:footer_column)
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 { |row| { path: row.path, label: row.label } } } }
163
+ .map { |name, group| { column: name, items: group.map { |item| entry(item) } } }
150
164
  end
151
165
 
152
- def admin(rows)
153
- rows.select(&:admin_key).sort_by { |row| [row.admin_order, row.admin_key] }
154
- .map { |row| { key: row.admin_key, path: row.path, label: row.label } }
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 === "*" ? ".*" : part.startsWith(":") ? "[^/]+" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
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
- /** Whether a pathname is one a route pattern (`/blog/:slug.html`, `/pay/*`) describes, with or without `.html`. */
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. `Regions` puts each between the markers of the same name in the project's
15
- # template, at the markers' indentation. The text is a pure function of the table and the
16
- # edge rows.
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, an off route that sits in a
9
- # navigation, an indexable route that is not public.
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
- def initialize(rows, problems)
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
- check_unique(@rows.filter_map(&:admin_key), "admin key")
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
- problem(path, "is off and sits in the #{slots.join(' and ')} navigation") if row.off
94
- problem(path, "sits in the navigation but is a #{row.kind}; only a page does") unless row.kind == "page"
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, keyword_init: true)
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 }.freeze
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
- row = Row.new(**fields, verbs: verbs, path: path, source: source,
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.verbs.each do |verb|
127
- next if @vocabulary.fetch(:http_method).include?(verb)
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
- problem(label, "has method #{verb.inspect}; methods are #{@vocabulary.fetch(:http_method).join(', ')}")
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
- Checks.new(@rows, problems).call
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