asciisourcerer 0.1.0 → 0.2.1
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/README.adoc +393 -37
- data/lib/asciidoctor/extensions/source-skim-tree-processor/extension.rb +55 -0
- data/lib/asciisourcerer.rb +1 -0
- data/lib/sourcerer/asciidoc.rb +13 -9
- data/lib/sourcerer/attributes_filter.rb +72 -0
- data/lib/sourcerer/rendering.rb +29 -0
- data/lib/sourcerer/source_skim/config.rb +53 -0
- data/lib/sourcerer/source_skim/skimmer.rb +298 -0
- data/lib/sourcerer/source_skim.rb +76 -0
- data/lib/sourcerer/sync/block_parser.rb +245 -0
- data/lib/sourcerer/sync/cast.rb +274 -0
- data/lib/sourcerer/sync.rb +33 -0
- data/lib/sourcerer/util/list_amend.rb +63 -0
- data/lib/sourcerer/util/pathifier.rb +101 -0
- data/lib/sourcerer/version.rb +1 -1
- data/lib/sourcerer.rb +3 -0
- metadata +13 -6
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sourcerer
|
|
4
|
+
module Sync
|
|
5
|
+
# Parses tagged regions from any text file, regardless of comment style
|
|
6
|
+
#
|
|
7
|
+
# Recognizes AsciiDoc `tag::`/`end::` markers in HTML comments, AsciiDoc line comments,
|
|
8
|
+
# and shell/Ruby/YAML comments.
|
|
9
|
+
# The trailing `[]` is optional.
|
|
10
|
+
# See the project README for the full tag-syntax reference.
|
|
11
|
+
module BlockParser
|
|
12
|
+
# A tagged region extracted from a file
|
|
13
|
+
#
|
|
14
|
+
# @!attribute tag [String] The tag name (e.g. `universal-agency`)
|
|
15
|
+
# @!attribute open_line [String] The complete opening marker line, including newline
|
|
16
|
+
# @!attribute content [String] Everything between the open and close markers
|
|
17
|
+
# @!attribute close_line [String] The complete closing marker line, including newline
|
|
18
|
+
Block = Struct.new(:tag, :open_line, :content, :close_line, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
# Plain text in between (or around) tagged blocks
|
|
21
|
+
#
|
|
22
|
+
# @!attribute content [String] The raw text
|
|
23
|
+
TextSegment = Struct.new(:content, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
# Raised when tag markers are structurally invalid
|
|
26
|
+
class ParseError < StandardError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Default prefix that marks a block as canonical (managed by Sync/Cast).
|
|
30
|
+
DEFAULT_CANONICAL_PREFIX = 'universal-'
|
|
31
|
+
|
|
32
|
+
# Default opening tag marker template.
|
|
33
|
+
# `<tagged_block_name>` is the placeholder for the block name character class.
|
|
34
|
+
# A trailing `[]` is treated as optional in the compiled pattern.
|
|
35
|
+
DEFAULT_TAG_SYNTAX_START = 'tag::<tagged_block_name>[]'
|
|
36
|
+
|
|
37
|
+
# Default closing tag marker template.
|
|
38
|
+
DEFAULT_TAG_SYNTAX_END = 'end::<tagged_block_name>[]'
|
|
39
|
+
|
|
40
|
+
# Default comment-wrapper templates.
|
|
41
|
+
# `<tag_syntax>` is the placeholder for the compiled tag marker pattern.
|
|
42
|
+
# A space between the comment delimiter and `<tag_syntax>` compiles as `\s*`.
|
|
43
|
+
DEFAULT_COMMENT_SYNTAX_PATTERNS = [
|
|
44
|
+
'<!-- <tag_syntax> -->',
|
|
45
|
+
'// <tag_syntax>',
|
|
46
|
+
'# <tag_syntax>'
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
# Compile a tag marker template string into a plain regex fragment (no `\A` anchor).
|
|
50
|
+
#
|
|
51
|
+
# `<tagged_block_name>` is replaced with the `(?<tag>[\w-]+)` named capture group.
|
|
52
|
+
# A trailing `[]` in the template becomes `(?:\[\])?` (optional literal brackets).
|
|
53
|
+
#
|
|
54
|
+
# @param template [String] e.g. `'tag::<tagged_block_name>[]'`
|
|
55
|
+
# @return [String] regex source string
|
|
56
|
+
def self.tag_template_to_inner_regex template
|
|
57
|
+
parts = template.split('<tagged_block_name>', 2)
|
|
58
|
+
left = Regexp.escape(parts[0])
|
|
59
|
+
right = parts[1].to_s
|
|
60
|
+
suffix = right == '[]' ? '(?:\[\])?' : Regexp.escape(right)
|
|
61
|
+
"#{left}(?<tag>[\\w-]+)#{suffix}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Wrap a compiled inner-tag regex fragment with a comment-wrapper template.
|
|
65
|
+
#
|
|
66
|
+
# `<tag_syntax>` in `comment_template` is replaced by `inner_regex`.
|
|
67
|
+
# Adjacent literal spaces around `<tag_syntax>` are compiled as `\s*`.
|
|
68
|
+
# The result is anchored to `\A`.
|
|
69
|
+
#
|
|
70
|
+
# @param comment_template [String] e.g. `'<!-- <tag_syntax> -->'`
|
|
71
|
+
# @param inner_regex [String] regex source from {.tag_template_to_inner_regex}
|
|
72
|
+
# @return [String] full anchored regex source string
|
|
73
|
+
def self.comment_template_to_full_regex comment_template, inner_regex
|
|
74
|
+
halves = comment_template.split('<tag_syntax>', 2)
|
|
75
|
+
left_raw = halves[0]
|
|
76
|
+
right_raw = halves[1].to_s
|
|
77
|
+
left_trim = left_raw.rstrip
|
|
78
|
+
right_trim = right_raw.lstrip
|
|
79
|
+
left_re = Regexp.escape(left_trim) + (left_trim == left_raw ? '' : '\s*')
|
|
80
|
+
right_re = (right_trim == right_raw ? '' : '\s*') + Regexp.escape(right_trim)
|
|
81
|
+
"\\A#{left_re}#{inner_regex}#{right_re}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Compile template strings into a patterns array compatible with {.parse}.
|
|
85
|
+
#
|
|
86
|
+
# Each entry in the returned array is a `{open: Regexp, close: Regexp}` hash.
|
|
87
|
+
# This is the same shape as {DEFAULT_TAG_PATTERNS} and may be passed directly
|
|
88
|
+
# to {.parse} via the `tag_patterns:` keyword to avoid recompilation per call.
|
|
89
|
+
#
|
|
90
|
+
# @param tag_start [String] opening tag template (default {DEFAULT_TAG_SYNTAX_START})
|
|
91
|
+
# @param tag_end [String] closing tag template (default {DEFAULT_TAG_SYNTAX_END})
|
|
92
|
+
# @param comment_patterns [Array<String>] comment-wrapper templates
|
|
93
|
+
# (default {DEFAULT_COMMENT_SYNTAX_PATTERNS})
|
|
94
|
+
# @return [Array<Hash>]
|
|
95
|
+
def self.build_tag_patterns tag_start, tag_end, comment_patterns
|
|
96
|
+
open_inner = tag_template_to_inner_regex(tag_start)
|
|
97
|
+
close_inner = tag_template_to_inner_regex(tag_end)
|
|
98
|
+
comment_patterns.map do |cp|
|
|
99
|
+
{
|
|
100
|
+
open: Regexp.new(comment_template_to_full_regex(cp, open_inner)),
|
|
101
|
+
close: Regexp.new(comment_template_to_full_regex(cp, close_inner))
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Default compiled pattern set, built from the three DEFAULT_* template constants.
|
|
107
|
+
# Retained for backward compatibility; prefer the template constants for customisation.
|
|
108
|
+
DEFAULT_TAG_PATTERNS = build_tag_patterns(
|
|
109
|
+
DEFAULT_TAG_SYNTAX_START,
|
|
110
|
+
DEFAULT_TAG_SYNTAX_END,
|
|
111
|
+
DEFAULT_COMMENT_SYNTAX_PATTERNS).freeze
|
|
112
|
+
|
|
113
|
+
# Backward-compatible alias for {DEFAULT_TAG_PATTERNS}.
|
|
114
|
+
TAG_PATTERNS = DEFAULT_TAG_PATTERNS
|
|
115
|
+
|
|
116
|
+
# Parse a text string into an array of {TextSegment} and {Block} objects.
|
|
117
|
+
#
|
|
118
|
+
# The result is ordered and reconstructable: joining every element's
|
|
119
|
+
# serialized form reproduces the original text character-perfectly.
|
|
120
|
+
#
|
|
121
|
+
# Only blocks whose tag name starts with `canonical_prefix` are parsed as
|
|
122
|
+
# proper {Block} objects; all other tag markers (open and close) are
|
|
123
|
+
# treated as ordinary text.
|
|
124
|
+
# This makes the parser robust against files that use tag markers for unrelated
|
|
125
|
+
# purposes (e.g. AsciiDoc `include::` target regions or non-canonical project sections)
|
|
126
|
+
# regardless of whether those regions are properly closed or even nested.
|
|
127
|
+
#
|
|
128
|
+
# When a canonical block is open, every line is treated as content until
|
|
129
|
+
# the matching close marker appears (including any inner tag markers).
|
|
130
|
+
# Canonical blocks therefore cannot be nested.
|
|
131
|
+
#
|
|
132
|
+
# @param text [String] Full text of the file to parse
|
|
133
|
+
# @param canonical_prefix [String] Only tags starting with this prefix
|
|
134
|
+
# are parsed as managed {Block} objects (default {DEFAULT_CANONICAL_PREFIX}).
|
|
135
|
+
# @param tag_syntax_start [String] Opening tag template; used to build
|
|
136
|
+
# patterns when `tag_patterns:` is not given (default {DEFAULT_TAG_SYNTAX_START}).
|
|
137
|
+
# @param tag_syntax_end [String] Closing tag template (default {DEFAULT_TAG_SYNTAX_END}).
|
|
138
|
+
# @param comment_syntax_patterns [Array<String>] Comment-wrapper templates
|
|
139
|
+
# (default {DEFAULT_COMMENT_SYNTAX_PATTERNS}).
|
|
140
|
+
# @param tag_patterns [Array<Hash>, nil] Pre-compiled pattern set; skips template
|
|
141
|
+
# compilation when provided. Build once with {.build_tag_patterns} and reuse.
|
|
142
|
+
# @return [Array<TextSegment, Block>]
|
|
143
|
+
# @raise [ParseError] if a canonical tag is opened but never closed.
|
|
144
|
+
def self.parse text,
|
|
145
|
+
canonical_prefix: DEFAULT_CANONICAL_PREFIX,
|
|
146
|
+
tag_syntax_start: DEFAULT_TAG_SYNTAX_START,
|
|
147
|
+
tag_syntax_end: DEFAULT_TAG_SYNTAX_END,
|
|
148
|
+
comment_syntax_patterns: DEFAULT_COMMENT_SYNTAX_PATTERNS,
|
|
149
|
+
tag_patterns: nil
|
|
150
|
+
patterns = tag_patterns ||
|
|
151
|
+
build_tag_patterns(tag_syntax_start, tag_syntax_end, comment_syntax_patterns)
|
|
152
|
+
lines = text.lines
|
|
153
|
+
segments = []
|
|
154
|
+
text_acc = []
|
|
155
|
+
block_state = nil # nil or { tag:, open_line:, content_lines: [] }
|
|
156
|
+
|
|
157
|
+
lines.each do |line|
|
|
158
|
+
stripped = line.chomp
|
|
159
|
+
|
|
160
|
+
if block_state.nil?
|
|
161
|
+
tag = detect_open_tag(stripped, patterns)
|
|
162
|
+
if tag&.start_with?(canonical_prefix)
|
|
163
|
+
segments << TextSegment.new(content: text_acc.join) unless text_acc.empty?
|
|
164
|
+
text_acc = []
|
|
165
|
+
block_state = { tag: tag, open_line: line, content_lines: [] }
|
|
166
|
+
else
|
|
167
|
+
# Non-canonical open tags and all close tags at the top level are
|
|
168
|
+
# treated as ordinary text.
|
|
169
|
+
text_acc << line
|
|
170
|
+
end
|
|
171
|
+
else
|
|
172
|
+
close_tag = detect_close_tag(stripped, patterns)
|
|
173
|
+
if close_tag == block_state[:tag]
|
|
174
|
+
segments << Block.new(
|
|
175
|
+
tag: block_state[:tag],
|
|
176
|
+
open_line: block_state[:open_line],
|
|
177
|
+
content: block_state[:content_lines].join,
|
|
178
|
+
close_line: line)
|
|
179
|
+
block_state = nil
|
|
180
|
+
else
|
|
181
|
+
# Nested open tags or mismatched close tags: treat as block content
|
|
182
|
+
block_state[:content_lines] << line
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
raise ParseError, "Unclosed canonical tag '#{block_state[:tag]}'" if block_state
|
|
188
|
+
|
|
189
|
+
segments << TextSegment.new(content: text_acc.join) unless text_acc.empty?
|
|
190
|
+
segments
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Return the tag name if `stripped_line` is an opening tag marker, else nil.
|
|
194
|
+
#
|
|
195
|
+
# @param stripped_line [String] A single line with the trailing newline removed
|
|
196
|
+
# @param patterns [Array<Hash>] compiled pattern set from {.build_tag_patterns}
|
|
197
|
+
# @return [String, nil]
|
|
198
|
+
def self.detect_open_tag stripped_line, patterns
|
|
199
|
+
patterns.each do |p|
|
|
200
|
+
m = stripped_line.match(p[:open])
|
|
201
|
+
return m[:tag] if m
|
|
202
|
+
end
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Return the tag name if `stripped_line` is a closing tag marker, else nil.
|
|
207
|
+
#
|
|
208
|
+
# @param stripped_line [String] A single line with the trailing newline removed
|
|
209
|
+
# @param patterns [Array<Hash>] compiled pattern set from {.build_tag_patterns}
|
|
210
|
+
# @return [String, nil]
|
|
211
|
+
def self.detect_close_tag stripped_line, patterns
|
|
212
|
+
patterns.each do |p|
|
|
213
|
+
m = stripped_line.match(p[:close])
|
|
214
|
+
return m[:tag] if m
|
|
215
|
+
end
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Extract all canonical blocks (those whose tag name starts with
|
|
220
|
+
# `canonical_prefix`) as a Hash keyed by tag name.
|
|
221
|
+
#
|
|
222
|
+
# Because {.parse} already filters for canonical blocks when given the
|
|
223
|
+
# same `canonical_prefix`, this method is largely a deduplication check.
|
|
224
|
+
# It raises {ParseError} if more than one canonical block carries the same
|
|
225
|
+
# tag name, which would make synchronization ambiguous.
|
|
226
|
+
#
|
|
227
|
+
# @param segments [Array<TextSegment, Block>]
|
|
228
|
+
# @param canonical_prefix [String] Prefix that identifies managed blocks
|
|
229
|
+
# @return [Hash{String => Block}]
|
|
230
|
+
def self.extract_canonical segments, canonical_prefix: DEFAULT_CANONICAL_PREFIX
|
|
231
|
+
result = {}
|
|
232
|
+
segments.each do |s|
|
|
233
|
+
next unless s.is_a?(Block) && s.tag.start_with?(canonical_prefix)
|
|
234
|
+
|
|
235
|
+
raise ParseError, "Duplicate canonical block '#{s.tag}'" if result.key?(s.tag)
|
|
236
|
+
|
|
237
|
+
result[s.tag] = s
|
|
238
|
+
end
|
|
239
|
+
result
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private_class_method :detect_open_tag, :detect_close_tag
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require_relative 'block_parser'
|
|
5
|
+
|
|
6
|
+
module Sourcerer
|
|
7
|
+
module Sync
|
|
8
|
+
# Synchronizes canonical blocks from a prime template into one target file.
|
|
9
|
+
#
|
|
10
|
+
# See {Sourcerer::Sync} for the high-level interface and the project README
|
|
11
|
+
# for usage examples and a full description of the Sync/Cast model.
|
|
12
|
+
class Cast
|
|
13
|
+
# Returned by both {.sync} and {.init}.
|
|
14
|
+
#
|
|
15
|
+
# @!attribute target_path [String] Absolute path of the target file.
|
|
16
|
+
# @!attribute applied_changes [Array<String>] Tag names whose block
|
|
17
|
+
# content was replaced (empty on a dry run even when differences exist).
|
|
18
|
+
# @!attribute warnings [Array<String>] Non-fatal diagnostic messages.
|
|
19
|
+
# @!attribute errors [Array<String>] Fatal messages; file was not written.
|
|
20
|
+
# @!attribute diff [String, nil] Unified diff output when differences were
|
|
21
|
+
# detected (populated on dry runs and when changes were applied).
|
|
22
|
+
CastResult = Struct.new(
|
|
23
|
+
:target_path,
|
|
24
|
+
:applied_changes,
|
|
25
|
+
:warnings,
|
|
26
|
+
:errors,
|
|
27
|
+
:diff,
|
|
28
|
+
keyword_init: true)
|
|
29
|
+
|
|
30
|
+
# Synchronize canonical blocks from `prime_path` into `target_path`.
|
|
31
|
+
#
|
|
32
|
+
# @param prime_path [String] Path to the prime template file
|
|
33
|
+
# @param target_path [String] Path to the target file
|
|
34
|
+
# @param data [Hash] Liquid variables used when rendering block content
|
|
35
|
+
# @param canonical_prefix [String] Tag prefix that marks managed blocks
|
|
36
|
+
# @param tag_syntax_start [String] Opening tag marker template
|
|
37
|
+
# (see {BlockParser::DEFAULT_TAG_SYNTAX_START})
|
|
38
|
+
# @param tag_syntax_end [String] Closing tag marker template
|
|
39
|
+
# (see {BlockParser::DEFAULT_TAG_SYNTAX_END})
|
|
40
|
+
# @param comment_syntax_patterns [Array<String>] Comment-wrapper templates
|
|
41
|
+
# (see {BlockParser::DEFAULT_COMMENT_SYNTAX_PATTERNS})
|
|
42
|
+
# @param dry_run [Boolean] When true, compute the diff but do not write
|
|
43
|
+
# @return [CastResult]
|
|
44
|
+
def self.sync prime_path, target_path,
|
|
45
|
+
data: {},
|
|
46
|
+
canonical_prefix: BlockParser::DEFAULT_CANONICAL_PREFIX,
|
|
47
|
+
tag_syntax_start: BlockParser::DEFAULT_TAG_SYNTAX_START,
|
|
48
|
+
tag_syntax_end: BlockParser::DEFAULT_TAG_SYNTAX_END,
|
|
49
|
+
comment_syntax_patterns: BlockParser::DEFAULT_COMMENT_SYNTAX_PATTERNS,
|
|
50
|
+
dry_run: false
|
|
51
|
+
new(
|
|
52
|
+
prime_path, target_path,
|
|
53
|
+
data: data,
|
|
54
|
+
canonical_prefix: canonical_prefix,
|
|
55
|
+
tag_syntax_start: tag_syntax_start,
|
|
56
|
+
tag_syntax_end: tag_syntax_end,
|
|
57
|
+
comment_syntax_patterns: comment_syntax_patterns,
|
|
58
|
+
dry_run: dry_run).run_sync
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Bootstrap a new target file from the prime template.
|
|
62
|
+
#
|
|
63
|
+
# During init the entire prime is rendered through Liquid before writing;
|
|
64
|
+
# during sync only canonical block content is rendered.
|
|
65
|
+
# See the project README for a full description of init vs sync semantics.
|
|
66
|
+
#
|
|
67
|
+
# @param prime_path [String] Path to the prime template file
|
|
68
|
+
# @param target_path [String] Path to the target file to create
|
|
69
|
+
# @param data [Hash] Liquid variables used when rendering
|
|
70
|
+
# @param dry_run [Boolean] When true, return rendered content in `diff`
|
|
71
|
+
# but do not write.
|
|
72
|
+
# @return [CastResult]
|
|
73
|
+
def self.init prime_path, target_path, data: {}, dry_run: false
|
|
74
|
+
prime_text = File.read(prime_path)
|
|
75
|
+
rendered = data.empty? ? prime_text : render_liquid_string(prime_text, data)
|
|
76
|
+
|
|
77
|
+
unless dry_run
|
|
78
|
+
FileUtils.mkdir_p(File.dirname(File.expand_path(target_path)))
|
|
79
|
+
File.write(target_path, rendered)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
CastResult.new(
|
|
83
|
+
target_path: target_path,
|
|
84
|
+
applied_changes: [],
|
|
85
|
+
warnings: [],
|
|
86
|
+
errors: [],
|
|
87
|
+
diff: dry_run ? rendered : nil)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @api private
|
|
91
|
+
def initialize prime_path, target_path,
|
|
92
|
+
data:, canonical_prefix:,
|
|
93
|
+
tag_syntax_start:, tag_syntax_end:, comment_syntax_patterns:,
|
|
94
|
+
dry_run:
|
|
95
|
+
@prime_path = prime_path
|
|
96
|
+
@target_path = target_path
|
|
97
|
+
@data = data
|
|
98
|
+
@canonical_prefix = canonical_prefix
|
|
99
|
+
@tag_syntax_start = tag_syntax_start
|
|
100
|
+
@dry_run = dry_run
|
|
101
|
+
@tag_patterns = BlockParser.build_tag_patterns(
|
|
102
|
+
tag_syntax_start, tag_syntax_end, comment_syntax_patterns)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @api private
|
|
106
|
+
def run_sync
|
|
107
|
+
prime_text = File.read(@prime_path)
|
|
108
|
+
target_text = File.read(@target_path)
|
|
109
|
+
|
|
110
|
+
prime_segments = BlockParser.parse(
|
|
111
|
+
prime_text,
|
|
112
|
+
canonical_prefix: @canonical_prefix,
|
|
113
|
+
tag_patterns: @tag_patterns)
|
|
114
|
+
target_segments = BlockParser.parse(
|
|
115
|
+
target_text,
|
|
116
|
+
canonical_prefix: @canonical_prefix,
|
|
117
|
+
tag_patterns: @tag_patterns)
|
|
118
|
+
|
|
119
|
+
prime_blocks = BlockParser.extract_canonical(prime_segments, canonical_prefix: @canonical_prefix)
|
|
120
|
+
target_blocks, errors = validate_target_canonical(target_segments)
|
|
121
|
+
|
|
122
|
+
if errors.any?
|
|
123
|
+
return CastResult.new(
|
|
124
|
+
target_path: @target_path,
|
|
125
|
+
applied_changes: [],
|
|
126
|
+
warnings: [],
|
|
127
|
+
errors: errors,
|
|
128
|
+
diff: nil)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
warnings = collect_warnings(prime_blocks, target_blocks, target_text)
|
|
132
|
+
new_segments, applied_changes = apply_prime_blocks(target_segments, prime_blocks)
|
|
133
|
+
|
|
134
|
+
new_text = reconstruct(new_segments)
|
|
135
|
+
diff = generate_diff(target_text, new_text) if applied_changes.any? || @dry_run
|
|
136
|
+
|
|
137
|
+
File.write(@target_path, new_text) unless @dry_run
|
|
138
|
+
|
|
139
|
+
CastResult.new(
|
|
140
|
+
target_path: @target_path,
|
|
141
|
+
applied_changes: @dry_run ? [] : applied_changes,
|
|
142
|
+
warnings: warnings,
|
|
143
|
+
errors: [],
|
|
144
|
+
diff: diff)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @api private
|
|
148
|
+
def self.render_liquid_string content, data
|
|
149
|
+
require_relative '../jekyll'
|
|
150
|
+
require_relative '../jekyll/liquid/filters'
|
|
151
|
+
require_relative '../jekyll/liquid/tags'
|
|
152
|
+
require 'liquid' unless defined?(Liquid::Template)
|
|
153
|
+
Sourcerer::Jekyll.initialize_liquid_runtime
|
|
154
|
+
|
|
155
|
+
template = Liquid::Template.parse(content)
|
|
156
|
+
template.render(data.transform_keys(&:to_s))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
# Collect canonical blocks from target, raising errors for duplicates.
|
|
162
|
+
# Returns [hash_of_canonical_blocks, errors_array].
|
|
163
|
+
def validate_target_canonical target_segments
|
|
164
|
+
seen = {}
|
|
165
|
+
errors = []
|
|
166
|
+
target_segments.each do |s|
|
|
167
|
+
next unless s.is_a?(BlockParser::Block) && canonical?(s.tag)
|
|
168
|
+
|
|
169
|
+
if seen.key?(s.tag)
|
|
170
|
+
errors << "Duplicate canonical block '#{s.tag}' in target file"
|
|
171
|
+
else
|
|
172
|
+
seen[s.tag] = s
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
[seen, errors]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def collect_warnings prime_blocks, target_blocks, target_text
|
|
179
|
+
warnings = []
|
|
180
|
+
|
|
181
|
+
prime_blocks.each_key do |tag|
|
|
182
|
+
next if target_blocks.key?(tag)
|
|
183
|
+
next if alternate_exists?(tag, target_text)
|
|
184
|
+
|
|
185
|
+
warnings << "Prime canonical block '#{tag}' not found in target"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
target_blocks.each_key do |tag|
|
|
189
|
+
warnings << "Target canonical block '#{tag}' not found in prime" unless prime_blocks.key?(tag)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
warnings
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def apply_prime_blocks target_segments, prime_blocks
|
|
196
|
+
applied_changes = []
|
|
197
|
+
|
|
198
|
+
new_segments = target_segments.map do |segment|
|
|
199
|
+
next segment unless segment.is_a?(BlockParser::Block) && canonical?(segment.tag)
|
|
200
|
+
next segment unless prime_blocks.key?(segment.tag)
|
|
201
|
+
|
|
202
|
+
prime_content = prime_blocks[segment.tag].content
|
|
203
|
+
rendered_content = render_content(prime_content)
|
|
204
|
+
|
|
205
|
+
if rendered_content == segment.content
|
|
206
|
+
segment
|
|
207
|
+
else
|
|
208
|
+
applied_changes << segment.tag
|
|
209
|
+
BlockParser::Block.new(
|
|
210
|
+
tag: segment.tag,
|
|
211
|
+
open_line: segment.open_line,
|
|
212
|
+
content: rendered_content,
|
|
213
|
+
close_line: segment.close_line)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
[new_segments, applied_changes]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def reconstruct segments
|
|
221
|
+
segments.map do |s|
|
|
222
|
+
case s
|
|
223
|
+
when BlockParser::Block
|
|
224
|
+
"#{s.open_line}#{s.content}#{s.close_line}"
|
|
225
|
+
when BlockParser::TextSegment
|
|
226
|
+
s.content
|
|
227
|
+
end
|
|
228
|
+
end.join
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def canonical? tag
|
|
232
|
+
tag.start_with?(@canonical_prefix)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def alternate_exists? canonical_tag, target_text
|
|
236
|
+
# Scan the raw target text for any tag marker that shares the suffix of
|
|
237
|
+
# the canonical tag but uses a different (non-canonical) prefix.
|
|
238
|
+
# Ex: `local-agency` is an alternate for `universal-agency`.
|
|
239
|
+
suffix = canonical_tag.delete_prefix(@canonical_prefix)
|
|
240
|
+
inner = BlockParser.tag_template_to_inner_regex(@tag_syntax_start)
|
|
241
|
+
scan_pat = Regexp.new(inner.gsub('(?<tag>', '('))
|
|
242
|
+
target_text.scan(scan_pat).flatten.any? do |found_tag|
|
|
243
|
+
found_tag.end_with?(suffix) && !found_tag.start_with?(@canonical_prefix)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def render_content content
|
|
248
|
+
return content if @data.empty?
|
|
249
|
+
|
|
250
|
+
self.class.render_liquid_string(content, @data)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def generate_diff old_text, new_text
|
|
254
|
+
return nil if old_text == new_text
|
|
255
|
+
|
|
256
|
+
require 'open3'
|
|
257
|
+
require 'tempfile'
|
|
258
|
+
|
|
259
|
+
result = nil
|
|
260
|
+
Tempfile.open(['cast_old', '.txt']) do |old_f|
|
|
261
|
+
old_f.write(old_text)
|
|
262
|
+
old_f.flush
|
|
263
|
+
Tempfile.open(['cast_new', '.txt']) do |new_f|
|
|
264
|
+
new_f.write(new_text)
|
|
265
|
+
new_f.flush
|
|
266
|
+
stdout, = Open3.capture2('diff', '-u', old_f.path, new_f.path)
|
|
267
|
+
result = stdout
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
result
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'sync/block_parser'
|
|
4
|
+
require_relative 'sync/cast'
|
|
5
|
+
|
|
6
|
+
module Sourcerer
|
|
7
|
+
# Canonical block synchronization and Liquid rendering for flat text files.
|
|
8
|
+
#
|
|
9
|
+
# @see Sourcerer::Sync::Cast The main orchestrator class.
|
|
10
|
+
# @see Sourcerer::Sync::BlockParser The file-agnostic block parser.
|
|
11
|
+
# @see https://github.com/DocOps/asciisourcerer Sync/Cast documentation
|
|
12
|
+
module Sync
|
|
13
|
+
# Synchronise canonical blocks from `prime_path` into `target_path`.
|
|
14
|
+
#
|
|
15
|
+
# @param prime_path [String]
|
|
16
|
+
# @param target_path [String]
|
|
17
|
+
# @param options [Hash] Passed through to {Cast.sync}.
|
|
18
|
+
# @return [Cast::CastResult]
|
|
19
|
+
def self.sync(prime_path, target_path, **)
|
|
20
|
+
Cast.sync(prime_path, target_path, **)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Bootstrap a brand-new target file from the prime template.
|
|
24
|
+
#
|
|
25
|
+
# @param prime_path [String]
|
|
26
|
+
# @param target_path [String]
|
|
27
|
+
# @param options [Hash] Passed through to {Cast.init}.
|
|
28
|
+
# @return [Cast::CastResult]
|
|
29
|
+
def self.init(prime_path, target_path, **)
|
|
30
|
+
Cast.init(prime_path, target_path, **)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sourcerer
|
|
4
|
+
module Util
|
|
5
|
+
# Merge a user-supplied list into a default list using +/- amendment tokens.
|
|
6
|
+
#
|
|
7
|
+
# Not required internally; callers must require this file explicitly.
|
|
8
|
+
module ListAmend
|
|
9
|
+
# Apply a custom list on top of a default list.
|
|
10
|
+
#
|
|
11
|
+
# @param default_list [Array<#to_s>] the baseline list of items
|
|
12
|
+
# @param custom_list [nil, String, Array<#to_s>] the user-supplied overrides
|
|
13
|
+
# @param normalize [nil, #call] optional normalizer for deduplication comparisons (e.g. +:downcase+.to_proc)
|
|
14
|
+
# @return [Array<String>]
|
|
15
|
+
#
|
|
16
|
+
# Behavior:
|
|
17
|
+
# - +nil+ / empty custom ⇒ return stringified +default_list+
|
|
18
|
+
# - custom with no +/- tokens ⇒ fixed-list mode: return stringified custom
|
|
19
|
+
# - custom with any +/- token ⇒ amendment mode:
|
|
20
|
+
# -slug removes slug from working set (or no-op)
|
|
21
|
+
# +slug adds slug if not already present
|
|
22
|
+
# bare treated as +slug
|
|
23
|
+
def self.apply default_list, custom_list, normalize: nil
|
|
24
|
+
tokens = parse_tokens(custom_list)
|
|
25
|
+
return default_list.map(&:to_s) if tokens.empty?
|
|
26
|
+
|
|
27
|
+
amendment_mode = tokens.any? { |t| t.start_with?('+', '-') }
|
|
28
|
+
return tokens.map(&:to_s) unless amendment_mode
|
|
29
|
+
|
|
30
|
+
working = default_list.map(&:to_s)
|
|
31
|
+
norm = normalize || ->(s) { s }
|
|
32
|
+
|
|
33
|
+
# Apply removals first, then additions in token order.
|
|
34
|
+
tokens.each do |token|
|
|
35
|
+
if token.start_with?('-')
|
|
36
|
+
slug = token[1..]
|
|
37
|
+
working.reject! { |item| norm.call(item) == norm.call(slug) }
|
|
38
|
+
else
|
|
39
|
+
slug = token.start_with?('+') ? token[1..] : token
|
|
40
|
+
working << slug unless working.any? { |item| norm.call(item) == norm.call(slug) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
working
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Normalize a raw custom_list value into an array of non-empty token strings.
|
|
48
|
+
def self.parse_tokens raw
|
|
49
|
+
case raw
|
|
50
|
+
when nil
|
|
51
|
+
[]
|
|
52
|
+
when Array
|
|
53
|
+
raw.map(&:to_s).reject(&:empty?)
|
|
54
|
+
when String
|
|
55
|
+
raw.split(/[\s,]+/).reject(&:empty?)
|
|
56
|
+
else
|
|
57
|
+
Array(raw).map(&:to_s).reject(&:empty?)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
private_class_method :parse_tokens
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|