textus 0.5.0 → 0.8.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +29 -21
  4. data/SPEC.md +75 -142
  5. data/docs/architecture.md +42 -23
  6. data/lib/textus/builder/pipeline.rb +56 -0
  7. data/lib/textus/builder/renderer/json.rb +42 -0
  8. data/lib/textus/builder/renderer/markdown.rb +22 -0
  9. data/lib/textus/builder/renderer/text.rb +14 -0
  10. data/lib/textus/builder/renderer/yaml.rb +42 -0
  11. data/lib/textus/builder/renderer.rb +17 -0
  12. data/lib/textus/builder.rb +9 -114
  13. data/lib/textus/cli/group/hook.rb +11 -0
  14. data/lib/textus/cli/group/key.rb +12 -0
  15. data/lib/textus/cli/group/schema.rb +13 -0
  16. data/lib/textus/cli/verb/accept.rb +15 -0
  17. data/lib/textus/cli/verb/build.rb +13 -0
  18. data/lib/textus/cli/verb/delete.rb +16 -0
  19. data/lib/textus/cli/verb/deps.rb +12 -0
  20. data/lib/textus/cli/verb/doctor.rb +15 -0
  21. data/lib/textus/cli/verb/get.rb +12 -0
  22. data/lib/textus/cli/verb/hook_run.rb +48 -0
  23. data/lib/textus/cli/verb/hooks.rb +50 -0
  24. data/lib/textus/cli/verb/init.rb +14 -0
  25. data/lib/textus/cli/verb/intro.rb +11 -0
  26. data/lib/textus/cli/verb/list.rb +14 -0
  27. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  28. data/lib/textus/cli/verb/mv.rb +17 -0
  29. data/lib/textus/cli/verb/published.rb +11 -0
  30. data/lib/textus/cli/verb/put.rb +50 -0
  31. data/lib/textus/cli/verb/rdeps.rb +12 -0
  32. data/lib/textus/cli/verb/refresh.rb +15 -0
  33. data/lib/textus/cli/verb/schema.rb +12 -0
  34. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  35. data/lib/textus/cli/verb/schema_init.rb +16 -0
  36. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  37. data/lib/textus/cli/verb/stale.rb +14 -0
  38. data/lib/textus/cli/verb/uid.rb +12 -0
  39. data/lib/textus/cli/verb/where.rb +12 -0
  40. data/lib/textus/cli.rb +23 -42
  41. data/lib/textus/doctor/check/audit_log.rb +50 -0
  42. data/lib/textus/doctor/check/hooks.rb +29 -0
  43. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  44. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  45. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  46. data/lib/textus/doctor/check/schemas.rb +26 -0
  47. data/lib/textus/doctor/check/sentinels.rb +57 -0
  48. data/lib/textus/doctor/check/templates.rb +26 -0
  49. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  50. data/lib/textus/doctor/check.rb +30 -0
  51. data/lib/textus/doctor.rb +22 -288
  52. data/lib/textus/entry/base.rb +30 -0
  53. data/lib/textus/entry/json.rb +5 -1
  54. data/lib/textus/entry/markdown.rb +1 -1
  55. data/lib/textus/entry/text.rb +1 -1
  56. data/lib/textus/entry/yaml.rb +5 -1
  57. data/lib/textus/entry.rb +0 -5
  58. data/lib/textus/envelope.rb +30 -0
  59. data/lib/textus/hooks/builtin.rb +70 -0
  60. data/lib/textus/hooks/dispatcher.rb +49 -0
  61. data/lib/textus/hooks/loader.rb +26 -0
  62. data/lib/textus/hooks/registry.rb +73 -0
  63. data/lib/textus/init.rb +13 -10
  64. data/lib/textus/intro.rb +14 -16
  65. data/lib/textus/key/distance.rb +55 -0
  66. data/lib/textus/key/grammar.rb +33 -0
  67. data/lib/textus/key/path.rb +17 -0
  68. data/lib/textus/manifest/entry.rb +199 -0
  69. data/lib/textus/manifest.rb +10 -34
  70. data/lib/textus/migrate_keys.rb +1 -1
  71. data/lib/textus/projection.rb +5 -4
  72. data/lib/textus/proposal.rb +1 -1
  73. data/lib/textus/refresh.rb +11 -11
  74. data/lib/textus/schema/tools.rb +89 -0
  75. data/lib/textus/store/audit_log.rb +71 -0
  76. data/lib/textus/store/mover.rb +19 -16
  77. data/lib/textus/store/reader.rb +67 -0
  78. data/lib/textus/store/staleness.rb +10 -19
  79. data/lib/textus/store/validator.rb +11 -8
  80. data/lib/textus/store/view.rb +29 -0
  81. data/lib/textus/store/writer.rb +132 -0
  82. data/lib/textus/store.rb +25 -221
  83. data/lib/textus/version.rb +1 -1
  84. data/lib/textus.rb +14 -67
  85. metadata +73 -40
  86. data/lib/textus/audit_log.rb +0 -67
  87. data/lib/textus/builtin_actions.rb +0 -68
  88. data/lib/textus/cli/accept.rb +0 -13
  89. data/lib/textus/cli/action.rb +0 -51
  90. data/lib/textus/cli/build.rb +0 -11
  91. data/lib/textus/cli/delete.rb +0 -14
  92. data/lib/textus/cli/deprecated_alias.rb +0 -31
  93. data/lib/textus/cli/deps.rb +0 -10
  94. data/lib/textus/cli/doctor.rb +0 -13
  95. data/lib/textus/cli/extension_group.rb +0 -9
  96. data/lib/textus/cli/extensions.rb +0 -49
  97. data/lib/textus/cli/get.rb +0 -10
  98. data/lib/textus/cli/init.rb +0 -12
  99. data/lib/textus/cli/intro.rb +0 -9
  100. data/lib/textus/cli/key_group.rb +0 -10
  101. data/lib/textus/cli/list.rb +0 -12
  102. data/lib/textus/cli/migrate.rb +0 -41
  103. data/lib/textus/cli/migrate_keys.rb +0 -19
  104. data/lib/textus/cli/mv.rb +0 -20
  105. data/lib/textus/cli/published.rb +0 -9
  106. data/lib/textus/cli/put.rb +0 -48
  107. data/lib/textus/cli/rdeps.rb +0 -10
  108. data/lib/textus/cli/refresh.rb +0 -13
  109. data/lib/textus/cli/schema.rb +0 -10
  110. data/lib/textus/cli/schema_diff.rb +0 -15
  111. data/lib/textus/cli/schema_group.rb +0 -33
  112. data/lib/textus/cli/schema_init.rb +0 -19
  113. data/lib/textus/cli/schema_migrate.rb +0 -19
  114. data/lib/textus/cli/stale.rb +0 -12
  115. data/lib/textus/cli/uid.rb +0 -15
  116. data/lib/textus/cli/where.rb +0 -10
  117. data/lib/textus/extension_registry.rb +0 -61
  118. data/lib/textus/extensions.rb +0 -33
  119. data/lib/textus/key_distance.rb +0 -53
  120. data/lib/textus/manifest_entry.rb +0 -185
  121. data/lib/textus/migrate_v2.rb +0 -27
  122. data/lib/textus/schema_tools.rb +0 -87
  123. data/lib/textus/store/events.rb +0 -31
  124. data/lib/textus/store_view.rb +0 -27
data/lib/textus/doctor.rb CHANGED
@@ -1,32 +1,41 @@
1
- require "digest"
2
- require "json"
3
1
  require "timeout"
4
2
 
5
3
  module Textus
6
4
  # Health check for a Textus store. Returns a JSON-friendly Hash envelope
7
5
  # with an `issues` array and a summary. Each issue is a Hash with
8
6
  # `code`, `level`, `subject`, `message`, and optionally `fix`.
9
- module Doctor # rubocop:disable Metrics/ModuleLength -- 9 built-in checks + extension dispatch
7
+ module Doctor
10
8
  LEVELS = %w[error warning info].freeze
11
9
  DOCTOR_CHECK_TIMEOUT_SECONDS = 2
12
- ALL_CHECKS = %w[
13
- manifest_files schemas templates extensions illegal_keys
14
- sentinels audit_log unowned_schema_fields schema_violations
10
+
11
+ CHECKS = [
12
+ Check::ManifestFiles,
13
+ Check::Schemas,
14
+ Check::Templates,
15
+ Check::Hooks,
16
+ Check::IllegalKeys,
17
+ Check::Sentinels,
18
+ Check::AuditLog,
19
+ Check::UnownedSchemaFields,
20
+ Check::SchemaViolations,
15
21
  ].freeze
16
22
 
23
+ ALL_CHECKS = CHECKS.map(&:name_key).freeze
24
+
17
25
  module_function
18
26
 
19
27
  def run(store, checks: nil)
20
- selected = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
21
- unknown = selected - ALL_CHECKS
28
+ selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
29
+ unknown = selected_keys - ALL_CHECKS
22
30
  unless unknown.empty?
23
31
  raise UsageError.new(
24
32
  "unknown doctor check: #{unknown.first}. Valid checks: #{ALL_CHECKS.join(", ")}",
25
33
  )
26
34
  end
27
35
 
28
- issues = run_builtin_checks(store, selected)
29
- issues.concat(run_registered_checks(store)) # extensions always run
36
+ selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
37
+ issues = selected.flat_map { |c| c.new(store).call }
38
+ issues.concat(run_registered_checks(store))
30
39
 
31
40
  summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
32
41
  {
@@ -37,263 +46,11 @@ module Textus
37
46
  }
38
47
  end
39
48
 
40
- def run_builtin_checks(store, selected)
41
- issues = []
42
- issues.concat(check_manifest_files(store)) if selected.include?("manifest_files")
43
- issues.concat(check_schemas(store)) if selected.include?("schemas")
44
- issues.concat(check_templates(store)) if selected.include?("templates")
45
- issues.concat(check_extensions(store)) if selected.include?("extensions")
46
- issues.concat(check_illegal_keys(store)) if selected.include?("illegal_keys")
47
- issues.concat(check_sentinels(store)) if selected.include?("sentinels")
48
- issues.concat(check_audit_log(store)) if selected.include?("audit_log")
49
- issues.concat(check_unowned_schema_fields(store)) if selected.include?("unowned_schema_fields")
50
- issues.concat(check_schema_violations(store)) if selected.include?("schema_violations")
51
- issues
52
- end
53
-
54
- # --- Checks -----------------------------------------------------------
55
-
56
- def check_manifest_files(store)
57
- out = []
58
- store.manifest.entries.each do |entry|
59
- next if entry.nested
60
-
61
- path = leaf_path_for(store, entry)
62
- next if File.exist?(path)
63
-
64
- out << {
65
- "code" => "manifest.missing_file",
66
- "level" => "info",
67
- "subject" => entry.key,
68
- "message" => "declared entry has no file on disk at #{path}",
69
- "fix" => "create the entry with 'textus put #{entry.key} --stdin --as=<role>' " \
70
- "(or leave empty if not yet authored)",
71
- }
72
- end
73
- out
74
- end
75
-
76
- def check_schemas(store)
77
- out = []
78
- store.manifest.entries.each do |entry|
79
- next if entry.schema.nil?
80
-
81
- sp = File.join(store.root, "schemas", "#{entry.schema}.yaml")
82
- next if File.exist?(sp)
83
-
84
- out << {
85
- "code" => "schema.missing",
86
- "level" => "error",
87
- "subject" => entry.key,
88
- "message" => "schema '#{entry.schema}' not found at #{sp}",
89
- "fix" => "create the schema file or run 'textus schema-init #{entry.schema} --from=<key>'",
90
- }
91
- end
92
- out
93
- end
94
-
95
- def check_templates(store)
96
- out = []
97
- store.manifest.entries.each do |entry|
98
- next if entry.template.nil?
99
-
100
- tp = File.join(store.root, "templates", entry.template)
101
- next if File.exist?(tp)
102
-
103
- out << {
104
- "code" => "template.missing",
105
- "level" => "error",
106
- "subject" => entry.key,
107
- "message" => "template '#{entry.template}' not found at #{tp}",
108
- "fix" => "create the file at #{tp} or update the entry's template: field",
109
- }
110
- end
111
- out
112
- end
113
-
114
- def check_extensions(store)
115
- out = []
116
- dir = File.join(store.root, "extensions")
117
- return out unless File.directory?(dir)
118
-
119
- Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
120
- registry = ExtensionRegistry.new
121
- Textus.with_registry(registry) do
122
- load(f)
123
- end
124
- rescue StandardError, ScriptError => e
125
- out << {
126
- "code" => "extension.load_failed",
127
- "level" => "error",
128
- "subject" => File.basename(f),
129
- "message" => "#{e.class}: #{e.message}",
130
- "fix" => "open #{f} and fix the syntax/load error",
131
- }
132
- end
133
- out
134
- end
135
-
136
- def check_illegal_keys(store)
137
- out = []
138
- store.manifest.entries.each do |entry|
139
- next unless entry.nested
140
-
141
- base = File.join(store.root, "zones", entry.path)
142
- next unless File.directory?(base)
143
-
144
- walk_nested(base) do |abs_path, is_dir|
145
- basename = File.basename(abs_path)
146
- stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
147
- next if stem.match?(Manifest::KEY_SEGMENT)
148
-
149
- proposed = Textus::MigrateKeys.normalize(stem)
150
- out << {
151
- "code" => "key.illegal",
152
- "level" => "error",
153
- "subject" => abs_path,
154
- "path" => abs_path,
155
- "proposed_key" => proposed,
156
- "message" => "illegal key segment '#{stem}' at #{abs_path}",
157
- "fix" => "run 'textus migrate-keys --dry-run' then '--write' to rename to '#{proposed}'",
158
- }
159
- end
160
- end
161
- out
162
- end
163
-
164
- def check_sentinels(store)
165
- out = []
166
- dir = File.join(store.root, "sentinels")
167
- return out unless File.directory?(dir)
168
-
169
- Dir.glob(File.join(dir, "**", "*.textus-managed.json")).each do |sp| # rubocop:disable Metrics/BlockLength
170
- begin
171
- data = JSON.parse(File.read(sp))
172
- rescue JSON::ParserError => e
173
- out << {
174
- "code" => "sentinel.parse_error",
175
- "level" => "warning",
176
- "subject" => sp,
177
- "message" => "sentinel is not valid JSON: #{e.message}",
178
- "fix" => "delete #{sp} and re-run 'textus build' to regenerate",
179
- }
180
- next
181
- end
182
-
183
- target = data["target"]
184
- recorded_sha = data["sha256"]
185
-
186
- if target.nil? || !File.exist?(target)
187
- out << {
188
- "code" => "sentinel.orphan",
189
- "level" => "warning",
190
- "subject" => sp,
191
- "message" => "sentinel target #{target.inspect} no longer exists",
192
- "fix" => "delete #{sp} (the published file is gone) or restore the target",
193
- }
194
- next
195
- end
196
-
197
- current_sha = Digest::SHA256.hexdigest(File.binread(target))
198
- next if recorded_sha.nil? || current_sha == recorded_sha
199
-
200
- out << {
201
- "code" => "sentinel.drift",
202
- "level" => "warning",
203
- "subject" => target,
204
- "message" => "published file at #{target} was modified out-of-band",
205
- "fix" => "re-run 'textus build' to overwrite, or copy the manual edit back into the store source",
206
- }
207
- end
208
- out
209
- end
210
-
211
- def check_audit_log(store)
212
- out = []
213
- path = File.join(store.root, "audit.log")
214
- return out unless File.exist?(path)
215
-
216
- File.foreach(path).with_index(1) do |line, lineno| # rubocop:disable Metrics/BlockLength
217
- stripped = line.chomp
218
- next if stripped.empty?
219
-
220
- if stripped.start_with?("{")
221
- begin
222
- JSON.parse(stripped)
223
- rescue JSON::ParserError => e
224
- out << {
225
- "code" => "audit.parse_error",
226
- "level" => "warning",
227
- "subject" => "#{path}:#{lineno}",
228
- "message" => "audit log line #{lineno} is invalid JSON: #{e.message}",
229
- "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
230
- }
231
- end
232
- else
233
- # Legacy TSV: minimum 6 fields. Removed in 0.6.
234
- fields = stripped.split("\t")
235
- next if fields.length >= 6
236
-
237
- out << {
238
- "code" => "audit.parse_error",
239
- "level" => "warning",
240
- "subject" => "#{path}:#{lineno}",
241
- "message" => "audit log line #{lineno} has #{fields.length} fields " \
242
- "(expected >=6 for legacy TSV; consider migrating to NDJSON)",
243
- "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
244
- }
245
- end
246
- end
247
- out
248
- end
249
-
250
- def check_unowned_schema_fields(store)
251
- out = []
252
- dir = File.join(store.root, "schemas")
253
- return out unless File.directory?(dir)
254
-
255
- Dir.glob(File.join(dir, "*.yaml")).sort.each do |sp| # rubocop:disable Lint/RedundantDirGlobSort
256
- schema = begin
257
- Schema.load(sp)
258
- rescue StandardError
259
- next
260
- end
261
- unowned = schema.fields.each_with_object([]) do |(name, spec), acc|
262
- acc << name if spec.is_a?(Hash) && spec["maintained_by"].nil?
263
- end
264
- next if unowned.empty?
265
-
266
- out << {
267
- "code" => "schema.unowned_fields",
268
- "level" => "info",
269
- "subject" => schema.name || File.basename(sp, ".yaml"),
270
- "message" => "schema has fields without maintained_by: #{unowned.join(", ")}",
271
- "fix" => "add 'maintained_by: <role>' to each field in #{sp} (optional but recommended)",
272
- }
273
- end
274
- out
275
- end
276
-
277
- def check_schema_violations(store)
278
- res = store.validate_all
279
- res["violations"].map do |v|
280
- fix = v["expected"] &&
281
- "field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
282
- {
283
- "code" => v["code"],
284
- "level" => "error",
285
- "subject" => v["key"],
286
- "message" => v["message"] || "#{v["code"]} on #{v["key"]}",
287
- "fix" => fix,
288
- }.compact
289
- end
290
- end
291
-
292
49
  def run_registered_checks(store)
293
50
  out = []
294
- view = StoreView.new(store)
295
- store.registry.doctor_check_names.each do |name|
296
- callable = store.registry.doctor_check(name)
51
+ view = Store::View.new(store)
52
+ store.registry.rpc_names(:check).each do |name|
53
+ callable = store.registry.rpc_callable(:check, name)
297
54
  begin
298
55
  result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: view) }
299
56
  if result.is_a?(Array)
@@ -325,28 +82,5 @@ module Textus
325
82
  "fix" => fix,
326
83
  }
327
84
  end
328
-
329
- # --- Helpers ----------------------------------------------------------
330
-
331
- def leaf_path_for(store, entry)
332
- primary_ext = Entry.for_format(entry.format).extensions.first
333
- if File.extname(entry.path) == ""
334
- File.join(store.root, "zones", entry.path + primary_ext)
335
- else
336
- File.join(store.root, "zones", entry.path)
337
- end
338
- end
339
-
340
- def walk_nested(root, &block)
341
- Dir.each_child(root) do |name|
342
- abs = File.join(root, name)
343
- if File.directory?(abs)
344
- walk_nested(abs, &block)
345
- yield abs, true
346
- else
347
- yield abs, false
348
- end
349
- end
350
- end
351
85
  end
352
86
  end
@@ -0,0 +1,30 @@
1
+ module Textus
2
+ module Entry
3
+ # Abstract base for entry format strategies. Each concrete strategy
4
+ # owns parsing, serialization, file-extension claims, and schema
5
+ # validation for entries declared with its format.
6
+ class Base
7
+ def self.parse(_raw, path: nil)
8
+ _ = path
9
+ raise NotImplementedError.new("#{name}.parse not implemented")
10
+ end
11
+
12
+ def self.serialize(meta: {}, body: "", content: nil)
13
+ _ = meta
14
+ _ = body
15
+ _ = content
16
+ raise NotImplementedError.new("#{name}.serialize not implemented")
17
+ end
18
+
19
+ def self.extensions
20
+ raise NotImplementedError.new("#{name}.extensions not implemented")
21
+ end
22
+
23
+ # Default: validate the meta hash. Overridden by formats that put the
24
+ # validatable payload elsewhere (json/yaml put it under "content").
25
+ def self.validate_against(schema, parsed)
26
+ schema.validate!(parsed["_meta"] || {})
27
+ end
28
+ end
29
+ end
30
+ end
@@ -3,7 +3,7 @@ require "json"
3
3
  module Textus
4
4
  module Entry
5
5
  # JSON entry storage. Top-level must be an object so we can carry _meta.
6
- module Json
6
+ class Json < Base
7
7
  def self.parse(raw, path: nil)
8
8
  raw = raw.dup.force_encoding(Encoding::UTF_8)
9
9
  raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
@@ -37,6 +37,10 @@ module Textus
37
37
  end
38
38
  end
39
39
 
40
+ def self.validate_against(schema, parsed)
41
+ schema.validate!(parsed["content"] || {})
42
+ end
43
+
40
44
  def self.extensions = [".json"]
41
45
  end
42
46
  end
@@ -3,7 +3,7 @@ require "yaml"
3
3
  module Textus
4
4
  module Entry
5
5
  # Markdown with YAML frontmatter. Original Entry implementation.
6
- module Markdown
6
+ class Markdown < Base
7
7
  def self.parse(raw, path: nil)
8
8
  raw = raw.dup.force_encoding(Encoding::UTF_8)
9
9
  raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  module Entry
3
3
  # Plain-text entry storage. No frontmatter or structured content.
4
- module Text
4
+ class Text < Base
5
5
  def self.parse(raw, path: nil)
6
6
  raw = raw.dup.force_encoding(Encoding::UTF_8)
7
7
  raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
@@ -3,7 +3,7 @@ require "yaml"
3
3
  module Textus
4
4
  module Entry
5
5
  # YAML entry storage. Top-level must be a mapping so we can carry _meta.
6
- module Yaml
6
+ class Yaml < Base
7
7
  def self.parse(raw, path: nil)
8
8
  raw = raw.dup.force_encoding(Encoding::UTF_8)
9
9
  raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
@@ -35,6 +35,10 @@ module Textus
35
35
  end
36
36
  end
37
37
 
38
+ def self.validate_against(schema, parsed)
39
+ schema.validate!(parsed["content"] || {})
40
+ end
41
+
38
42
  def self.extensions = [".yaml", ".yml"]
39
43
  end
40
44
  end
data/lib/textus/entry.rb CHANGED
@@ -1,8 +1,3 @@
1
- require_relative "entry/markdown"
2
- require_relative "entry/json"
3
- require_relative "entry/yaml"
4
- require_relative "entry/text"
5
-
6
1
  module Textus
7
2
  # Public entry-format dispatcher.
8
3
  module Entry
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Envelope
5
+ # rubocop:disable Metrics/ParameterLists
6
+ def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil)
7
+ # rubocop:enable Metrics/ParameterLists
8
+ env = {
9
+ "protocol" => PROTOCOL,
10
+ "key" => key,
11
+ "zone" => mentry.zone,
12
+ "owner" => mentry.owner,
13
+ "path" => path,
14
+ "format" => mentry.format,
15
+ "_meta" => meta,
16
+ "body" => body,
17
+ "etag" => etag,
18
+ "schema_ref" => mentry.schema,
19
+ "uid" => extract_uid(meta),
20
+ }
21
+ env["content"] = content unless content.nil?
22
+ env
23
+ end
24
+
25
+ def self.extract_uid(meta)
26
+ v = meta.is_a?(Hash) ? meta["uid"] : nil
27
+ v.is_a?(String) ? v : nil
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,70 @@
1
+ require "json"
2
+ require "csv"
3
+ require "yaml"
4
+ require "rexml/document"
5
+
6
+ module Textus
7
+ module Hooks
8
+ module Builtin
9
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
+ def self.register_all
11
+ Textus.hook(:fetch, :json) do |store:, config:, args:|
12
+ _ = store
13
+ _ = args
14
+ data = JSON.parse(config["bytes"].to_s)
15
+ { _meta: {}, body: YAML.dump(data) }
16
+ end
17
+
18
+ Textus.hook(:fetch, :csv) do |store:, config:, args:|
19
+ _ = store
20
+ _ = args
21
+ rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
22
+ { _meta: {}, body: YAML.dump(rows) }
23
+ end
24
+
25
+ Textus.hook(:fetch, :"markdown-links") do |store:, config:, args:|
26
+ _ = store
27
+ _ = args
28
+ links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
29
+ { "text" => text, "href" => href }
30
+ end
31
+ { _meta: {}, body: YAML.dump(links) }
32
+ end
33
+
34
+ Textus.hook(:fetch, :"ical-events") do |store:, config:, args:|
35
+ _ = store
36
+ _ = args
37
+ events = []
38
+ current = nil
39
+ config["bytes"].to_s.each_line do |line|
40
+ line = line.strip
41
+ case line
42
+ when "BEGIN:VEVENT" then current = {}
43
+ when "END:VEVENT"
44
+ events << current if current
45
+ current = nil
46
+ when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
47
+ current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
48
+ end
49
+ end
50
+ { _meta: {}, body: YAML.dump(events) }
51
+ end
52
+
53
+ Textus.hook(:fetch, :rss) do |store:, config:, args:|
54
+ _ = store
55
+ _ = args
56
+ doc = REXML::Document.new(config["bytes"].to_s)
57
+ items = doc.elements.to_a("//item").map do |item|
58
+ {
59
+ "title" => item.elements["title"]&.text,
60
+ "link" => item.elements["link"]&.text,
61
+ "pubDate" => item.elements["pubDate"]&.text,
62
+ }
63
+ end
64
+ { _meta: {}, body: YAML.dump(items) }
65
+ end
66
+ end
67
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Textus
6
+ module Hooks
7
+ class Dispatcher
8
+ HOOK_TIMEOUT_SECONDS = 2
9
+
10
+ def initialize(audit_log:)
11
+ @audit_log = audit_log
12
+ @subscribers = Hash.new { |h, k| h[k] = [] }
13
+ end
14
+
15
+ def subscribe(event, name, keys: nil, &block)
16
+ @subscribers[event.to_sym] << { name: name.to_sym, callable: block, keys: keys }
17
+ end
18
+
19
+ def publish(event, **kwargs)
20
+ key = kwargs[:key] || "-"
21
+ @subscribers[event.to_sym].each do |sub|
22
+ next unless match?(sub[:keys], key)
23
+
24
+ invoke(event, sub, key, kwargs)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def invoke(event, sub, key, kwargs)
31
+ Timeout.timeout(HOOK_TIMEOUT_SECONDS) { sub[:callable].call(**kwargs) }
32
+ rescue StandardError => e
33
+ extras = { "event" => event.to_s, "hook" => sub[:name].to_s, "error" => "#{e.class}: #{e.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: "script", verb: "event_error", key: key,
38
+ etag_before: nil, etag_after: nil, extras: extras
39
+ )
40
+ end
41
+
42
+ def match?(globs, key)
43
+ return true if globs.nil?
44
+
45
+ Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Hooks
3
+ module Loader
4
+ THREAD_REGISTRY_KEY = :__textus_active_registry__
5
+ private_constant :THREAD_REGISTRY_KEY
6
+
7
+ def self.with_registry(registry)
8
+ prev = Thread.current[THREAD_REGISTRY_KEY]
9
+ Thread.current[THREAD_REGISTRY_KEY] = registry
10
+ yield
11
+ ensure
12
+ Thread.current[THREAD_REGISTRY_KEY] = prev
13
+ end
14
+
15
+ def self.current_registry
16
+ Thread.current[THREAD_REGISTRY_KEY] or
17
+ raise UsageError.new("no active registry; hook code must be loaded by a Store")
18
+ end
19
+ end
20
+ end
21
+
22
+ # Public DSL — unchanged surface
23
+ def self.with_registry(registry, &) = Hooks::Loader.with_registry(registry, &)
24
+ def self.current_registry = Hooks::Loader.current_registry
25
+ def self.hook(event, name, **, &) = Hooks::Loader.current_registry.register(event, name, **, &)
26
+ end