textus 0.15.0 → 0.20.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/ARCHITECTURE.md +50 -55
- data/CHANGELOG.md +486 -0
- data/README.md +13 -9
- data/SPEC.md +13 -10
- data/docs/conventions.md +2 -2
- data/lib/textus/application/context.rb +20 -34
- data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
- data/lib/textus/application/projection.rb +91 -0
- data/lib/textus/application/reads/audit.rb +4 -4
- data/lib/textus/application/reads/blame.rb +11 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +17 -6
- data/lib/textus/application/reads/get.rb +37 -11
- data/lib/textus/application/reads/get_or_refresh.rb +8 -8
- data/lib/textus/application/reads/list.rb +5 -3
- data/lib/textus/application/reads/policy_explain.rb +3 -3
- data/lib/textus/application/reads/published.rb +5 -3
- data/lib/textus/application/reads/rdeps.rb +15 -3
- data/lib/textus/application/reads/schema_envelope.rb +6 -3
- data/lib/textus/application/reads/stale.rb +3 -3
- data/lib/textus/application/reads/uid.rb +11 -3
- data/lib/textus/application/reads/validate_all.rb +12 -3
- data/lib/textus/application/reads/validator.rb +84 -0
- data/lib/textus/application/reads/where.rb +6 -3
- data/lib/textus/application/refresh/all.rb +16 -5
- data/lib/textus/application/refresh/orchestrator.rb +9 -9
- data/lib/textus/application/refresh/worker.rb +59 -32
- data/lib/textus/application/tools/migrate_keys.rb +191 -0
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
- data/lib/textus/application/writes/accept.rb +36 -13
- data/lib/textus/application/writes/delete.rb +13 -15
- data/lib/textus/application/writes/envelope_io.rb +166 -0
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +56 -95
- data/lib/textus/application/writes/publish.rb +132 -27
- data/lib/textus/application/writes/put.rb +17 -20
- data/lib/textus/application/writes/reject.rb +18 -9
- data/lib/textus/builder/pipeline.rb +21 -15
- data/lib/textus/builder/renderer/json.rb +4 -1
- data/lib/textus/builder/renderer/markdown.rb +7 -1
- data/lib/textus/builder/renderer/yaml.rb +4 -1
- data/lib/textus/cli/group/hook.rb +1 -3
- data/lib/textus/cli/group/key.rb +1 -4
- data/lib/textus/cli/group/refresh.rb +1 -2
- data/lib/textus/cli/group/rule.rb +1 -3
- data/lib/textus/cli/group/schema.rb +1 -5
- data/lib/textus/cli/group.rb +12 -16
- data/lib/textus/cli/verb/accept.rb +3 -1
- data/lib/textus/cli/verb/audit.rb +3 -1
- data/lib/textus/cli/verb/blame.rb +3 -1
- data/lib/textus/cli/verb/build.rb +4 -5
- data/lib/textus/cli/verb/delete.rb +3 -1
- data/lib/textus/cli/verb/deps.rb +3 -1
- data/lib/textus/cli/verb/doctor.rb +2 -0
- data/lib/textus/cli/verb/freshness.rb +3 -1
- data/lib/textus/cli/verb/get.rb +4 -2
- data/lib/textus/cli/verb/hook_run.rb +6 -4
- data/lib/textus/cli/verb/hooks.rb +8 -5
- data/lib/textus/cli/verb/init.rb +2 -0
- data/lib/textus/cli/verb/intro.rb +2 -0
- data/lib/textus/cli/verb/key_normalize.rb +35 -3
- data/lib/textus/cli/verb/list.rb +3 -1
- data/lib/textus/cli/verb/mv.rb +4 -1
- data/lib/textus/cli/verb/published.rb +3 -1
- data/lib/textus/cli/verb/put.rb +5 -4
- data/lib/textus/cli/verb/rdeps.rb +3 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +4 -2
- data/lib/textus/cli/verb/reject.rb +3 -1
- data/lib/textus/cli/verb/rule_explain.rb +4 -1
- data/lib/textus/cli/verb/rule_list.rb +3 -0
- data/lib/textus/cli/verb/schema.rb +4 -1
- data/lib/textus/cli/verb/schema_diff.rb +3 -0
- data/lib/textus/cli/verb/schema_init.rb +3 -0
- data/lib/textus/cli/verb/schema_migrate.rb +3 -0
- data/lib/textus/cli/verb/uid.rb +4 -1
- data/lib/textus/cli/verb/where.rb +3 -1
- data/lib/textus/cli/verb.rb +30 -0
- data/lib/textus/cli.rb +18 -27
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +4 -2
- data/lib/textus/doctor/check/illegal_keys.rb +6 -5
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/manifest_files.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +4 -3
- data/lib/textus/doctor.rb +3 -4
- data/lib/textus/domain/authorizer.rb +37 -0
- data/lib/textus/domain/freshness/evaluator.rb +1 -1
- data/lib/textus/domain/freshness/policy.rb +1 -1
- data/lib/textus/domain/freshness/verdict.rb +1 -1
- data/lib/textus/domain/freshness.rb +40 -0
- data/lib/textus/{store → domain}/sentinel.rb +1 -1
- data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
- data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
- data/lib/textus/{store → domain}/staleness.rb +1 -1
- data/lib/textus/entry/json.rb +1 -1
- data/lib/textus/entry/markdown.rb +1 -1
- data/lib/textus/entry/yaml.rb +1 -1
- data/lib/textus/envelope.rb +7 -3
- data/lib/textus/errors.rb +19 -0
- data/lib/textus/hooks/builtin.rb +6 -6
- data/lib/textus/hooks/bus.rb +155 -0
- data/lib/textus/hooks/context.rb +38 -0
- data/lib/textus/hooks/fire_report.rb +23 -0
- data/lib/textus/hooks/loader.rb +20 -17
- data/lib/textus/{store → infra}/audit_log.rb +1 -1
- data/lib/textus/infra/audit_subscriber.rb +43 -0
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/publisher.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/infra/storage/file_store.rb +26 -0
- data/lib/textus/init.rb +14 -11
- data/lib/textus/intro.rb +7 -7
- data/lib/textus/manifest/entry/base.rb +38 -0
- data/lib/textus/manifest/entry/derived.rb +25 -0
- data/lib/textus/manifest/entry/intake.rb +19 -0
- data/lib/textus/manifest/entry/leaf.rb +16 -0
- data/lib/textus/manifest/entry/nested.rb +39 -0
- data/lib/textus/manifest/entry/parser.rb +64 -31
- data/lib/textus/manifest/entry/validators/events.rb +3 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
- data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
- data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
- data/lib/textus/manifest/entry.rb +0 -72
- data/lib/textus/manifest/resolution.rb +5 -0
- data/lib/textus/manifest/resolver.rb +109 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/manifest.rb +4 -100
- data/lib/textus/operations.rb +147 -23
- data/lib/textus/schema/tools.rb +7 -7
- data/lib/textus/schemas.rb +46 -0
- data/lib/textus/store.rb +12 -49
- data/lib/textus/uid.rb +18 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +17 -1
- metadata +31 -23
- data/lib/textus/application/writes/build.rb +0 -79
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/hooks/dispatcher.rb +0 -63
- data/lib/textus/hooks/dsl.rb +0 -11
- data/lib/textus/hooks/registry.rb +0 -81
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/operations/reads.rb +0 -56
- data/lib/textus/operations/refresh.rb +0 -27
- data/lib/textus/operations/writes.rb +0 -21
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
- data/lib/textus/store/reader.rb +0 -69
- data/lib/textus/store/validator.rb +0 -82
- data/lib/textus/store/writer.rb +0 -102
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Infra
|
|
5
|
+
# Writes an "event_error" audit row when a user hook raises during
|
|
6
|
+
# Hooks::Bus publish. Attached at Store boot.
|
|
7
|
+
#
|
|
8
|
+
# Integration: uses Hooks::Bus#on_error callback (chosen over a
|
|
9
|
+
# synthetic :hook_error event because the bus already owns the
|
|
10
|
+
# rescue and the failure is a bus-internal concern, not a domain
|
|
11
|
+
# event subscribers should be able to filter by key glob).
|
|
12
|
+
#
|
|
13
|
+
# NOTE (0.16 scope): lifecycle audit rows for verb: "put" / "delete" /
|
|
14
|
+
# "rename" are still written directly by Store::Writer and
|
|
15
|
+
# Application::Writes::Mv. Moving those into this subscriber requires
|
|
16
|
+
# event payloads to carry etag_before/etag_after across many write paths;
|
|
17
|
+
# that is properly a 0.18 port-extraction concern.
|
|
18
|
+
class AuditSubscriber
|
|
19
|
+
def initialize(audit_log)
|
|
20
|
+
@audit_log = audit_log
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def attach(bus)
|
|
24
|
+
bus.on_error do |event:, hook:, key:, kwargs:, error:|
|
|
25
|
+
record_error(event: event, hook: hook, key: key, kwargs: kwargs, error: error)
|
|
26
|
+
end
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def record_error(event:, hook:, key:, kwargs:, error:)
|
|
33
|
+
extras = { "event" => event.to_s, "hook" => hook.to_s, "error" => "#{error.class}: #{error.message}" }
|
|
34
|
+
extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
|
|
35
|
+
extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
|
|
36
|
+
@audit_log.append(
|
|
37
|
+
role: "runner", verb: "event_error", key: key,
|
|
38
|
+
etag_before: nil, etag_after: nil, extras: extras
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Infra
|
|
3
3
|
class EventBus
|
|
4
|
-
def initialize(
|
|
5
|
-
@
|
|
4
|
+
def initialize(bus:)
|
|
5
|
+
@bus = bus
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def publish(event, **payload)
|
|
9
|
-
@
|
|
9
|
+
@bus.pubsub_handlers(event).each do |entry|
|
|
10
10
|
next unless entry[:keys].nil? || matches?(entry[:keys], payload[:key])
|
|
11
11
|
|
|
12
12
|
entry[:callable].call(**payload)
|
|
@@ -6,7 +6,7 @@ module Textus
|
|
|
6
6
|
# Publish = copy + sentinel. The in-store file is already the consumer-shaped
|
|
7
7
|
# artifact; no parsing or stripping.
|
|
8
8
|
#
|
|
9
|
-
# Sentinel I/O is delegated to
|
|
9
|
+
# Sentinel I/O is delegated to Textus::Domain::Sentinel. Sentinels live under
|
|
10
10
|
# `<store_root>/sentinels/` and mirror the target's repo-relative layout so
|
|
11
11
|
# consumer directories aren't polluted with `.textus-managed.json` siblings.
|
|
12
12
|
module Publisher
|
|
@@ -15,7 +15,7 @@ module Textus
|
|
|
15
15
|
refuse_if_unmanaged(target, store_root)
|
|
16
16
|
File.delete(target) if File.symlink?(target)
|
|
17
17
|
FileUtils.cp(source, target)
|
|
18
|
-
|
|
18
|
+
Textus::Domain::Sentinel.write!(target: target, source: source, store_root: store_root)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def self.refuse_if_unmanaged(target, store_root)
|
|
@@ -26,7 +26,7 @@ module Textus
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def self.managed?(target, store_root)
|
|
29
|
-
File.exist?(
|
|
29
|
+
File.exist?(Textus::Domain::Sentinel.sentinel_path(target, store_root))
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
end
|
|
@@ -21,7 +21,7 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
begin
|
|
23
23
|
store = Textus::Store.new(store_root)
|
|
24
|
-
Textus::
|
|
24
|
+
Textus::Operations.for(store, role: "runner").refresh(key)
|
|
25
25
|
rescue StandardError
|
|
26
26
|
# Already logged via :refresh_failed; exit cleanly.
|
|
27
27
|
ensure
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Infra
|
|
5
|
+
module Storage
|
|
6
|
+
# Pure filesystem I/O port. Wraps File/FileUtils/Etag with no knowledge
|
|
7
|
+
# of envelopes, entries, schemas, or audit.
|
|
8
|
+
class FileStore
|
|
9
|
+
def read(path) = File.binread(path)
|
|
10
|
+
|
|
11
|
+
def write(path, bytes)
|
|
12
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
13
|
+
File.binwrite(path, bytes)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raises Errno::ENOENT if absent — mirrors File.delete and matches the
|
|
17
|
+
# semantics used by Store::Writer (which guards with File.exist? first).
|
|
18
|
+
def delete(path) = File.delete(path)
|
|
19
|
+
|
|
20
|
+
def exists?(path) = File.exist?(path)
|
|
21
|
+
|
|
22
|
+
def etag(path) = Etag.for_file(path)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -13,8 +13,8 @@ module Textus
|
|
|
13
13
|
- { name: review, write_policy: [agent, human], read_policy: [all] }
|
|
14
14
|
- { name: output, write_policy: [builder], read_policy: [all] }
|
|
15
15
|
entries:
|
|
16
|
-
- { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self }
|
|
17
|
-
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
|
|
16
|
+
- { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self, kind: leaf }
|
|
17
|
+
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true, kind: nested }
|
|
18
18
|
YAML
|
|
19
19
|
|
|
20
20
|
HOOKS_README = <<~MD
|
|
@@ -28,17 +28,19 @@ module Textus
|
|
|
28
28
|
## DSL
|
|
29
29
|
|
|
30
30
|
```ruby
|
|
31
|
-
Textus.
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
Textus.hook do |reg|
|
|
32
|
+
reg.on(:resolve_intake, :my_source) do |config:, args:, **|
|
|
33
|
+
{ _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
|
|
34
|
+
end
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
|
|
37
|
+
reg.on(:validate, :my_check) { |store:, **| [] }
|
|
38
|
+
reg.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
# Run a side-effect every time textus writes a file to your repo:
|
|
41
|
+
reg.on(:file_published, :notify) do |key:, target:, **|
|
|
42
|
+
warn "wrote \#{target} (from \#{key})"
|
|
43
|
+
end
|
|
42
44
|
end
|
|
43
45
|
```
|
|
44
46
|
|
|
@@ -49,6 +51,7 @@ module Textus
|
|
|
49
51
|
```yaml
|
|
50
52
|
entries:
|
|
51
53
|
- key: intake.foo
|
|
54
|
+
kind: intake
|
|
52
55
|
path: intake/foo.md
|
|
53
56
|
zone: intake
|
|
54
57
|
intake:
|
data/lib/textus/intro.rb
CHANGED
|
@@ -135,26 +135,26 @@ module Textus
|
|
|
135
135
|
"key" => e.key,
|
|
136
136
|
"zone" => e.zone,
|
|
137
137
|
"schema" => e.schema,
|
|
138
|
-
"nested" => e.
|
|
138
|
+
"nested" => e.is_a?(Textus::Manifest::Entry::Nested),
|
|
139
139
|
"owner" => e.owner,
|
|
140
140
|
"format" => e.format,
|
|
141
141
|
"derived" => derived,
|
|
142
|
-
"intake" =>
|
|
142
|
+
"intake" => e.is_a?(Textus::Manifest::Entry::Intake),
|
|
143
143
|
"publish_to" => Array(e.publish_to),
|
|
144
|
-
"publish_each" => e.publish_each,
|
|
144
|
+
"publish_each" => e.respond_to?(:publish_each) ? e.publish_each : nil,
|
|
145
145
|
}
|
|
146
146
|
end
|
|
147
147
|
end
|
|
148
148
|
|
|
149
149
|
def self.hooks_for(store)
|
|
150
|
-
|
|
150
|
+
bus = store.bus
|
|
151
151
|
sections = {}
|
|
152
|
-
Hooks::
|
|
152
|
+
Hooks::Bus::EVENTS.each do |event, spec|
|
|
153
153
|
case spec[:mode]
|
|
154
154
|
when :rpc
|
|
155
|
-
sections[event.to_s] =
|
|
155
|
+
sections[event.to_s] = bus.rpc_names(event).map(&:to_s).sort
|
|
156
156
|
when :pubsub
|
|
157
|
-
sections[event.to_s] =
|
|
157
|
+
sections[event.to_s] = bus.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
|
|
158
158
|
end
|
|
159
159
|
end
|
|
160
160
|
sections
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
class Base < Entry
|
|
5
|
+
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :manifest
|
|
6
|
+
|
|
7
|
+
# rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
|
|
8
|
+
def initialize(manifest:, raw:, key:, path:, zone:, schema:, owner:, format:)
|
|
9
|
+
@manifest = manifest
|
|
10
|
+
@raw = raw
|
|
11
|
+
@key = key
|
|
12
|
+
@path = path
|
|
13
|
+
@zone = zone
|
|
14
|
+
@schema = schema
|
|
15
|
+
@owner = owner
|
|
16
|
+
@format = format
|
|
17
|
+
end
|
|
18
|
+
# rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
|
|
19
|
+
|
|
20
|
+
def kind = self.class.name.split("::").last.downcase.to_sym
|
|
21
|
+
|
|
22
|
+
def zone_writers
|
|
23
|
+
@manifest.zone_writers(@zone)
|
|
24
|
+
rescue UsageError => e
|
|
25
|
+
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def in_generator_zone? = zone_writers.include?("builder")
|
|
29
|
+
def in_proposal_zone? = zone_writers.include?("agent")
|
|
30
|
+
|
|
31
|
+
def nested? = false
|
|
32
|
+
def derived? = false
|
|
33
|
+
def intake? = false
|
|
34
|
+
def leaf? = false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
class Derived < Base
|
|
5
|
+
Projection = Data.define(:select, :pluck, :sort_by, :transform)
|
|
6
|
+
External = Data.define(:sources, :runner)
|
|
7
|
+
|
|
8
|
+
attr_reader :source, :template, :inject_intro, :publish_to, :events
|
|
9
|
+
|
|
10
|
+
def initialize(source:, template: nil, inject_intro: false, publish_to: [], events: {}, **rest)
|
|
11
|
+
super(**rest)
|
|
12
|
+
@source = source
|
|
13
|
+
@template = template
|
|
14
|
+
@inject_intro = inject_intro
|
|
15
|
+
@publish_to = Array(publish_to)
|
|
16
|
+
@events = events || {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def derived? = true
|
|
20
|
+
def projection? = @source.is_a?(Projection)
|
|
21
|
+
def external? = @source.is_a?(External)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
class Intake < Base
|
|
5
|
+
attr_reader :handler, :config, :events, :publish_to
|
|
6
|
+
|
|
7
|
+
def initialize(handler:, config: {}, events: {}, **rest)
|
|
8
|
+
super(**rest)
|
|
9
|
+
@handler = handler
|
|
10
|
+
@config = config || {}
|
|
11
|
+
@events = events || {}
|
|
12
|
+
@publish_to = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def intake? = true
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require_relative "validators/publish_each"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Manifest
|
|
5
|
+
class Entry
|
|
6
|
+
class Nested < Base
|
|
7
|
+
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
8
|
+
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
9
|
+
|
|
10
|
+
attr_reader :index_filename, :publish_each, :publish_to
|
|
11
|
+
|
|
12
|
+
def initialize(index_filename: nil, publish_each: nil, publish_to: [], **rest)
|
|
13
|
+
super(**rest)
|
|
14
|
+
@index_filename = index_filename
|
|
15
|
+
@publish_each = publish_each
|
|
16
|
+
@publish_to = Array(publish_to)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def nested? = true
|
|
20
|
+
|
|
21
|
+
def publish_target_for(full_key)
|
|
22
|
+
return nil if @publish_each.nil?
|
|
23
|
+
|
|
24
|
+
entry_segs = @key.split(".")
|
|
25
|
+
key_segs = full_key.split(".")
|
|
26
|
+
raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
|
|
27
|
+
|
|
28
|
+
remaining = key_segs[entry_segs.length..] || []
|
|
29
|
+
leaf = remaining.join("/")
|
|
30
|
+
basename = remaining.last || ""
|
|
31
|
+
ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
|
|
32
|
+
|
|
33
|
+
vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
|
|
34
|
+
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -9,61 +9,94 @@ module Textus
|
|
|
9
9
|
path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
|
|
10
10
|
zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
format = resolve_format(raw, path, nested)
|
|
12
|
+
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (leaf|nested|derived|intake)")
|
|
13
|
+
kind = raw_kind.to_sym
|
|
14
|
+
format = resolve_format(raw, path)
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
common = {
|
|
18
17
|
manifest: manifest, raw: raw,
|
|
19
18
|
key: key, path: path, zone: zone,
|
|
20
19
|
schema: raw["schema"], owner: raw["owner"],
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
format: format
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
case kind
|
|
24
|
+
when :leaf then build_leaf(common, raw)
|
|
25
|
+
when :nested then build_nested(common, raw)
|
|
26
|
+
when :derived then build_derived(common, raw, key)
|
|
27
|
+
when :intake then build_intake(common, raw, key)
|
|
28
|
+
else raise BadManifest.new("entry '#{key}': unknown kind: #{kind.inspect}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.build_leaf(common, raw)
|
|
33
|
+
Leaf.new(publish_to: raw["publish_to"], **common)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.build_nested(common, raw)
|
|
37
|
+
Nested.new(
|
|
38
|
+
index_filename: raw["index_filename"],
|
|
24
39
|
publish_each: raw["publish_each"],
|
|
25
|
-
|
|
40
|
+
publish_to: raw["publish_to"],
|
|
41
|
+
**common,
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.build_derived(common, raw, key)
|
|
46
|
+
source = parse_source(raw, key)
|
|
47
|
+
Derived.new(
|
|
48
|
+
source: source,
|
|
49
|
+
template: raw["template"],
|
|
26
50
|
inject_intro: raw["inject_intro"] == true,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
intake_handler: intake_handler, intake_config: intake_config
|
|
51
|
+
publish_to: raw["publish_to"],
|
|
52
|
+
events: raw["events"] || {},
|
|
53
|
+
**common,
|
|
31
54
|
)
|
|
32
55
|
end
|
|
33
56
|
|
|
34
|
-
def self.
|
|
35
|
-
|
|
36
|
-
|
|
57
|
+
def self.build_intake(common, raw, key)
|
|
58
|
+
intake = raw["intake"] || {}
|
|
59
|
+
handler = intake["handler"] || raw["intake_handler"] or
|
|
60
|
+
raise UsageError.new("intake entry '#{key}' missing handler")
|
|
61
|
+
config = intake["config"] || raw["intake_config"] || {}
|
|
62
|
+
Intake.new(handler: handler, config: config, events: raw["events"] || {}, **common)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.parse_source(raw, key)
|
|
66
|
+
compute = raw["compute"]
|
|
67
|
+
if compute.nil?
|
|
68
|
+
# Tolerate legacy derived entries with bare template (no compute block):
|
|
69
|
+
# treat as projection with no select.
|
|
70
|
+
return Derived::Projection.new(select: nil, pluck: nil, sort_by: nil, transform: nil) if raw["template"]
|
|
37
71
|
|
|
38
|
-
|
|
39
|
-
|
|
72
|
+
raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
unless COMPUTE_KINDS.include?(compute["kind"])
|
|
40
76
|
raise BadManifest.new(
|
|
41
|
-
"entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{kind.inspect})",
|
|
77
|
+
"entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{compute["kind"].inspect})",
|
|
42
78
|
)
|
|
43
79
|
end
|
|
44
80
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
81
|
+
if compute["kind"] == "projection"
|
|
82
|
+
Derived::Projection.new(
|
|
83
|
+
select: compute["select"],
|
|
84
|
+
pluck: compute["pluck"],
|
|
85
|
+
sort_by: compute["sort_by"],
|
|
86
|
+
transform: compute["transform"],
|
|
87
|
+
)
|
|
48
88
|
else
|
|
49
|
-
[
|
|
89
|
+
Derived::External.new(sources: compute["sources"], runner: compute["runner"])
|
|
50
90
|
end
|
|
51
91
|
end
|
|
52
92
|
|
|
53
|
-
def self.
|
|
54
|
-
src ||= {}
|
|
55
|
-
[src["handler"], src["config"] || {}]
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def self.resolve_format(raw, path, nested)
|
|
93
|
+
def self.resolve_format(raw, path)
|
|
59
94
|
declared = raw["format"]
|
|
60
95
|
ext = File.extname(path)
|
|
61
96
|
inferred = Textus::Entry.infer_from_extension(ext)
|
|
62
97
|
|
|
63
98
|
if declared.nil?
|
|
64
99
|
return inferred if inferred
|
|
65
|
-
return "markdown" if ext == "" && nested
|
|
66
|
-
return "markdown" if ext == ""
|
|
67
100
|
|
|
68
101
|
return "markdown"
|
|
69
102
|
end
|
|
@@ -4,8 +4,9 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module Events
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
pubsub_events = Textus::Hooks::
|
|
8
|
-
entry.events.
|
|
7
|
+
pubsub_events = Textus::Hooks::Bus::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
|
|
8
|
+
events = entry.respond_to?(:events) ? entry.events : {}
|
|
9
|
+
events.each_key do |evt|
|
|
9
10
|
next if pubsub_events.include?(evt.to_sym)
|
|
10
11
|
|
|
11
12
|
raise UsageError.new(
|
|
@@ -5,7 +5,7 @@ module Textus
|
|
|
5
5
|
module FormatMatrix
|
|
6
6
|
def self.call(entry)
|
|
7
7
|
begin
|
|
8
|
-
Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested)
|
|
8
|
+
Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested?)
|
|
9
9
|
rescue UsageError => e
|
|
10
10
|
raise UsageError.new("entry '#{entry.key}': #{e.message}")
|
|
11
11
|
end
|
|
@@ -14,8 +14,10 @@ module Textus
|
|
|
14
14
|
raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
has_template = entry.respond_to?(:template) && !entry.template.nil?
|
|
18
|
+
is_external = entry.derived? && entry.external?
|
|
19
|
+
return unless entry.in_generator_zone? && !has_template && !is_external &&
|
|
20
|
+
%w[markdown text].include?(entry.format) && !entry.nested?
|
|
19
21
|
|
|
20
22
|
raise UsageError.new("entry '#{entry.key}': derived #{entry.format} entries require a template")
|
|
21
23
|
end
|
|
@@ -4,31 +4,32 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module IndexFilename
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
|
|
7
|
+
index_filename = entry.respond_to?(:index_filename) ? entry.index_filename : entry.raw["index_filename"]
|
|
8
|
+
return if index_filename.nil?
|
|
8
9
|
|
|
9
|
-
check_shape!(entry)
|
|
10
|
-
check_extension!(entry)
|
|
10
|
+
check_shape!(entry, index_filename)
|
|
11
|
+
check_extension!(entry, index_filename)
|
|
11
12
|
end
|
|
12
13
|
|
|
13
|
-
def self.check_shape!(entry)
|
|
14
|
-
raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested
|
|
14
|
+
def self.check_shape!(entry, index_filename)
|
|
15
|
+
raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested?
|
|
15
16
|
|
|
16
|
-
unless
|
|
17
|
+
unless index_filename.is_a?(String) && !index_filename.empty?
|
|
17
18
|
raise UsageError.new("entry '#{entry.key}': index_filename must be a non-empty string")
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
return unless
|
|
21
|
+
return unless index_filename.include?("/") || File.basename(index_filename) != index_filename
|
|
21
22
|
|
|
22
23
|
raise UsageError.new("entry '#{entry.key}': index_filename must be a bare basename (no slashes)")
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
def self.check_extension!(entry)
|
|
26
|
-
ext = File.extname(
|
|
26
|
+
def self.check_extension!(entry, index_filename)
|
|
27
|
+
ext = File.extname(index_filename)
|
|
27
28
|
inferred = Textus::Entry.infer_from_extension(ext)
|
|
28
29
|
|
|
29
30
|
if inferred.nil?
|
|
30
31
|
raise UsageError.new(
|
|
31
|
-
"entry '#{entry.key}': index_filename #{
|
|
32
|
+
"entry '#{entry.key}': index_filename #{index_filename.inspect} has unknown extension #{ext.inspect}",
|
|
32
33
|
)
|
|
33
34
|
end
|
|
34
35
|
return if inferred == entry.format
|
|
@@ -4,10 +4,13 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module InjectIntro
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
|
|
7
|
+
inject_intro = entry.respond_to?(:inject_intro) ? entry.inject_intro : false
|
|
8
|
+
return unless inject_intro
|
|
8
9
|
|
|
9
10
|
raise UsageError.new("entry '#{entry.key}': inject_intro: is only valid on derived entries") unless entry.in_generator_zone?
|
|
10
|
-
|
|
11
|
+
|
|
12
|
+
has_template = entry.respond_to?(:template) && !entry.template.nil?
|
|
13
|
+
return if has_template
|
|
11
14
|
|
|
12
15
|
raise UsageError.new("entry '#{entry.key}': inject_intro: requires a template:")
|
|
13
16
|
end
|
|
@@ -7,14 +7,17 @@ module Textus
|
|
|
7
7
|
VAR_RE = /\{([a-z]+)\}/
|
|
8
8
|
REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
|
|
9
9
|
|
|
10
|
-
def self.call(entry)
|
|
11
|
-
|
|
10
|
+
def self.call(entry) # rubocop:disable Metrics/AbcSize
|
|
11
|
+
publish_each = entry.respond_to?(:publish_each) ? entry.publish_each : entry.raw["publish_each"]
|
|
12
|
+
return if publish_each.nil?
|
|
12
13
|
|
|
13
|
-
raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested
|
|
14
|
-
raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless entry.publish_to.empty?
|
|
15
|
-
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless entry.publish_each.is_a?(String)
|
|
14
|
+
raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested?
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
publish_to = entry.respond_to?(:publish_to) ? entry.publish_to : Array(entry.raw["publish_to"])
|
|
17
|
+
raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless publish_to.empty?
|
|
18
|
+
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
|
|
19
|
+
|
|
20
|
+
used_vars = publish_each.scan(VAR_RE).flatten
|
|
18
21
|
unknown = used_vars - KNOWN_VARS
|
|
19
22
|
unless unknown.empty?
|
|
20
23
|
raise UsageError.new(
|