hecks 3.0.4 → 3.1.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.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +25 -7
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  5. data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
  6. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
  7. data/lib/hecks/bluebook/hexagon.rb +45 -25
  8. data/lib/hecks/bluebook/model_check.rb +7 -7
  9. data/lib/hecks/chapters.rb +31 -3
  10. data/lib/hecks/cli/check_era.rb +1 -1
  11. data/lib/hecks/cli/console.rb +24 -8
  12. data/lib/hecks/cli/deploy_template_diff.rb +1 -1
  13. data/lib/hecks/cli/doc_coverage.rb +1 -1
  14. data/lib/hecks/cli/domain_stub.rb +128 -0
  15. data/lib/hecks/cli/interview_draft.rb +246 -0
  16. data/lib/hecks/cli/model_check.rb +1 -1
  17. data/lib/hecks/cli/project_cli.rb +69 -18
  18. data/lib/hecks/cli/reference.rb +2 -1
  19. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  20. data/lib/hecks/cli/seed_semantics_corpus.rb +38 -9
  21. data/lib/hecks/cli/spec_example.rb +1 -1
  22. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  23. data/lib/hecks/corpus.rb +11 -8
  24. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  25. data/lib/hecks/deploy/oidc.json +5 -0
  26. data/lib/hecks/doc/reference.rb +4 -3
  27. data/lib/hecks/doors/cli_door.rb +13 -13
  28. data/lib/hecks/doors/cli_runner.rb +52 -32
  29. data/lib/hecks/doors/launcher_options.rb +23 -5
  30. data/lib/hecks/doors/mcp_door.rb +40 -18
  31. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  32. data/lib/hecks/doors.rb +1 -1
  33. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  34. data/lib/hecks/forms/banking_presentation.rb +1 -1
  35. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  36. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  37. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  38. data/lib/hecks/framework.rb +1 -1
  39. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  40. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  41. data/lib/hecks/fuzzing/differential.rb +4 -2
  42. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  43. data/lib/hecks/fuzzing/form_census.rb +2 -2
  44. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  45. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  46. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  47. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  48. data/lib/hecks/gate/stages.yml +8 -4
  49. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  50. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  51. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  52. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  53. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  54. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  55. data/lib/hecks/hecks/codebase.bluebook +4 -3
  56. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  57. data/lib/hecks/hecks/custodian.bluebook +45 -3
  58. data/lib/hecks/hecks/hecks.hecksagon +10 -4
  59. data/lib/hecks/hecks/hecks.world +7 -4
  60. data/lib/hecks/hecks/site.behaviors +17 -0
  61. data/lib/hecks/hecks/site.world +10 -0
  62. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  63. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  64. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  65. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
  66. data/lib/hecks/projections/deploy/box/settings.rb +248 -0
  67. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
  68. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
  69. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  70. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
  71. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  72. data/lib/hecks/projections/deploy/box.rb +387 -0
  73. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  74. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  75. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  76. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  77. data/lib/hecks/projections/diagrams.rb +2 -2
  78. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  79. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  80. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  81. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  82. data/lib/hecks/projections/site/edge.rb +136 -0
  83. data/lib/hecks/projections/site/regions.rb +44 -0
  84. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  85. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  86. data/lib/hecks/projections/site/table/checks.rb +124 -0
  87. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  88. data/lib/hecks/projections/site/table.rb +98 -0
  89. data/lib/hecks/projections.rb +2 -0
  90. data/lib/hecks/projector/cli_projector.rb +172 -95
  91. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  92. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  93. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  94. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  95. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  96. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  97. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  98. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  99. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  100. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  101. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  102. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  103. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  104. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  105. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  106. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  107. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  108. data/lib/hecks/runtime/command_interpreter.rb +2 -0
  109. data/lib/hecks/runtime/interpreting.rb +21 -0
  110. data/lib/hecks/runtime/invocation.rb +5 -3
  111. data/lib/hecks/runtime/registry/verification.rb +15 -29
  112. data/lib/hecks/runtime/registry.rb +14 -20
  113. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  114. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  115. data/lib/hecks/rust_build/domain_name.rb +26 -0
  116. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  117. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  118. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  119. data/lib/hecks/rust_build/project_rust.rb +57 -24
  120. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  121. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  122. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  123. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  124. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  125. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  126. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  127. data/lib/hecks/storehouse.rb +37 -7
  128. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  129. data/lib/hecks/three_zero/forms.yml +88 -88
  130. data/lib/hecks/tools/ci_gates.rb +1 -1
  131. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  132. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  133. data/lib/hecks/tools/sequence_script.rb +2 -2
  134. data/lib/hecks/tools/site_routes.rb +152 -0
  135. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  136. data/lib/hecks/tools.rb +1 -0
  137. data/lib/hecks/version.rb +1 -1
  138. data/rust/build/src/cargo_sync.rs +1 -1
  139. data/rust/build/src/pipeline.rs +23 -16
  140. data/rust/build/src/resolve.rs +39 -6
  141. data/rust/host/HECKS_RELEASE +1 -1
  142. data/rust/host/src/api.rs +1 -1
  143. data/rust/host/src/dispatch.rs +87 -7
  144. data/rust/host/src/needs.rs +111 -0
  145. data/rust/host/src/web/newsletter/template.rs +69 -0
  146. data/rust/host/src/web/newsletter/tests.rs +43 -0
  147. data/rust/host/src/web/newsletter.rs +6 -1
  148. data/rust/host/src/web/newsletter_send.rs +1 -1
  149. data/rust/parser/src/keywords.rs +7 -6
  150. data/rust/parser/src/main.rs +4 -3
  151. data/rust/parser/src/parse/chapter.rs +1 -1
  152. data/rust/parser/src/parse/hecksagon.rs +25 -10
  153. data/rust/project/mutations.rb +4 -30
  154. data/rust/project/naming.rb +8 -28
  155. data/rust/project/write_if_changed.rb +4 -80
  156. data/rust/project_rust_pipeline.rb +16 -12
  157. data/rust/src/kernel/cli.rs +2 -0
  158. data/rust/src/kernel/needs.rs +74 -1
  159. metadata +39 -2
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ # The marked regions of a template a project owns, where the generated blocks go.
7
+ #
8
+ # A region is a pair of comment lines, `# BEGIN GENERATED site_cdn <name> ...` and
9
+ # `# END GENERATED site_cdn <name>`, at the indentation the block's lines take. Everything
10
+ # outside the pair is the project's; everything inside is replaced on each run.
11
+ module Regions
12
+ module_function
13
+
14
+ # @param name [String] a region's name
15
+ # @return [Regexp] the region: its begin line, whatever is inside, its end line
16
+ def pattern(name)
17
+ escaped = Regexp.escape(name)
18
+ /^([ \t]*)# BEGIN GENERATED site_cdn #{escaped}\b[^\n]*\n.*?^\1# END GENERATED site_cdn #{escaped}$/m
19
+ end
20
+
21
+ # @param text [String] a template
22
+ # @param name [String] a region's name
23
+ # @return [Boolean] whether the template has the region
24
+ def region?(text, name) = text.match?(pattern(name))
25
+
26
+ # @param text [String] a template
27
+ # @param name [String] a region's name
28
+ # @param block [String] the lines that go inside, from the left margin
29
+ # @return [String] the template with the region's markers and block rewritten
30
+ def replace(text, name, block)
31
+ text.sub(pattern(name)) do
32
+ indent = Regexp.last_match(1)
33
+ [
34
+ "#{indent}# BEGIN GENERATED site_cdn #{name} (hecks site site_projection.project_site; " \
35
+ "edit the Route and Edge rows). Do not hand-edit.",
36
+ block.each_line.map { |line| line.strip.empty? ? line : "#{indent}#{line}" }.join.chomp,
37
+ "#{indent}# END GENERATED site_cdn #{name}"
38
+ ].join("\n")
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../../projector"
5
+ require_relative "table"
6
+
7
+ module Hecks
8
+ module Projections
9
+ module Site
10
+ # A site's route table as one dependency-free TypeScript module, `routes.ts`.
11
+ #
12
+ # The module holds the table as `as const` data and the few pure helpers the site's
13
+ # middleware, navigation, sitemap, robots file and content system need over it, so each reads
14
+ # one file instead of carrying its own copy of which pages exist. Nothing in it depends on a
15
+ # framework: it is an ES module that Node (type stripping) and any bundler load as it stands.
16
+ #
17
+ # The text is a pure function of the table: rows keep the order the client declared them in,
18
+ # every list derived from them is sorted by an explicit key, and nothing in it names a time
19
+ # or a machine, so regenerating from the same table changes nothing.
20
+ module RoutesTs
21
+ extend Projector::Target
22
+
23
+ projects_as :site_routes_ts, emits: :files
24
+
25
+ # The banner every generated file starts with.
26
+ BANNER = "// Generated by hecks site site_projection.project_site. Do not edit."
27
+
28
+ # The path of the row that stands for the edge's default behaviour. It matches every path,
29
+ # so it hides none from search.
30
+ CATCH_ALL = "/*"
31
+
32
+ # The URL prefix under which a draft preview is served.
33
+ PREVIEW_PREFIX = "/admin-preview"
34
+
35
+ module_function
36
+
37
+ # Renders `routes.ts` for a route table.
38
+ #
39
+ # @param bluebook [Bluebook::Chapter] the chapter that declares the route table
40
+ # @param options [Hash{Symbol => Object}] `:registry` (Runtime::Registry) the project booted
41
+ # into, which holds the Site chapter; `:table` (Table) a table already read, instead
42
+ # @return [Hash{String => String}] `"routes.ts"` to its text
43
+ # @raise [Table::Invalid] when the table is refused
44
+ def call(bluebook:, options: {})
45
+ table = options[:table] || Table.read(bluebook, registry: options.fetch(:registry))
46
+ { "routes.ts" => render(table) }
47
+ end
48
+
49
+ # @param table [Table] a checked table
50
+ # @return [String] the module's text, ending in a newline
51
+ def render(table)
52
+ rows = table.rows
53
+ [BANNER, header, routes(rows), types, switches(rows), redirects(rows), middleware(rows),
54
+ navigation(table), search(rows), sources(rows), helpers].join("\n\n") << "\n"
55
+ end
56
+
57
+ # @param rows [Array<Table::Row>] the table's rows
58
+ # @return [Array<Table::Row>] the rows that exist: those not switched off
59
+ def live(rows) = rows.reject(&:off)
60
+
61
+ def header
62
+ <<~TS.chomp
63
+ // The route table of this site, projected from its bluebook's Route rows. Change the rows and run
64
+ // `hecks site site_projection.project_site`; `hecks site site_projection.project_site --check` fails when this file is out of date.
65
+ TS
66
+ end
67
+
68
+ def types
69
+ <<~TS.chomp
70
+ export type Route = (typeof ROUTES)[number];
71
+ TS
72
+ end
73
+
74
+ def routes(rows)
75
+ lines = rows.map { |row| " #{literal(route_fields(row))}," }
76
+ "export const ROUTES = [\n#{lines.join("\n")}\n] as const;"
77
+ end
78
+
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 }
84
+ end
85
+
86
+ def switches(rows)
87
+ switched = rows.reject { |row| row.switch.empty? }.group_by(&:switch)
88
+ ids = switched.sort.to_h { |id, group| [id.to_sym, group.none?(&:off)] }
89
+ "// A page's switch: false while the page is off. A page without a switch is always on.\n" \
90
+ "export const SWITCHES: Readonly<Record<string, boolean>> = #{literal(ids)};"
91
+ end
92
+
93
+ def redirects(rows)
94
+ pairs = rows.flat_map do |row|
95
+ targeted = row.kind == "redirect" ? [[row.path, row.redirect_to]] : []
96
+ targeted + row.aliases.map { |from| [from, row.path] }
97
+ end
98
+ items = pairs.sort.map { |from, to| " #{literal({ from: from, to: to, status: 301 })}," }
99
+ "export const REDIRECTS = [\n#{items.join("\n")}\n] as const;".sub("[\n\n]", "[]")
100
+ end
101
+
102
+ def middleware(rows)
103
+ off = rows.select(&:off).flat_map { |row| [row.path, *row.aliases] }.sort
104
+ <<~TS.chomp
105
+ // The three rules the site's middleware applies, as data: a switched-off page answers 404, a
106
+ // request under the preview prefix is served from draft content, and `.html` is dropped.
107
+ export const MIDDLEWARE = {
108
+ off: { status: 404, paths: #{literal(off)} },
109
+ preview: { prefix: #{literal(PREVIEW_PREFIX)} },
110
+ stripHtml: { suffix: ".html" },
111
+ } as const;
112
+ TS
113
+ end
114
+
115
+ def navigation(table)
116
+ rows = live(table.rows)
117
+ [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")
122
+ end
123
+
124
+ def nav_const(name, comment, entries)
125
+ body = entries.map { |entry| " #{literal(entry)}," }
126
+ "// #{comment}\nexport const #{name} = #{body.empty? ? '[]' : "[\n#{body.join("\n")}\n]"} as const;"
127
+ end
128
+
129
+ def desktop(rows)
130
+ items = rows.reject { |row| row.nav_order.nil? }.sort_by { |row| [row.nav_order, row.path] }
131
+ 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] }
137
+ end
138
+ entries
139
+ end
140
+
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 } }
144
+ end
145
+
146
+ def footer(rows)
147
+ columns = rows.select(&:footer_column).sort_by { |row| [row.footer_order, row.path] }.group_by(&:footer_column)
148
+ 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 } } } }
150
+ end
151
+
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 } }
155
+ end
156
+
157
+ def search(rows)
158
+ sitemap = live(rows).select { |row| row.indexable && !row.path.match?(/[:*]/) }.map(&:path).sort
159
+ hidden = rows.reject { |row| row.indexable || row.path == CATCH_ALL }.flat_map { |row| [row.path, *row.aliases] }.sort
160
+ <<~TS.chomp
161
+ // The paths a sitemap lists: indexable pages with no parameter. A collection's pages are listed
162
+ // from its entries, through `collectionPages`.
163
+ export const SITEMAP_PATHS = #{literal(sitemap)} as const;
164
+
165
+ // The routes a search engine is kept away from: every route that is not indexable.
166
+ export const NOT_FOR_SEARCH = #{literal(hidden)} as const;
167
+
168
+ // robots.txt `Disallow` prefixes: each non-public route up to its first parameter.
169
+ export const ROBOTS_DISALLOW = #{literal(robots(live(rows)))} as const;
170
+ TS
171
+ end
172
+
173
+ def robots(rows)
174
+ prefixes = rows.reject { |row| row.auth == "public" }.map { |row| robots_prefix(row.path) }
175
+ prefixes = (prefixes + ["#{PREVIEW_PREFIX}/"]).uniq.sort
176
+ prefixes.reject { |prefix| prefixes.any? { |other| other != prefix && prefix.start_with?(other) } }
177
+ end
178
+
179
+ def robots_prefix(path)
180
+ head = path.split("/").take_while { |part| !part.match?(/\A[:*]/) }.join("/")
181
+ head == path ? path : "#{head}/"
182
+ end
183
+
184
+ def sources(rows)
185
+ globals = sourced(rows, "global:", &:path)
186
+ collections = sourced(rows, "collection:") { |row| { path: row.path, indexable: row.indexable } }
187
+ previews = rows.select { |row| row.source.match?(/\A(global|collection):/) }
188
+ .to_h { |row| [row.source, { path: row.path, draft: row.preview == "draft" }] }.sort.to_h
189
+ <<~TS.chomp
190
+ // A CMS global's slug to the page that shows it.
191
+ export const globalPages = #{literal(globals)} as const;
192
+
193
+ // A CMS collection's name to the page that shows one of its entries, and whether the sitemap lists them.
194
+ export const collectionPages = #{literal(collections)} as const;
195
+
196
+ const PREVIEWS: Readonly<Record<string, { readonly path: string; readonly draft: boolean }>> = #{literal(previews)};
197
+ TS
198
+ end
199
+
200
+ def sourced(rows, prefix)
201
+ rows.select { |row| row.source.start_with?(prefix) }
202
+ .to_h { |row| [row.source.delete_prefix(prefix), yield(row)] }.sort.to_h
203
+ end
204
+
205
+ def helpers = [path_helpers, page_helpers].join("\n\n")
206
+
207
+ def path_helpers
208
+ <<~'TS'.chomp
209
+ /** Drops a trailing `.html` and a trailing slash, so `/about.html`, `/about/` and `/about` are one path. */
210
+ export function stripHtml(pathname: string): string {
211
+ const bare = pathname.endsWith(".html") ? pathname.slice(0, -".html".length) : pathname;
212
+ return bare.length > 1 && bare.endsWith("/") ? bare.slice(0, -1) : bare;
213
+ }
214
+
215
+ function toRegExp(pattern: string): RegExp {
216
+ const source = stripHtml(pattern)
217
+ .split("/")
218
+ .map((part) =>
219
+ part === "*" ? ".*" : part.startsWith(":") ? "[^/]+" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
220
+ )
221
+ .join("/");
222
+ return new RegExp("^" + source + "$");
223
+ }
224
+
225
+ /** Whether a pathname is one a route pattern (`/blog/:slug.html`, `/pay/*`) describes, with or without `.html`. */
226
+ export function matchesPath(pattern: string, pathname: string): boolean {
227
+ return toRegExp(pattern).test(stripHtml(pathname));
228
+ }
229
+ TS
230
+ end
231
+
232
+ def page_helpers
233
+ <<~'TS'.chomp
234
+ /** Whether a page is on: true for an id no row switches off, and for an id no row names. */
235
+ export function pageIsOn(id: string): boolean {
236
+ return Object.prototype.hasOwnProperty.call(SWITCHES, id) ? SWITCHES[id] : true;
237
+ }
238
+
239
+ /** Whether a pathname belongs to a switched-off page, with or without `.html`. */
240
+ export function isOffPath(pathname: string): boolean {
241
+ return MIDDLEWARE.off.paths.some((pattern) => matchesPath(pattern, pathname));
242
+ }
243
+
244
+ /** Whether a search engine is kept away from a pathname. */
245
+ export function notForSearch(pathname: string): boolean {
246
+ return NOT_FOR_SEARCH.some((pattern) => matchesPath(pattern, pathname));
247
+ }
248
+
249
+ /** The URL that previews a CMS source (`global:<slug>` or `collection:<name>`), under the preview prefix when it is a draft. */
250
+ export function previewUrl(source: string, slug?: string): string {
251
+ const entry = PREVIEWS[source];
252
+ if (entry === undefined) throw new Error(`no route has the source ${source}`);
253
+ let path = entry.path;
254
+ if (path.includes(":")) {
255
+ if (slug === undefined) throw new Error(`${source} previews one entry: pass its slug`);
256
+ path = path.replace(/:[A-Za-z_]\w*/, encodeURIComponent(slug));
257
+ }
258
+ return entry.draft ? MIDDLEWARE.preview.prefix + stripHtml(path) : path;
259
+ }
260
+ TS
261
+ end
262
+
263
+ def blank_to_nil(text) = text.nil? || text.empty? ? nil : text
264
+
265
+ # @param value [Object] a string, number, boolean, nil, array or hash
266
+ # @return [String] the TypeScript literal for it: objects with bare keys, on one line
267
+ def literal(value)
268
+ case value
269
+ when nil then "null"
270
+ when true, false, Integer then value.to_s
271
+ when String then JSON.generate(value)
272
+ when Array then "[#{value.map { |item| literal(item) }.join(', ')}]"
273
+ when Hash then "{ #{value.map { |key, item| "#{key_literal(key)}: #{literal(item)}" }.join(', ')} }".sub("{ }", "{}")
274
+ else raise ArgumentError, "no TypeScript literal for #{value.inspect}"
275
+ end
276
+ end
277
+
278
+ def key_literal(key)
279
+ key.to_s.match?(/\A[A-Za-z_$][\w$]*\z/) ? key.to_s : JSON.generate(key.to_s)
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../projector"
4
+ require_relative "../deploy/fargate/yaml"
5
+ require_relative "edge"
6
+
7
+ module Hecks
8
+ module Projections
9
+ module Site
10
+ # The edge of a site's route table as the two blocks of a CloudFormation template that depend
11
+ # on it: the distribution's cache behaviours and the load balancer's listener rules.
12
+ #
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.
17
+ module SiteCdn
18
+ extend Projector::Target
19
+
20
+ projects_as :site_cdn, emits: :files
21
+
22
+ module_function
23
+
24
+ # @param bluebook [Bluebook::Chapter] the chapter that declares the route table and the edge
25
+ # @param options [Hash{Symbol => Object}] `:registry` (Runtime::Registry) the project booted
26
+ # into; or `:table` (Table) and `:edge` (Edge), already read
27
+ # @return [Hash{String => String}] each region's name to its text; empty when the chapter
28
+ # declares no edge
29
+ # @raise [Table::Invalid] when the table or the edge is refused
30
+ def call(bluebook:, options: {})
31
+ registry = options[:registry]
32
+ table = options[:table] || Table.read(bluebook, registry: registry)
33
+ edge = options[:edge] || Edge.read(bluebook, table: table, vocabulary: Table.vocabulary(registry))
34
+ return {} unless edge
35
+
36
+ { "behaviors" => behaviors(edge), "listener_rules" => listener_rules(edge) }
37
+ end
38
+
39
+ # @param edge [Edge] a checked edge
40
+ # @return [String] the default behaviour and the ordered list, ending in a newline
41
+ def behaviors(edge)
42
+ fargate = Deploy::Fargate::Cdn
43
+ lines = fargate.behavior_lines("DefaultCacheBehavior", edge.default_behaviour, "")
44
+ unless edge.behaviours.empty?
45
+ lines << "CacheBehaviors:"
46
+ edge.behaviours.each { |entry| lines.concat(fargate.behavior_lines(nil, entry, " ")) }
47
+ end
48
+ "#{lines.join("\n")}\n"
49
+ end
50
+
51
+ # @param edge [Edge] a checked edge
52
+ # @return [String] one listener rule resource per rule, by priority, ending in a newline
53
+ def listener_rules(edge)
54
+ edge.listener_rules.map { |rule| listener_rule(edge.setting, rule) }.join("\n")
55
+ end
56
+
57
+ def listener_rule(setting, rule)
58
+ paths = rule.fetch(:paths)
59
+ conditions = []
60
+ unless paths == ["/*"]
61
+ conditions.push(" - Field: path-pattern", " Values: #{Deploy::Fargate::Yaml.flow_list(paths)}")
62
+ end
63
+ if setting.secret_header
64
+ conditions.push(" - Field: http-header", " HttpHeaderConfig:",
65
+ " HttpHeaderName: #{setting.secret_header}",
66
+ " Values: #{Deploy::Fargate::Yaml.flow_list([setting.secret_value])}")
67
+ end
68
+ [
69
+ "#{rule.fetch(:rule)}:", " Type: AWS::ElasticLoadBalancingV2::ListenerRule", " Properties:",
70
+ " ListenerArn: #{setting.listener}", " Priority: #{rule.fetch(:priority)}",
71
+ *(" Conditions:" unless conditions.empty?), *conditions,
72
+ " Actions:", " - Type: forward", " TargetGroupArn: #{rule.fetch(:target_group)}"
73
+ ].join("\n") << "\n"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ class Table
7
+ # 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
+ class Checks
11
+ SWITCH_ID = /\A[a-z][a-z0-9_]*\z/
12
+ PUBLIC_MENUS = %w[desktop mobile footer].freeze
13
+
14
+ # The navigation slots a row sits in.
15
+ #
16
+ # @param row [Row] a row
17
+ # @return [Array<String>] some of `desktop`, `mobile`, `footer`, `admin`
18
+ def self.navigation(row)
19
+ { "desktop" => row.nav_order, "mobile" => row.mobile_order, "footer" => row.footer_column,
20
+ "admin" => row.admin_key }.compact.keys
21
+ end
22
+
23
+ # @param rows [Array<Row>] the table's rows, defaults filled in
24
+ # @param problems [Array<String>] collects each problem found, one line each
25
+ def initialize(rows, problems)
26
+ @rows = rows
27
+ @problems = problems
28
+ end
29
+
30
+ # @return [void]
31
+ def call
32
+ check_duplicate_paths
33
+ check_switches
34
+ @rows.each { |row| check_row(row) }
35
+ check_unique(@rows.filter_map(&:admin_key), "admin key")
36
+ check_unique(@rows.map(&:source).grep(/\Aglobal:/), "source")
37
+ end
38
+
39
+ private
40
+
41
+ def check_duplicate_paths
42
+ seen = Hash.new { |hash, path| hash[path] = [] }
43
+ @rows.each do |row|
44
+ [row.path, *row.aliases].compact.each do |path|
45
+ problem(path, "is declared twice") if seen[path].any? { |other| other.verbs.intersect?(row.verbs) }
46
+ seen[path] << row
47
+ end
48
+ end
49
+ end
50
+
51
+ def check_switches
52
+ @rows.reject { |row| row.switch.empty? }.group_by(&:switch).each do |id, rows|
53
+ next if rows.map(&:off).uniq.size == 1
54
+
55
+ problem("switch #{id}", "is on for #{rows.reject(&:off).map(&:path).join(', ')} and off for the rest")
56
+ end
57
+ end
58
+
59
+ def check_row(row)
60
+ path = row.path.to_s
61
+ problem(path, "must start with /") unless path.start_with?("/")
62
+ check_switch(row, path)
63
+ check_redirect(row, path)
64
+ check_navigation(row, path)
65
+ check_indexing(row, path)
66
+ return unless row.preview == "draft" && row.source == "none"
67
+
68
+ problem(path, "previews as a draft but has source none; a draft preview needs a global: or collection: source")
69
+ end
70
+
71
+ def check_switch(row, path)
72
+ problem(path, "is off but names no switch (add switch: \"<id>\")") if row.off && row.switch.empty?
73
+ return if row.switch.empty? || row.switch.match?(SWITCH_ID)
74
+
75
+ problem(path, "has switch #{row.switch.inspect}; a switch is a lowercase id")
76
+ end
77
+
78
+ def check_redirect(row, path)
79
+ targeted = %w[redirect rewrite].include?(row.kind)
80
+ problem(path, "is a #{row.kind} and needs redirect_to") if targeted && row.redirect_to.to_s.empty?
81
+ if !targeted && row.redirect_to
82
+ problem(path, "has redirect_to but is a #{row.kind}; only a redirect or rewrite has a target")
83
+ end
84
+ return if row.aliases.empty? || row.kind == "page"
85
+
86
+ problem(path, "has aliases but is a #{row.kind}; aliases redirect to a page")
87
+ end
88
+
89
+ def check_navigation(row, path)
90
+ slots = self.class.navigation(row)
91
+ return if slots.empty?
92
+
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"
95
+ problem(path, "sits in the navigation and has a parameter") if path.match?(/[:*]/)
96
+ problem(path, "sits in the navigation and has no label") if row.label.to_s.empty?
97
+ check_menu_audience(row, path, slots)
98
+ end
99
+
100
+ def check_menu_audience(row, path, slots)
101
+ public_slots = slots & PUBLIC_MENUS
102
+ if row.auth != "public" && public_slots.any?
103
+ problem(path, "is an admin page in the #{public_slots.join(' and ')} navigation")
104
+ end
105
+ problem(path, "is public and sits in the admin navigation") if row.auth == "public" && slots.include?("admin")
106
+ end
107
+
108
+ def check_indexing(row, path)
109
+ return unless row.indexable
110
+
111
+ problem(path, "is indexable but is #{row.auth}") unless row.auth == "public"
112
+ problem(path, "is indexable but is off") if row.off
113
+ end
114
+
115
+ def check_unique(values, name)
116
+ values.tally.each { |value, count| problem("#{name} #{value}", "is used by #{count} routes") if count > 1 }
117
+ end
118
+
119
+ def problem(label, text) = @problems << "#{label} #{text}"
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end