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.
- checksums.yaml +4 -4
- data/exe/hecks +25 -7
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/lambda.rb +1 -1
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
- data/lib/hecks/bluebook/hexagon.rb +45 -25
- data/lib/hecks/bluebook/model_check.rb +7 -7
- data/lib/hecks/chapters.rb +31 -3
- data/lib/hecks/cli/check_era.rb +1 -1
- data/lib/hecks/cli/console.rb +24 -8
- data/lib/hecks/cli/deploy_template_diff.rb +1 -1
- data/lib/hecks/cli/doc_coverage.rb +1 -1
- data/lib/hecks/cli/domain_stub.rb +128 -0
- data/lib/hecks/cli/interview_draft.rb +246 -0
- data/lib/hecks/cli/model_check.rb +1 -1
- data/lib/hecks/cli/project_cli.rb +69 -18
- data/lib/hecks/cli/reference.rb +2 -1
- data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
- data/lib/hecks/cli/seed_semantics_corpus.rb +38 -9
- data/lib/hecks/cli/spec_example.rb +1 -1
- data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
- data/lib/hecks/corpus.rb +11 -8
- data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
- data/lib/hecks/deploy/oidc.json +5 -0
- data/lib/hecks/doc/reference.rb +4 -3
- data/lib/hecks/doors/cli_door.rb +13 -13
- data/lib/hecks/doors/cli_runner.rb +52 -32
- data/lib/hecks/doors/launcher_options.rb +23 -5
- data/lib/hecks/doors/mcp_door.rb +40 -18
- data/lib/hecks/doors/mcp_door_scope.rb +244 -24
- data/lib/hecks/doors.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +2 -2
- data/lib/hecks/forms/banking_presentation.rb +1 -1
- data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
- data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
- data/lib/hecks/framework.rb +1 -1
- data/lib/hecks/fuzzing/combination_miner.rb +1 -1
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
- data/lib/hecks/fuzzing/differential.rb +4 -2
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/form_census.rb +2 -2
- data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
- data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
- data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
- data/lib/hecks/gate/stages.yml +8 -4
- data/lib/hecks/grammar/grammar.hecksagon +2 -2
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
- data/lib/hecks/hecks/adapters/host_http.rb +4 -3
- data/lib/hecks/hecks/adapters/local_files.rb +59 -0
- data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
- data/lib/hecks/hecks/codebase.bluebook +4 -3
- data/lib/hecks/hecks/context_map.hecksagon +2 -2
- data/lib/hecks/hecks/custodian.bluebook +45 -3
- data/lib/hecks/hecks/hecks.hecksagon +10 -4
- data/lib/hecks/hecks/hecks.world +7 -4
- data/lib/hecks/hecks/site.behaviors +17 -0
- data/lib/hecks/hecks/site.world +10 -0
- data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
- data/lib/hecks/projections/deploy/box/settings.rb +248 -0
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
- data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
- data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
- data/lib/hecks/projections/deploy/box.rb +387 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
- data/lib/hecks/projections/deploy/fargate.rb +1 -1
- data/lib/hecks/projections/deploy/lambda.rb +11 -10
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
- data/lib/hecks/projections/diagrams.rb +2 -2
- data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
- data/lib/hecks/projections/site/edge/checks.rb +137 -0
- data/lib/hecks/projections/site/edge/pattern.rb +50 -0
- data/lib/hecks/projections/site/edge/rules.rb +128 -0
- data/lib/hecks/projections/site/edge.rb +136 -0
- data/lib/hecks/projections/site/regions.rb +44 -0
- data/lib/hecks/projections/site/routes_ts.rb +284 -0
- data/lib/hecks/projections/site/site_cdn.rb +78 -0
- data/lib/hecks/projections/site/table/checks.rb +124 -0
- data/lib/hecks/projections/site/table/row_builder.rb +155 -0
- data/lib/hecks/projections/site/table.rb +98 -0
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/projector/cli_projector.rb +172 -95
- data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
- data/lib/hecks/quality_control/adapters/agent.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
- data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
- data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
- data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
- data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
- data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
- data/lib/hecks/quality_control/quality_control.bluebook +18 -18
- data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
- data/lib/hecks/runtime/command_interpreter.rb +2 -0
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/invocation.rb +5 -3
- data/lib/hecks/runtime/registry/verification.rb +15 -29
- data/lib/hecks/runtime/registry.rb +14 -20
- data/lib/hecks/rust_build/append_optionals.rb +79 -0
- data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
- data/lib/hecks/rust_build/domain_name.rb +26 -0
- data/lib/hecks/rust_build/kernel_input.rb +58 -0
- data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
- data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
- data/lib/hecks/rust_build/project_rust.rb +57 -24
- data/lib/hecks/rust_build/rust_literal.rb +33 -0
- data/lib/hecks/rust_build/write_if_changed.rb +86 -0
- data/lib/hecks/site/bluebook/site.bluebook +205 -0
- data/lib/hecks/site/bluebook/site.hecksagon +5 -0
- data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
- data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
- data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
- data/lib/hecks/storehouse.rb +37 -7
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
- data/lib/hecks/three_zero/forms.yml +88 -88
- data/lib/hecks/tools/ci_gates.rb +1 -1
- data/lib/hecks/tools/deploy_recipe.rb +14 -6
- data/lib/hecks/tools/oidc_manifests.rb +2 -1
- data/lib/hecks/tools/sequence_script.rb +2 -2
- data/lib/hecks/tools/site_routes.rb +152 -0
- data/lib/hecks/tools/tenant_provisioning.rb +1 -1
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/rust/build/src/cargo_sync.rs +1 -1
- data/rust/build/src/pipeline.rs +23 -16
- data/rust/build/src/resolve.rs +39 -6
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +1 -1
- data/rust/host/src/dispatch.rs +87 -7
- data/rust/host/src/needs.rs +111 -0
- data/rust/host/src/web/newsletter/template.rs +69 -0
- data/rust/host/src/web/newsletter/tests.rs +43 -0
- data/rust/host/src/web/newsletter.rs +6 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/parser/src/keywords.rs +7 -6
- data/rust/parser/src/main.rs +4 -3
- data/rust/parser/src/parse/chapter.rs +1 -1
- data/rust/parser/src/parse/hecksagon.rs +25 -10
- data/rust/project/mutations.rb +4 -30
- data/rust/project/naming.rb +8 -28
- data/rust/project/write_if_changed.rb +4 -80
- data/rust/project_rust_pipeline.rb +16 -12
- data/rust/src/kernel/cli.rs +2 -0
- data/rust/src/kernel/needs.rs +74 -1
- 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
|