distorted-jekyll 0.5.3 → 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.
@@ -0,0 +1,259 @@
1
+ # Our custom Exceptions
2
+ require 'distorted/error_code'
3
+
4
+ # MIME::Typer
5
+ require 'distorted/checking_you_out'
6
+
7
+ # Configuration-loading code
8
+ require 'distorted-jekyll/floor'
9
+ require 'distorted-jekyll/static_state'
10
+
11
+ # Media-type drivers
12
+ require 'distorted-jekyll/molecule/font'
13
+ require 'distorted-jekyll/molecule/image'
14
+ require 'distorted-jekyll/molecule/text'
15
+ require 'distorted-jekyll/molecule/pdf'
16
+ require 'distorted-jekyll/molecule/svg'
17
+ require 'distorted-jekyll/molecule/video'
18
+ require 'distorted-jekyll/molecule/lastresort'
19
+
20
+ # Set.to_hash
21
+ require 'distorted/monkey_business/set'
22
+
23
+ # Slip in and out of phenomenon
24
+ require 'liquid/tag'
25
+ require 'liquid/tag/parser'
26
+
27
+ # Explicitly required for l/t/parser since a1cfa27c27cf4d4c308da2f75fbae88e9d5ae893
28
+ require 'shellwords'
29
+
30
+ # Set is in stdlib but is not in core.
31
+ require 'set'
32
+
33
+ # I mean, this is why we're here, right?
34
+ require 'jekyll'
35
+
36
+
37
+ module Jekyll
38
+ module DistorteD
39
+ class Invoker < Liquid::Tag
40
+
41
+ GEM_ROOT = File.dirname(__FILE__).freeze
42
+
43
+ # Mix in config-loading methods.
44
+ include Jekyll::DistorteD::Floor
45
+ include Jekyll::DistorteD::StaticState
46
+
47
+ # Enabled media_type drivers. These will be attempted back to front.
48
+ # TODO: Make this configurable.
49
+ MEDIA_MOLECULES = [
50
+ Jekyll::DistorteD::Molecule::LastResort,
51
+ Jekyll::DistorteD::Molecule::Font,
52
+ Jekyll::DistorteD::Molecule::Text,
53
+ Jekyll::DistorteD::Molecule::PDF,
54
+ Jekyll::DistorteD::Molecule::SVG,
55
+ Jekyll::DistorteD::Molecule::Video,
56
+ Jekyll::DistorteD::Molecule::Image,
57
+ ]
58
+ # Reduce the above to a Hash of Sets of MediaMolecules-per-Type, keyed by Type.
59
+ TYPE_MOLECULES = MEDIA_MOLECULES.reduce(
60
+ Hash.new{|hash, key| hash[key] = Set[]}
61
+ ) { |types, molecule|
62
+ if molecule.const_defined?(:LOWER_WORLD)
63
+ molecule.const_get(:LOWER_WORLD).each { |t|
64
+ types.update(t => Set[molecule]) { |k,o,n| o.merge(n) }
65
+ }
66
+ end
67
+ types
68
+ }
69
+
70
+ # Any any attr value will get a to_sym if shorter than this
71
+ # totally arbitrary length, or if the attr key is in the plugged
72
+ # Molecule's set of attrs that take only a defined set of values.
73
+ # My chosen boundary length fits all of the outer-limit tag names I use,
74
+ # like 'medium'. It fits the longest value of Vips::Interesting too,
75
+ # though `crop` will be symbolized based on the other condition.
76
+ ARBITRARY_ATTR_SYMBOL_STRING_LENGTH_BOUNDARY = 13
77
+
78
+
79
+ # 𝘏𝘖𝘞 𝘈𝘙𝘌 𝘠𝘖𝘜 𝘎𝘌𝘕𝘛𝘓𝘌𝘔𝘌𝘕 !!
80
+ def initialize(tag_name, arguments, liquid_options)
81
+ super
82
+ # Tag name as given to Liquid::Template.register_tag().
83
+ @tag_name = tag_name.to_sym
84
+
85
+ # Liquid leaves argument parsing totally up to us.
86
+ # Use the envygeeks/liquid-tag-parser library to wrangle them.
87
+ parsed_arguments = Liquid::Tag::Parser.new(arguments)
88
+
89
+ # Filename is the only non-keyword argument our tag should ever get.
90
+ # It's spe-shul and gets its own definition outside the attr loop.
91
+ if parsed_arguments.key?(:src)
92
+ @name = parsed_arguments.delete(:src)
93
+ else
94
+ @name = parsed_arguments.delete(:argv1)
95
+ end
96
+ @liquid_liquid = parsed_arguments.select{ |attr, val|
97
+ not [nil, ''.freeze].include?(val)
98
+ }.transform_keys { |attr|
99
+ attr.length <= ARBITRARY_ATTR_SYMBOL_STRING_LENGTH_BOUNDARY ? attr.to_sym : attr.freeze
100
+ }.transform_values { |val|
101
+ if val.respond_to?(:length)
102
+ val.length <= ARBITRARY_ATTR_SYMBOL_STRING_LENGTH_BOUNDARY ? val.to_sym : val.freeze
103
+ else
104
+ val
105
+ end
106
+ }
107
+
108
+ # If we didn't get one of the two above options there is nothing we
109
+ # can do but bail.
110
+ unless @name
111
+ raise "Failed to get a usable filename from #{arguments}"
112
+ end
113
+
114
+ end
115
+
116
+ # Returns a Set of DD MIME::Types descriving our file,
117
+ # optionally falling through to a plain file copy.
118
+ def type_mars
119
+ @type_mars ||= begin
120
+ mime = CHECKING::YOU::OUT(@name)
121
+ if mime.empty?
122
+ if Jekyll::DistorteD::Floor::config(Jekyll::DistorteD::Floor::CONFIG_ROOT, :last_resort)
123
+ mime = Jekyll::DistorteD::Molecule::LastResort::LOWER_WORLD
124
+ end
125
+ end
126
+ mime
127
+ end
128
+ end
129
+
130
+ # Return any arguments given by the user to our Liquid tag.
131
+ # This method name is generic across all DD entrypoints so it can be
132
+ # referenced from lower layers in the pile.
133
+ def user_arguments
134
+ @liquid_liquid || Hash[]
135
+ end
136
+
137
+ # Decides which MediaMolecule is most appropriate for our file and returns it.
138
+ def media_molecule
139
+ available_molecules = TYPE_MOLECULES.keys.to_set & type_mars
140
+ # TODO: Handle multiple molecules for the same file
141
+ case available_molecules.length
142
+ when 0
143
+ raise MediaTypeNotImplementedError.new(@name)
144
+ when 1
145
+ return TYPE_MOLECULES[available_molecules.first].first
146
+ end
147
+ end
148
+
149
+ def plug
150
+ unless self.singleton_class.instance_variable_defined?(:@media_molecule)
151
+ self.singleton_class.instance_variable_set(:@media_molecule, media_molecule)
152
+ self.singleton_class.prepend(media_molecule)
153
+ Jekyll.logger.info(@name, "Plugging #{media_molecule}")
154
+ end
155
+ end
156
+
157
+ # Called by Jekyll::Renderer
158
+ # https://github.com/jekyll/jekyll/blob/HEAD/lib/jekyll/renderer.rb
159
+ # https://jekyllrb.com/tutorials/orderofinterpretation/
160
+ def render(context)
161
+ plug
162
+ render_to_output_buffer(context, '')
163
+ end
164
+
165
+ # A future Liquid version (5.0?) will call this function directly
166
+ # instead of calling render()
167
+ def render_to_output_buffer(context, output)
168
+ plug
169
+ # Get Jekyll Site object back from tag rendering context registers so we
170
+ # can get configuration data and path information from it and
171
+ # then pass it along to our StaticFile subclass.
172
+ @site = context.registers[:site]
173
+
174
+ # The rendering context's `first` page will be the one that invoked us.
175
+ page_data = context.environments.first['page'.freeze]
176
+
177
+ #
178
+ # Our subclass' additional args:
179
+ # dest - The String path to the generated `url` folder of the page HTML output
180
+ @base = @site.source
181
+
182
+ # `relative_path` doesn't seem to always exist, but `path` does? idk.
183
+ # I was testing with `relative_path` only with `_posts`, but it broke
184
+ # when I invoked DD on a _page. Both have `path`.
185
+ @dir = File.dirname(page_data['path'.freeze])
186
+
187
+ # Every one of Ruby's `File.directory?` / `Pathname.directory?` /
188
+ # `FileTest.directory?` methods actually tests that path on the
189
+ # real filesystem, but we shouldn't look at the FS here because
190
+ # this function gets called when the Site.dest directory does
191
+ # not exist yet!
192
+ # Hackily look at the last character to see if the URL is a
193
+ # directory (like configured on cooltrainer) or a `.html`
194
+ # (or other extension) like the default Jekyll config.
195
+ # Get the dirname if the url is not a dir itself.
196
+ @relative_dest = page_data['url'.freeze]
197
+ unless @relative_dest[-1] == Jekyll::DistorteD::Floor::PATH_SEPARATOR
198
+ @relative_dest = File.dirname(@relative_dest)
199
+ # Append the trailing slash so we don't have to do it
200
+ # in the Liquid templates.
201
+ @relative_dest << Jekyll::DistorteD::Floor::PATH_SEPARATOR
202
+ end
203
+
204
+ # Add our new file to the list that will be handled
205
+ # by Jekyll's built-in StaticFile generator.
206
+ @site.static_files << self
207
+ output
208
+ end
209
+
210
+ # Generic Liquid template loader that will be used in every MediaMolecule.
211
+ # Callers will call `render(**{:template => vars})` on the Object returned
212
+ # by this method.
213
+ def parse_template(site: nil, name: nil)
214
+ site = site || @site || Jekyll.sites.first
215
+ begin
216
+ # Use a given filename, or detect one based on media-type.
217
+ if name.nil?
218
+ # e.g. Jekyll::DistorteD::Molecule::Image -> 'image.liquid'
219
+ name = "#{self.singleton_class.instance_variable_get(:@media_molecule).name.gsub(/^.*::/, '').downcase}.liquid".freeze
220
+ elsif not name.include?('.liquid'.freeze)
221
+ # Support filename arguments with and without file extension.
222
+ # The given String might already be frozen, so concatenating
223
+ # the extension might fail. Just set a new version.
224
+ name = "#{name}.liquid"
225
+ end
226
+ template = File.join(
227
+ self.singleton_class.const_get(:GEM_ROOT),
228
+ 'template'.freeze,
229
+ name,
230
+ )
231
+
232
+ # Jekyll's Liquid renderer caches in 4.0+.
233
+ if Jekyll::DistorteD::Floor::config(
234
+ Jekyll::DistorteD::Floor::CONFIG_ROOT,
235
+ :cache_templates,
236
+ )
237
+ # file(path) is the caching function, with path as the cache key.
238
+ # The `template` here will be the full path, so no versions of this
239
+ # gem should ever conflict. For example, right now during dev it's:
240
+ # `/home/okeeblow/Works/DistorteD/lib/image.liquid`
241
+ Jekyll.logger.debug('DistorteD', "Parsing #{template} with caching renderer.")
242
+ site.liquid_renderer.file(template).parse(File.read(template))
243
+ else
244
+ # Re-read the template just for this piece of media.
245
+ Jekyll.logger.debug('DistorteD', "Parsing #{template} with fresh (uncached) renderer.")
246
+ Liquid::Template.parse(File.read(template))
247
+ end
248
+
249
+ rescue Liquid::SyntaxError => l
250
+ # This shouldn't ever happen unless a new version of Liquid
251
+ # breaks syntax compatibility with our templates somehow.
252
+ l.message
253
+ end
254
+ end # parse_template
255
+
256
+
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,305 @@
1
+ require 'kramdown'
2
+
3
+ # Replace standard Markdown image syntax with instances of DistorteD
4
+ # via its Liquid tag.
5
+ #
6
+ #
7
+ ### SYNTAX
8
+ #
9
+ # I'm calling individual media elements "images" here because I'm overloading
10
+ # the Markdown image syntax to express them. There is no dedicated
11
+ # syntax for other media types in any flavor of Markdown as of 2019,
12
+ # so that seemed like the cleanest and sanest way to deal
13
+ # with non-images in DistorteD.
14
+ #
15
+ # DistorteD::Invoker will do the media type inspection and handling
16
+ # once we get into Liquid Land. This is the approach suggested by
17
+ # CommonMark: https://talk.commonmark.org/t/embedded-audio-and-video/441/15
18
+ #
19
+ # Media elements will display as a visibly related group when
20
+ # two or more Markdown image tags exist in consecutive Markdown
21
+ # unordered (e.g. *-+) list item or ordered (e.g. 1.) list item lines.
22
+ # Their captions should be hidden until opened in a lightbox or as a tooltip
23
+ # on desktop browsers via ::hover state.
24
+ #
25
+ # The inspiration for this display can be seen in the handling
26
+ # of two, three, or four images in any single post on Tw*tter.
27
+ # DistorteD expands on the concept by supporting things such as groups
28
+ # of more than four media elements and elements of heterogeneous media types.
29
+ #
30
+ # Standalone image elements (not contained in list item) should be displayed
31
+ # as single solo elements spanning the entire usable width of the
32
+ # container element, whatever that happens to be at the point where
33
+ # our regex excised a block of Markdown for us to work with.
34
+ #
35
+ #
36
+ ### TECHNICAL CONSIDERATIONS
37
+ #
38
+ # Jekyll processes Liquid templates before processing page/post Markdown,
39
+ # so we can't rely on customizing the Markdown renderer's `:img` element
40
+ # output as a means to invoke DistorteD.
41
+ # https://jekyllrb.com/tutorials/orderofinterpretation/
42
+ #
43
+ # Prefer the POSIX-style bracket expressions (e.g. [[:digit:]]) over
44
+ # basic character classes (e.g. \d) in regex because they match Unicode
45
+ # instead of just ASCII.
46
+ # https://ruby-doc.org/core/Regexp.html#class-Regexp-label-Character+Classes
47
+ #
48
+ #
49
+ ### INLINE ATTRIBUTE LISTS
50
+ #
51
+ # Support additional arguments passed to DistorteD via Kramdown-style
52
+ # inline attribute lists.
53
+ # https://kramdown.gettalong.org/syntax.html#images
54
+ # https://kramdown.gettalong.org/syntax.html#inline-attribute-lists
55
+ #
56
+ # IALs can be on the same line or on a line before or after their
57
+ # associated flow element.
58
+ # In ambiguous situations (flow elements both before and after an IAL),
59
+ # the IAL applies to the flow element before it.
60
+ # All associated elements and IALs must be on contiguous lines.
61
+ #
62
+ # This page provides a regex for parsing IALs, Section 5.3:
63
+ # https://golem.ph.utexas.edu/~distler/maruku/proposal.html
64
+ #
65
+ #
66
+ ### SOLUTION
67
+ #
68
+ # A `pre_render` hook uses this regex to process Markdown source files
69
+ # and replace instances of the Markdown image syntax with instances of
70
+ # DistorteD's Liquid tags.
71
+ # Single images will be replaced with {% distorted %}.
72
+ # Multiple list-item images will be replaced with a {% distort %} block.
73
+ #
74
+ # By doing with with a regex (sorry!!) I hope to avoid a hard dependency on
75
+ # any one particular Markdown engine. Though I only support Kramdown for now,
76
+ # any engine that supports IALs should be fine.
77
+ #
78
+ # High-level explanation of what we intend to match:
79
+ #
80
+ # {:optional_ial => line_before_image} # Iff preceded by a blank line!
81
+ # (optional_list_item)? ![alt](image){:optional_ial => same_line}
82
+ # {:optional_ial => next_consecutive_line}
83
+ # Repeat both preceding matches (together) any number of times to parse
84
+ # a {% distort %} block.
85
+ # See inline comments below for more detail.
86
+ MD_IMAGE_REGEX = %r&
87
+ # Matching group of a single image tag.
88
+ (
89
+ # Optional preceding-line attribute list.
90
+ (
91
+ # One blank line, because:
92
+ # "If a block IAL is directly after and before a block-level element,
93
+ # it is applied to preceding element." —Kramdown BAL docs
94
+ #
95
+ # /\R/ - A linebreak: \n, \v, \f, \r \u0085 (NEXT LINE),
96
+ # \u2028 (LINE SEPARATOR), \u2029 (PARAGRAPH SEPARATOR) or \r\n.
97
+ ^$\R
98
+ # Any amount of blank space on the line before block IAL
99
+ ^[[:blank:]]*
100
+ # IAL regex from Section 5.3:
101
+ # https://golem.ph.utexas.edu/~distler/maruku/proposal.html
102
+ (?<block_ial_before>\{:(\\\}|[^\}])*\})
103
+ # Any amount of trailing whitespace followed by a newline.
104
+ [[:blank:]]*$\R
105
+ )? # Match all of that, or nothing.
106
+ # Begin matching the line that contains an image.
107
+ ^
108
+ # Match anything that might be between that start-of-line
109
+ # and the first character (!) of an image.
110
+ (
111
+ # From Gruber's original Markdown page:
112
+ # "List markers typically start at the left margin, but may be indented
113
+ # by up to three spaces."
114
+ # Include both unordered (-+*) and ordered (\d\. like `1.`) lists.
115
+ (?<li>[ ]{0,3}[-\*\+|\d\.]
116
+ # Support an optional IAL for the list element as shown in Kramdown docs:
117
+ # https://kramdown.gettalong.org/syntax.html#lists
118
+ # Ctrl+F "{:.cls}"
119
+ (?<li_ial>\{:(\\\}|[^\}])*\})?
120
+ # "List markers must be followed by one or more spaces or a tab."
121
+ # https://daringfireball.net/projects/markdown/syntax#list
122
+ ([ ]+|\t))
123
+ )? # Although any preceding elements are optional!
124
+ # Match Markdown image syntax:
125
+ # ![alt text](/some/path/to/image.png 'title text'){:other="options"}
126
+ # beginning with the alt tag:
127
+ !\[(?<alt>(\\[[:print:]]|[^\]])*)\]
128
+ # Continuing with img src as anything after the '(' and before ')' or
129
+ # before anything that could be a title.
130
+ # Assume titles will be quoted.
131
+ \((?<src>(\\[[:print:]]|[^'")]+))
132
+ # Title is optional.
133
+ # Ignore double-quotes in single-quoted titles and single-quotes
134
+ # in double-quoted titles otherwise we can't use contractions.
135
+ # Don't include the title's opening or closing quotes in the capture.
136
+ ('(?<title>(\\[[:print:]]|[^']*))'|"(?<title>(\\[[:print:]]|[^"]*))")?
137
+ # The closing ')' will always be present, title or no.
138
+ \)
139
+ # Optional IAL on the same line as the :img element **with no space between them**:
140
+ # "A span IAL (or two or more span IALs) has to be put directly after
141
+ # the span-level element to which it should be applied, no additional
142
+ # character is allowed between, otherwise it is ignored and only
143
+ # removed from the output." —Kramdown IAL docs
144
+ (?<span_ial>\{:(\\\}|[^\}])*\})*[[:print:]]*$\R
145
+ # Also support optional BALs on the lines following the image.
146
+ (^[[:blank:]]*(?<block_ial_after>\{:(\\\}|[^\}])*\})+$\R)*
147
+ )+ # Capture multiple images together for block display.
148
+ &x
149
+
150
+
151
+ def md_injection
152
+ Proc.new { |document, payload|
153
+ # Compare any given document's file extension to the list of enabled
154
+ # Markdown file extensions in Jekyll's config.
155
+ if payload['site']['markdown_ext'].include? document.extname.downcase[1..-1]
156
+ # Convert Markdown images to {% distorted %} tags.
157
+ #
158
+ # Use the same Markdown parser as Jekyll to avoid parsing inconsistencies
159
+ # between this pre_render hook and the main Markdown render step.
160
+ # This is still effectively a Markdown-parsing regex (and still
161
+ # effectively a bad idea lol) but it's the cleanest way I can come up
162
+ # with right now for separating DistorteD-destined Markdown from
163
+ # the rest of any given page.
164
+ # NOTE: Attribute List Definitions elsewhere in a Markdown document
165
+ # will be lost when converting this way. I might end up just parsing
166
+ # the entire document once with my own `to_liquid` converter, but I've been
167
+ # avoiding that as it seems wasteful because Jekyll then renders the entire
168
+ # Markdown document a second time immediately after our Liquid tag.
169
+ # It's fast enough that I should stop trying to prematurely optimize this :)
170
+ # TODO: Implement MD → DD love using only the #{CONFIGURED_MARKDOWN_ENGINE},
171
+ # searching for :img elements inside :li elements to build BLOCKS.
172
+ document.content = document.content.gsub(MD_IMAGE_REGEX) { |match|
173
+ Kramdown::Document.new(match).to_liquid
174
+ }
175
+ end
176
+ }
177
+ end
178
+
179
+
180
+ # Kramdown implementation of Markdown AST -> Liquid converter.
181
+ # Renders Markdown element attributes as key=value, also under the assumption
182
+ # of using gem 'liquid-tag-parser': https://github.com/dmalan/liquid-tag-parser
183
+ module Kramdown
184
+ module Converter
185
+ class Liquid < Base
186
+
187
+ # The incoming parsed Markdown tree will include many spurious elements,
188
+ # like container paragraph elements and the list item elements when
189
+ # parsing DD Grid style Markdown. Use this function to map a tree of
190
+ # arbitrary elements to a flat list of elements of a single type.
191
+ def children(el, type)
192
+ matched = []
193
+
194
+ if el.is_a? Enumerable
195
+ # We might want to run this against an Array output from an
196
+ # earlier invokation of this method.
197
+ el.each {
198
+ |item| matched.push(*children(item, type))
199
+ }
200
+ elsif el.type.equal? type
201
+ # If we find the type we're looking for, stop and return it.
202
+ # Let it bring its children along with it instead of recursing
203
+ # into them. This will let us match container-only elements
204
+ # such as <li> by type without considering the type of its children,
205
+ # for situation where its children are really what we want.
206
+ matched.push(el)
207
+ else
208
+ # Otherwise keep looking down the tree.
209
+ unless el.children.empty?
210
+ el.children.each {
211
+ |child| matched.push(*children(child, type))
212
+ }
213
+ end
214
+ end
215
+ matched
216
+ end
217
+
218
+ def attrs(el, type = :img)
219
+ matched = []
220
+
221
+ if el.is_a? Enumerable
222
+ # Support an Array of elements...
223
+ el.each {
224
+ |child| matched.push(*attrs(child, type))
225
+ }
226
+ else
227
+ # ...or a tree of elements.
228
+ if el.type.equal? type
229
+ # Images won't have a `:value` — only `:attr`s — and the only
230
+ # important things in their `:options` (e.g. IAL contents)
231
+ # will be duplicated in `class` or some other `:attr` anyway.
232
+ # Those things should be added here if this is ever used in a
233
+ # more generic context than just parsing the image tags.
234
+ matched << el.attr unless el.attr.empty?
235
+ end
236
+ unless el.children.empty?
237
+ # Keep looking even if this element was one we are looking for.
238
+ el.children.each {
239
+ |child| matched.push(*attrs(child, type))
240
+ }
241
+ end
242
+ end
243
+ matched
244
+ end
245
+
246
+ # Convert Markdown element attributes to a string key=value,
247
+ # except for `src` (DD-specific)
248
+ def to_attrs(k, v)
249
+ # DistorteD prefers the media filename as a positional argument,
250
+ # not a named kwarg.
251
+ if k == 'src'
252
+ v.to_s
253
+ else
254
+ k.to_s + '="' + v.to_s + '"'
255
+ end
256
+ end
257
+
258
+ def distorted(attrs)
259
+ "{% distorted #{attrs.map{|k,v| to_attrs(k, v)}.join(' ')} %}"
260
+ end
261
+
262
+ # Kramdown entry point
263
+ def convert(el)
264
+ # The parsed "images" may also be audio, video, or some other
265
+ # media type. There is only one Markdown image tag, however.
266
+ imgs = children(el, :img)
267
+
268
+ # Enable conceptual-grouping (BLOCKS) mode if the count of list item
269
+ # elements matches the count of image elements in our
270
+ # chunk of Markdown. Technically I should check to make sure each
271
+ # image is the child of one of those list items,
272
+ # but this is way easier until I (hopefully never) find a parsing
273
+ # corner-case where this doesn't hold up.
274
+ lists = children(el, :li)
275
+ list_imgs = lists.map{|li| children(li, :img)}.flatten
276
+
277
+ case lists.count
278
+ when 0..1
279
+ # Render one (1) image/video/whatever. This behavior is the same
280
+ # regardless if the image is in a single-item list or just by itself.
281
+ distorted(attrs(imgs.first)&.first&.merge({
282
+ # Images default to `attention` cropping for desktop/mobile versatility.
283
+ # Override this for single images unless a cropping value was already set.
284
+ 'crop'.freeze => attrs(imgs.first)&.first&.dig('crop'.freeze) || 'none'.freeze,
285
+ }))
286
+ else
287
+ # Render a conceptual group (DD::BLOCKS)
288
+
289
+ if imgs.count != list_imgs.count
290
+ # Sanity check :img count vs :img-in-:li count.
291
+ # We should support the corner case where the regex matches
292
+ # multiple consecutive lines, but with mixed list item status,
293
+ # e.g. a solo image abuts a conceptual group and gets globbed
294
+ # into a single match.
295
+ # For now, however:
296
+ raise "MD->img regex returned an unequal number of listed and unlisted tags."
297
+ end
298
+
299
+ "{% distort -%}\n#{list_imgs.map{|img| distorted(*attrs(img))}.join("\n")}\n{% enddistort %}"
300
+ end
301
+ end
302
+
303
+ end
304
+ end
305
+ end