asciisourcerer 0.4.0 → 0.5.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.
@@ -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:, **options
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])
@@ -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
 
@@ -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
@@ -18,12 +18,17 @@ module Sourcerer
18
18
  Bootstrapper.load_plugins
19
19
  Monkeypatches.patch_jekyll
20
20
 
21
- # Ensure Sourcerer filters are registered
22
- ::Liquid::Template.register_filter(::Sourcerer::Jekyll::Liquid::Filters)
21
+ # Registration order matters: Liquid's Strainer `include`s each module in
22
+ # turn, so a later registration wins over an earlier one for any
23
+ # same-named filter method. Sourcerer's filters register LAST so that,
24
+ # e.g., its `inspect` (which adds a `format` argument) overrides
25
+ # Jekyll's `inspect` rather than being shadowed by it.
23
26
  # Ensure Jekyll filters are registered
24
27
  ::Liquid::Template.register_filter(::Jekyll::Filters)
25
28
  # Ensure jekyll-asciidoc filters are registered
26
29
  ::Liquid::Template.register_filter(::Jekyll::AsciiDoc::Filters)
30
+ # Ensure Sourcerer filters are registered (last, so they can override)
31
+ ::Liquid::Template.register_filter(::Sourcerer::Jekyll::Liquid::Filters)
27
32
  # Ensure Sourcerer tags are registered
28
33
  ::Liquid::Template.register_tag('embed', ::Sourcerer::Jekyll::Liquid::Tags::EmbedTag)
29
34
  end