hecks 3.2.0 → 3.3.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/lib/hecks/bluebook/assembly/contracts.rb +2 -1
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
- data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
- data/lib/hecks/bluebook/query.rb +8 -5
- data/lib/hecks/cli/check_era.rb +3 -39
- data/lib/hecks/cli/interview_draft.rb +86 -31
- data/lib/hecks/cli/interview_session.rb +77 -26
- data/lib/hecks/cli/interview_shape.rb +179 -0
- data/lib/hecks/doors/cli_runner.rb +14 -1
- data/lib/hecks/doors/launcher_options.rb +13 -3
- data/lib/hecks/doors/mcp_door.rb +16 -2
- data/lib/hecks/doors/mcp_door_scope.rb +45 -5
- data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
- data/lib/hecks/doors.rb +1 -1
- data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
- data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +2 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
- data/lib/hecks/fuzzing/qa_settings.rb +15 -34
- data/lib/hecks/gate/stages.yml +113 -8
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
- data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
- data/lib/hecks/hecks/adapters/git.rb +59 -0
- data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
- data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
- data/lib/hecks/hecks/adapters/site_toolchain.rb +88 -11
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/custodian.behaviors +6 -0
- data/lib/hecks/hecks/custodian.bluebook +217 -0
- data/lib/hecks/hecks/hecks.hecksagon +24 -0
- data/lib/hecks/hecks/hecks.world +8 -3
- data/lib/hecks/hecks/site.world +5 -2
- data/lib/hecks/language/bluebook/query.bluebook +25 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +220 -7
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
- data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
- data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
- data/lib/hecks/projections/deploy/box/settings.rb +40 -6
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
- data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
- data/lib/hecks/projections/deploy/box.rb +28 -4
- data/lib/hecks/projections/deploy/fargate.rb +3 -3
- data/lib/hecks/projections/deploy/lambda.rb +8 -8
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
- data/lib/hecks/projections/site/edge/checks.rb +31 -5
- data/lib/hecks/projections/site/edge.rb +25 -11
- data/lib/hecks/projections/site/live_edge.rb +162 -0
- data/lib/hecks/projections/site/routes_ts.rb +50 -27
- data/lib/hecks/projections/site/site_cdn.rb +7 -4
- data/lib/hecks/projections/site/table/checks.rb +53 -7
- data/lib/hecks/projections/site/table/link_builder.rb +64 -0
- data/lib/hecks/projections/site/table/row_builder.rb +21 -6
- data/lib/hecks/projections/site/table.rb +16 -5
- data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
- data/lib/hecks/quality_control/quality_control.bluebook +76 -124
- data/lib/hecks/runtime/interpreting.rb +3 -22
- data/lib/hecks/runtime/needs.rb +48 -0
- data/lib/hecks/runtime/query_interpreter.rb +3 -0
- data/lib/hecks/runtime/registry/verification.rb +3 -3
- data/lib/hecks/rust_build/host.rb +147 -0
- data/lib/hecks/rust_build/kernel_input.rb +51 -17
- data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
- data/lib/hecks/rust_build/project_rust.rb +7 -140
- data/lib/hecks/rust_build.rb +1 -0
- data/lib/hecks/site/bluebook/site.bluebook +80 -14
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
- data/lib/hecks/storehouse.rb +35 -1
- data/lib/hecks/tools/comment_style.rb +4 -2
- data/lib/hecks/tools/gate.rb +1 -1
- data/lib/hecks/tools/rust_comment_style.rb +2 -4
- data/lib/hecks/tools/site_routes.rb +69 -28
- data/lib/hecks/tools/tools_doc.rb +204 -0
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +152 -1
- data/lib/hecks.rb +1 -0
- data/qa/settings.yml +1 -2
- data/rust/build/src/lineage_pass.rs +31 -16
- data/rust/build/src/main.rs +4 -2
- data/rust/build/src/pipeline.rs +9 -20
- data/rust/codegen/src/attr.rs +1 -1
- data/rust/codegen/src/bridging.rs +8 -8
- data/rust/codegen/src/commands.rs +1 -1
- data/rust/codegen/src/constraints.rs +1 -1
- data/rust/codegen/src/dependency_planning.rs +1 -1
- data/rust/codegen/src/domain_generator.rs +2 -2
- data/rust/codegen/src/exemplar.rs +1 -1
- data/rust/codegen/src/expr_emitter.rs +1 -1
- data/rust/codegen/src/fielded.rs +1 -1
- data/rust/codegen/src/json.rs +214 -23
- data/rust/codegen/src/json_codec.rs +1 -1
- data/rust/codegen/src/literal.rs +6 -3
- data/rust/codegen/src/main.rs +23 -13
- data/rust/codegen/src/mutations.rs +1 -1
- data/rust/codegen/src/naming.rs +5 -3
- data/rust/codegen/src/optional_pass.rs +226 -0
- data/rust/codegen/src/ports.rs +1 -1
- data/rust/codegen/src/queries.rs +3 -3
- data/rust/codegen/src/reactions.rs +1 -1
- data/rust/codegen/src/read_models.rs +2 -2
- data/rust/codegen/src/reference_specs.rs +1 -1
- data/rust/codegen/src/registry.rs +4 -4
- data/rust/codegen/src/shared.rs +1 -1
- data/rust/{build → codegen}/src/sidecars.rs +9 -5
- data/rust/codegen/src/skip_reason.rs +1 -1
- data/rust/codegen/src/types.rs +4 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +1 -1
- data/rust/host/src/dispatch.rs +5 -0
- data/rust/host/src/needs.rs +193 -29
- data/rust/host/src/resend.rs +4 -4
- data/rust/host/src/ui_schema.rs +1 -1
- data/rust/parser/src/emit.rs +12 -0
- data/rust/parser/src/ir.rs +2 -0
- data/rust/parser/src/keywords.rs +2 -0
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +2 -44
- data/rust/parser/src/parse/mod.rs +1 -0
- data/rust/parser/src/parse/needs.rs +60 -0
- data/rust/parser/src/parse/query.rs +3 -0
- data/rust/src/exemplar/commands.rs +1 -1
- data/rust/src/exemplar/constraints.rs +1 -1
- data/rust/src/exemplar/fielded.rs +1 -1
- data/rust/src/exemplar/json.rs +1 -1
- data/rust/src/exemplar/mod.rs +1 -1
- data/rust/src/exemplar/mutations.rs +1 -1
- data/rust/src/exemplar/queries.rs +1 -1
- data/rust/src/exemplar/reactions.rs +1 -1
- data/rust/src/exemplar/read_models.rs +1 -1
- data/rust/src/exemplar/registry.rs +1 -1
- data/rust/src/exemplar/types.rs +1 -1
- data/rust/src/kernel/cli.rs +18 -8
- data/rust/src/kernel/needs.rs +104 -9
- data/rust/src/kernel/read_model.rs +1 -1
- data/rust/src/lib.rs +1 -1
- metadata +24 -30
- data/lib/hecks/rust_build/append_optionals.rb +0 -79
- data/lib/hecks/rust_build/rust_literal.rb +0 -33
- data/lib/hecks/three_zero/forms.yml +0 -127
- data/lib/hecks/three_zero.rb +0 -12
- data/rust/build/src/json.rs +0 -421
- data/rust/build/src/optional_pass.rs +0 -150
- data/rust/project/bridging.rb +0 -257
- data/rust/project/commands.rb +0 -877
- data/rust/project/constraints.rb +0 -86
- data/rust/project/dependency_planning.rb +0 -178
- data/rust/project/domain_generator.rb +0 -983
- data/rust/project/exemplar.rb +0 -148
- data/rust/project/expr_emitter.rb +0 -72
- data/rust/project/fielded.rb +0 -141
- data/rust/project/json_codec.rb +0 -641
- data/rust/project/mutations.rb +0 -695
- data/rust/project/naming.rb +0 -223
- data/rust/project/ports.rb +0 -65
- data/rust/project/queries.rb +0 -493
- data/rust/project/reactions.rb +0 -317
- data/rust/project/read_models.rb +0 -690
- data/rust/project/reference_specs.rb +0 -27
- data/rust/project/registry.rb +0 -396
- data/rust/project/skip_reason.rb +0 -29
- data/rust/project/types.rb +0 -225
- data/rust/project/write_if_changed.rb +0 -7
- data/rust/project.rb +0 -30
- data/rust/project_rust_pipeline.rb +0 -391
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require_relative "../../projector"
|
|
3
3
|
require_relative "box/settings"
|
|
4
|
+
require_relative "box/hosting"
|
|
4
5
|
|
|
5
6
|
module Hecks
|
|
6
7
|
module Projections
|
|
@@ -94,7 +95,7 @@ module Hecks
|
|
|
94
95
|
"fetch-secrets.sh" => File.read(File.join(TEMPLATE_DIR, "fetch-secrets.sh")),
|
|
95
96
|
"deploy-box.sh" => deploy_box_sh(plan, region),
|
|
96
97
|
"Makefile" => makefile(plan)
|
|
97
|
-
}.merge(migration_files(plan))
|
|
98
|
+
}.merge(migration_files(plan)).then { |files| Hosting.extend_files(files, plan: plan, region: region) }
|
|
98
99
|
end
|
|
99
100
|
|
|
100
101
|
# The tooling that moves a project's data from its old database into the RDS instance, for
|
|
@@ -379,9 +380,19 @@ module Hecks
|
|
|
379
380
|
def route_blocks(plan)
|
|
380
381
|
routes = plan.routes.each_with_index.map do |route, i|
|
|
381
382
|
port = plan.containers.find { |c| c.name == route.container }.port
|
|
382
|
-
"@r#{i + 1} path #{route.paths.join(' ')}\nhandle @r#{i + 1} {\n
|
|
383
|
+
"@r#{i + 1} path #{route.paths.join(' ')}\nhandle @r#{i + 1} {\n#{upstream(port)}}\n\n"
|
|
383
384
|
end
|
|
384
|
-
"#{routes.join}handle {\n
|
|
385
|
+
"#{routes.join}handle {\n#{upstream(plan.default.port)}}\n"
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# One upstream. A request that arrives while its container is being replaced
|
|
389
|
+
# waits, and is tried again every quarter second for up to 15 seconds, so a
|
|
390
|
+
# deploy shows a visitor a slow page instead of a 502.
|
|
391
|
+
#
|
|
392
|
+
# @param port [Integer] the container's port
|
|
393
|
+
# @return [String] the `reverse_proxy` block, one tab in, ending in a newline
|
|
394
|
+
def upstream(port)
|
|
395
|
+
"\treverse_proxy 127.0.0.1:#{port} {\n\t\tlb_try_duration 15s\n\t\tlb_try_interval 250ms\n\t}\n"
|
|
385
396
|
end
|
|
386
397
|
|
|
387
398
|
# @param plan [Settings::Plan] the resolved settings
|
|
@@ -425,6 +436,7 @@ module Hecks
|
|
|
425
436
|
def services_json(plan)
|
|
426
437
|
services = plan.containers.to_h { |c| [c.name, service_entry(plan, c)] }
|
|
427
438
|
origin = plan.origin_secret ? { "header" => plan.origin_header, "secret" => plan.origin_secret } : nil
|
|
439
|
+
origin["env"] = plan.origin_env if origin && plan.origin_env.any?
|
|
428
440
|
document = { "services" => services, "origin" => origin }
|
|
429
441
|
document["task_definition"] = plan.task_definition if plan.task_definition
|
|
430
442
|
document["tunnel"] = tunnel_entry(plan.tunnel_service) if plan.tunnel_service
|
|
@@ -535,7 +547,7 @@ module Hecks
|
|
|
535
547
|
# @param plan [Settings::Plan] the resolved settings
|
|
536
548
|
# @return [String] a Makefile whose targets create the two stacks and roll the box
|
|
537
549
|
def makefile(plan)
|
|
538
|
-
<<~MAKE
|
|
550
|
+
base = <<~MAKE
|
|
539
551
|
# #{plan.infra_name}: one app box and one RDS instance.
|
|
540
552
|
# make stacks VPC=vpc-... PRIVATE_SUBNETS=subnet-a,subnet-b PUBLIC_SUBNET=subnet-c [REHEARSAL=true]
|
|
541
553
|
# make deploy #{plan.task_definition ? '[TASKDEF=family:revision]' : '[TAGS="web=20260101 worker=20260101"]'}
|
|
@@ -559,6 +571,18 @@ module Hecks
|
|
|
559
571
|
deploy:
|
|
560
572
|
\tbash ./deploy-box.sh $(#{plan.task_definition ? 'TASKDEF' : 'TAGS'})
|
|
561
573
|
MAKE
|
|
574
|
+
"#{base}#{hosting_makefile_tail(plan)}"
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# With hosting scripts, a deploy ends with the post-deploy smoke and the Makefile includes
|
|
578
|
+
# the fragment that adds the hosting targets.
|
|
579
|
+
#
|
|
580
|
+
# @param plan [Settings::Plan] the resolved settings
|
|
581
|
+
# @return [String] the Makefile lines that follow the `deploy` recipe, or nothing
|
|
582
|
+
def hosting_makefile_tail(plan)
|
|
583
|
+
return "" unless plan.hosting
|
|
584
|
+
|
|
585
|
+
"\t#{'TASKDEF="$(TASKDEF)" ' if plan.task_definition}bash ./smoke-after-deploy.sh\n\ninclude hosting.mk\n"
|
|
562
586
|
end
|
|
563
587
|
|
|
564
588
|
# Fills `@@NAME@@` markers. A marker alone on its line is replaced together with the line,
|
|
@@ -100,7 +100,7 @@ module Hecks
|
|
|
100
100
|
deployed_to("AwsFargate") do
|
|
101
101
|
...
|
|
102
102
|
database "Shared"
|
|
103
|
-
owner "
|
|
103
|
+
owner "Core"
|
|
104
104
|
end
|
|
105
105
|
|
|
106
106
|
naming the already-deployed domain whose Postgres instance this one borrows.
|
|
@@ -327,8 +327,8 @@ module Hecks
|
|
|
327
327
|
RetentionInDays: 30
|
|
328
328
|
|
|
329
329
|
# Always minted — HECKS_SERVE_MODE always runs web.rs, which
|
|
330
|
-
# panics on an empty SESSION_SECRET (
|
|
331
|
-
#
|
|
330
|
+
# panics on an empty SESSION_SECRET (seen live as a 502
|
|
331
|
+
# after a database cutover). rust/host fetches this at cold
|
|
332
332
|
# start (secrets.rs), never a plain env var.
|
|
333
333
|
#{session_secret_id}:
|
|
334
334
|
Type: AWS::SecretsManager::Secret
|
|
@@ -147,7 +147,7 @@ module Hecks
|
|
|
147
147
|
deployed_to("AwsLambda") do
|
|
148
148
|
...
|
|
149
149
|
database "Shared"
|
|
150
|
-
owner "
|
|
150
|
+
owner "Core"
|
|
151
151
|
end
|
|
152
152
|
|
|
153
153
|
naming the already-deployed domain whose Postgres instance this one borrows.
|
|
@@ -273,7 +273,7 @@ module Hecks
|
|
|
273
273
|
# GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_REDIRECT_URI
|
|
274
274
|
# deliberately NOT set here — #{domain}'s own .env.local has
|
|
275
275
|
# no real GOOGLE_CLIENT_ID yet, matching Fly's own current
|
|
276
|
-
# reality (
|
|
276
|
+
# reality (its secrets list holds only
|
|
277
277
|
# DATABASE_URL and SESSION_SECRET) that nobody has configured
|
|
278
278
|
# OAuth for this app at all. Drop real credentials into
|
|
279
279
|
# .env.local and regenerate to wire this up.
|
|
@@ -410,7 +410,7 @@ module Hecks
|
|
|
410
410
|
# `stack_name` above exactly — one name, two places it's read
|
|
411
411
|
# from, never out of sync since both derive from `infra_name`.
|
|
412
412
|
FunctionName: #{stack_name}
|
|
413
|
-
# EXPLICIT, not omitted -- `sam build
|
|
413
|
+
# EXPLICIT, not omitted -- `sam build WebFunction` and
|
|
414
414
|
# `sam build --use-container WebFunction` run as TWO SEPARATE
|
|
415
415
|
# single-resource invocations (deploy:'s own comment on why:
|
|
416
416
|
# cargo-lambda cross-compiles on this host directly, pg's
|
|
@@ -434,7 +434,7 @@ module Hecks
|
|
|
434
434
|
# "." NOT "#{logical_id}" -- SAM's Makefile build workflow
|
|
435
435
|
# requires the Makefile to live INSIDE CodeUri itself
|
|
436
436
|
# (confirmed live: "Makefile not found at
|
|
437
|
-
# .../
|
|
437
|
+
# .../WebFunction/Makefile" when CodeUri named a
|
|
438
438
|
# directory of its own). This project's one Makefile lives at
|
|
439
439
|
# the deploy directory's own root, alongside this template --
|
|
440
440
|
# "." is where it actually is.
|
|
@@ -568,7 +568,7 @@ module Hecks
|
|
|
568
568
|
"SubnetIds: [!Ref #{db_id}SubnetA, !Ref #{db_id}SubnetB]\n SecurityGroupIds: [!Ref #{logical_id}SecurityGroup]"
|
|
569
569
|
end}
|
|
570
570
|
# AWS_IAM by default — this dispatches real domain commands
|
|
571
|
-
# (cap-table/governance-shaped data
|
|
571
|
+
# (cap-table/governance-shaped data);
|
|
572
572
|
# an unauthenticated public URL is the wrong default for that,
|
|
573
573
|
# even though it's the simpler one to demo with. NONE only when
|
|
574
574
|
# `web "Rust"` (rust/host/src/web.rs) makes this the public web
|
|
@@ -586,7 +586,7 @@ module Hecks
|
|
|
586
586
|
# THE WEB APP'S OWN LAMBDA — opt-in, see `web_handler_present` above.
|
|
587
587
|
# VPC-attached for the SAME reason #{logical_id} is (Member's own
|
|
588
588
|
# persistence needs the private RDS instance directly, permanently —
|
|
589
|
-
#
|
|
589
|
+
# the hecksagon's own comment on why), sharing
|
|
590
590
|
# #{logical_id}SecurityGroup rather than minting a second one, since
|
|
591
591
|
# both need the identical egress-to-DB rule. That VPC attachment
|
|
592
592
|
# ALSO cuts off the public internet by default (this stack's own "NO
|
|
@@ -674,7 +674,7 @@ module Hecks
|
|
|
674
674
|
# without reworking that whole from-source pg build for a newer
|
|
675
675
|
# image is a real, separate undertaking (untested container image
|
|
676
676
|
# availability, a different vendor/bundle native-extension path)
|
|
677
|
-
# out of scope here. Matches
|
|
677
|
+
# out of scope here. Matches the live, currently-
|
|
678
678
|
# deployed choice exactly, so both consumers of this one shared
|
|
679
679
|
# WebFunction code path stay on the same, PROVEN runtime.
|
|
680
680
|
Properties:
|
|
@@ -763,7 +763,7 @@ module Hecks
|
|
|
763
763
|
# documents. But THIS function's consumer (`lambda_handler.rb`)
|
|
764
764
|
# is not a file this generator writes or this repo carries --
|
|
765
765
|
# it lives in the deploying app's own domain directory (real,
|
|
766
|
-
# live example:
|
|
766
|
+
# live example: a Sinatra app), outside this
|
|
767
767
|
# codebase entirely. Redeploying a domain with an existing
|
|
768
768
|
# `lambda_handler.rb` BEFORE that handler is updated to fetch
|
|
769
769
|
# these itself (`aws-sdk-secretsmanager`, not yet in this
|
|
@@ -48,7 +48,7 @@ module Hecks
|
|
|
48
48
|
# Fields emitted by `to_h`'s own merge rather than `emits_ir`, for constructs
|
|
49
49
|
# whose shape is not fixed.
|
|
50
50
|
DYNAMIC_TAIL = {
|
|
51
|
-
"Query" => %i[options returns],
|
|
51
|
+
"Query" => %i[options returns needs],
|
|
52
52
|
"ReadModel" => %i[options group_by aggregate_heads count median_field sum_field avg_field
|
|
53
53
|
min_field max_field percentile_field percentile_at any_field all_field]
|
|
54
54
|
}.freeze
|
|
@@ -63,9 +63,11 @@ module Hecks
|
|
|
63
63
|
|
|
64
64
|
def reference(text) = text.nil? || text == "none" ? nil : Checks.resolve(text)
|
|
65
65
|
|
|
66
|
-
# CloudFront takes three sets of methods: read, read with `OPTIONS`, and all.
|
|
66
|
+
# CloudFront takes three sets of methods: read, read with `OPTIONS`, and all. A route's
|
|
67
|
+
# `edge_methods` are the verbs the edge lets through; they are its own `methods` unless
|
|
68
|
+
# the row says more.
|
|
67
69
|
def methods(rows)
|
|
68
|
-
verbs = rows.flat_map(&:
|
|
70
|
+
verbs = rows.flat_map(&:edge_verbs).uniq
|
|
69
71
|
return Deploy::Fargate::Cdn::METHODS.fetch("all") unless (verbs - ["GET"]).empty?
|
|
70
72
|
|
|
71
73
|
assets = rows.all? { |row| row.origin == "assets" }
|
|
@@ -76,7 +78,7 @@ module Hecks
|
|
|
76
78
|
# http and redirected; anything else (a form, an API, a signed or admin page, a route of
|
|
77
79
|
# the cms or the domain) is served over https only.
|
|
78
80
|
def protocol(row)
|
|
79
|
-
plain = row.auth == "public" && row.
|
|
81
|
+
plain = row.auth == "public" && row.edge_verbs == ["GET"] && %w[website assets].include?(row.origin)
|
|
80
82
|
plain ? "redirect-to-https" : "https-only"
|
|
81
83
|
end
|
|
82
84
|
|
|
@@ -28,7 +28,9 @@ module Hecks
|
|
|
28
28
|
# @param rows [Array<Table::Row>] the route table's rows
|
|
29
29
|
# @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
|
|
30
30
|
# @param problems [Array<String>] collects each problem found, one line each
|
|
31
|
-
|
|
31
|
+
# @param template_named [Boolean] whether the caller names the template itself
|
|
32
|
+
def initialize(edge, rows, vocabulary, problems, template_named: false)
|
|
33
|
+
@template_named = template_named
|
|
32
34
|
@edge = edge
|
|
33
35
|
@rows = rows
|
|
34
36
|
@vocabulary = vocabulary
|
|
@@ -48,15 +50,21 @@ module Hecks
|
|
|
48
50
|
|
|
49
51
|
def check_setting
|
|
50
52
|
setting = @edge.setting
|
|
51
|
-
return problem("Edge", "has no row
|
|
53
|
+
return problem("Edge", "has no row; declare one member with template:") unless setting
|
|
54
|
+
|
|
55
|
+
check_template(setting.template) if setting.template || !@template_named
|
|
52
56
|
|
|
53
|
-
template = setting.template.to_s
|
|
54
|
-
problem("Edge", "template #{template.inspect} must be a path inside the project") if
|
|
55
|
-
template.empty? || template.start_with?("/") || template.split("/").include?("..")
|
|
56
57
|
problem("Edge", "has secret_header but no secret_value") if setting.secret_header && !setting.secret_value
|
|
57
58
|
problem("Edge", "has secret_value but no secret_header") if setting.secret_value && !setting.secret_header
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
def check_template(template)
|
|
62
|
+
template = template.to_s
|
|
63
|
+
problem("Edge", "has no template; declare template: or name the file to the tool") if template.empty?
|
|
64
|
+
problem("Edge", "template #{template.inspect} must be a path inside the project") if
|
|
65
|
+
template.start_with?("/") || template.split("/").include?("..")
|
|
66
|
+
end
|
|
67
|
+
|
|
60
68
|
def check_policies
|
|
61
69
|
@edge.policies.each do |policy|
|
|
62
70
|
label = "EdgePolicy #{policy.cache_class}#{" on #{policy.origin}" if policy.origin}"
|
|
@@ -86,12 +94,30 @@ module Hecks
|
|
|
86
94
|
end
|
|
87
95
|
|
|
88
96
|
def check_rules
|
|
97
|
+
return check_no_alb unless @edge.alb?
|
|
98
|
+
|
|
89
99
|
problem("EdgeRule", "rows need an Edge row with listener:") if @edge.rules.any? && !@edge.setting&.listener
|
|
90
100
|
@edge.rules.each { |rule| check_rule(rule) }
|
|
91
101
|
repeated(@edge.rules.map(&:rule), "EdgeRule", &:itself)
|
|
92
102
|
repeated(@edge.rules.map(&:priority), "EdgeRule priority", &:to_s)
|
|
93
103
|
end
|
|
94
104
|
|
|
105
|
+
# With `alb: false` nothing routes by listener rule, so a rule row, or a route that names
|
|
106
|
+
# one, describes a load balancer the project says it does not have. Every route that is
|
|
107
|
+
# served by a server behind the edge still needs the origin that reaches it.
|
|
108
|
+
def check_no_alb
|
|
109
|
+
problem("EdgeRule", "rows describe a load balancer, and the Edge row says alb: false") if @edge.rules.any?
|
|
110
|
+
@rows.select(&:alb_rule).each do |row|
|
|
111
|
+
problem(row.path, "names alb_rule #{row.alb_rule}, and the Edge row says alb: false")
|
|
112
|
+
end
|
|
113
|
+
@rows.each do |row|
|
|
114
|
+
next if row.cdn || !%w[cms domain].include?(row.origin) || @edge.upstream(row.origin)
|
|
115
|
+
|
|
116
|
+
problem(row.path, "is served from #{row.origin}, which no EdgeOrigin maps, and with alb: false " \
|
|
117
|
+
"nothing else reaches it")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
95
121
|
def check_rule(rule)
|
|
96
122
|
label = "EdgeRule #{rule.rule}"
|
|
97
123
|
member_of(label, "origin", rule.origin, :origin)
|
|
@@ -19,7 +19,7 @@ module Hecks
|
|
|
19
19
|
# `EdgePolicy`, `EdgeOrigin` and `EdgeRule`. `docs/site-routes.md` lists their fields. A
|
|
20
20
|
# project that declares none of the four has no edge to project.
|
|
21
21
|
class Edge
|
|
22
|
-
Setting = Struct.new(:template, :listener, :secret_header, :secret_value, keyword_init: true)
|
|
22
|
+
Setting = Struct.new(:template, :listener, :secret_header, :secret_value, :alb, keyword_init: true)
|
|
23
23
|
Policy = Struct.new(:cache_class, :origin, :cache, :origin_request, :response_headers, keyword_init: true)
|
|
24
24
|
Upstream = Struct.new(:origin, :id, :target_group, keyword_init: true)
|
|
25
25
|
Rule = Struct.new(:rule, :priority, :origin, keyword_init: true)
|
|
@@ -29,15 +29,15 @@ module Hecks
|
|
|
29
29
|
|
|
30
30
|
# The fields each kind of row may carry, with the Ruby class each takes.
|
|
31
31
|
FIELDS = {
|
|
32
|
-
setting: { template: String, listener: String, secret_header: String, secret_value: String },
|
|
32
|
+
setting: { template: String, listener: String, secret_header: String, secret_value: String, alb: :bool },
|
|
33
33
|
policy: { cache_class: String, origin: String, cache: String, origin_request: String,
|
|
34
34
|
response_headers: String },
|
|
35
35
|
upstream: { origin: String, id: String, target_group: String },
|
|
36
36
|
rule: { rule: String, priority: Integer, origin: String }
|
|
37
37
|
}.freeze
|
|
38
38
|
|
|
39
|
-
# The fields a row of each kind must carry.
|
|
40
|
-
REQUIRED = { setting: [
|
|
39
|
+
# The fields a row of each kind must carry. The template may instead be named to the tool.
|
|
40
|
+
REQUIRED = { setting: [], policy: %i[cache_class cache], upstream: %i[origin id],
|
|
41
41
|
rule: %i[rule priority origin] }.freeze
|
|
42
42
|
|
|
43
43
|
STRUCTS = { setting: Setting, policy: Policy, upstream: Upstream, rule: Rule }.freeze
|
|
@@ -62,15 +62,17 @@ module Hecks
|
|
|
62
62
|
# @param chapter [Bluebook::Chapter] the chapter that declares the route table
|
|
63
63
|
# @param table [Table] the checked route table
|
|
64
64
|
# @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
|
|
65
|
+
# @param template [String, nil] the template the caller names, which stands in for the
|
|
66
|
+
# `Edge` row's own `template:`
|
|
65
67
|
# @return [Edge, nil] the checked edge, or nil when the chapter declares none
|
|
66
68
|
# @raise [Table::Invalid] when a row is refused or the edge contradicts the routes
|
|
67
|
-
def self.read(chapter, table:, vocabulary:)
|
|
69
|
+
def self.read(chapter, table:, vocabulary:, template: nil)
|
|
68
70
|
declared = OBJECTS.transform_values { |name| Table.rows_of(chapter, name) }
|
|
69
71
|
return nil if declared.values.all?(&:empty?)
|
|
70
72
|
|
|
71
73
|
problems = []
|
|
72
74
|
records = declared.to_h { |kind, members| [kind, build(kind, members, problems)] }
|
|
73
|
-
new(records, table: table, vocabulary: vocabulary, problems: problems)
|
|
75
|
+
new(records, table: table, vocabulary: vocabulary, problems: problems, template: template)
|
|
74
76
|
end
|
|
75
77
|
|
|
76
78
|
# @param kind [Symbol] a key of `OBJECTS`
|
|
@@ -86,27 +88,35 @@ module Hecks
|
|
|
86
88
|
end
|
|
87
89
|
(REQUIRED.fetch(kind) - member.keys).each { |field| problems << "#{label} needs #{field}" }
|
|
88
90
|
typed = member.slice(*FIELDS.fetch(kind).keys).select do |field, value|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
typed?(value, FIELDS.fetch(kind).fetch(field)) ||
|
|
92
|
+
(problems << "#{label} has #{field} #{value.inspect}; " \
|
|
93
|
+
"#{field} is #{type_name(FIELDS.fetch(kind).fetch(field))}")
|
|
91
94
|
end
|
|
92
95
|
STRUCTS.fetch(kind).new(**typed)
|
|
93
96
|
end
|
|
94
97
|
end
|
|
95
98
|
private_class_method :build
|
|
96
99
|
|
|
100
|
+
def self.typed?(value, type) = type == :bool ? [true, false].include?(value) : value.is_a?(type)
|
|
101
|
+
private_class_method :typed?
|
|
102
|
+
|
|
103
|
+
def self.type_name(type) = type == :bool ? "true or false" : "a #{type}"
|
|
104
|
+
private_class_method :type_name
|
|
105
|
+
|
|
97
106
|
# @param records [Hash{Symbol => Array<Struct>}] the rows of each kind
|
|
98
107
|
# @param table [Table] the checked route table
|
|
99
108
|
# @param vocabulary [Hash{Symbol => Array<String>}] the closed sets
|
|
100
109
|
# @param problems [Array<String>] the problems already found in reading the rows
|
|
110
|
+
# @param template [String, nil] the template the caller names, if any
|
|
101
111
|
# @raise [Table::Invalid] naming every problem when there is one
|
|
102
|
-
def initialize(records, table:, vocabulary:, problems:)
|
|
112
|
+
def initialize(records, table:, vocabulary:, problems:, template: nil)
|
|
103
113
|
@setting = records.fetch(:setting).first
|
|
104
114
|
@policies = records.fetch(:policy)
|
|
105
115
|
@upstreams = records.fetch(:upstream)
|
|
106
116
|
@rules = records.fetch(:rule)
|
|
107
|
-
Checks.new(self, table.rows, vocabulary, problems).call
|
|
117
|
+
Checks.new(self, table.rows, vocabulary, problems, template_named: !template.nil?).call
|
|
108
118
|
if problems.empty?
|
|
109
|
-
@listener_rules = Rules.new(self, table.rows, problems).call
|
|
119
|
+
@listener_rules = alb? ? Rules.new(self, table.rows, problems).call : []
|
|
110
120
|
built = Behaviours.new(self, table.rows, problems).call
|
|
111
121
|
@default_behaviour = built.fetch(:default)
|
|
112
122
|
@behaviours = built.fetch(:list)
|
|
@@ -116,6 +126,10 @@ module Hecks
|
|
|
116
126
|
raise Table::Invalid, "the edge is refused:\n#{problems.map { |line| " - #{line}" }.join("\n")}"
|
|
117
127
|
end
|
|
118
128
|
|
|
129
|
+
# @return [Boolean] whether the project has a load balancer, and so listener rules; true
|
|
130
|
+
# unless the `Edge` row says `alb: false`
|
|
131
|
+
def alb? = setting.nil? || setting.alb != false
|
|
132
|
+
|
|
119
133
|
# @param origin [String] an origin of the Origin set
|
|
120
134
|
# @return [Upstream, nil] its CloudFront origin and target group
|
|
121
135
|
def upstream(origin) = upstreams.find { |candidate| candidate.origin == origin }
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "edge"
|
|
5
|
+
|
|
6
|
+
module Hecks
|
|
7
|
+
module Projections
|
|
8
|
+
module Site
|
|
9
|
+
# Compares the CloudFront behaviours a site's edge generates with the ones a live
|
|
10
|
+
# distribution has, and says every way they differ. It only reads: the live side is a
|
|
11
|
+
# `get-distribution-config` answer, however it was obtained.
|
|
12
|
+
#
|
|
13
|
+
# A behaviour is identified by its path pattern (`(default)` for the default one) and judged
|
|
14
|
+
# on its origin, allowed and cached methods, viewer protocol, compression and three policy
|
|
15
|
+
# ids. The edge may name a policy by a CloudFormation intrinsic (`!Ref PageCachePolicy`) that
|
|
16
|
+
# the live distribution holds as an id, so a reference is compared through the `refs` the
|
|
17
|
+
# caller gives for it, and one with none is reported as unchecked. The behaviours a change is
|
|
18
|
+
# about to add (`expect_new`) are reported as expected additions, not differences.
|
|
19
|
+
class LiveEdge
|
|
20
|
+
# The default behaviour's name, since it has no path pattern.
|
|
21
|
+
DEFAULT = "(default)"
|
|
22
|
+
|
|
23
|
+
# The fields a behaviour is compared on, besides its path.
|
|
24
|
+
FIELDS = %i[origin methods cached protocol compress cache request response].freeze
|
|
25
|
+
|
|
26
|
+
# What a comparison found.
|
|
27
|
+
#
|
|
28
|
+
# @!attribute [r] expected
|
|
29
|
+
# @return [Array<String>] a line per behaviour only the project has, that was expected
|
|
30
|
+
# @!attribute [r] differences
|
|
31
|
+
# @return [Array<String>] a line per difference that was not expected
|
|
32
|
+
# @!attribute [r] unchecked
|
|
33
|
+
# @return [Array<String>] the policy references no `refs` entry resolves
|
|
34
|
+
# @!attribute [r] counts
|
|
35
|
+
# @return [Array<Integer>] the number of generated and of live behaviours, default aside
|
|
36
|
+
Comparison = Struct.new(:expected, :differences, :unchecked, :counts, keyword_init: true) do
|
|
37
|
+
# @return [Boolean] whether live is as generated, expected additions aside
|
|
38
|
+
def clean? = differences.empty? && unchecked.empty?
|
|
39
|
+
|
|
40
|
+
# @return [String] the report, one section per kind of finding
|
|
41
|
+
def to_s
|
|
42
|
+
lines = ["generated: #{counts[0]} behaviours + default; live: #{counts[1]} + default"]
|
|
43
|
+
lines += section("expected additions (only in the project):", expected)
|
|
44
|
+
lines += section("differences:", differences)
|
|
45
|
+
lines += section("policy references with no refs entry (unchecked):", unchecked)
|
|
46
|
+
lines << "the live distribution matches the project" if clean?
|
|
47
|
+
lines.join("\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def section(title, entries) = entries.empty? ? [] : [title, *entries.map { |entry| " #{entry}" }]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param edge [Edge] the project's checked edge
|
|
56
|
+
# @param live [Hash] a CloudFront distribution configuration, or an answer wrapping one in
|
|
57
|
+
# `DistributionConfig`
|
|
58
|
+
# @param expect_new [Array<String>] path patterns the project may have and the live one not
|
|
59
|
+
# @param refs [Hash{String => String}] a policy intrinsic to the id it stands for live
|
|
60
|
+
def initialize(edge, live, expect_new: [], refs: {})
|
|
61
|
+
@edge = edge
|
|
62
|
+
@live = live.fetch("DistributionConfig", live)
|
|
63
|
+
@expect_new = expect_new
|
|
64
|
+
@refs = refs
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Comparison] every way the project and the live distribution differ
|
|
68
|
+
def call
|
|
69
|
+
generated = generated_behaviours
|
|
70
|
+
current = live_behaviours
|
|
71
|
+
unchecked = unresolved(generated.values)
|
|
72
|
+
generated = generated.transform_values { |entry| resolve(entry) }
|
|
73
|
+
expected, differences = missing(generated, current)
|
|
74
|
+
differences.concat(changed(generated, current), order(generated, current))
|
|
75
|
+
Comparison.new(expected: expected, differences: differences, unchecked: unchecked,
|
|
76
|
+
counts: [generated.size - 1, current.size - 1])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reads a policy reference mapping written as `!Ref Name=id` words.
|
|
80
|
+
#
|
|
81
|
+
# @param text [String, nil] the words, separated by commas
|
|
82
|
+
# @return [Hash{String => String}] each intrinsic to its id
|
|
83
|
+
def self.refs_from(text)
|
|
84
|
+
text.to_s.split(",").to_h { |pair| pair.strip.split("=", 2).map(&:strip) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def generated_behaviours
|
|
90
|
+
list = [[DEFAULT, @edge.default_behaviour], *@edge.behaviours.map { |entry| [entry[:path], entry] }]
|
|
91
|
+
list.to_h do |path, entry|
|
|
92
|
+
[path, { path: path, origin: entry[:origin], methods: Array(entry[:methods]).sort, cached: %w[GET HEAD],
|
|
93
|
+
protocol: entry[:viewer_protocol], compress: entry[:compress] == true,
|
|
94
|
+
cache: policy_id(entry[:cache_policy]), request: policy_id(entry[:origin_request_policy]),
|
|
95
|
+
response: policy_id(entry[:response_headers_policy]) }]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def policy_id(policy) = policy&.first
|
|
100
|
+
|
|
101
|
+
def live_behaviours
|
|
102
|
+
list = [[DEFAULT, @live.fetch("DefaultCacheBehavior")],
|
|
103
|
+
*Array(@live.dig("CacheBehaviors", "Items")).map { |entry| [entry.fetch("PathPattern"), entry] }]
|
|
104
|
+
list.to_h do |path, entry|
|
|
105
|
+
[path, { path: path, origin: entry.fetch("TargetOriginId"), methods: methods_of(entry, "Items"),
|
|
106
|
+
cached: cached_of(entry), protocol: entry.fetch("ViewerProtocolPolicy"),
|
|
107
|
+
compress: entry.fetch("Compress", false) == true, cache: entry["CachePolicyId"],
|
|
108
|
+
request: blank(entry["OriginRequestPolicyId"]), response: blank(entry["ResponseHeadersPolicyId"]) }]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def methods_of(entry, key) = entry.fetch("AllowedMethods").fetch(key).sort
|
|
113
|
+
|
|
114
|
+
def cached_of(entry) = entry.dig("AllowedMethods", "CachedMethods", "Items").sort
|
|
115
|
+
|
|
116
|
+
def blank(value) = value.to_s.empty? ? nil : value
|
|
117
|
+
|
|
118
|
+
def intrinsic?(value) = value.is_a?(String) && value.start_with?("!")
|
|
119
|
+
|
|
120
|
+
def unresolved(entries)
|
|
121
|
+
entries.flat_map { |entry| entry.values_at(:cache, :request, :response) }
|
|
122
|
+
.select { |value| intrinsic?(value) && !@refs.key?(value) }.uniq.sort
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def resolve(entry) = entry.transform_values { |value| intrinsic?(value) ? @refs.fetch(value, value) : value }
|
|
126
|
+
|
|
127
|
+
def missing(generated, current)
|
|
128
|
+
expected = []
|
|
129
|
+
differences = []
|
|
130
|
+
generated.each do |path, entry|
|
|
131
|
+
next if current.key?(path)
|
|
132
|
+
|
|
133
|
+
detail = entry.except(:path).map { |key, value| "#{key}=#{value.inspect}" }.join(" ")
|
|
134
|
+
(@expect_new.include?(path) ? expected : differences) << "only in the project: #{path} #{detail}"
|
|
135
|
+
end
|
|
136
|
+
current.each_key { |path| differences << "only live: #{path}" unless generated.key?(path) }
|
|
137
|
+
[expected, differences]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def changed(generated, current)
|
|
141
|
+
generated.flat_map do |path, entry|
|
|
142
|
+
next [] unless current.key?(path)
|
|
143
|
+
|
|
144
|
+
FIELDS.filter_map do |field|
|
|
145
|
+
next if entry[field] == current[path][field]
|
|
146
|
+
|
|
147
|
+
"#{path} differs in #{field}: project=#{entry[field].inspect} live=#{current[path][field].inspect}"
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def order(generated, current)
|
|
153
|
+
mine = generated.keys.reject { |path| path == DEFAULT || !current.key?(path) }
|
|
154
|
+
theirs = current.keys.reject { |path| path == DEFAULT || !generated.key?(path) }
|
|
155
|
+
return [] if mine == theirs
|
|
156
|
+
|
|
157
|
+
["order differs among the behaviours both have:\n project: #{mine.join(' ')}\n live: #{theirs.join(' ')}"]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -77,10 +77,12 @@ module Hecks
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def route_fields(row)
|
|
80
|
-
{ path: row.path, kind: row.kind, render: row.render, auth: row.auth, methods: row.verbs,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
fields = { path: row.path, kind: row.kind, render: row.render, auth: row.auth, methods: row.verbs,
|
|
81
|
+
cache: row.cache, origin: row.origin, source: row.source, indexable: row.indexable,
|
|
82
|
+
switch: blank_to_nil(row.switch), off: row.off, preview: row.preview,
|
|
83
|
+
label: blank_to_nil(row.label), seo: blank_to_nil(row.seo), redirectTo: row.redirect_to }
|
|
84
|
+
fields[:seoTitle] = row.seo_title if row.seo_title
|
|
85
|
+
fields
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
def switches(rows)
|
|
@@ -112,13 +114,16 @@ module Hecks
|
|
|
112
114
|
TS
|
|
113
115
|
end
|
|
114
116
|
|
|
117
|
+
# The navigation arrays hold every entry that has a slot, a switched-off page's too: an
|
|
118
|
+
# entry of an off page carries its `switch` and `on: false`, and the site drops it while
|
|
119
|
+
# `pageIsOn(switch)` is false. An entry of a page that is on carries neither.
|
|
115
120
|
def navigation(table)
|
|
116
|
-
|
|
121
|
+
items = table.rows + table.links
|
|
117
122
|
[nav_const("NAV_DESKTOP", "The desktop navigation: a page with no group is its own entry; a group holds its pages.",
|
|
118
|
-
desktop(
|
|
119
|
-
nav_const("NAV_MOBILE", "The mobile navigation, in its own order.", mobile(
|
|
120
|
-
nav_const("NAV_FOOTER", "The footer columns, left to right, each with its links.", footer(
|
|
121
|
-
nav_const("NAV_ADMIN", "The admin navigation: each admin page under its key.", admin(
|
|
123
|
+
desktop(items)),
|
|
124
|
+
nav_const("NAV_MOBILE", "The mobile navigation, in its own order.", mobile(items)),
|
|
125
|
+
nav_const("NAV_FOOTER", "The footer columns, left to right, each with its links.", footer(items)),
|
|
126
|
+
nav_const("NAV_ADMIN", "The admin navigation: each admin page under its key.", admin(items))].join("\n\n")
|
|
122
127
|
end
|
|
123
128
|
|
|
124
129
|
def nav_const(name, comment, entries)
|
|
@@ -126,32 +131,41 @@ module Hecks
|
|
|
126
131
|
"// #{comment}\nexport const #{name} = #{body.empty? ? '[]' : "[\n#{body.join("\n")}\n]"} as const;"
|
|
127
132
|
end
|
|
128
133
|
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
# One link: a row's or a link's path and label, the fragment a link names, and for a page
|
|
135
|
+
# that is off the switch that turns it on.
|
|
136
|
+
def entry(item, **before)
|
|
137
|
+
link = { **before, path: item.path, label: item.label }
|
|
138
|
+
link[:fragment] = item.fragment if item.respond_to?(:fragment) && item.fragment
|
|
139
|
+
link.merge!(switch: item.switch, on: false) if item.off
|
|
140
|
+
link
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def order_key(item, order) = [order, item.path, item.respond_to?(:fragment) ? item.fragment.to_s : ""]
|
|
144
|
+
|
|
145
|
+
def desktop(items)
|
|
131
146
|
entries = []
|
|
132
|
-
items.each do |
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
slot
|
|
136
|
-
slot ? slot[:items] << item : entries << { group: group, items: [item] }
|
|
147
|
+
items.reject { |item| item.nav_order.nil? }.sort_by { |item| order_key(item, item.nav_order) }.each do |item|
|
|
148
|
+
group = item.nav_group
|
|
149
|
+
slot = group && entries.find { |candidate| candidate[:group] == group }
|
|
150
|
+
slot ? slot[:items] << entry(item) : entries << { group: group, items: [entry(item)] }
|
|
137
151
|
end
|
|
138
152
|
entries
|
|
139
153
|
end
|
|
140
154
|
|
|
141
|
-
def mobile(
|
|
142
|
-
|
|
143
|
-
|
|
155
|
+
def mobile(items)
|
|
156
|
+
items.reject { |item| item.mobile_order.nil? }.sort_by { |item| order_key(item, item.mobile_order) }
|
|
157
|
+
.map { |item| item.mobile_heading ? entry(item, heading: item.mobile_heading) : entry(item) }
|
|
144
158
|
end
|
|
145
159
|
|
|
146
|
-
def footer(
|
|
147
|
-
columns =
|
|
160
|
+
def footer(items)
|
|
161
|
+
columns = items.select(&:footer_column).sort_by { |item| order_key(item, item.footer_order) }.group_by(&:footer_column)
|
|
148
162
|
columns.sort_by { |name, group| [group.first.footer_order, name] }
|
|
149
|
-
.map { |name, group| { column: name, items: group.map { |
|
|
163
|
+
.map { |name, group| { column: name, items: group.map { |item| entry(item) } } }
|
|
150
164
|
end
|
|
151
165
|
|
|
152
|
-
def admin(
|
|
153
|
-
|
|
154
|
-
|
|
166
|
+
def admin(items)
|
|
167
|
+
items.select(&:admin_key).sort_by { |item| [item.admin_order, item.admin_key] }
|
|
168
|
+
.map { |item| entry(item, key: item.admin_key) }
|
|
155
169
|
end
|
|
156
170
|
|
|
157
171
|
def search(rows)
|
|
@@ -216,13 +230,22 @@ module Hecks
|
|
|
216
230
|
const source = stripHtml(pattern)
|
|
217
231
|
.split("/")
|
|
218
232
|
.map((part) =>
|
|
219
|
-
part
|
|
233
|
+
part.startsWith(":")
|
|
234
|
+
? "[^/]+"
|
|
235
|
+
: part
|
|
236
|
+
.split("*")
|
|
237
|
+
.map((piece) => piece.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
238
|
+
.join(".*"),
|
|
220
239
|
)
|
|
221
240
|
.join("/");
|
|
222
241
|
return new RegExp("^" + source + "$");
|
|
223
242
|
}
|
|
224
243
|
|
|
225
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* Whether a pathname is one a route pattern describes, with or without `.html`. A `:name` is one
|
|
246
|
+
* segment; a `*` is any run of characters, `/` included, wherever it stands, as in a CDN's path
|
|
247
|
+
* pattern: `/pay/*` matches `/pay/7` and `/admin*` matches `/admin`, `/admin-inbox` and `/admin/x`.
|
|
248
|
+
*/
|
|
226
249
|
export function matchesPath(pattern: string, pathname: string): boolean {
|
|
227
250
|
return toRegExp(pattern).test(stripHtml(pathname));
|
|
228
251
|
}
|