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