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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -1
- data/README.md +29 -21
- data/SPEC.md +75 -142
- data/docs/architecture.md +42 -23
- data/lib/textus/builder/pipeline.rb +56 -0
- data/lib/textus/builder/renderer/json.rb +42 -0
- data/lib/textus/builder/renderer/markdown.rb +22 -0
- data/lib/textus/builder/renderer/text.rb +14 -0
- data/lib/textus/builder/renderer/yaml.rb +42 -0
- data/lib/textus/builder/renderer.rb +17 -0
- data/lib/textus/builder.rb +9 -114
- data/lib/textus/cli/group/hook.rb +11 -0
- data/lib/textus/cli/group/key.rb +12 -0
- data/lib/textus/cli/group/schema.rb +13 -0
- data/lib/textus/cli/verb/accept.rb +15 -0
- data/lib/textus/cli/verb/build.rb +13 -0
- data/lib/textus/cli/verb/delete.rb +16 -0
- data/lib/textus/cli/verb/deps.rb +12 -0
- data/lib/textus/cli/verb/doctor.rb +15 -0
- data/lib/textus/cli/verb/get.rb +12 -0
- data/lib/textus/cli/verb/hook_run.rb +48 -0
- data/lib/textus/cli/verb/hooks.rb +50 -0
- data/lib/textus/cli/verb/init.rb +14 -0
- data/lib/textus/cli/verb/intro.rb +11 -0
- data/lib/textus/cli/verb/list.rb +14 -0
- data/lib/textus/cli/verb/migrate_keys.rb +16 -0
- data/lib/textus/cli/verb/mv.rb +17 -0
- data/lib/textus/cli/verb/published.rb +11 -0
- data/lib/textus/cli/verb/put.rb +50 -0
- data/lib/textus/cli/verb/rdeps.rb +12 -0
- data/lib/textus/cli/verb/refresh.rb +15 -0
- data/lib/textus/cli/verb/schema.rb +12 -0
- data/lib/textus/cli/verb/schema_diff.rb +12 -0
- data/lib/textus/cli/verb/schema_init.rb +16 -0
- data/lib/textus/cli/verb/schema_migrate.rb +16 -0
- data/lib/textus/cli/verb/stale.rb +14 -0
- data/lib/textus/cli/verb/uid.rb +12 -0
- data/lib/textus/cli/verb/where.rb +12 -0
- data/lib/textus/cli.rb +23 -42
- data/lib/textus/doctor/check/audit_log.rb +50 -0
- data/lib/textus/doctor/check/hooks.rb +29 -0
- data/lib/textus/doctor/check/illegal_keys.rb +49 -0
- data/lib/textus/doctor/check/manifest_files.rb +38 -0
- data/lib/textus/doctor/check/schema_violations.rb +22 -0
- data/lib/textus/doctor/check/schemas.rb +26 -0
- data/lib/textus/doctor/check/sentinels.rb +57 -0
- data/lib/textus/doctor/check/templates.rb +26 -0
- data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
- data/lib/textus/doctor/check.rb +30 -0
- data/lib/textus/doctor.rb +22 -288
- data/lib/textus/entry/base.rb +30 -0
- data/lib/textus/entry/json.rb +5 -1
- data/lib/textus/entry/markdown.rb +1 -1
- data/lib/textus/entry/text.rb +1 -1
- data/lib/textus/entry/yaml.rb +5 -1
- data/lib/textus/entry.rb +0 -5
- data/lib/textus/envelope.rb +30 -0
- data/lib/textus/hooks/builtin.rb +70 -0
- data/lib/textus/hooks/dispatcher.rb +49 -0
- data/lib/textus/hooks/loader.rb +26 -0
- data/lib/textus/hooks/registry.rb +73 -0
- data/lib/textus/init.rb +13 -10
- data/lib/textus/intro.rb +14 -16
- data/lib/textus/key/distance.rb +55 -0
- data/lib/textus/key/grammar.rb +33 -0
- data/lib/textus/key/path.rb +17 -0
- data/lib/textus/manifest/entry.rb +199 -0
- data/lib/textus/manifest.rb +10 -34
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/projection.rb +5 -4
- data/lib/textus/proposal.rb +1 -1
- data/lib/textus/refresh.rb +11 -11
- data/lib/textus/schema/tools.rb +89 -0
- data/lib/textus/store/audit_log.rb +71 -0
- data/lib/textus/store/mover.rb +19 -16
- data/lib/textus/store/reader.rb +67 -0
- data/lib/textus/store/staleness.rb +10 -19
- data/lib/textus/store/validator.rb +11 -8
- data/lib/textus/store/view.rb +29 -0
- data/lib/textus/store/writer.rb +132 -0
- data/lib/textus/store.rb +25 -221
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +14 -67
- metadata +73 -40
- data/lib/textus/audit_log.rb +0 -67
- data/lib/textus/builtin_actions.rb +0 -68
- data/lib/textus/cli/accept.rb +0 -13
- data/lib/textus/cli/action.rb +0 -51
- data/lib/textus/cli/build.rb +0 -11
- data/lib/textus/cli/delete.rb +0 -14
- data/lib/textus/cli/deprecated_alias.rb +0 -31
- data/lib/textus/cli/deps.rb +0 -10
- data/lib/textus/cli/doctor.rb +0 -13
- data/lib/textus/cli/extension_group.rb +0 -9
- data/lib/textus/cli/extensions.rb +0 -49
- data/lib/textus/cli/get.rb +0 -10
- data/lib/textus/cli/init.rb +0 -12
- data/lib/textus/cli/intro.rb +0 -9
- data/lib/textus/cli/key_group.rb +0 -10
- data/lib/textus/cli/list.rb +0 -12
- data/lib/textus/cli/migrate.rb +0 -41
- data/lib/textus/cli/migrate_keys.rb +0 -19
- data/lib/textus/cli/mv.rb +0 -20
- data/lib/textus/cli/published.rb +0 -9
- data/lib/textus/cli/put.rb +0 -48
- data/lib/textus/cli/rdeps.rb +0 -10
- data/lib/textus/cli/refresh.rb +0 -13
- data/lib/textus/cli/schema.rb +0 -10
- data/lib/textus/cli/schema_diff.rb +0 -15
- data/lib/textus/cli/schema_group.rb +0 -33
- data/lib/textus/cli/schema_init.rb +0 -19
- data/lib/textus/cli/schema_migrate.rb +0 -19
- data/lib/textus/cli/stale.rb +0 -12
- data/lib/textus/cli/uid.rb +0 -15
- data/lib/textus/cli/where.rb +0 -10
- data/lib/textus/extension_registry.rb +0 -61
- data/lib/textus/extensions.rb +0 -33
- data/lib/textus/key_distance.rb +0 -53
- data/lib/textus/manifest_entry.rb +0 -185
- data/lib/textus/migrate_v2.rb +0 -27
- data/lib/textus/schema_tools.rb +0 -87
- data/lib/textus/store/events.rb +0 -31
- 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
|
|
7
|
+
module Doctor
|
|
10
8
|
LEVELS = %w[error warning info].freeze
|
|
11
9
|
DOCTOR_CHECK_TIMEOUT_SECONDS = 2
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
21
|
-
unknown =
|
|
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
|
-
|
|
29
|
-
issues.
|
|
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 =
|
|
295
|
-
store.registry.
|
|
296
|
-
callable = store.registry.
|
|
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
|
data/lib/textus/entry/json.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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?
|
data/lib/textus/entry/text.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Entry
|
|
3
3
|
# Plain-text entry storage. No frontmatter or structured content.
|
|
4
|
-
|
|
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?
|
data/lib/textus/entry/yaml.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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
|