asciisourcerer 0.4.0 → 0.6.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/BILL_OF_MATERIALS.adoc +17 -0
- data/README.adoc +111 -11
- data/lib/sourcerer/_docs/partials/liquid-filters-by-kind.adoc +3227 -0
- data/lib/sourcerer/_docs/partials/liquid-filters-by-source.adoc +3155 -0
- data/lib/sourcerer/asciidoc.rb +10 -3
- data/lib/sourcerer/builder.rb +1 -1
- data/lib/sourcerer/jekyll/bootstrapper.rb +1 -2
- data/lib/sourcerer/jekyll/liquid/filters.rb +295 -0
- data/lib/sourcerer/jekyll/liquid/preserve_missing_variables.rb +148 -0
- data/lib/sourcerer/jekyll/liquid/tags.rb +31 -3
- data/lib/sourcerer/jekyll.rb +8 -2
- data/lib/sourcerer/mark_down_grade.rb +181 -28
- data/lib/sourcerer/rendering.rb +70 -41
- data/lib/sourcerer/source_skim/config.rb +8 -2
- data/lib/sourcerer/source_skim/ruby_skimmer.rb +83 -0
- data/lib/sourcerer/source_skim/skimmer.rb +95 -3
- data/lib/sourcerer/source_skim.rb +11 -2
- data/lib/sourcerer/util/gem_uri.rb +34 -0
- data/lib/sourcerer/version.rb +1 -1
- data/lib/sourcerer.rb +0 -1
- data/specs/data/liquid-filters.yml +1314 -0
- metadata +22 -2
- data/specs/docs/frontmatter-reader_prd.adoc +0 -47
data/lib/sourcerer/asciidoc.rb
CHANGED
|
@@ -43,9 +43,13 @@ module Sourcerer
|
|
|
43
43
|
# Loads AsciiDoc attributes from a document header as a Hash.
|
|
44
44
|
#
|
|
45
45
|
# @param path [String] The path to the AsciiDoc file.
|
|
46
|
+
# @param user_only [Boolean] When true, strips Asciidoctor built-in attributes
|
|
47
|
+
# and returns only user-defined attributes. Defaults to false.
|
|
46
48
|
# @return [Hash] A hash of the document attributes.
|
|
47
|
-
def self.load_attributes path
|
|
49
|
+
def self.load_attributes path, user_only: false
|
|
48
50
|
doc = Asciidoctor.load_file(path, safe: :unsafe)
|
|
51
|
+
return AttributesFilter.user_attributes(doc) if user_only
|
|
52
|
+
|
|
49
53
|
doc.attributes
|
|
50
54
|
end
|
|
51
55
|
|
|
@@ -192,17 +196,20 @@ module Sourcerer
|
|
|
192
196
|
#
|
|
193
197
|
# @param source_path [String] Path to AsciiDoc source file.
|
|
194
198
|
# @param markdown_output_path [String, nil] Optional markdown output path.
|
|
199
|
+
# @param markdown_converter [#call, nil] Callable that accepts `(html, markdown_options)`.
|
|
200
|
+
# Defaults to Sourcerer::MarkDownGrade.convert_html when nil.
|
|
195
201
|
# @param html_output_path [String, nil] Optional HTML output path.
|
|
196
202
|
# @param backend [String] HTML backend request (`html5` or `asciidoctor-html5s`).
|
|
197
203
|
# @param header_footer [Boolean] Whether interim HTML should include document wrapper.
|
|
198
204
|
# @param include_frontmatter [Boolean] Whether to prepend markdown YAML front matter.
|
|
199
205
|
# @param markdown_options [Hash] Options passed to markdown converter.
|
|
200
|
-
# @param markdown_converter [#call] Callable that accepts `(html, markdown_options)`.
|
|
201
206
|
# @param convert_tables_to_markdown [Boolean] Convert all tables to markdown UNLESS they have .no-markdown class.
|
|
202
207
|
# @return [Hash] Conversion result containing markdown, frontmatter, and backend info.
|
|
203
|
-
def self.mark_down_grade source_path, markdown_output_path=nil, markdown_converter
|
|
208
|
+
def self.mark_down_grade source_path, markdown_output_path=nil, markdown_converter: nil, **options
|
|
204
209
|
options = normalize_mark_down_grade_options(options)
|
|
205
210
|
|
|
211
|
+
markdown_converter ||= ->(html, opts) { Sourcerer::MarkDownGrade.convert_html(html, opts || {}) }
|
|
212
|
+
|
|
206
213
|
source_text = File.read(source_path)
|
|
207
214
|
conversion_source_text = strip_yaml_frontmatter(source_text)
|
|
208
215
|
selected_backend = resolve_html_backend(options[:backend])
|
data/lib/sourcerer/builder.rb
CHANGED
|
@@ -62,7 +62,7 @@ module Sourcerer
|
|
|
62
62
|
attributes.each_with_object({}) do |entry, acc|
|
|
63
63
|
source = entry[:source]
|
|
64
64
|
name = entry[:name] || File.basename(source, '.adoc').to_sym
|
|
65
|
-
acc[name.to_sym] = Sourcerer::AsciiDoc.load_attributes(source)
|
|
65
|
+
acc[name.to_sym] = Sourcerer::AsciiDoc.load_attributes(source, user_only: true)
|
|
66
66
|
end
|
|
67
67
|
end
|
|
68
68
|
|
|
@@ -37,7 +37,7 @@ module Sourcerer
|
|
|
37
37
|
# @param includes_load_paths [Array<String>] Paths to load includes from.
|
|
38
38
|
# @param plugin_dirs [Array<String>] Paths to load plugins from.
|
|
39
39
|
# @return [Jekyll::Site] The initialized fake Jekyll site object.
|
|
40
|
-
# rubocop:disable Lint/UnusedMethodArgument
|
|
40
|
+
# rubocop:disable-next Lint/UnusedMethodArgument
|
|
41
41
|
def self.fake_site includes_load_paths: [], plugin_dirs: []
|
|
42
42
|
# NOTE: plugin_dirs parameter is accepted but not yet implemented; reserved for future plugin loading
|
|
43
43
|
::Jekyll.logger.log_level = :error if ::Jekyll.logger.respond_to?(:log_level=)
|
|
@@ -72,7 +72,6 @@ module Sourcerer
|
|
|
72
72
|
|
|
73
73
|
site
|
|
74
74
|
end
|
|
75
|
-
# rubocop:enable Lint/UnusedMethodArgument
|
|
76
75
|
end
|
|
77
76
|
end
|
|
78
77
|
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'base64'
|
|
4
|
+
require 'bigdecimal'
|
|
4
5
|
require 'cgi'
|
|
6
|
+
require 'date'
|
|
5
7
|
require 'kramdown-asciidoc'
|
|
6
8
|
|
|
7
9
|
module Sourcerer
|
|
@@ -13,6 +15,22 @@ module Sourcerer
|
|
|
13
15
|
# Core transformation logic lives in `Ops` so behavior is reusable and easier
|
|
14
16
|
# to test/refactor without changing the Liquid surface.
|
|
15
17
|
module Filters
|
|
18
|
+
# Load canonical CLI args templates from YAML once at module load time
|
|
19
|
+
def self.load_cli_args_parameters
|
|
20
|
+
require 'yaml'
|
|
21
|
+
# Path from filters.rb: lib/sourcerer/jekyll/liquid/filters.rb
|
|
22
|
+
# To specs/data/liquid-filters.yml: go up 4 levels to gem root, then to specs/data
|
|
23
|
+
yaml_path = File.join(__dir__, '../../../../specs/data/liquid-filters.yml')
|
|
24
|
+
yaml_path = File.expand_path(yaml_path)
|
|
25
|
+
data = YAML.load_file(yaml_path, permitted_classes: [Date, Time])
|
|
26
|
+
filters = data.is_a?(Array) ? data : data['filters'] || []
|
|
27
|
+
to_cli_args_filter = filters.find { |f| f['key'] == 'to_cli_args' }
|
|
28
|
+
to_cli_args_filter&.dig('parameters') || {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Canonical template definitions loaded from specs/data/liquid-filters.yml
|
|
32
|
+
CLI_ARGS_TEMPLATES = load_cli_args_parameters.freeze
|
|
33
|
+
|
|
16
34
|
# Internal operations for filter behavior.
|
|
17
35
|
module Ops
|
|
18
36
|
module_function
|
|
@@ -42,6 +60,15 @@ module Sourcerer
|
|
|
42
60
|
end
|
|
43
61
|
end
|
|
44
62
|
|
|
63
|
+
# Convert a string into a lowercase URL slug, joining words with the
|
|
64
|
+
# given separator (default `-`).
|
|
65
|
+
def slugify input, *args
|
|
66
|
+
separator = args[0] || '-'
|
|
67
|
+
input.downcase
|
|
68
|
+
.gsub(/[^a-z0-9]+/, separator)
|
|
69
|
+
.gsub(/\A#{Regexp.escape(separator)}+|#{Regexp.escape(separator)}+\z/, '')
|
|
70
|
+
end
|
|
71
|
+
|
|
45
72
|
def plusify input
|
|
46
73
|
input.gsub(/\n\n+/, "\n+\n")
|
|
47
74
|
end
|
|
@@ -113,6 +140,210 @@ module Sourcerer
|
|
|
113
140
|
def html_unescape input
|
|
114
141
|
CGI.unescapeHTML(input.to_s)
|
|
115
142
|
end
|
|
143
|
+
|
|
144
|
+
def wrap input, width=80
|
|
145
|
+
return input unless input.is_a?(String)
|
|
146
|
+
|
|
147
|
+
width = width.to_i
|
|
148
|
+
return input if width <= 0
|
|
149
|
+
|
|
150
|
+
input.split("\n").map do |line|
|
|
151
|
+
line.gsub(/(.{1,#{width}})(\s+|$)/, "\\1\n").rstrip
|
|
152
|
+
end.join("\n")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def commentwrap input, width=80, prefix=nil
|
|
156
|
+
return input unless input.is_a?(String)
|
|
157
|
+
|
|
158
|
+
wrapped = wrap(input, width)
|
|
159
|
+
return wrapped unless prefix
|
|
160
|
+
|
|
161
|
+
case prefix
|
|
162
|
+
when 'xml'
|
|
163
|
+
"<!-- #{wrapped} -->"
|
|
164
|
+
when /\|/
|
|
165
|
+
# Format like "/*|*/" becomes /* ... */
|
|
166
|
+
parts = prefix.split('|')
|
|
167
|
+
open_tag = parts[0]
|
|
168
|
+
close_tag = parts[1] || ''
|
|
169
|
+
"#{open_tag} #{wrapped}\n#{close_tag}"
|
|
170
|
+
else
|
|
171
|
+
# Regular comment prefix
|
|
172
|
+
wrapped.split("\n").map { |line| "#{prefix}#{line}" }.join("\n")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def to_yaml input, *args
|
|
177
|
+
require 'yaml'
|
|
178
|
+
|
|
179
|
+
# Parse args: can be "flow" and/or "quotes"
|
|
180
|
+
flow = args.include?('flow')
|
|
181
|
+
quotes = args.include?('quotes')
|
|
182
|
+
|
|
183
|
+
if flow
|
|
184
|
+
# Flow format (inline): {key: val}
|
|
185
|
+
if input.is_a?(Array)
|
|
186
|
+
if quotes
|
|
187
|
+
"[#{input.map { |item| "\"#{item}\"" }.join(', ')}]"
|
|
188
|
+
else
|
|
189
|
+
"[#{input.map(&:inspect).join(', ')}]"
|
|
190
|
+
end
|
|
191
|
+
elsif input.is_a?(Hash)
|
|
192
|
+
"{#{input.map { |k, v| "#{k}: #{v.inspect}" }.join(', ')}}"
|
|
193
|
+
else
|
|
194
|
+
input.to_s
|
|
195
|
+
end
|
|
196
|
+
else
|
|
197
|
+
# Block format (YAML): key: val
|
|
198
|
+
output = YAML.dump(input)
|
|
199
|
+
output.chomp
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def to_json input
|
|
204
|
+
require 'json'
|
|
205
|
+
input.to_json
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def replace_regex input, pattern, replacement=''
|
|
209
|
+
return input unless input.is_a?(String)
|
|
210
|
+
|
|
211
|
+
input.gsub(/#{pattern}/, replacement)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def match input, pattern
|
|
215
|
+
return false unless input.is_a?(String)
|
|
216
|
+
|
|
217
|
+
!!(input =~ /#{pattern}/)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def holds_liquid input
|
|
221
|
+
return false unless input.is_a?(String)
|
|
222
|
+
|
|
223
|
+
# Check for Liquid tags: {{ }}, {% %}, {%- -%}, {{- -}}
|
|
224
|
+
!!(input =~ /\{\{.*?\}\}|\{%-?.*?-?%\}/)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def to_cli_args input, template=nil, delimiter=' '
|
|
228
|
+
return input unless input.is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
# Use templates loaded from canonical YAML source (Filters::CLI_ARGS_TEMPLATES)
|
|
231
|
+
selected_template = Filters::CLI_ARGS_TEMPLATES[template] || template || '--<option> <argument>'
|
|
232
|
+
|
|
233
|
+
input.map do |key, value|
|
|
234
|
+
selected_template
|
|
235
|
+
.gsub('<option>', key.to_s)
|
|
236
|
+
.gsub('<o>', key.to_s[0])
|
|
237
|
+
.gsub('<argument>', value.to_s)
|
|
238
|
+
.gsub('<VARIABLE>', key.to_s.upcase)
|
|
239
|
+
.gsub('<key>', key.to_s)
|
|
240
|
+
.gsub('<value>', value.to_s)
|
|
241
|
+
end.join(delimiter)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def store_list_concat input, key_name
|
|
245
|
+
return input unless input.is_a?(Array)
|
|
246
|
+
|
|
247
|
+
input.each_with_object([]) do |item, acc|
|
|
248
|
+
values = item[key_name.to_s]
|
|
249
|
+
acc.concat(values) if values.is_a?(Array)
|
|
250
|
+
end.uniq
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def store_list_dupes input, key_name
|
|
254
|
+
return input unless input.is_a?(Array)
|
|
255
|
+
|
|
256
|
+
# Collect all arrays from specified key
|
|
257
|
+
arrays = input.map { |item| item[key_name.to_s] }.compact.grep(Array)
|
|
258
|
+
|
|
259
|
+
# Find items that appear in multiple arrays
|
|
260
|
+
all_items = arrays.flatten
|
|
261
|
+
all_items.select { |item| all_items.count(item) > 1 }.uniq
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Overrides Jekyll's `inspect` filter, adding a `format` argument.
|
|
265
|
+
# `html` (the default) reproduces Jekyll's own behavior exactly:
|
|
266
|
+
# an HTML-escaped `Object#inspect` string.
|
|
267
|
+
def inspect input, format='html'
|
|
268
|
+
case format
|
|
269
|
+
when 'yaml'
|
|
270
|
+
require 'yaml'
|
|
271
|
+
YAML.dump(input)
|
|
272
|
+
when 'json'
|
|
273
|
+
require 'json'
|
|
274
|
+
input.to_json
|
|
275
|
+
else
|
|
276
|
+
html_escape(input.inspect)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# -- Ports of Liquid 5 StandardFilters --
|
|
281
|
+
#
|
|
282
|
+
# Jekyll pins to Liquid 4, which lacks these four filters (added in
|
|
283
|
+
# Liquid 5). Ported here so downstream templates can use them
|
|
284
|
+
# regardless of the Liquid version Jekyll pulls in.
|
|
285
|
+
# See BILL_OF_MATERIALS.adoc for provenance/license details.
|
|
286
|
+
|
|
287
|
+
# Removes the last instance of a substring from a string.
|
|
288
|
+
def remove_last input, string
|
|
289
|
+
replace_last(input, string, '')
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Replaces the last instance of a substring in a string with a replacement.
|
|
293
|
+
def replace_last input, string, replacement=''
|
|
294
|
+
return input unless input.is_a?(String)
|
|
295
|
+
|
|
296
|
+
target = string.to_s
|
|
297
|
+
start_index = input.rindex(target)
|
|
298
|
+
return input unless start_index
|
|
299
|
+
|
|
300
|
+
output = input.dup
|
|
301
|
+
output[start_index, target.length] = replacement.to_s
|
|
302
|
+
output
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Strips leading/trailing whitespace and collapses interior runs of
|
|
306
|
+
# whitespace to a single space.
|
|
307
|
+
def squish input
|
|
308
|
+
return input unless input.is_a?(String)
|
|
309
|
+
|
|
310
|
+
input.strip.gsub(/\s+/, ' ')
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Sums a numeric array, or an array of Hashes at the given property.
|
|
314
|
+
def sum input, property=nil
|
|
315
|
+
return 0 unless input.is_a?(Array)
|
|
316
|
+
|
|
317
|
+
values = input.map do |item|
|
|
318
|
+
if property.nil?
|
|
319
|
+
item
|
|
320
|
+
elsif item.respond_to?(:[])
|
|
321
|
+
item[property]
|
|
322
|
+
else
|
|
323
|
+
0
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
result = values.sum { |value| to_number(value) }
|
|
328
|
+
result.is_a?(BigDecimal) ? result.to_f : result
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# @api private
|
|
332
|
+
# Coerces a Liquid value to a number, matching Liquid 5's
|
|
333
|
+
# `Utils.to_number` (Float -> BigDecimal for precision, numeric
|
|
334
|
+
# strings parsed, everything else 0).
|
|
335
|
+
def to_number obj
|
|
336
|
+
case obj
|
|
337
|
+
when Float
|
|
338
|
+
BigDecimal(obj.to_s)
|
|
339
|
+
when Numeric
|
|
340
|
+
obj
|
|
341
|
+
when String
|
|
342
|
+
/\A-?\d+\.\d+\z/.match?(obj.strip) ? BigDecimal(obj) : obj.to_i
|
|
343
|
+
else
|
|
344
|
+
0
|
|
345
|
+
end
|
|
346
|
+
end
|
|
116
347
|
end
|
|
117
348
|
private_constant :Ops
|
|
118
349
|
|
|
@@ -124,6 +355,10 @@ module Sourcerer
|
|
|
124
355
|
Ops.sluggerize(input, format)
|
|
125
356
|
end
|
|
126
357
|
|
|
358
|
+
def slugify(input, *)
|
|
359
|
+
Ops.slugify(input, *)
|
|
360
|
+
end
|
|
361
|
+
|
|
127
362
|
def plusify input
|
|
128
363
|
Ops.plusify(input)
|
|
129
364
|
end
|
|
@@ -167,6 +402,66 @@ module Sourcerer
|
|
|
167
402
|
def html_unescape input
|
|
168
403
|
Ops.html_unescape(input)
|
|
169
404
|
end
|
|
405
|
+
|
|
406
|
+
def wrap input, width=80
|
|
407
|
+
Ops.wrap(input, width)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def commentwrap input, width=80, prefix=nil
|
|
411
|
+
Ops.commentwrap(input, width, prefix)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def to_yaml(input, *)
|
|
415
|
+
Ops.to_yaml(input, *)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def to_json input
|
|
419
|
+
Ops.to_json(input)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def replace_regex input, pattern, replacement=''
|
|
423
|
+
Ops.replace_regex(input, pattern, replacement)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def match input, pattern
|
|
427
|
+
Ops.match(input, pattern)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def holds_liquid input
|
|
431
|
+
Ops.holds_liquid(input)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def to_cli_args input, template=nil, delimiter=' '
|
|
435
|
+
Ops.to_cli_args(input, template, delimiter)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def store_list_concat input, key_name
|
|
439
|
+
Ops.store_list_concat(input, key_name)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def store_list_dupes input, key_name
|
|
443
|
+
Ops.store_list_dupes(input, key_name)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def inspect input, format='html'
|
|
447
|
+
Ops.inspect(input, format)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def remove_last input, string
|
|
451
|
+
Ops.remove_last(input, string)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def replace_last input, string, replacement=''
|
|
455
|
+
Ops.replace_last(input, string, replacement)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def squish input
|
|
459
|
+
Ops.squish(input)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def sum input, property=nil
|
|
463
|
+
Ops.sum(input, property)
|
|
464
|
+
end
|
|
170
465
|
end
|
|
171
466
|
end
|
|
172
467
|
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'liquid'
|
|
4
|
+
|
|
5
|
+
module Sourcerer
|
|
6
|
+
module Jekyll
|
|
7
|
+
module Liquid
|
|
8
|
+
# Opt-in alternative to Liquid's default "render nil/empty as empty
|
|
9
|
+
# string" behavior: when active, a `{{ ... }}` expression that
|
|
10
|
+
# evaluates to a missing or empty value renders as its own original
|
|
11
|
+
# source text (e.g. `{{ data.foo | capitalize }}`) instead of
|
|
12
|
+
# vanishing. Useful for document templates where an unfilled field
|
|
13
|
+
# should stay visible for the preparer to fill in by hand, rather
|
|
14
|
+
# than silently disappearing.
|
|
15
|
+
#
|
|
16
|
+
# Two independent toggles, each off by default:
|
|
17
|
+
#
|
|
18
|
+
# - `preserve_missing`: an outright `nil` result is preserved.
|
|
19
|
+
# - `preserve_empty`: a defined-but-empty String/Array/Hash result
|
|
20
|
+
# (`""`, `[]`, `{}`) is preserved.
|
|
21
|
+
#
|
|
22
|
+
# Neither affects the other's target, and neither affects `0` or
|
|
23
|
+
# `false`. Both checks run against the fully-filtered result (i.e.
|
|
24
|
+
# any `| filters` in the tag have already run), matching what Liquid
|
|
25
|
+
# itself would otherwise render -- we never re-run the filters, we
|
|
26
|
+
# just restore the tag's own pre-render source text once a render
|
|
27
|
+
# has been judged missing/empty. `raw` is used verbatim (no
|
|
28
|
+
# whitespace trimming), so the reconstructed tag matches the
|
|
29
|
+
# original byte-for-byte -- except for `{{-`/`-}}` whitespace
|
|
30
|
+
# control markers, which Liquid's tokenizer strips before the
|
|
31
|
+
# markup ever reaches a `Variable` instance and so cannot be
|
|
32
|
+
# recovered here.
|
|
33
|
+
#
|
|
34
|
+
# Substitution only ever applies to a *top-level output* `{{ }}`
|
|
35
|
+
# tag -- never to a `Liquid::Variable` some other tag builds
|
|
36
|
+
# privately to parse its own syntax. `Assign`, for example, does
|
|
37
|
+
# exactly that (`lib/liquid/tags/assign.rb`: `@from = Variable.new(
|
|
38
|
+
# $2, options)`, then `@from.render(context)` directly), and that
|
|
39
|
+
# Variable's `#render` is the same patched method, with the same
|
|
40
|
+
# global toggle active. Without this distinction,
|
|
41
|
+
# `{% assign name = data.missing %}` under `preserve_missing: true`
|
|
42
|
+
# would assign the *reconstructed source text of the assign's own
|
|
43
|
+
# right-hand side* (a String) to `name`, rather than the real `nil`
|
|
44
|
+
# -- corrupting every later `{{ name }}` reference instead of
|
|
45
|
+
# leaving it to render its own clean placeholder. `BlockBodyPatch`
|
|
46
|
+
# below marks the one call site (`BlockBody#render_node_to_output`,
|
|
47
|
+
# when the node being rendered is itself a `Variable`) that
|
|
48
|
+
# corresponds to a literal `{{ }}` sitting directly in a template
|
|
49
|
+
# body's nodelist -- the only place a Variable's rendered text
|
|
50
|
+
# actually becomes document output. Any other tag's internal use of
|
|
51
|
+
# a Variable to compute a value (Assign's right-hand side, or
|
|
52
|
+
# anything similar) renders outside that marker and is left with
|
|
53
|
+
# Liquid's normal nil/empty behavior, exactly as if this feature
|
|
54
|
+
# were off.
|
|
55
|
+
#
|
|
56
|
+
# Liquid 4 has no built-in hook for this -- there is no Environment or
|
|
57
|
+
# parser-injection point (introduced only in Liquid 5) to substitute a
|
|
58
|
+
# custom Variable class per template -- so this prepends onto
|
|
59
|
+
# `::Liquid::Variable` globally and gates the actual behavior change
|
|
60
|
+
# with toggles scoped to one render call. See
|
|
61
|
+
# `Sourcerer::Rendering.render_template`'s `preserve_missing:`/
|
|
62
|
+
# `preserve_empty:` options, which flip these toggles for the
|
|
63
|
+
# duration of that one render.
|
|
64
|
+
module PreserveMissingVariables
|
|
65
|
+
def self.preserve_missing?
|
|
66
|
+
Thread.current[:sourcerer_preserve_missing] == true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.preserve_empty?
|
|
70
|
+
Thread.current[:sourcerer_preserve_empty] == true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.top_level_output?
|
|
74
|
+
Thread.current[:sourcerer_rendering_output_variable] == true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Activates preserve-missing/-empty rendering for the duration of
|
|
78
|
+
# the block. Restores the prior values afterward so nested/
|
|
79
|
+
# sequential renders that don't ask for it are unaffected.
|
|
80
|
+
#
|
|
81
|
+
# @param preserve_missing [Boolean] See {PreserveMissingVariables}.
|
|
82
|
+
# @param preserve_empty [Boolean] See {PreserveMissingVariables}.
|
|
83
|
+
def self.with_active preserve_missing: false, preserve_empty: false
|
|
84
|
+
previous_missing = Thread.current[:sourcerer_preserve_missing]
|
|
85
|
+
previous_empty = Thread.current[:sourcerer_preserve_empty]
|
|
86
|
+
Thread.current[:sourcerer_preserve_missing] = preserve_missing
|
|
87
|
+
Thread.current[:sourcerer_preserve_empty] = preserve_empty
|
|
88
|
+
yield
|
|
89
|
+
ensure
|
|
90
|
+
Thread.current[:sourcerer_preserve_missing] = previous_missing
|
|
91
|
+
Thread.current[:sourcerer_preserve_empty] = previous_empty
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Prepended onto ::Liquid::Variable. Falls through to Liquid's own
|
|
95
|
+
# `render` untouched unless: this Variable is currently being
|
|
96
|
+
# rendered as a top-level output node (see `BlockBodyPatch`
|
|
97
|
+
# below), preserve-missing/-empty is active for this thread, and
|
|
98
|
+
# the fully-filtered result counts as missing (nil) or empty (a
|
|
99
|
+
# defined-but-empty String/Array/Hash).
|
|
100
|
+
module VariablePatch
|
|
101
|
+
def render context
|
|
102
|
+
result = super
|
|
103
|
+
return result unless PreserveMissingVariables.top_level_output?
|
|
104
|
+
return "{{#{raw}}}" if PreserveMissingVariables.preserve_missing? && result.nil?
|
|
105
|
+
return "{{#{raw}}}" if PreserveMissingVariables.preserve_empty? && empty_result?(result)
|
|
106
|
+
|
|
107
|
+
result
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Only the documented value types count as "empty" -- not any
|
|
113
|
+
# arbitrary object/Drop that happens to implement `empty?` with
|
|
114
|
+
# its own, possibly unrelated, meaning.
|
|
115
|
+
def empty_result? value
|
|
116
|
+
(value.is_a?(String) || value.is_a?(Array) || value.is_a?(Hash)) && value.empty?
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Prepended onto ::Liquid::BlockBody. `render_node_to_output` is
|
|
121
|
+
# the one call site where a node from a body's own nodelist --
|
|
122
|
+
# i.e. a literal `{{ }}` or `{% tag %}` written directly in
|
|
123
|
+
# template source -- gets rendered and appended to output. We
|
|
124
|
+
# only care about the `Variable` case: that's a genuine top-level
|
|
125
|
+
# `{{ }}` output tag, as opposed to a `Variable` some other tag
|
|
126
|
+
# (Assign, etc.) builds privately and renders itself, bypassing
|
|
127
|
+
# BlockBody entirely. See {PreserveMissingVariables} above for why
|
|
128
|
+
# this distinction matters.
|
|
129
|
+
module BlockBodyPatch
|
|
130
|
+
# rubocop:disable-next Style/OptionalBooleanParameter -- must match
|
|
131
|
+
# ::Liquid::BlockBody#render_node_to_output's own positional signature
|
|
132
|
+
def render_node_to_output node, output, context, skip_output = false
|
|
133
|
+
return super unless node.is_a?(::Liquid::Variable)
|
|
134
|
+
|
|
135
|
+
previous = Thread.current[:sourcerer_rendering_output_variable]
|
|
136
|
+
Thread.current[:sourcerer_rendering_output_variable] = true
|
|
137
|
+
super
|
|
138
|
+
ensure
|
|
139
|
+
Thread.current[:sourcerer_rendering_output_variable] = previous if node.is_a?(::Liquid::Variable)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
Liquid::Variable.prepend(Sourcerer::Jekyll::Liquid::PreserveMissingVariables::VariablePatch)
|
|
148
|
+
Liquid::BlockBody.prepend(Sourcerer::Jekyll::Liquid::PreserveMissingVariables::BlockBodyPatch)
|
|
@@ -8,12 +8,25 @@ module Sourcerer
|
|
|
8
8
|
# A Liquid tag for embedding and rendering a file within a template.
|
|
9
9
|
# It searches for the file in the configured include paths.
|
|
10
10
|
class EmbedTag < ::Liquid::Tag
|
|
11
|
+
# Matches an optional single- or double-quoted string, capturing the
|
|
12
|
+
# inner content; falls back to the whole (trimmed) markup as a bareword.
|
|
13
|
+
# Requires at least one character inside the quotes: an empty quoted
|
|
14
|
+
# name (`""`/`''`) would otherwise expand to the includes directory
|
|
15
|
+
# itself, pass File.exist?, and raise Errno::EISDIR from File.read
|
|
16
|
+
# instead of the intended missing-file error. Left unmatched, it
|
|
17
|
+
# falls through to the bareword branch instead, where the literal
|
|
18
|
+
# (quoted) name is looked up and not found -- reported normally.
|
|
19
|
+
PARTIAL_NAME_PATTERN = /\A(?:"([^"]+)"|'([^']+)')\z/
|
|
20
|
+
|
|
11
21
|
# @param tag_name [String] The name of the tag ('embed').
|
|
12
|
-
# @param markup [String] The name of the partial to embed
|
|
22
|
+
# @param markup [String] The name of the partial to embed, quoted
|
|
23
|
+
# (+"foo.liquid"+ / +'foo.liquid'+) or bareword (+foo.liquid+).
|
|
13
24
|
# @param tokens [Array<String>] The list of tokens.
|
|
14
25
|
def initialize tag_name, markup, tokens
|
|
15
26
|
super
|
|
16
|
-
|
|
27
|
+
trimmed = markup.strip
|
|
28
|
+
match = trimmed.match(PARTIAL_NAME_PATTERN)
|
|
29
|
+
@partial_name = match ? (match[1] || match[2]) : trimmed
|
|
17
30
|
end
|
|
18
31
|
|
|
19
32
|
# Renders the embedded file.
|
|
@@ -22,7 +35,9 @@ module Sourcerer
|
|
|
22
35
|
# @return [String] The rendered content of the embedded file.
|
|
23
36
|
# @raise [IOError] if the embed file is not found.
|
|
24
37
|
def render context
|
|
25
|
-
includes_paths = context.registers[:includes_load_paths]
|
|
38
|
+
includes_paths = context.registers[:includes_load_paths]
|
|
39
|
+
includes_paths = site_includes_load_paths(context) if includes_paths.nil? || includes_paths.empty?
|
|
40
|
+
includes_paths ||= []
|
|
26
41
|
|
|
27
42
|
found_path = includes_paths.find do |base|
|
|
28
43
|
candidate = File.expand_path(@partial_name, base)
|
|
@@ -37,6 +52,19 @@ module Sourcerer
|
|
|
37
52
|
partial = ::Liquid::Template.parse(source)
|
|
38
53
|
partial.render!(context)
|
|
39
54
|
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Fallback for callers that register a fake/real Jekyll +:site+ but
|
|
59
|
+
# forget the separate +:includes_load_paths+ register (e.g. any
|
|
60
|
+
# future caller mirroring {Sourcerer::Rendering.render_liquid}).
|
|
61
|
+
# Reads +site.config['includes_load_paths']+ directly rather than
|
|
62
|
+
# +site.includes_load_paths+: the latter is Jekyll's own attribute,
|
|
63
|
+
# derived only from +config['includes_dir']+ (effectively just the
|
|
64
|
+
# first path), not the full list Sourcerer stores in config.
|
|
65
|
+
def site_includes_load_paths context
|
|
66
|
+
context.registers[:site]&.config&.[]('includes_load_paths')
|
|
67
|
+
end
|
|
40
68
|
end
|
|
41
69
|
end
|
|
42
70
|
end
|
data/lib/sourcerer/jekyll.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative 'jekyll/monkeypatches'
|
|
|
5
5
|
require_relative 'jekyll/liquid/file_system'
|
|
6
6
|
require_relative 'jekyll/liquid/filters'
|
|
7
7
|
require_relative 'jekyll/liquid/tags'
|
|
8
|
+
require_relative 'jekyll/liquid/preserve_missing_variables'
|
|
8
9
|
require 'jekyll-asciidoc'
|
|
9
10
|
|
|
10
11
|
module Sourcerer
|
|
@@ -18,12 +19,17 @@ module Sourcerer
|
|
|
18
19
|
Bootstrapper.load_plugins
|
|
19
20
|
Monkeypatches.patch_jekyll
|
|
20
21
|
|
|
21
|
-
#
|
|
22
|
-
|
|
22
|
+
# Registration order matters: Liquid's Strainer `include`s each module in
|
|
23
|
+
# turn, so a later registration wins over an earlier one for any
|
|
24
|
+
# same-named filter method. Sourcerer's filters register LAST so that,
|
|
25
|
+
# e.g., its `inspect` (which adds a `format` argument) overrides
|
|
26
|
+
# Jekyll's `inspect` rather than being shadowed by it.
|
|
23
27
|
# Ensure Jekyll filters are registered
|
|
24
28
|
::Liquid::Template.register_filter(::Jekyll::Filters)
|
|
25
29
|
# Ensure jekyll-asciidoc filters are registered
|
|
26
30
|
::Liquid::Template.register_filter(::Jekyll::AsciiDoc::Filters)
|
|
31
|
+
# Ensure Sourcerer filters are registered (last, so they can override)
|
|
32
|
+
::Liquid::Template.register_filter(::Sourcerer::Jekyll::Liquid::Filters)
|
|
27
33
|
# Ensure Sourcerer tags are registered
|
|
28
34
|
::Liquid::Template.register_tag('embed', ::Sourcerer::Jekyll::Liquid::Tags::EmbedTag)
|
|
29
35
|
end
|