distorted-jekyll 0.5.3 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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