textus 0.39.1 → 0.41.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 +36 -1
- data/SPEC.md +31 -7
- data/docs/architecture/README.md +256 -0
- data/docs/reference/conventions.md +148 -0
- data/lib/textus/boot.rb +2 -2
- data/lib/textus/cli/verb/build.rb +5 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -1
- data/lib/textus/cli/verb/hooks.rb +3 -3
- data/lib/textus/cli/verb/put.rb +1 -1
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +35 -0
- data/lib/textus/doctor/check/publish_tree_index_overlap.rb +48 -0
- data/lib/textus/doctor.rb +2 -0
- data/lib/textus/hooks/catalog.rb +36 -0
- data/lib/textus/hooks/event_bus.rb +2 -19
- data/lib/textus/hooks/loader.rb +1 -1
- data/lib/textus/hooks/rpc_registry.rb +3 -11
- data/lib/textus/manifest/capabilities.rb +3 -3
- data/lib/textus/manifest/entry/base.rb +12 -20
- data/lib/textus/manifest/entry/nested.rb +8 -49
- data/lib/textus/manifest/entry/publish/each.rb +83 -0
- data/lib/textus/manifest/entry/publish/each_dir.rb +74 -0
- data/lib/textus/manifest/entry/publish/each_file.rb +29 -0
- data/lib/textus/manifest/entry/publish/mode.rb +39 -0
- data/lib/textus/manifest/entry/publish/none.rb +14 -0
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +72 -0
- data/lib/textus/manifest/entry/publish/template.rb +22 -0
- data/lib/textus/manifest/entry/publish/to_paths.rb +27 -0
- data/lib/textus/manifest/entry/publish/tree.rb +54 -0
- data/lib/textus/manifest/entry/publish.rb +45 -0
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/publish.rb +26 -0
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/policy.rb +7 -0
- data/lib/textus/manifest/resolver.rb +15 -2
- data/lib/textus/manifest/schema.rb +56 -1
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/fetch/detached.rb +11 -1
- data/lib/textus/ports/publisher.rb +24 -2
- data/lib/textus/ports/sentinel_store.rb +15 -0
- data/lib/textus/role.rb +18 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/fetch_events.rb +42 -0
- data/lib/textus/write/fetch_orchestrator.rb +2 -3
- data/lib/textus/write/fetch_worker.rb +13 -22
- data/lib/textus/write/intake_fetch.rb +8 -6
- data/lib/textus/write/publish.rb +6 -3
- metadata +18 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +0 -41
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Textus
|
|
|
18
18
|
payload =
|
|
19
19
|
if fetch_name
|
|
20
20
|
result = Textus::Write::IntakeFetch.invoke(
|
|
21
|
-
|
|
21
|
+
caps: store.container, handler: fetch_name,
|
|
22
22
|
config: { "bytes" => raw }, args: {}, label: "fetch"
|
|
23
23
|
)
|
|
24
24
|
basename = key.split(".").last
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
# Flags published files whose recorded source no longer exists in the
|
|
5
|
+
# store. Per-leaf prune (ADR 0046) reconciles within a still-present
|
|
6
|
+
# leaf; a renamed or removed *whole* leaf orphans its entire target
|
|
7
|
+
# directory, which a per-entry build won't revisit. This check catches
|
|
8
|
+
# that drift without making `build` scan globally.
|
|
9
|
+
class OrphanedPublishTargets < Check
|
|
10
|
+
def call
|
|
11
|
+
sdir = File.join(root, Textus::Ports::SentinelStore::DIR)
|
|
12
|
+
return [] unless File.directory?(sdir)
|
|
13
|
+
|
|
14
|
+
repo_root = File.dirname(root)
|
|
15
|
+
store = Textus::Ports::SentinelStore.new
|
|
16
|
+
glob = File.join(sdir, "**", "*#{Textus::Ports::SentinelStore::SUFFIX}")
|
|
17
|
+
Dir.glob(glob).filter_map do |spath|
|
|
18
|
+
sentinel = store.load(spath, repo_root)
|
|
19
|
+
next nil if sentinel.nil? || sentinel.source.nil?
|
|
20
|
+
next nil if File.exist?(sentinel.source)
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
"code" => "publish.orphaned_target",
|
|
24
|
+
"level" => "warning",
|
|
25
|
+
"subject" => sentinel.target,
|
|
26
|
+
"message" => "published file #{sentinel.target} has no source in the store " \
|
|
27
|
+
"(recorded source #{sentinel.source} is gone) — likely a renamed or removed leaf",
|
|
28
|
+
"fix" => "remove the stale copy and its sentinel: rm '#{sentinel.target}' '#{spath}'",
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
# ADR 0047 Decision 4. A publish_tree entry prunes its WHOLE target dir on
|
|
5
|
+
# every build. If a derived entry's publish_to writes a file into that same
|
|
6
|
+
# dir, the tree's prune will delete it unless the tree `ignore`s that
|
|
7
|
+
# filename. Warn so the author adds the ignore before prune eats the index.
|
|
8
|
+
class PublishTreeIndexOverlap < Check
|
|
9
|
+
def call
|
|
10
|
+
entries = manifest.data.entries
|
|
11
|
+
trees = entries.select { |e| e.nested? && e.publish_tree }
|
|
12
|
+
return [] if trees.empty?
|
|
13
|
+
|
|
14
|
+
derived_targets = entries.flat_map do |e|
|
|
15
|
+
Array(e.publish_to).map { |rel| [e, rel] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
trees.flat_map do |tree|
|
|
19
|
+
target_prefix = "#{tree.publish_tree.chomp("/")}/"
|
|
20
|
+
derived_targets.filter_map do |(derived, rel)|
|
|
21
|
+
next nil unless rel.start_with?(target_prefix)
|
|
22
|
+
|
|
23
|
+
rel_to_target = rel.delete_prefix(target_prefix)
|
|
24
|
+
next nil if tree.ignored?(rel_to_target)
|
|
25
|
+
|
|
26
|
+
issue(tree, derived, rel, rel_to_target)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def issue(tree, derived, rel, rel_to_target)
|
|
34
|
+
basename = File.basename(rel_to_target)
|
|
35
|
+
{
|
|
36
|
+
"code" => "publish.tree_index_overlap",
|
|
37
|
+
"level" => "warning",
|
|
38
|
+
"subject" => tree.key,
|
|
39
|
+
"message" => "publish_tree '#{tree.publish_tree}' overlaps derived entry " \
|
|
40
|
+
"'#{derived.key}' publish_to '#{rel}'; the tree's prune will delete it on rebuild",
|
|
41
|
+
"fix" => "add a glob covering '#{rel_to_target}' to entry '#{tree.key}' ignore " \
|
|
42
|
+
"(e.g. ignore: [\"**/#{basename}\"])",
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
# The single source of truth for hook event names and their required
|
|
6
|
+
# kwargs. EventBus, RpcRegistry, and the Loader DSL router all read these
|
|
7
|
+
# tables directly — the registries do not keep their own copies. Catalog
|
|
8
|
+
# references no other constant, so it has no load-order cycle, which is
|
|
9
|
+
# what removed the previous drift hazard (EventBus held a hard-coded
|
|
10
|
+
# `RPC_EVENTS` list that could fall out of sync with RpcRegistry's table).
|
|
11
|
+
module Catalog
|
|
12
|
+
# Pub-sub events: 0..N handlers, fire-and-forget, receive `ctx:`.
|
|
13
|
+
PUBSUB = {
|
|
14
|
+
entry_put: %i[ctx key envelope],
|
|
15
|
+
entry_deleted: %i[ctx key],
|
|
16
|
+
entry_fetched: %i[ctx key envelope change],
|
|
17
|
+
entry_renamed: %i[ctx key from_key to_key envelope],
|
|
18
|
+
build_completed: %i[ctx key envelope sources],
|
|
19
|
+
proposal_accepted: %i[ctx key target_key],
|
|
20
|
+
proposal_rejected: %i[ctx key target_key],
|
|
21
|
+
file_published: %i[ctx key envelope source target],
|
|
22
|
+
store_loaded: %i[ctx],
|
|
23
|
+
fetch_started: %i[ctx key mode],
|
|
24
|
+
fetch_failed: %i[ctx key error_class error_message],
|
|
25
|
+
fetch_backgrounded: %i[ctx key started_at budget_ms],
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# RPC events: single handler, return value matters, receive `caps:`.
|
|
29
|
+
RPC = {
|
|
30
|
+
resolve_intake: %i[caps config args],
|
|
31
|
+
transform_rows: %i[caps rows config],
|
|
32
|
+
validate: %i[caps],
|
|
33
|
+
}.freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -7,23 +7,6 @@ module Textus
|
|
|
7
7
|
|
|
8
8
|
class HookTimeout < StandardError; end
|
|
9
9
|
|
|
10
|
-
EVENTS = {
|
|
11
|
-
entry_put: %i[ctx key envelope],
|
|
12
|
-
entry_deleted: %i[ctx key],
|
|
13
|
-
entry_fetched: %i[ctx key envelope change],
|
|
14
|
-
entry_renamed: %i[ctx key from_key to_key envelope],
|
|
15
|
-
build_completed: %i[ctx key envelope sources],
|
|
16
|
-
proposal_accepted: %i[ctx key target_key],
|
|
17
|
-
proposal_rejected: %i[ctx key target_key],
|
|
18
|
-
file_published: %i[ctx key envelope source target],
|
|
19
|
-
store_loaded: %i[ctx],
|
|
20
|
-
fetch_started: %i[ctx key mode],
|
|
21
|
-
fetch_failed: %i[ctx key error_class error_message],
|
|
22
|
-
fetch_backgrounded: %i[ctx key started_at budget_ms],
|
|
23
|
-
}.freeze
|
|
24
|
-
|
|
25
|
-
RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
|
|
26
|
-
|
|
27
10
|
def initialize(error_log: ErrorLog.new)
|
|
28
11
|
@pubsub = Hash.new { |h, k| h[k] = [] }
|
|
29
12
|
@error_handlers = []
|
|
@@ -36,9 +19,9 @@ module Textus
|
|
|
36
19
|
|
|
37
20
|
def register(event, name, keys: nil, &blk)
|
|
38
21
|
event_sym = event.to_sym
|
|
39
|
-
raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if
|
|
22
|
+
raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if Catalog::RPC.key?(event_sym)
|
|
40
23
|
|
|
41
|
-
required =
|
|
24
|
+
required = Catalog::PUBSUB[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
42
25
|
sig = Signature.new(blk)
|
|
43
26
|
missing = sig.missing(required)
|
|
44
27
|
if missing.any?
|
data/lib/textus/hooks/loader.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Textus
|
|
|
12
12
|
# Pubsub registration — delegates to EventBus.
|
|
13
13
|
# Also handles RPC event names by delegating to RpcRegistry.
|
|
14
14
|
def on(event, name, keys: nil, &)
|
|
15
|
-
if Hooks::
|
|
15
|
+
if Hooks::Catalog::RPC.key?(event.to_sym)
|
|
16
16
|
@rpc.register(event, name, &)
|
|
17
17
|
else
|
|
18
18
|
@events.register(event, name, keys: keys, &)
|
|
@@ -3,23 +3,15 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Hooks
|
|
5
5
|
class RpcRegistry
|
|
6
|
-
EVENTS = {
|
|
7
|
-
resolve_intake: %i[caps config args],
|
|
8
|
-
transform_rows: %i[caps rows config],
|
|
9
|
-
validate: %i[caps],
|
|
10
|
-
}.freeze
|
|
11
|
-
|
|
12
|
-
PUBSUB_EVENTS = EventBus::EVENTS.keys.freeze
|
|
13
|
-
|
|
14
6
|
def initialize
|
|
15
7
|
@table = Hash.new { |h, k| h[k] = {} }
|
|
16
8
|
end
|
|
17
9
|
|
|
18
10
|
def register(event, name, &blk)
|
|
19
11
|
event_sym = event.to_sym
|
|
20
|
-
raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if
|
|
12
|
+
raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if Catalog::PUBSUB.key?(event_sym)
|
|
21
13
|
|
|
22
|
-
required =
|
|
14
|
+
required = Catalog::RPC[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
|
|
23
15
|
sig = Signature.new(blk)
|
|
24
16
|
missing = sig.missing(required)
|
|
25
17
|
raise UsageError.new("#{event_sym} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})") if missing.any?
|
|
@@ -38,7 +30,7 @@ module Textus
|
|
|
38
30
|
|
|
39
31
|
# Invoke a registered callable, injecting `caps:` only if the callable
|
|
40
32
|
# declares it (or accepts keyrest). Mis-named kwargs (e.g. the legacy
|
|
41
|
-
# `
|
|
33
|
+
# `store:`) are rejected at registration time, not here.
|
|
42
34
|
def invoke(event, name, caps:, **other)
|
|
43
35
|
blk = callable(event, name)
|
|
44
36
|
sig = Signature.new(blk)
|
|
@@ -11,9 +11,9 @@ module Textus
|
|
|
11
11
|
# declares a `kind: workspace` zone is therefore rejected at load (no
|
|
12
12
|
# `keep`-holder); declare `roles:` to opt into a workspace lane (ADR 0033).
|
|
13
13
|
DEFAULT_MAPPING = {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
Textus::Role::HUMAN => %w[author propose].freeze,
|
|
15
|
+
Textus::Role::AGENT => %w[propose].freeze,
|
|
16
|
+
Textus::Role::AUTOMATION => %w[fetch build].freeze,
|
|
17
17
|
}.freeze
|
|
18
18
|
|
|
19
19
|
# Returns { role_name => [verbs] }. When `roles:` is declared we use
|
|
@@ -44,6 +44,7 @@ module Textus
|
|
|
44
44
|
def inject_boot = false # rubocop:disable Naming/PredicateMethod
|
|
45
45
|
def events = {}
|
|
46
46
|
def publish_each = nil
|
|
47
|
+
def publish_tree = nil
|
|
47
48
|
def index_filename = nil
|
|
48
49
|
def ignore = []
|
|
49
50
|
|
|
@@ -78,27 +79,18 @@ module Textus
|
|
|
78
79
|
end
|
|
79
80
|
end
|
|
80
81
|
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
source_path = pctx.manifest.resolver.resolve(@key).path
|
|
89
|
-
envelope = pctx.reader.call(@key)
|
|
90
|
-
|
|
91
|
-
publish_to.each do |rel|
|
|
92
|
-
target_abs = File.join(pctx.repo_root, rel)
|
|
93
|
-
Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
|
|
94
|
-
pctx.emit(:file_published,
|
|
95
|
-
key: @key,
|
|
96
|
-
envelope: envelope,
|
|
97
|
-
source: source_path,
|
|
98
|
-
target: target_abs)
|
|
99
|
-
end
|
|
82
|
+
# ADR 0049: an entry resolves, once, to one Publish::* mode that owns its
|
|
83
|
+
# publish algorithm. A plain entry publishes via ToPaths (publish_to) or
|
|
84
|
+
# None; Nested resolves among the key/path-driven modes. Derived
|
|
85
|
+
# overrides publish_via to materialize first.
|
|
86
|
+
def publish_mode
|
|
87
|
+
@publish_mode ||= Publish.resolve(self)
|
|
88
|
+
end
|
|
100
89
|
|
|
101
|
-
|
|
90
|
+
# Returns: { kind: :built|:leaves, value: ... } to be accumulated by
|
|
91
|
+
# Write::Publish, or nil to skip.
|
|
92
|
+
def publish_via(pctx, prefix: nil)
|
|
93
|
+
publish_mode.publish(pctx, prefix: prefix)
|
|
102
94
|
end
|
|
103
95
|
end
|
|
104
96
|
end
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
|
+
# A directory entry: enumerates a tree of leaves and resolves to one
|
|
5
|
+
# publish mode (ADR 0049). The publish algorithms themselves live in
|
|
6
|
+
# Entry::Publish::* — Nested is just the value (attributes + ignore
|
|
7
|
+
# predicate) those modes read.
|
|
4
8
|
class Nested < Base
|
|
5
|
-
|
|
6
|
-
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
9
|
+
attr_reader :index_filename, :publish_each, :publish_tree, :ignore
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def initialize(index_filename: nil, publish_each: nil, ignore: nil, **rest)
|
|
11
|
+
def initialize(index_filename: nil, publish_each: nil, publish_tree: nil, ignore: nil, **rest)
|
|
11
12
|
super(**rest)
|
|
12
13
|
@index_filename = index_filename
|
|
13
14
|
@publish_each = publish_each
|
|
15
|
+
@publish_tree = publish_tree
|
|
14
16
|
@ignore = Array(ignore)
|
|
15
17
|
end
|
|
16
18
|
|
|
@@ -21,56 +23,13 @@ module Textus
|
|
|
21
23
|
# an ignored path is excluded, never judged.
|
|
22
24
|
def ignored?(rel_path) = IgnoreMatcher.match?(@ignore, rel_path)
|
|
23
25
|
|
|
24
|
-
def publish_target_for(full_key)
|
|
25
|
-
return nil if @publish_each.nil?
|
|
26
|
-
|
|
27
|
-
entry_segs = @key.split(".")
|
|
28
|
-
key_segs = full_key.split(".")
|
|
29
|
-
raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
|
|
30
|
-
|
|
31
|
-
remaining = key_segs[entry_segs.length..] || []
|
|
32
|
-
leaf = remaining.join("/")
|
|
33
|
-
basename = remaining.last || ""
|
|
34
|
-
ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
|
|
35
|
-
|
|
36
|
-
vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
|
|
37
|
-
@publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def publish_via(pctx, prefix: nil)
|
|
41
|
-
return nil if @publish_each.nil?
|
|
42
|
-
|
|
43
|
-
leaves = []
|
|
44
|
-
pctx.manifest.resolver.enumerate(prefix: @key).each do |row|
|
|
45
|
-
next unless row[:manifest_entry].equal?(self)
|
|
46
|
-
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
47
|
-
|
|
48
|
-
target_rel = publish_target_for(row[:key])
|
|
49
|
-
target_abs = File.expand_path(File.join(pctx.repo_root, target_rel))
|
|
50
|
-
unless target_abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
|
|
51
|
-
raise Textus::PublishError.new(
|
|
52
|
-
"entry '#{@key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
53
|
-
)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
Textus::Ports::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
|
|
57
|
-
pctx.emit(:file_published,
|
|
58
|
-
key: row[:key],
|
|
59
|
-
envelope: pctx.reader.call(row[:key]),
|
|
60
|
-
source: row[:path],
|
|
61
|
-
target: target_abs)
|
|
62
|
-
leaves << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
{ kind: :leaves, value: leaves }
|
|
66
|
-
end
|
|
67
|
-
|
|
68
26
|
KIND = :nested
|
|
69
27
|
|
|
70
28
|
def self.from_raw(common, raw)
|
|
71
29
|
new(
|
|
72
30
|
index_filename: raw["index_filename"],
|
|
73
31
|
publish_each: raw["publish_each"],
|
|
32
|
+
publish_tree: raw["publish_tree"],
|
|
74
33
|
ignore: raw["ignore"],
|
|
75
34
|
**common,
|
|
76
35
|
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# Shared base for the two key-driven publish_each modes (EachFile,
|
|
6
|
+
# EachDir). Owns the leaf enumeration, the `{...}` target templating, and
|
|
7
|
+
# the per-leaf repo-escape guard. Subclasses implement `#publish_leaf`,
|
|
8
|
+
# returning `{ written:, pruned: }`, and the discriminator half of
|
|
9
|
+
# `#validate!`.
|
|
10
|
+
class Each < Mode
|
|
11
|
+
def publish(pctx, prefix: nil)
|
|
12
|
+
leaves = []
|
|
13
|
+
pruned = []
|
|
14
|
+
pctx.manifest.resolver.enumerate(prefix: entry.key).each do |row|
|
|
15
|
+
next unless row[:manifest_entry].equal?(entry)
|
|
16
|
+
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
17
|
+
|
|
18
|
+
target_abs = guarded_target(pctx, row)
|
|
19
|
+
result = publish_leaf(row, target_abs, pctx)
|
|
20
|
+
pruned.concat(result[:pruned])
|
|
21
|
+
result[:written].each do |w|
|
|
22
|
+
leaves << { "key" => row[:key], "source" => w["source"], "target" => w["target"] }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
{ kind: :leaves, value: leaves, pruned: pruned }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Expand this entry's publish_each template for a full leaf key.
|
|
30
|
+
def target_for(full_key)
|
|
31
|
+
entry_segs = entry.key.split(".")
|
|
32
|
+
key_segs = full_key.split(".")
|
|
33
|
+
raise UsageError.new("key '#{full_key}' is not under entry '#{entry.key}'") unless key_segs[0, entry_segs.length] == entry_segs
|
|
34
|
+
|
|
35
|
+
remaining = key_segs[entry_segs.length..] || []
|
|
36
|
+
Template.expand(
|
|
37
|
+
entry.publish_each,
|
|
38
|
+
"leaf" => remaining.join("/"),
|
|
39
|
+
"basename" => remaining.last || "",
|
|
40
|
+
"key" => full_key,
|
|
41
|
+
"ext" => ext,
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def guarded_target(pctx, row)
|
|
48
|
+
target_rel = target_for(row[:key])
|
|
49
|
+
target_abs = repo_abs(pctx, target_rel)
|
|
50
|
+
return target_abs if inside_repo?(pctx, target_abs)
|
|
51
|
+
|
|
52
|
+
raise Textus::PublishError.new(
|
|
53
|
+
"entry '#{entry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ext
|
|
58
|
+
Textus::Entry.for_format(entry.format).extensions.first.to_s.sub(/^\./, "")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# publish_each shape rules common to file and directory leaves: a
|
|
62
|
+
# String value with only known template vars. Returns the used vars so
|
|
63
|
+
# subclasses can apply their discriminator rule.
|
|
64
|
+
def validate_template_basics
|
|
65
|
+
publish_each = entry.publish_each
|
|
66
|
+
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
|
|
67
|
+
|
|
68
|
+
used_vars = publish_each.scan(Template::VAR_RE).flatten
|
|
69
|
+
unknown = used_vars - Template::KNOWN_VARS
|
|
70
|
+
unless unknown.empty?
|
|
71
|
+
raise UsageError.new(
|
|
72
|
+
"entry '#{entry.key}': publish_each uses unknown template variable(s) " \
|
|
73
|
+
"#{unknown.map { |v| "{#{v}}" }.join(", ")}. Known: #{Template::KNOWN_VARS.map { |v| "{#{v}}" }.join(", ")}.",
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
used_vars
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# publish_each + index_filename (ADR 0046): each leaf is a whole subtree
|
|
6
|
+
# copied into one templated directory, layout preserved, then pruned.
|
|
7
|
+
# The template names the target DIRECTORY (not the index file).
|
|
8
|
+
class EachDir < Each
|
|
9
|
+
def publish_leaf(row, target_abs, pctx)
|
|
10
|
+
SubtreeMirror.new(entry, pctx).mirror(
|
|
11
|
+
base: store_base(pctx),
|
|
12
|
+
walk_root: File.dirname(row[:path]),
|
|
13
|
+
target_dir: target_abs,
|
|
14
|
+
key: row[:key],
|
|
15
|
+
envelope: pctx.reader.call(row[:key]),
|
|
16
|
+
prune_honors_ignore: false,
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def validate!
|
|
21
|
+
used_vars = validate_template_basics
|
|
22
|
+
reject_file_only_vars(used_vars)
|
|
23
|
+
reject_index_filename_segment
|
|
24
|
+
reject_file_looking_segment
|
|
25
|
+
return if used_vars.intersect?(%w[leaf key])
|
|
26
|
+
|
|
27
|
+
raise UsageError.new(
|
|
28
|
+
"entry '#{entry.key}': directory-leaf publish_each must reference {leaf} or {key} " \
|
|
29
|
+
"(else every leaf would clobber the same directory).",
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def reject_file_only_vars(used_vars)
|
|
36
|
+
forbidden = used_vars & %w[basename ext]
|
|
37
|
+
return if forbidden.empty?
|
|
38
|
+
|
|
39
|
+
raise UsageError.new(
|
|
40
|
+
"entry '#{entry.key}': publish_each names a directory " \
|
|
41
|
+
"(index_filename: '#{entry.index_filename}'); {basename}/{ext} are file-only — " \
|
|
42
|
+
"use {leaf} or {key}.",
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reject_index_filename_segment
|
|
47
|
+
return unless last_segment == entry.index_filename
|
|
48
|
+
|
|
49
|
+
raise UsageError.new(
|
|
50
|
+
"entry '#{entry.key}': directory-leaf publish_each must name the target DIRECTORY, " \
|
|
51
|
+
"not the index file — drop the trailing '/#{entry.index_filename}' " \
|
|
52
|
+
"(the whole leaf subtree is copied into the named directory).",
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reject_file_looking_segment
|
|
57
|
+
ext = File.extname(last_segment)
|
|
58
|
+
return if ext.empty?
|
|
59
|
+
|
|
60
|
+
raise UsageError.new(
|
|
61
|
+
"entry '#{entry.key}': directory-leaf publish_each names a DIRECTORY target, but its " \
|
|
62
|
+
"final segment '#{last_segment}' looks like a file (extension '#{ext}') — " \
|
|
63
|
+
"drop the extension (the whole leaf subtree is copied into the named directory).",
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def last_segment
|
|
68
|
+
entry.publish_each.sub(%r{/\z}, "").split("/").last
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# publish_each over file leaves (no index_filename): one stored leaf file
|
|
6
|
+
# copied to one templated repo path. No prune — each leaf is a single
|
|
7
|
+
# file, not a subtree.
|
|
8
|
+
class EachFile < Each
|
|
9
|
+
def publish_leaf(row, target_abs, pctx)
|
|
10
|
+
Textus::Ports::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
|
|
11
|
+
pctx.emit(:file_published, key: row[:key], envelope: pctx.reader.call(row[:key]),
|
|
12
|
+
source: row[:path], target: target_abs)
|
|
13
|
+
{ written: [{ "source" => row[:path], "target" => target_abs }], pruned: [] }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate!
|
|
17
|
+
used_vars = validate_template_basics
|
|
18
|
+
return if used_vars.intersect?(Template::REQUIRED_DISCRIMINATOR_VARS)
|
|
19
|
+
|
|
20
|
+
raise UsageError.new(
|
|
21
|
+
"entry '#{entry.key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
|
|
22
|
+
"(else every leaf would clobber the same target).",
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# Base for every publish mode: wraps the resolved entry and owns the one
|
|
6
|
+
# repo-root escape guard the writing modes share (ADR 0049). Subclasses
|
|
7
|
+
# implement `#publish(pctx, prefix:)` returning the existing
|
|
8
|
+
# `{ kind:, value:, pruned: }` shape (or nil), and `#validate!` for the
|
|
9
|
+
# per-mode shape rules reached *because* this mode resolved.
|
|
10
|
+
class Mode
|
|
11
|
+
def initialize(entry)
|
|
12
|
+
@entry = entry
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :entry
|
|
16
|
+
|
|
17
|
+
# No shape rules by default — ToPaths/None publish without templating.
|
|
18
|
+
def validate!; end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# Expand `rel` under repo_root and confirm it stays inside it.
|
|
23
|
+
def repo_abs(pctx, rel)
|
|
24
|
+
File.expand_path(File.join(pctx.repo_root, rel))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def inside_repo?(pctx, abs)
|
|
28
|
+
abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Store-side directory this entry's tree lives under.
|
|
32
|
+
def store_base(pctx)
|
|
33
|
+
File.join(pctx.root, "zones", entry.path)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# An entry with no publish_* key — nothing to publish.
|
|
6
|
+
class None < Mode
|
|
7
|
+
def publish(_pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
8
|
+
nil
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|