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.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +50 -55
  3. data/CHANGELOG.md +486 -0
  4. data/README.md +13 -9
  5. data/SPEC.md +13 -10
  6. data/docs/conventions.md +2 -2
  7. data/lib/textus/application/context.rb +20 -34
  8. data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
  9. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  10. data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
  11. data/lib/textus/application/projection.rb +91 -0
  12. data/lib/textus/application/reads/audit.rb +4 -4
  13. data/lib/textus/application/reads/blame.rb +11 -8
  14. data/lib/textus/application/reads/deps.rb +14 -3
  15. data/lib/textus/application/reads/freshness.rb +17 -6
  16. data/lib/textus/application/reads/get.rb +37 -11
  17. data/lib/textus/application/reads/get_or_refresh.rb +8 -8
  18. data/lib/textus/application/reads/list.rb +5 -3
  19. data/lib/textus/application/reads/policy_explain.rb +3 -3
  20. data/lib/textus/application/reads/published.rb +5 -3
  21. data/lib/textus/application/reads/rdeps.rb +15 -3
  22. data/lib/textus/application/reads/schema_envelope.rb +6 -3
  23. data/lib/textus/application/reads/stale.rb +3 -3
  24. data/lib/textus/application/reads/uid.rb +11 -3
  25. data/lib/textus/application/reads/validate_all.rb +12 -3
  26. data/lib/textus/application/reads/validator.rb +84 -0
  27. data/lib/textus/application/reads/where.rb +6 -3
  28. data/lib/textus/application/refresh/all.rb +16 -5
  29. data/lib/textus/application/refresh/orchestrator.rb +9 -9
  30. data/lib/textus/application/refresh/worker.rb +59 -32
  31. data/lib/textus/application/tools/migrate_keys.rb +191 -0
  32. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
  33. data/lib/textus/application/writes/accept.rb +36 -13
  34. data/lib/textus/application/writes/delete.rb +13 -15
  35. data/lib/textus/application/writes/envelope_io.rb +166 -0
  36. data/lib/textus/application/writes/materializer.rb +50 -0
  37. data/lib/textus/application/writes/mv.rb +56 -95
  38. data/lib/textus/application/writes/publish.rb +132 -27
  39. data/lib/textus/application/writes/put.rb +17 -20
  40. data/lib/textus/application/writes/reject.rb +18 -9
  41. data/lib/textus/builder/pipeline.rb +21 -15
  42. data/lib/textus/builder/renderer/json.rb +4 -1
  43. data/lib/textus/builder/renderer/markdown.rb +7 -1
  44. data/lib/textus/builder/renderer/yaml.rb +4 -1
  45. data/lib/textus/cli/group/hook.rb +1 -3
  46. data/lib/textus/cli/group/key.rb +1 -4
  47. data/lib/textus/cli/group/refresh.rb +1 -2
  48. data/lib/textus/cli/group/rule.rb +1 -3
  49. data/lib/textus/cli/group/schema.rb +1 -5
  50. data/lib/textus/cli/group.rb +12 -16
  51. data/lib/textus/cli/verb/accept.rb +3 -1
  52. data/lib/textus/cli/verb/audit.rb +3 -1
  53. data/lib/textus/cli/verb/blame.rb +3 -1
  54. data/lib/textus/cli/verb/build.rb +4 -5
  55. data/lib/textus/cli/verb/delete.rb +3 -1
  56. data/lib/textus/cli/verb/deps.rb +3 -1
  57. data/lib/textus/cli/verb/doctor.rb +2 -0
  58. data/lib/textus/cli/verb/freshness.rb +3 -1
  59. data/lib/textus/cli/verb/get.rb +4 -2
  60. data/lib/textus/cli/verb/hook_run.rb +6 -4
  61. data/lib/textus/cli/verb/hooks.rb +8 -5
  62. data/lib/textus/cli/verb/init.rb +2 -0
  63. data/lib/textus/cli/verb/intro.rb +2 -0
  64. data/lib/textus/cli/verb/key_normalize.rb +35 -3
  65. data/lib/textus/cli/verb/list.rb +3 -1
  66. data/lib/textus/cli/verb/mv.rb +4 -1
  67. data/lib/textus/cli/verb/published.rb +3 -1
  68. data/lib/textus/cli/verb/put.rb +5 -4
  69. data/lib/textus/cli/verb/rdeps.rb +3 -1
  70. data/lib/textus/cli/verb/refresh.rb +1 -1
  71. data/lib/textus/cli/verb/refresh_stale.rb +4 -2
  72. data/lib/textus/cli/verb/reject.rb +3 -1
  73. data/lib/textus/cli/verb/rule_explain.rb +4 -1
  74. data/lib/textus/cli/verb/rule_list.rb +3 -0
  75. data/lib/textus/cli/verb/schema.rb +4 -1
  76. data/lib/textus/cli/verb/schema_diff.rb +3 -0
  77. data/lib/textus/cli/verb/schema_init.rb +3 -0
  78. data/lib/textus/cli/verb/schema_migrate.rb +3 -0
  79. data/lib/textus/cli/verb/uid.rb +4 -1
  80. data/lib/textus/cli/verb/where.rb +3 -1
  81. data/lib/textus/cli/verb.rb +30 -0
  82. data/lib/textus/cli.rb +18 -27
  83. data/lib/textus/doctor/check/audit_log.rb +1 -1
  84. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  85. data/lib/textus/doctor/check/hooks.rb +4 -2
  86. data/lib/textus/doctor/check/illegal_keys.rb +6 -5
  87. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  88. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  89. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  90. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  91. data/lib/textus/doctor/check/sentinels.rb +2 -2
  92. data/lib/textus/doctor/check/templates.rb +4 -3
  93. data/lib/textus/doctor.rb +3 -4
  94. data/lib/textus/domain/authorizer.rb +37 -0
  95. data/lib/textus/domain/freshness/evaluator.rb +1 -1
  96. data/lib/textus/domain/freshness/policy.rb +1 -1
  97. data/lib/textus/domain/freshness/verdict.rb +1 -1
  98. data/lib/textus/domain/freshness.rb +40 -0
  99. data/lib/textus/{store → domain}/sentinel.rb +1 -1
  100. data/lib/textus/{store → domain}/staleness/generator_check.rb +9 -8
  101. data/lib/textus/{store → domain}/staleness/intake_check.rb +3 -3
  102. data/lib/textus/{store → domain}/staleness.rb +1 -1
  103. data/lib/textus/entry/json.rb +1 -1
  104. data/lib/textus/entry/markdown.rb +1 -1
  105. data/lib/textus/entry/yaml.rb +1 -1
  106. data/lib/textus/envelope.rb +7 -3
  107. data/lib/textus/errors.rb +19 -0
  108. data/lib/textus/hooks/builtin.rb +6 -6
  109. data/lib/textus/hooks/bus.rb +155 -0
  110. data/lib/textus/hooks/context.rb +38 -0
  111. data/lib/textus/hooks/fire_report.rb +23 -0
  112. data/lib/textus/hooks/loader.rb +20 -17
  113. data/lib/textus/{store → infra}/audit_log.rb +1 -1
  114. data/lib/textus/infra/audit_subscriber.rb +43 -0
  115. data/lib/textus/infra/event_bus.rb +3 -3
  116. data/lib/textus/infra/publisher.rb +3 -3
  117. data/lib/textus/infra/refresh/detached.rb +1 -1
  118. data/lib/textus/infra/storage/file_store.rb +26 -0
  119. data/lib/textus/init.rb +14 -11
  120. data/lib/textus/intro.rb +7 -7
  121. data/lib/textus/manifest/entry/base.rb +38 -0
  122. data/lib/textus/manifest/entry/derived.rb +25 -0
  123. data/lib/textus/manifest/entry/intake.rb +19 -0
  124. data/lib/textus/manifest/entry/leaf.rb +16 -0
  125. data/lib/textus/manifest/entry/nested.rb +39 -0
  126. data/lib/textus/manifest/entry/parser.rb +64 -31
  127. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  128. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  129. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  130. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  131. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  132. data/lib/textus/manifest/entry.rb +0 -72
  133. data/lib/textus/manifest/resolution.rb +5 -0
  134. data/lib/textus/manifest/resolver.rb +109 -0
  135. data/lib/textus/manifest/schema.rb +1 -1
  136. data/lib/textus/manifest.rb +4 -100
  137. data/lib/textus/operations.rb +147 -23
  138. data/lib/textus/schema/tools.rb +7 -7
  139. data/lib/textus/schemas.rb +46 -0
  140. data/lib/textus/store.rb +12 -49
  141. data/lib/textus/uid.rb +18 -0
  142. data/lib/textus/version.rb +1 -1
  143. data/lib/textus.rb +17 -1
  144. metadata +31 -23
  145. data/lib/textus/application/writes/build.rb +0 -79
  146. data/lib/textus/dependencies.rb +0 -23
  147. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  148. data/lib/textus/hooks/dispatcher.rb +0 -63
  149. data/lib/textus/hooks/dsl.rb +0 -11
  150. data/lib/textus/hooks/registry.rb +0 -81
  151. data/lib/textus/migrate_keys.rb +0 -187
  152. data/lib/textus/operations/reads.rb +0 -56
  153. data/lib/textus/operations/refresh.rb +0 -27
  154. data/lib/textus/operations/writes.rb +0 -21
  155. data/lib/textus/projection.rb +0 -89
  156. data/lib/textus/refresh.rb +0 -39
  157. data/lib/textus/store/reader.rb +0 -69
  158. data/lib/textus/store/validator.rb +0 -82
  159. 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(registry:)
5
- @registry = registry
4
+ def initialize(bus:)
5
+ @bus = bus
6
6
  end
7
7
 
8
8
  def publish(event, **payload)
9
- @registry.pubsub_handlers(event).each do |entry|
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 Store::Sentinel. Sentinels live under
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
- Store::Sentinel.write!(target: target, source: source, store_root: store_root)
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?(Store::Sentinel.sentinel_path(target, store_root))
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::Refresh.call(store, key, as: "runner")
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.on(:resolve_intake, :my_source) do |config:, args:, **|
32
- { _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
33
- end
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
- Textus.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
36
- Textus.on(:validate, :my_check) { |store:, **| [] }
37
- Textus.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
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
- # Run a side-effect every time textus writes a file to your repo:
40
- Textus.on(:file_published, :notify) do |key:, target:, **|
41
- warn "wrote \#{target} (from \#{key})"
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.nested ? true : false,
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" => !e.intake_handler.nil?,
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
- reg = store.registry
150
+ bus = store.bus
151
151
  sections = {}
152
- Hooks::Registry::EVENTS.each do |event, spec|
152
+ Hooks::Bus::EVENTS.each do |event, spec|
153
153
  case spec[:mode]
154
154
  when :rpc
155
- sections[event.to_s] = reg.rpc_names(event).map(&:to_s).sort
155
+ sections[event.to_s] = bus.rpc_names(event).map(&:to_s).sort
156
156
  when :pubsub
157
- sections[event.to_s] = reg.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
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,16 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ class Leaf < Base
5
+ attr_reader :publish_to
6
+
7
+ def initialize(publish_to: [], **rest)
8
+ super(**rest)
9
+ @publish_to = Array(publish_to)
10
+ end
11
+
12
+ def leaf? = true
13
+ end
14
+ end
15
+ end
16
+ 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
- nested = raw["nested"] == true
13
- compute, projection, generator = parse_compute(raw, key)
14
- intake_handler, intake_config = parse_intake(raw["intake"])
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
- Textus::Manifest::Entry.new(
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
- nested: nested,
22
- template: raw["template"],
23
- publish_to: Array(raw["publish_to"]),
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
- events: raw["events"] || {},
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
- index_filename: raw["index_filename"],
28
- format: format,
29
- compute: compute, projection: projection, generator: generator,
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.parse_compute(raw, key)
35
- src = raw["compute"]
36
- return [nil, nil, nil] if src.nil?
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
- kind = src["kind"]
39
- unless COMPUTE_KINDS.include?(kind)
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
- frozen = src.freeze
46
- if kind == "projection"
47
- [frozen, frozen, nil]
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
- [frozen, nil, frozen]
89
+ Derived::External.new(sources: compute["sources"], runner: compute["runner"])
50
90
  end
51
91
  end
52
92
 
53
- def self.parse_intake(src)
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::Registry::EVENTS.select { |_, s| s[:mode] == :pubsub }.keys
8
- entry.events.each_key do |evt|
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
- return unless entry.in_generator_zone? && entry.template.nil? && entry.generator.nil? &&
18
- %w[markdown text].include?(entry.format) && !entry.nested
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
- return if entry.index_filename.nil?
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 entry.index_filename.is_a?(String) && !entry.index_filename.empty?
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 entry.index_filename.include?("/") || File.basename(entry.index_filename) != entry.index_filename
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(entry.index_filename)
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 #{entry.index_filename.inspect} has unknown extension #{ext.inspect}",
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
- return unless entry.inject_intro
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
- return unless entry.template.nil?
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
- return if entry.publish_each.nil?
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
- used_vars = entry.publish_each.scan(VAR_RE).flatten
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(