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
@@ -0,0 +1,57 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module Textus
5
+ module Doctor
6
+ class Check
7
+ class Sentinels < Check
8
+ def call
9
+ out = []
10
+ dir = File.join(store.root, "sentinels")
11
+ return out unless File.directory?(dir)
12
+
13
+ Dir.glob(File.join(dir, "**", "*.textus-managed.json")).each do |sp| # rubocop:disable Metrics/BlockLength
14
+ begin
15
+ data = JSON.parse(File.read(sp))
16
+ rescue JSON::ParserError => e
17
+ out << {
18
+ "code" => "sentinel.parse_error",
19
+ "level" => "warning",
20
+ "subject" => sp,
21
+ "message" => "sentinel is not valid JSON: #{e.message}",
22
+ "fix" => "delete #{sp} and re-run 'textus build' to regenerate",
23
+ }
24
+ next
25
+ end
26
+
27
+ target = data["target"]
28
+ recorded_sha = data["sha256"]
29
+
30
+ if target.nil? || !File.exist?(target)
31
+ out << {
32
+ "code" => "sentinel.orphan",
33
+ "level" => "warning",
34
+ "subject" => sp,
35
+ "message" => "sentinel target #{target.inspect} no longer exists",
36
+ "fix" => "delete #{sp} (the published file is gone) or restore the target",
37
+ }
38
+ next
39
+ end
40
+
41
+ current_sha = Digest::SHA256.hexdigest(File.binread(target))
42
+ next if recorded_sha.nil? || current_sha == recorded_sha
43
+
44
+ out << {
45
+ "code" => "sentinel.drift",
46
+ "level" => "warning",
47
+ "subject" => target,
48
+ "message" => "published file at #{target} was modified out-of-band",
49
+ "fix" => "re-run 'textus build' to overwrite, or copy the manual edit back into the store source",
50
+ }
51
+ end
52
+ out
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class Templates < Check
5
+ def call
6
+ out = []
7
+ store.manifest.entries.each do |entry|
8
+ next if entry.template.nil?
9
+
10
+ tp = File.join(store.root, "templates", entry.template)
11
+ next if File.exist?(tp)
12
+
13
+ out << {
14
+ "code" => "template.missing",
15
+ "level" => "error",
16
+ "subject" => entry.key,
17
+ "message" => "template '#{entry.template}' not found at #{tp}",
18
+ "fix" => "create the file at #{tp} or update the entry's template: field",
19
+ }
20
+ end
21
+ out
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,34 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class UnownedSchemaFields < Check
5
+ def call
6
+ out = []
7
+ dir = File.join(store.root, "schemas")
8
+ return out unless File.directory?(dir)
9
+
10
+ Dir.glob(File.join(dir, "*.yaml")).sort.each do |sp| # rubocop:disable Lint/RedundantDirGlobSort
11
+ schema = begin
12
+ Schema.load(sp)
13
+ rescue StandardError
14
+ next
15
+ end
16
+ unowned = schema.fields.each_with_object([]) do |(name, spec), acc|
17
+ acc << name if spec.is_a?(Hash) && spec["maintained_by"].nil?
18
+ end
19
+ next if unowned.empty?
20
+
21
+ out << {
22
+ "code" => "schema.unowned_fields",
23
+ "level" => "info",
24
+ "subject" => schema.name || File.basename(sp, ".yaml"),
25
+ "message" => "schema has fields without maintained_by: #{unowned.join(", ")}",
26
+ "fix" => "add 'maintained_by: <role>' to each field in #{sp} (optional but recommended)",
27
+ }
28
+ end
29
+ out
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,30 @@
1
+ module Textus
2
+ module Doctor
3
+ # Abstract base for a single doctor check. Each concrete check inspects
4
+ # one slice of store health and returns an array of issue hashes:
5
+ # { "code" => String, "level" => "error"|"warning"|"info",
6
+ # "subject" => String, "message" => String, "fix" => String (optional) }
7
+ class Check
8
+ # Snake-case name used in --checks flag and ALL_CHECKS list. Default
9
+ # derives from the class name; override if the SPEC name diverges.
10
+ def self.name_key
11
+ @name_key ||= name.split("::").last
12
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
13
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
14
+ .downcase
15
+ end
16
+
17
+ def initialize(store)
18
+ @store = store
19
+ end
20
+
21
+ def call
22
+ raise NotImplementedError.new("#{self.class.name}#call not implemented")
23
+ end
24
+
25
+ protected
26
+
27
+ attr_reader :store
28
+ end
29
+ end
30
+ end
data/lib/textus/doctor.rb CHANGED
@@ -1,27 +1,40 @@
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 -- 8 built-in checks + extension dispatch
7
+ module Doctor
10
8
  LEVELS = %w[error warning info].freeze
11
9
  DOCTOR_CHECK_TIMEOUT_SECONDS = 2
12
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,
21
+ ].freeze
22
+
23
+ ALL_CHECKS = CHECKS.map(&:name_key).freeze
24
+
13
25
  module_function
14
26
 
15
- def run(store)
16
- issues = []
17
- issues.concat(check_manifest_files(store))
18
- issues.concat(check_schemas(store))
19
- issues.concat(check_templates(store))
20
- issues.concat(check_extensions(store))
21
- issues.concat(check_illegal_keys(store))
22
- issues.concat(check_sentinels(store))
23
- issues.concat(check_audit_log(store))
24
- issues.concat(check_unowned_schema_fields(store))
27
+ def run(store, checks: nil)
28
+ selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
29
+ unknown = selected_keys - ALL_CHECKS
30
+ unless unknown.empty?
31
+ raise UsageError.new(
32
+ "unknown doctor check: #{unknown.first}. Valid checks: #{ALL_CHECKS.join(", ")}",
33
+ )
34
+ end
35
+
36
+ selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
37
+ issues = selected.flat_map { |c| c.new(store).call }
25
38
  issues.concat(run_registered_checks(store))
26
39
 
27
40
  summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
@@ -33,236 +46,11 @@ module Textus
33
46
  }
34
47
  end
35
48
 
36
- # --- Checks -----------------------------------------------------------
37
-
38
- def check_manifest_files(store)
39
- out = []
40
- store.manifest.entries.each do |entry|
41
- next if entry.nested
42
-
43
- path = leaf_path_for(store, entry)
44
- next if File.exist?(path)
45
-
46
- out << {
47
- "code" => "manifest.missing_file",
48
- "level" => "info",
49
- "subject" => entry.key,
50
- "message" => "declared entry has no file on disk at #{path}",
51
- "fix" => "create the entry with 'textus put #{entry.key} --stdin --as=<role>' " \
52
- "(or leave empty if not yet authored)",
53
- }
54
- end
55
- out
56
- end
57
-
58
- def check_schemas(store)
59
- out = []
60
- store.manifest.entries.each do |entry|
61
- next if entry.schema.nil?
62
-
63
- sp = File.join(store.root, "schemas", "#{entry.schema}.yaml")
64
- next if File.exist?(sp)
65
-
66
- out << {
67
- "code" => "schema.missing",
68
- "level" => "error",
69
- "subject" => entry.key,
70
- "message" => "schema '#{entry.schema}' not found at #{sp}",
71
- "fix" => "create the schema file or run 'textus schema-init #{entry.schema} --from=<key>'",
72
- }
73
- end
74
- out
75
- end
76
-
77
- def check_templates(store)
78
- out = []
79
- store.manifest.entries.each do |entry|
80
- next if entry.template.nil?
81
-
82
- tp = File.join(store.root, "templates", entry.template)
83
- next if File.exist?(tp)
84
-
85
- out << {
86
- "code" => "template.missing",
87
- "level" => "error",
88
- "subject" => entry.key,
89
- "message" => "template '#{entry.template}' not found at #{tp}",
90
- "fix" => "create the file at #{tp} or update the entry's template: field",
91
- }
92
- end
93
- out
94
- end
95
-
96
- def check_extensions(store)
97
- out = []
98
- dir = File.join(store.root, "extensions")
99
- return out unless File.directory?(dir)
100
-
101
- Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
102
- registry = ExtensionRegistry.new
103
- Textus.with_registry(registry) do
104
- load(f)
105
- end
106
- rescue StandardError, ScriptError => e
107
- out << {
108
- "code" => "extension.load_failed",
109
- "level" => "error",
110
- "subject" => File.basename(f),
111
- "message" => "#{e.class}: #{e.message}",
112
- "fix" => "open #{f} and fix the syntax/load error",
113
- }
114
- end
115
- out
116
- end
117
-
118
- def check_illegal_keys(store)
119
- out = []
120
- store.manifest.entries.each do |entry|
121
- next unless entry.nested
122
-
123
- base = File.join(store.root, "zones", entry.path)
124
- next unless File.directory?(base)
125
-
126
- walk_nested(base) do |abs_path, is_dir|
127
- basename = File.basename(abs_path)
128
- stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
129
- next if stem.match?(Manifest::KEY_SEGMENT)
130
-
131
- proposed = Textus::MigrateKeys.normalize(stem)
132
- out << {
133
- "code" => "key.illegal",
134
- "level" => "error",
135
- "subject" => abs_path,
136
- "path" => abs_path,
137
- "proposed_key" => proposed,
138
- "message" => "illegal key segment '#{stem}' at #{abs_path}",
139
- "fix" => "run 'textus migrate-keys --dry-run' then '--write' to rename to '#{proposed}'",
140
- }
141
- end
142
- end
143
- out
144
- end
145
-
146
- def check_sentinels(store)
147
- out = []
148
- dir = File.join(store.root, "sentinels")
149
- return out unless File.directory?(dir)
150
-
151
- Dir.glob(File.join(dir, "**", "*.textus-managed.json")).each do |sp| # rubocop:disable Metrics/BlockLength
152
- begin
153
- data = JSON.parse(File.read(sp))
154
- rescue JSON::ParserError => e
155
- out << {
156
- "code" => "sentinel.parse_error",
157
- "level" => "warning",
158
- "subject" => sp,
159
- "message" => "sentinel is not valid JSON: #{e.message}",
160
- "fix" => "delete #{sp} and re-run 'textus build' to regenerate",
161
- }
162
- next
163
- end
164
-
165
- target = data["target"]
166
- recorded_sha = data["sha256"]
167
-
168
- if target.nil? || !File.exist?(target)
169
- out << {
170
- "code" => "sentinel.orphan",
171
- "level" => "warning",
172
- "subject" => sp,
173
- "message" => "sentinel target #{target.inspect} no longer exists",
174
- "fix" => "delete #{sp} (the published file is gone) or restore the target",
175
- }
176
- next
177
- end
178
-
179
- current_sha = Digest::SHA256.hexdigest(File.binread(target))
180
- next if recorded_sha.nil? || current_sha == recorded_sha
181
-
182
- out << {
183
- "code" => "sentinel.drift",
184
- "level" => "warning",
185
- "subject" => target,
186
- "message" => "published file at #{target} was modified out-of-band",
187
- "fix" => "re-run 'textus build' to overwrite, or copy the manual edit back into the store source",
188
- }
189
- end
190
- out
191
- end
192
-
193
- def check_audit_log(store)
194
- out = []
195
- path = File.join(store.root, "audit.log")
196
- return out unless File.exist?(path)
197
-
198
- File.foreach(path).with_index(1) do |line, lineno| # rubocop:disable Metrics/BlockLength
199
- stripped = line.chomp
200
- next if stripped.empty?
201
-
202
- # Audit log is TSV, not NDJSON. Treat as malformed if it doesn't have
203
- # at least 6 tab-separated fields (timestamp, role, verb, key, etag_before, etag_after).
204
- fields = stripped.split("\t")
205
- if fields.length < 6
206
- out << {
207
- "code" => "audit.parse_error",
208
- "level" => "warning",
209
- "subject" => "#{path}:#{lineno}",
210
- "message" => "audit log line #{lineno} has #{fields.length} fields (expected >=6)",
211
- "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
212
- }
213
- next
214
- end
215
-
216
- extras = fields[6]
217
- next if extras.nil? || extras.empty?
218
-
219
- begin
220
- JSON.parse(extras)
221
- rescue JSON::ParserError => e
222
- out << {
223
- "code" => "audit.parse_error",
224
- "level" => "warning",
225
- "subject" => "#{path}:#{lineno}",
226
- "message" => "audit log line #{lineno} extras JSON malformed: #{e.message}",
227
- "fix" => "inspect #{path} at line #{lineno} and fix the JSON in the last column",
228
- }
229
- end
230
- end
231
- out
232
- end
233
-
234
- def check_unowned_schema_fields(store)
235
- out = []
236
- dir = File.join(store.root, "schemas")
237
- return out unless File.directory?(dir)
238
-
239
- Dir.glob(File.join(dir, "*.yaml")).sort.each do |sp| # rubocop:disable Lint/RedundantDirGlobSort
240
- schema = begin
241
- Schema.load(sp)
242
- rescue StandardError
243
- next
244
- end
245
- unowned = schema.fields.each_with_object([]) do |(name, spec), acc|
246
- acc << name if spec.is_a?(Hash) && spec["maintained_by"].nil?
247
- end
248
- next if unowned.empty?
249
-
250
- out << {
251
- "code" => "schema.unowned_fields",
252
- "level" => "info",
253
- "subject" => schema.name || File.basename(sp, ".yaml"),
254
- "message" => "schema has fields without maintained_by: #{unowned.join(", ")}",
255
- "fix" => "add 'maintained_by: <role>' to each field in #{sp} (optional but recommended)",
256
- }
257
- end
258
- out
259
- end
260
-
261
49
  def run_registered_checks(store)
262
50
  out = []
263
- view = StoreView.new(store)
264
- store.registry.doctor_check_names.each do |name|
265
- 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)
266
54
  begin
267
55
  result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: view) }
268
56
  if result.is_a?(Array)
@@ -294,28 +82,5 @@ module Textus
294
82
  "fix" => fix,
295
83
  }
296
84
  end
297
-
298
- # --- Helpers ----------------------------------------------------------
299
-
300
- def leaf_path_for(store, entry)
301
- primary_ext = Entry.for_format(entry.format).extensions.first
302
- if File.extname(entry.path) == ""
303
- File.join(store.root, "zones", entry.path + primary_ext)
304
- else
305
- File.join(store.root, "zones", entry.path)
306
- end
307
- end
308
-
309
- def walk_nested(root, &block)
310
- Dir.each_child(root) do |name|
311
- abs = File.join(root, name)
312
- if File.directory?(abs)
313
- walk_nested(abs, &block)
314
- yield abs, true
315
- else
316
- yield abs, false
317
- end
318
- end
319
- end
320
85
  end
321
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?
@@ -17,13 +17,15 @@ module Textus
17
17
 
18
18
  meta = parsed["_meta"]
19
19
  fm = meta.is_a?(Hash) ? meta : {}
20
- { "frontmatter" => fm, "body" => raw, "content" => parsed }
20
+ content_without_meta = parsed.except("_meta")
21
+ { "_meta" => fm, "body" => raw, "content" => content_without_meta }
21
22
  end
22
23
 
23
- def self.serialize(frontmatter:, body:, content: nil)
24
- _ = frontmatter
24
+ def self.serialize(meta:, body:, content: nil)
25
25
  if content.is_a?(Hash)
26
- out = ::JSON.pretty_generate(content)
26
+ # Re-inject _meta as the first key so on-disk shape is stable.
27
+ on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
28
+ out = ::JSON.pretty_generate(on_disk)
27
29
  out += "\n" unless out.end_with?("\n")
28
30
  out
29
31
  elsif body && !body.to_s.empty?
@@ -35,6 +37,10 @@ module Textus
35
37
  end
36
38
  end
37
39
 
40
+ def self.validate_against(schema, parsed)
41
+ schema.validate!(parsed["content"] || {})
42
+ end
43
+
38
44
  def self.extensions = [".json"]
39
45
  end
40
46
  end
@@ -3,11 +3,11 @@ 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?
10
- return { "frontmatter" => {}, "body" => raw, "content" => nil } unless raw.start_with?("---\n") || raw.start_with?("---\r\n")
10
+ return { "_meta" => {}, "body" => raw, "content" => nil } unless raw.start_with?("---\n") || raw.start_with?("---\r\n")
11
11
 
12
12
  lines = raw.split(/\r?\n/, -1)
13
13
  close_idx = lines[1..].index("---")
@@ -22,12 +22,12 @@ module Textus
22
22
  raise BadFrontmatter.new(path, "YAML parse failed: #{e.message}")
23
23
  end
24
24
  fm = {} unless fm.is_a?(Hash)
25
- { "frontmatter" => fm, "body" => body, "content" => nil }
25
+ { "_meta" => fm, "body" => body, "content" => nil }
26
26
  end
27
27
 
28
- def self.serialize(frontmatter:, body:, content: nil)
28
+ def self.serialize(meta:, body:, content: nil)
29
29
  _ = content # markdown ignores content
30
- fm_yaml = frontmatter.empty? ? "" : ::YAML.dump(frontmatter).sub(/\A---\n/, "")
30
+ fm_yaml = meta.empty? ? "" : ::YAML.dump(meta).sub(/\A---\n/, "")
31
31
  body = body.to_s
32
32
  body += "\n" unless body.empty? || body.end_with?("\n")
33
33
  "---\n#{fm_yaml}---\n#{body}"
@@ -1,16 +1,16 @@
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?
8
8
 
9
- { "frontmatter" => {}, "body" => raw, "content" => nil }
9
+ { "_meta" => {}, "body" => raw, "content" => nil }
10
10
  end
11
11
 
12
- def self.serialize(frontmatter:, body:, content: nil)
13
- _ = frontmatter
12
+ def self.serialize(meta:, body:, content: nil)
13
+ _ = meta
14
14
  _ = content
15
15
  b = body.to_s
16
16
  b += "\n" unless b.empty? || b.end_with?("\n")
@@ -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?
@@ -17,13 +17,15 @@ module Textus
17
17
 
18
18
  meta = parsed["_meta"]
19
19
  fm = meta.is_a?(Hash) ? meta : {}
20
- { "frontmatter" => fm, "body" => raw, "content" => parsed }
20
+ content_without_meta = parsed.except("_meta")
21
+ { "_meta" => fm, "body" => raw, "content" => content_without_meta }
21
22
  end
22
23
 
23
- def self.serialize(frontmatter:, body:, content: nil)
24
- _ = frontmatter
24
+ def self.serialize(meta:, body:, content: nil)
25
25
  if content.is_a?(Hash)
26
- ::YAML.dump(content).sub(/\A---\n/, "")
26
+ # Re-inject _meta as the first key so on-disk shape is stable.
27
+ on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
28
+ ::YAML.dump(on_disk).sub(/\A---\n/, "")
27
29
  elsif body && !body.to_s.empty?
28
30
  b = body.to_s
29
31
  b += "\n" unless b.end_with?("\n")
@@ -33,6 +35,10 @@ module Textus
33
35
  end
34
36
  end
35
37
 
38
+ def self.validate_against(schema, parsed)
39
+ schema.validate!(parsed["content"] || {})
40
+ end
41
+
36
42
  def self.extensions = [".yaml", ".yml"]
37
43
  end
38
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
@@ -23,8 +18,8 @@ module Textus
23
18
  for_format(format).parse(raw, path: path)
24
19
  end
25
20
 
26
- def self.serialize(frontmatter: {}, body: "", content: nil, format: "markdown")
27
- for_format(format).serialize(frontmatter: frontmatter, body: body, content: content)
21
+ def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
22
+ for_format(format).serialize(meta: meta, body: body, content: content)
28
23
  end
29
24
  end
30
25
  end