textus 0.4.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -2
  3. data/README.md +38 -28
  4. data/SPEC.md +84 -147
  5. data/docs/architecture.md +82 -28
  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/group.rb +51 -0
  17. data/lib/textus/cli/verb/accept.rb +15 -0
  18. data/lib/textus/cli/verb/build.rb +13 -0
  19. data/lib/textus/cli/verb/delete.rb +16 -0
  20. data/lib/textus/cli/verb/deps.rb +12 -0
  21. data/lib/textus/cli/verb/doctor.rb +15 -0
  22. data/lib/textus/cli/verb/get.rb +12 -0
  23. data/lib/textus/cli/verb/hook_run.rb +48 -0
  24. data/lib/textus/cli/verb/hooks.rb +50 -0
  25. data/lib/textus/cli/verb/init.rb +14 -0
  26. data/lib/textus/cli/verb/intro.rb +11 -0
  27. data/lib/textus/cli/verb/list.rb +14 -0
  28. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  29. data/lib/textus/cli/verb/mv.rb +17 -0
  30. data/lib/textus/cli/verb/published.rb +11 -0
  31. data/lib/textus/cli/verb/put.rb +50 -0
  32. data/lib/textus/cli/verb/rdeps.rb +12 -0
  33. data/lib/textus/cli/verb/refresh.rb +15 -0
  34. data/lib/textus/cli/verb/schema.rb +12 -0
  35. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  36. data/lib/textus/cli/verb/schema_init.rb +16 -0
  37. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  38. data/lib/textus/cli/verb/stale.rb +14 -0
  39. data/lib/textus/cli/verb/uid.rb +12 -0
  40. data/lib/textus/cli/verb/where.rb +12 -0
  41. data/lib/textus/cli/verb.rb +62 -0
  42. data/lib/textus/cli.rb +44 -385
  43. data/lib/textus/doctor/check/audit_log.rb +50 -0
  44. data/lib/textus/doctor/check/hooks.rb +29 -0
  45. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  46. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  47. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  48. data/lib/textus/doctor/check/schemas.rb +26 -0
  49. data/lib/textus/doctor/check/sentinels.rb +57 -0
  50. data/lib/textus/doctor/check/templates.rb +26 -0
  51. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  52. data/lib/textus/doctor/check.rb +30 -0
  53. data/lib/textus/doctor.rb +29 -264
  54. data/lib/textus/entry/base.rb +30 -0
  55. data/lib/textus/entry/json.rb +11 -5
  56. data/lib/textus/entry/markdown.rb +5 -5
  57. data/lib/textus/entry/text.rb +4 -4
  58. data/lib/textus/entry/yaml.rb +11 -5
  59. data/lib/textus/entry.rb +2 -7
  60. data/lib/textus/envelope.rb +30 -0
  61. data/lib/textus/errors.rb +2 -2
  62. data/lib/textus/hooks/builtin.rb +70 -0
  63. data/lib/textus/hooks/dispatcher.rb +49 -0
  64. data/lib/textus/hooks/loader.rb +26 -0
  65. data/lib/textus/hooks/registry.rb +73 -0
  66. data/lib/textus/init.rb +14 -11
  67. data/lib/textus/intro.rb +16 -18
  68. data/lib/textus/key/distance.rb +55 -0
  69. data/lib/textus/key/grammar.rb +33 -0
  70. data/lib/textus/key/path.rb +17 -0
  71. data/lib/textus/manifest/entry.rb +199 -0
  72. data/lib/textus/manifest.rb +20 -254
  73. data/lib/textus/migrate_keys.rb +1 -1
  74. data/lib/textus/projection.rb +6 -5
  75. data/lib/textus/proposal.rb +4 -4
  76. data/lib/textus/refresh.rb +17 -17
  77. data/lib/textus/schema/tools.rb +89 -0
  78. data/lib/textus/store/audit_log.rb +71 -0
  79. data/lib/textus/store/mover.rb +121 -0
  80. data/lib/textus/store/reader.rb +67 -0
  81. data/lib/textus/store/staleness.rb +133 -0
  82. data/lib/textus/store/validator.rb +56 -0
  83. data/lib/textus/store/view.rb +29 -0
  84. data/lib/textus/store/writer.rb +132 -0
  85. data/lib/textus/store.rb +26 -527
  86. data/lib/textus/version.rb +2 -2
  87. data/lib/textus.rb +14 -29
  88. metadata +78 -8
  89. data/lib/textus/audit_log.rb +0 -32
  90. data/lib/textus/builtin_actions.rb +0 -68
  91. data/lib/textus/extension_registry.rb +0 -61
  92. data/lib/textus/extensions.rb +0 -33
  93. data/lib/textus/key_distance.rb +0 -53
  94. data/lib/textus/schema_tools.rb +0 -87
  95. data/lib/textus/store_view.rb +0 -27
data/lib/textus/store.rb CHANGED
@@ -1,14 +1,9 @@
1
1
  require "fileutils"
2
2
  require "securerandom"
3
- require "time"
4
- require "timeout"
5
3
 
6
4
  module Textus
7
- # rubocop:disable Metrics/ClassLength
8
5
  class Store
9
- HOOK_TIMEOUT_SECONDS = 2
10
-
11
- attr_reader :root, :manifest, :registry
6
+ attr_reader :root, :manifest, :registry, :reader, :writer, :bus
12
7
 
13
8
  # A Textus UID: 16 lowercase hex chars (SecureRandom.hex(8)). Not a UUID —
14
9
  # short on purpose. Random enough for collision-never-in-practice within a
@@ -44,15 +39,18 @@ module Textus
44
39
  def initialize(root)
45
40
  @root = File.expand_path(root)
46
41
  @manifest = Manifest.load(@root)
47
- @registry = ExtensionRegistry.new
42
+ @bus = Hooks::Dispatcher.new(audit_log: audit_log)
43
+ @registry = Hooks::Registry.new(dispatcher: @bus)
48
44
  @schemas = {}
49
45
  load_extensions
46
+ @reader = Reader.new(self)
47
+ @writer = Writer.new(self)
50
48
  end
51
49
 
52
50
  def load_extensions
53
51
  Textus.with_registry(@registry) do
54
- BuiltinActions.register_all
55
- dir = File.join(@root, "extensions")
52
+ Hooks::Builtin.register_all
53
+ dir = File.join(@root, "hooks")
56
54
  return unless File.directory?(dir)
57
55
 
58
56
  Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
@@ -77,541 +75,42 @@ module Textus
77
75
  end
78
76
 
79
77
  def get(key)
80
- mentry, path, = @manifest.resolve(key)
81
- raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
82
-
83
- raw = File.binread(path)
84
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
85
- fm = parsed["frontmatter"]
86
- content = parsed["content"]
87
- enforce_name_match!(path, fm, mentry.format)
88
- schema = schema_for(mentry.schema)
89
- if schema
90
- case mentry.format
91
- when "markdown" then schema.validate!(fm)
92
- when "json", "yaml" then schema.validate!(content || {})
93
- # text: schema forbidden by manifest validation
94
- end
95
- end
96
- build_envelope(key, mentry, path, fm, parsed["body"], Etag.for_bytes(raw), content: content)
97
- end
98
-
99
- def where(key)
100
- mentry, path, = @manifest.resolve(key)
101
- {
102
- "protocol" => PROTOCOL,
103
- "key" => key,
104
- "zone" => mentry.zone,
105
- "owner" => mentry.owner,
106
- "path" => path,
107
- }
108
- end
109
-
110
- def list(prefix: nil, zone: nil)
111
- rows = @manifest.enumerate(prefix: prefix)
112
- rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
113
- rows.map do |row|
114
- {
115
- "key" => row[:key],
116
- "zone" => row[:manifest_entry].zone,
117
- "path" => row[:path],
118
- }
119
- end
120
- end
121
-
122
- def schema_envelope(key)
123
- mentry, = @manifest.resolve(key)
124
- schema = schema_for(mentry.schema)
125
- {
126
- "protocol" => PROTOCOL,
127
- "key" => key,
128
- "schema_ref" => mentry.schema,
129
- "schema" => schema&.to_h,
130
- }
131
- end
132
-
133
- # rubocop:disable Metrics/ParameterLists
134
- def put(key, frontmatter: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
135
- # rubocop:enable Metrics/ParameterLists
136
- @manifest.validate_key!(key)
137
- mentry, path, = @manifest.resolve(key)
138
- writers = @manifest.zone_writers(mentry.zone)
139
- raise WriteForbidden.new(key, mentry.zone, writers: writers) unless writers.include?(as)
140
-
141
- frontmatter ||= {}
142
- strategy = Entry.for_format(mentry.format)
143
-
144
- existing_uid = existing_uid_for(mentry, path)
145
- frontmatter, content = ensure_uid(mentry.format, frontmatter, content, existing_uid)
146
-
147
- bytes, eff_fm, eff_body, eff_content = serialize_for_put(
148
- mentry: mentry, path: path, strategy: strategy,
149
- frontmatter: frontmatter, body: body, content: content
150
- )
151
-
152
- enforce_name_match!(path, eff_fm, mentry.format)
153
-
154
- schema = schema_for(mentry.schema)
155
- if schema
156
- case mentry.format
157
- when "markdown" then schema.validate!(eff_fm)
158
- when "json", "yaml" then schema.validate!(eff_content || {})
159
- end
160
- end
161
-
162
- etag_before = File.exist?(path) ? Etag.for_file(path) : nil
163
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
164
-
165
- FileUtils.mkdir_p(File.dirname(path))
166
- File.binwrite(path, bytes)
167
- etag_after = Etag.for_bytes(bytes)
168
- audit_log.append(role: as, verb: "put", key: key, etag_before: etag_before, etag_after: etag_after)
169
- envelope = build_envelope(key, mentry, path, eff_fm, eff_body, etag_after, content: eff_content)
170
- fire_event(:put, key: key, envelope: envelope) unless suppress_events
171
- envelope
172
- end
173
-
174
- def delete(key, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
175
- mentry, path, = @manifest.resolve(key)
176
- writers = @manifest.zone_writers(mentry.zone)
177
- raise WriteForbidden.new(key, mentry.zone, writers: writers) unless writers.include?(as)
178
- raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
179
-
180
- etag_before = Etag.for_file(path)
181
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
182
-
183
- File.delete(path)
184
- audit_log.append(role: as, verb: "delete", key: key, etag_before: etag_before, etag_after: nil)
185
- fire_event(:delete, key: key) unless suppress_events
186
- { "protocol" => PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
187
- end
188
-
189
- def fire_event(event, **kwargs)
190
- view = StoreView.new(self)
191
- @registry.hooks(event).each do |entry|
192
- name = entry[:name]
193
- Timeout.timeout(HOOK_TIMEOUT_SECONDS) { entry[:callable].call(store: view, **kwargs) }
194
- rescue StandardError => e
195
- extras = { "event" => event.to_s, "hook" => name.to_s, "error" => "#{e.class}: #{e.message}" }
196
- extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
197
- extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
198
- audit_log.append(
199
- role: "script", verb: "event_error",
200
- key: kwargs[:key] || kwargs[:target_key] || kwargs[:pending_key] || "-",
201
- etag_before: nil, etag_after: nil,
202
- extras: extras
203
- )
204
- end
205
- end
206
-
207
- def accept(key, as:)
208
- Proposal.accept(self, key, as: as)
78
+ @reader.get(key)
209
79
  end
210
80
 
211
- def deps(key) = Dependencies.deps_of(@manifest, key)
212
- def rdeps(key) = Dependencies.rdeps_of(@manifest, key)
213
- def published = Dependencies.published_of(@manifest)
81
+ def where(key) = @reader.where(key)
82
+ def list(**) = @reader.list(**)
83
+ def schema_envelope(key) = @reader.schema_envelope(key)
214
84
 
215
- def validate_all
216
- violations = []
217
- @manifest.enumerate.each do |row|
218
- begin
219
- get(row[:key])
220
- rescue Textus::Error => e
221
- violations << { "key" => row[:key], "code" => e.code, "message" => e.message }
222
- end
223
- end
224
-
225
- @manifest.enumerate.each do |row|
226
- mentry = row[:manifest_entry]
227
- next unless mentry.schema
228
-
229
- schema = schema_for(mentry.schema)
230
- next unless schema
85
+ def put(...) = @writer.put(...)
231
86
 
232
- env = begin
233
- get(row[:key])
234
- rescue StandardError
235
- next
236
- end
237
- last_writer = audit_log.last_writer_for(row[:key])
238
- next if last_writer.nil?
239
-
240
- env["frontmatter"].each_key do |field|
241
- owner = schema.maintained_by(field)
242
- next if owner.nil?
243
- next if last_writer == owner
244
- next if last_writer == "human"
245
-
246
- violations << {
247
- "key" => row[:key],
248
- "code" => "role_authority",
249
- "field" => field,
250
- "expected" => owner,
251
- "last_writer" => last_writer,
252
- }
253
- end
254
- end
87
+ def delete(...) = @writer.delete(...)
255
88
 
256
- { "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
89
+ def fire_event(event, **)
90
+ view = Store::View.new(self)
91
+ @bus.publish(event, store: view, **)
257
92
  end
258
93
 
259
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
260
- def stale(prefix: nil, zone: nil)
261
- out = []
262
- @manifest.entries.each do |mentry|
263
- next unless mentry.zone == "derived"
264
- next if zone && mentry.zone != zone
265
-
266
- gen = mentry.generator
267
- next unless gen
268
- next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
269
-
270
- path = path_for_entry(mentry)
271
-
272
- unless File.exist?(path)
273
- out << stale_row(mentry, path, "derived entry has never been generated")
274
- next
275
- end
94
+ def accept(...) = @writer.accept(...)
276
95
 
277
- raw = File.binread(path)
278
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
279
- generated_at = parsed["frontmatter"].dig("generated", "at")
280
- unless generated_at
281
- out << stale_row(mentry, path, "missing generated.at frontmatter")
282
- next
283
- end
284
- gen_time = begin
285
- Time.parse(generated_at.to_s)
286
- rescue StandardError
287
- nil
288
- end
289
- unless gen_time
290
- out << stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")
291
- next
292
- end
96
+ def deps(key) = @reader.deps(key)
97
+ def rdeps(key) = @reader.rdeps(key)
98
+ def published = @reader.published
99
+ def stale(**) = @reader.stale(**)
100
+ def validate_all = @reader.validate_all
293
101
 
294
- offender = newest_source_after(gen, gen_time)
295
- out << stale_row(mentry, path, "source '#{offender}' modified after generated.at") if offender
296
- end
297
-
298
- @manifest.entries.each do |mentry|
299
- next unless mentry.action
300
- next if zone && mentry.zone != zone
301
- next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
302
-
303
- ttl = parse_ttl(mentry.ttl)
304
- next unless ttl
305
-
306
- path = path_for_entry(mentry)
307
-
308
- unless File.exist?(path)
309
- out << intake_stale_row(mentry, path, "never refreshed")
310
- next
311
- end
312
-
313
- fm = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["frontmatter"]
314
- last_str = fm["last_refreshed_at"]
315
- if last_str.nil?
316
- out << intake_stale_row(mentry, path, "never refreshed (no last_refreshed_at)")
317
- next
318
- end
319
-
320
- last = begin
321
- Time.parse(last_str.to_s)
322
- rescue StandardError
323
- nil
324
- end
325
- out << intake_stale_row(mentry, path, "ttl exceeded (#{ttl}s)") if last.nil? || (Time.now - last) > ttl
326
- end
327
-
328
- out
329
- end
330
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
331
-
332
- # Returns the Textus UID for a key (or nil if the entry has none yet).
333
- # Raises UnknownKey if the key doesn't resolve to a real file.
334
- def uid(key)
335
- env = get(key)
336
- env["uid"]
337
- end
102
+ def uid(key) = @reader.uid(key)
338
103
 
339
104
  # Move an entry from old_key to new_key within the same zone. Preserves
340
105
  # uid (minting one first if absent), validates both keys against the
341
106
  # manifest, refuses to clobber, and writes one mv audit row.
342
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
343
107
  def mv(old_key, new_key, as: Role::DEFAULT, dry_run: false)
344
- @manifest.validate_key!(old_key)
345
- @manifest.validate_key!(new_key)
346
- raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
347
-
348
- old_mentry, old_path, = @manifest.resolve(old_key)
349
- raise UnknownKey.new(old_key) unless File.exist?(old_path)
350
-
351
- new_mentry, new_path, = @manifest.resolve(new_key)
352
-
353
- if old_mentry.zone != new_mentry.zone
354
- raise UsageError.new(
355
- "mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
356
- "Use put+delete for cross-zone moves.",
357
- )
358
- end
359
- if old_mentry.format != new_mentry.format
360
- raise UsageError.new(
361
- "mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.",
362
- )
363
- end
364
-
365
- writers = @manifest.zone_writers(old_mentry.zone)
366
- raise WriteForbidden.new(old_key, old_mentry.zone, writers: writers) unless writers.include?(as)
367
-
368
- raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
369
-
370
- # Mint uid before the move so the audit row carries it.
371
- pre_env = get(old_key)
372
- current_uid = pre_env["uid"]
373
- etag_before = pre_env["etag"]
374
-
375
- if dry_run
376
- return {
377
- "protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
378
- "from_key" => old_key, "to_key" => new_key,
379
- "from_path" => old_path, "to_path" => new_path,
380
- "uid" => current_uid
381
- }
382
- end
383
-
384
- if current_uid.nil?
385
- # Write the uid in place first so the source file carries it before mv.
386
- pre_env = put(old_key,
387
- frontmatter: pre_env["frontmatter"],
388
- body: pre_env["body"],
389
- content: pre_env["content"],
390
- as: as,
391
- suppress_events: true)
392
- current_uid = pre_env["uid"]
393
- etag_before = pre_env["etag"]
394
- end
395
-
396
- FileUtils.mkdir_p(File.dirname(new_path))
397
- FileUtils.mv(old_path, new_path)
398
- rewrite_name_for_mv!(new_mentry, new_path, new_key)
399
- etag_after = Etag.for_file(new_path)
400
-
401
- audit_log.append(
402
- role: as, verb: "mv", key: new_key,
403
- etag_before: etag_before, etag_after: etag_after,
404
- extras: {
405
- "from_key" => old_key, "to_key" => new_key,
406
- "from_path" => old_path, "to_path" => new_path,
407
- "uid" => current_uid
408
- }
409
- )
410
-
411
- env = get(new_key)
412
- {
413
- "protocol" => PROTOCOL, "ok" => true,
414
- "from_key" => old_key, "to_key" => new_key,
415
- "from_path" => old_path, "to_path" => new_path,
416
- "uid" => current_uid,
417
- "envelope" => env
418
- }
419
- end
420
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
421
-
422
- private
423
-
424
- # If the moved file carries a `name:` field (markdown) or `_meta.name`
425
- # (json/yaml), rewrite it to the new basename so enforce_name_match! stays
426
- # happy on the next read. Only touches the bytes when name actually changes.
427
- def rewrite_name_for_mv!(mentry, new_path, new_key)
428
- strategy = Entry.for_format(mentry.format)
429
- raw = File.binread(new_path)
430
- parsed = strategy.parse(raw, path: new_path)
431
- basename = new_key.split(".").last
432
-
433
- case mentry.format
434
- when "markdown"
435
- fm = parsed["frontmatter"] || {}
436
- return unless fm.is_a?(Hash) && fm["name"].is_a?(String) && fm["name"] != basename
437
-
438
- fm = fm.merge("name" => basename)
439
- File.binwrite(new_path, strategy.serialize(frontmatter: fm, body: parsed["body"]))
440
- when "json", "yaml"
441
- content = parsed["content"]
442
- return unless content.is_a?(Hash) && content["_meta"].is_a?(Hash) &&
443
- content["_meta"]["name"].is_a?(String) && content["_meta"]["name"] != basename
444
-
445
- meta = content["_meta"].merge("name" => basename)
446
- content = { "_meta" => meta }.merge(content.except("_meta"))
447
- File.binwrite(new_path, strategy.serialize(frontmatter: {}, body: "", content: content))
448
- end
449
- end
450
-
451
- def existing_uid_for(mentry, path)
452
- return nil unless File.exist?(path)
453
-
454
- raw = File.binread(path)
455
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
456
- extract_uid(mentry.format, parsed["frontmatter"], parsed["content"])
457
- rescue StandardError
458
- nil
459
- end
460
-
461
- # Ensures the payload carries a uid: preserve existing, else mint.
462
- # Returns [frontmatter, content] possibly mutated.
463
- def ensure_uid(format, frontmatter, content, existing_uid)
464
- case format
465
- when "markdown"
466
- fm = frontmatter.is_a?(Hash) ? frontmatter.dup : {}
467
- fm["uid"] = existing_uid || Store.mint_uid unless fm["uid"].is_a?(String) && !fm["uid"].empty?
468
- [fm, content]
469
- when "json", "yaml"
470
- c = content.is_a?(Hash) ? content.dup : nil
471
- if c
472
- meta = c["_meta"].is_a?(Hash) ? c["_meta"].dup : {}
473
- meta["uid"] = existing_uid || Store.mint_uid if !meta["uid"].is_a?(String) || meta["uid"].empty?
474
- # Keep _meta first for etag stability.
475
- c = { "_meta" => meta }.merge(c.except("_meta"))
476
- end
477
- [frontmatter, c]
478
- else
479
- # text: no uid channel
480
- [frontmatter, content]
481
- end
108
+ Mover.new(reader: @reader, writer: @writer, manifest: @manifest, audit_log: audit_log)
109
+ .call(old_key, new_key, as: as, dry_run: dry_run)
482
110
  end
483
111
 
484
112
  def audit_log
485
- @audit_log ||= AuditLog.new(@root)
486
- end
487
-
488
- def path_for_entry(mentry)
489
- primary_ext = Entry.for_format(mentry.format).extensions.first
490
- if File.extname(mentry.path) == ""
491
- File.join(@root, "zones", mentry.path + primary_ext)
492
- else
493
- File.join(@root, "zones", mentry.path)
494
- end
495
- end
496
-
497
- def newest_source_after(gen, gen_time)
498
- Array(gen["sources"]).each do |src|
499
- if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
500
- @manifest.enumerate(prefix: src).each do |row|
501
- return src if File.mtime(row[:path]) > gen_time
502
- end
503
- else
504
- abs = File.absolute_path?(src) ? src : File.join(File.dirname(@root), src)
505
- if File.directory?(abs)
506
- Dir.glob(File.join(abs, "**", "*")).each do |fp|
507
- next unless File.file?(fp)
508
- return src if File.mtime(fp) > gen_time
509
- end
510
- elsif File.exist?(abs)
511
- return src if File.mtime(abs) > gen_time
512
- end
513
- end
514
- end
515
- nil
516
- end
517
-
518
- def parse_ttl(s)
519
- return nil unless s
520
-
521
- m = s.to_s.match(/\A(\d+)([smhd])\z/) or return nil
522
- n = m[1].to_i
523
- case m[2]
524
- when "s" then n
525
- when "m" then n * 60
526
- when "h" then n * 3600
527
- when "d" then n * 86_400
528
- end
529
- end
530
-
531
- def intake_stale_row(mentry, path, reason)
532
- { "key" => mentry.key, "path" => path, "action" => mentry.action, "reason" => reason }
533
- end
534
-
535
- def stale_row(mentry, path, reason)
536
- {
537
- "key" => mentry.key,
538
- "path" => path,
539
- "generator" => mentry.generator,
540
- "reason" => reason,
541
- }
542
- end
543
-
544
- def enforce_name_match!(path, fm, format)
545
- # Name<->basename check only meaningful for markdown frontmatter.
546
- return unless format == "markdown"
547
-
548
- basename = File.basename(path, ".md")
549
- return unless fm.is_a?(Hash) && fm["name"] && fm["name"] != basename
550
-
551
- raise BadFrontmatter.new(path, "frontmatter name '#{fm["name"]}' does not match basename '#{basename}'")
552
- end
553
-
554
- def serialize_for_put(mentry:, path:, strategy:, frontmatter:, body:, content:)
555
- case mentry.format
556
- when "markdown", "text"
557
- bytes = strategy.serialize(frontmatter: frontmatter, body: body.to_s)
558
- [bytes, frontmatter, body.to_s, nil]
559
- when "json", "yaml"
560
- raise UsageError.new("put for #{mentry.format} requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
561
-
562
- if content.nil?
563
- # Caller passed raw body; validate by parsing.
564
- begin
565
- parsed = strategy.parse(body.to_s, path: path)
566
- rescue BadFrontmatter => e
567
- raise BadContent.new(path, "bad_content: #{e.message}")
568
- end
569
- eff_content = parsed["content"]
570
- eff_fm = eff_content.is_a?(Hash) && eff_content["_meta"].is_a?(Hash) ? eff_content["_meta"] : {}
571
- [body.to_s, eff_fm, body.to_s, eff_content]
572
- else
573
- bytes = strategy.serialize(frontmatter: {}, body: "", content: content)
574
- eff_fm = content.is_a?(Hash) && content["_meta"].is_a?(Hash) ? content["_meta"] : (frontmatter || {})
575
- [bytes, eff_fm, bytes, content]
576
- end
577
- else
578
- raise UsageError.new("unknown format #{mentry.format.inspect}")
579
- end
580
- end
581
-
582
- # rubocop:disable Metrics/ParameterLists
583
- def build_envelope(key, mentry, path, fm, body, etag, content: nil)
584
- # rubocop:enable Metrics/ParameterLists
585
- env = {
586
- "protocol" => PROTOCOL,
587
- "key" => key,
588
- "zone" => mentry.zone,
589
- "owner" => mentry.owner,
590
- "path" => path,
591
- "format" => mentry.format,
592
- "frontmatter" => fm,
593
- "body" => body,
594
- "etag" => etag,
595
- "schema_ref" => mentry.schema,
596
- "uid" => extract_uid(mentry.format, fm, content),
597
- }
598
- env["content"] = content unless content.nil?
599
- env
600
- end
601
-
602
- # Pull a Textus UID out of the parsed entry shape per format.
603
- # markdown: frontmatter["uid"]; json/yaml: content["_meta"]["uid"]; text: nil.
604
- def extract_uid(format, fm, content)
605
- case format
606
- when "markdown"
607
- v = fm.is_a?(Hash) ? fm["uid"] : nil
608
- v.is_a?(String) ? v : nil
609
- when "json", "yaml"
610
- meta = content.is_a?(Hash) ? content["_meta"] : nil
611
- v = meta.is_a?(Hash) ? meta["uid"] : nil
612
- v.is_a?(String) ? v : nil
613
- end
113
+ @audit_log ||= Store::AuditLog.new(@root)
614
114
  end
615
115
  end
616
- # rubocop:enable Metrics/ClassLength
617
116
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.4.0"
3
- PROTOCOL = "textus/1"
2
+ VERSION = "0.8.0"
3
+ PROTOCOL = "textus/2"
4
4
  end
data/lib/textus.rb CHANGED
@@ -1,31 +1,16 @@
1
+ require "zeitwerk"
1
2
  require_relative "textus/version"
2
3
  require_relative "textus/errors"
3
- require_relative "textus/extension_registry"
4
- require_relative "textus/extensions"
5
- require_relative "textus/role"
6
- require_relative "textus/audit_log"
7
- require_relative "textus/etag"
8
- require_relative "textus/entry/markdown"
9
- require_relative "textus/entry/json"
10
- require_relative "textus/entry/yaml"
11
- require_relative "textus/entry/text"
12
- require_relative "textus/entry"
13
- require_relative "textus/schema"
14
- require_relative "textus/key_distance"
15
- require_relative "textus/manifest"
16
- require_relative "textus/dependencies"
17
- require_relative "textus/store"
18
- require_relative "textus/store_view"
19
- require_relative "textus/refresh"
20
- require_relative "textus/mustache"
21
- require_relative "textus/projection"
22
- require_relative "textus/builtin_actions"
23
- require_relative "textus/publisher"
24
- require_relative "textus/builder"
25
- require_relative "textus/proposal"
26
- require_relative "textus/init"
27
- require_relative "textus/schema_tools"
28
- require_relative "textus/migrate_keys"
29
- require_relative "textus/doctor"
30
- require_relative "textus/intro"
31
- require_relative "textus/cli"
4
+
5
+ loader = Zeitwerk::Loader.for_gem
6
+ loader.inflector.inflect(
7
+ "cli" => "CLI",
8
+ "json" => "Json",
9
+ "yaml" => "Yaml",
10
+ )
11
+ loader.ignore(File.expand_path("textus/errors.rb", __dir__))
12
+ loader.setup
13
+ loader.eager_load
14
+
15
+ module Textus
16
+ end