distorted-jekyll 0.5.4 → 0.7.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +661 -0
  3. data/README.md +7 -11
  4. data/lib/distorted-jekyll.rb +75 -0
  5. data/lib/distorted-jekyll/13th-style.css +79 -0
  6. data/lib/distorted-jekyll/13th-style.rb +58 -0
  7. data/lib/distorted-jekyll/_config_default.yml +63 -0
  8. data/lib/distorted-jekyll/blocks.rb +16 -0
  9. data/lib/distorted-jekyll/invoker.rb +234 -0
  10. data/lib/distorted-jekyll/liquid_liquid.rb +255 -0
  11. data/lib/distorted-jekyll/liquid_liquid/anchor.liquid +5 -0
  12. data/lib/distorted-jekyll/liquid_liquid/anchor_inline.liquid +1 -0
  13. data/lib/distorted-jekyll/liquid_liquid/embed.liquid +1 -0
  14. data/lib/distorted-jekyll/liquid_liquid/img.liquid +1 -0
  15. data/lib/distorted-jekyll/liquid_liquid/object.liquid +5 -0
  16. data/lib/distorted-jekyll/liquid_liquid/picture.liquid +15 -0
  17. data/lib/distorted-jekyll/liquid_liquid/picture.rb +48 -0
  18. data/lib/distorted-jekyll/liquid_liquid/picture_source.liquid +1 -0
  19. data/lib/distorted-jekyll/liquid_liquid/root.liquid +5 -0
  20. data/lib/distorted-jekyll/liquid_liquid/video.liquid +5 -0
  21. data/lib/distorted-jekyll/liquid_liquid/video_source.liquid +1 -0
  22. data/lib/distorted-jekyll/md_injection.rb +310 -0
  23. data/lib/distorted-jekyll/media_molecule.rb +20 -0
  24. data/lib/distorted-jekyll/media_molecule/font.rb +21 -0
  25. data/lib/distorted-jekyll/media_molecule/image.rb +15 -0
  26. data/lib/distorted-jekyll/media_molecule/never_let_you_down.rb +28 -0
  27. data/lib/distorted-jekyll/media_molecule/pdf.rb +108 -0
  28. data/lib/distorted-jekyll/media_molecule/svg.rb +20 -0
  29. data/lib/distorted-jekyll/media_molecule/text.rb +23 -0
  30. data/lib/distorted-jekyll/media_molecule/video.rb +45 -0
  31. data/lib/distorted-jekyll/monkey_business/jekyll/cleaner.rb +121 -0
  32. data/lib/distorted-jekyll/static_state.rb +160 -0
  33. data/lib/distorted-jekyll/the_setting_sun.rb +179 -0
  34. metadata +37 -34
@@ -0,0 +1,255 @@
1
+ require 'liquid/drop'
2
+ require 'liquid/template'
3
+
4
+
5
+ module Cooltrainer
6
+
7
+ # DistorteD Liquid::Template-caching Hash.
8
+ # Jekyll has its own Liquid cache enabled by default as of 4.0,
9
+ # but the Jekyll::LiquidRenderer::File has a different interface
10
+ # than Liquid::Template (e.g. no :assigns accessor).
11
+ @@watering rescue begin
12
+ @@watering = Hash[]
13
+ end
14
+
15
+
16
+ # Entry-point for MediaMolecules to render HTML (and maybe eventually other formats!).
17
+ #
18
+ # Liquid is arguably a poor choice for this use case since it is designed
19
+ # to handle arbitrary user-supplied templates in a safe way,
20
+ # versus e.g. ERB which allows in-template execution of arbitrary Ruby code,
21
+ # but our templates are bundled here in the Gem (ostensibly) should be trustworthy.
22
+ #
23
+ # I considered using Nokogiri's DocumentFragment Builder instead:
24
+ # fragment = Nokogiri::HTML::DocumentFragment.parse(''.freeze)
25
+ # Nokogiri::HTML::Builder.with(fragment) do |doc|
26
+ # doc.picture {
27
+ # changes.each { |change| doc.source(:srcset=change.name) }
28
+ # }
29
+ # end
30
+ # fragment.to_html
31
+ #
32
+ # But the way DistorteD works (with MIME::Type#distorted_template_method)
33
+ # means we would need a way to collate child elements anyway, since each call
34
+ # to a :distorted_template_method should generate only one variation,
35
+ # meaning we'd end up with a bunch of <source> tag Strings but would
36
+ # still need to collect them under a parent <picture> with a sibling <img>.
37
+ #
38
+ # Liquid is already heavily used by Jekyll, and of course DistorteD-Jekyll
39
+ # itself is a Liquid::Tag, so we may as well use it.
40
+ # Nokogiri, on the other hand, is not an explicit dependency of Jekyll.
41
+ # It will most likely be available, and DD itself pulls it in via SVGO
42
+ # and others, but Liquid will also allow us the flexibility to render
43
+ # formats other than just HTML/XML, e.g. BBCode or even Markdown.
44
+ #
45
+ # I might revisit this design decision once I experience working with more
46
+ # media formats and in more page contexts :)
47
+ ElementalCreation = Struct.new(:element, :name, :parents, :children, :template, :assigns, :change, keyword_init: true) do
48
+
49
+ def initialize(element, change = nil, parents: nil, children: nil, template: nil, assigns: Hash[])
50
+ super(
51
+ # Symbol Template name, corresponding to a Liquid template file name,
52
+ # not necessarily corresponding to the exact name of the element we return.
53
+ element: element,
54
+ change: change,
55
+ name: change&.name || element,
56
+ # Symbol name or Enumerable[Symbol] names of parent Element(s) we should be under,
57
+ # in order from outermost to innermost nesting.
58
+ # This is used to collate Change templates under a required parent Element,
59
+ # e.g. <source> tags must be under a <picture> or a <video> tag.
60
+ parents: parents.nil? ? nil : (parents.is_a?(Enumerable) ? parents.to_a : Array[parents]),
61
+ # Set up a Hash to store any children of this element in an Array-like way
62
+ # using auto-incrementing Integer keys. Its `&default_proc` responds to Symbol Element names,
63
+ # uses :detect to search for and return the first instance of that Symbol iff one exists,
64
+ # and if not it instantiates an Element Struct for that symbol and stores it.
65
+ children: Hash.new { |children_hash, element| children_hash.values.detect(
66
+ # Enumerable#detect will call this `ifnone` Proc if :detect's block returns nil.
67
+ # This Proc will instantiate a new Element with a copy of our :change, then store and return it.
68
+ # Can remove the destructured empty Hash once Ruby 2.7 is gone.
69
+ ->{ children_hash.store(children_hash.length, self.class.new(element, change, **{}))}
70
+ ) { |child| child.element == element } if element.is_a?(Symbol) }.tap { |children_hash|
71
+ # Merge the children-given-to-initialize() into our Hash.
72
+ case children
73
+ when Array then children.map.with_index.with_object(children_hash) { |(v, i), h| h.store(i, v) }
74
+ when Hash then ch.merge(children)
75
+ end
76
+ },
77
+ # Our associated Liquid::Template, based on our element name.
78
+ template: template || self.WATERING(element),
79
+ # Container of variables we want to render in Liquid besides what's covered by the basics.
80
+ assigns: assigns,
81
+ )
82
+
83
+ # Go ahead and define accessors for any assigns we already know about.
84
+ # Others are supported via the :method_missing below.
85
+ assigns.each_key { |assign|
86
+ define_method(assign) do; self[:assigns].fetch(assign, nil); end
87
+ define_method("#{assign.to_s}=") do |value|; self[:assigns].store(assign, value); end
88
+ }
89
+ end
90
+
91
+ # Hash[String] => Integer containing weights for MIME::Type#sub_type sorting weights.
92
+ # Weights are assigned in auto-incrementing Array order and will be called from :<=>.
93
+ # Sub-types near the beginning will sort before sub-types near the bottom.
94
+ # This is important for things like <picture> tags where the first supported <source> child
95
+ # encountered is the one that will be used, so we want vector types (SVG) to come first,
96
+ # then modern annoying formats like AVIF/WebP, then old standby types like PNG.
97
+ # TODO: Sort things other than Images
98
+ SORT_WEIGHTS = [
99
+ 'svg+xml'.freeze,
100
+ 'avif'.freeze,
101
+ 'webp'.freeze,
102
+ 'png'.freeze,
103
+ 'jpeg'.freeze,
104
+ 'gif'.freeze,
105
+ ].map.with_index.to_h
106
+ # Return a static 0 weight for unknown sub_types.
107
+ SORT_WEIGHTS.default_proc = Proc.new { 0 }
108
+
109
+ # Elements should sort themselves under their parent when rendering.
110
+ # Use predefined weights, e.g.:
111
+ # irb> SORT_WEIGHTS['avif']
112
+ # => 1
113
+ # irb> SORT_WEIGHTS['png']
114
+ # => 3
115
+ # irb> SORT_WEIGHTS['avif'] <=> SORT_WEIGHTS['png']
116
+ # => -1
117
+ def <=>(otra)
118
+ SORT_WEIGHTS[self.change&.type&.sub_type] <=> SORT_WEIGHTS[otra&.change&.type&.sub_type]
119
+ end
120
+
121
+ # Take a child Element and store it.
122
+ # If it requests no parents, store it with us.
123
+ # If it requests a parent, forward it there to the same method.
124
+ def mad_child(moon_child)
125
+ parent = moon_child.parents&.shift
126
+ if parent.nil? # When shifting/popping an empty :parents Array
127
+ # Store the child with an incrementing Integer key as if
128
+ # self[Lchildren] were an Array
129
+ self[:children].store(self[:children].length, moon_child)
130
+ else
131
+ # Forward the child to the next level of ElementalCreation.
132
+ # The Struct will be instantiated by the :children Hash's &default_proc
133
+ self[:children][parent].mad_child(moon_child)
134
+ end
135
+ end
136
+
137
+ # Generic Liquid template loader
138
+ # Jekyll's site-wide Liquid renderer caches in 4.0+ and is usable via
139
+ # `site.liquid_renderer.file(cache_key).parse(liquid_string)`,
140
+ # but the Jekyll::LiquidRenderer::File you get back doesn't let us
141
+ # play with the `assigns` directly, so I stopped using the site renderer
142
+ # in favor of our own cache.
143
+ def WATERING(template_filename)
144
+ begin
145
+ # Memoize parsed Templates to this Struct subclass.
146
+ @@watering[template_filename] ||= begin
147
+ template = File.join(__dir__, 'liquid_liquid'.freeze, "#{template_filename}.liquid".freeze)
148
+ Jekyll.logger.debug('DistorteD::WATERING', template)
149
+ Liquid::Template.parse(File.read(template))
150
+ end
151
+ rescue Liquid::SyntaxError => l
152
+ # This shouldn't ever happen unless a new version of Liquid
153
+ # breaks syntax compatibility with our templates somehow.
154
+ l.message
155
+ end
156
+ end #WATERING
157
+
158
+ # Returns the rendered String contents of this and all child Elements.
159
+ def render
160
+ self[:template].render(
161
+ self[:assigns].merge(Hash[
162
+ # Most Elements will be associated with a Change Struct
163
+ # encapsulating a single wanted variation on the source file.
164
+ :change => self[:change].nil? ? nil : Cooltrainer::ChangeDrop.new(self[:change]),
165
+ # Create Elements for every wanted child if they were only given
166
+ # to us as Symbols, then render them all to Strings to include
167
+ # in our own output.
168
+ :children => self[:children]&.values.map { |child|
169
+ child.is_a?(Symbol) ? self.class.new(child, self[:change], parents: self[:name], assigns: self[:assigns]) : child
170
+ }.sort.map(&:render), # :sort will use ElementalCreation's :<=> method.
171
+ ]).transform_keys(&:to_s).transform_values { |value|
172
+ # Liquid wants String keys and values, not Symbols.
173
+ value.is_a?(Symbol) ? value.to_s : value
174
+ }
175
+ )
176
+ end
177
+
178
+ # Calling :flatten on an Array[self] will fail unless we override :to_ary to stop
179
+ # implicitly looking like an Array. Or we could implement :flatten, but this probably
180
+ # fixes other situations too.
181
+ # https://tenderlovemaking.com/2011/06/28/til-its-ok-to-return-nil-from-to_ary.html
182
+ def to_ary; nil; end
183
+
184
+ # Expose Liquid's 'assigns' accessors for any keys we weren't given upfront.
185
+ def method_missing(meth, *a)
186
+ # Grab the key from the method name, minus the trailing '=' for writers.
187
+ meth.to_s.end_with?('='.freeze) ? self[:assigns].store(meth.to_s.chomp('='.freeze).to_sym, a.first) : self[:assigns].fetch(meth, nil)
188
+ end
189
+
190
+ # Destination filename, or the element name.
191
+ def to_s; (self[:name] || self[:element]).to_s; end
192
+
193
+ # e.g. <ElementalCreation::source name=IIDX-turntable-full.svg, parents=[:anchor, :picture]>
194
+ def inspect; "<#{self.class.name.split('::'.freeze).last}::#{self[:element]} #{
195
+ [:name, :parents, :children].map { |k| (self[k].nil? || self[k]&.empty?) ? nil : " #{k.to_s}=#{self[k]}" }.compact.join(','.freeze)
196
+ }>"; end
197
+
198
+ end # Struct ElementalCreation
199
+
200
+
201
+ # Wrap a Change in a Drop that emits Element attribute keys as well as the value.
202
+ # An underlying ChangeDrop will instantiate us as an instance variable
203
+ # and forward its Change to us, then return that instance from its own :attr method.
204
+ #
205
+ # Our templates can use this to avoid emitting empty attributes
206
+ # for corresponding empty values by calling {{ change.attr.whatever }}
207
+ # instead of invoking the regular ChangeDrop via {{ change.whatever }}.
208
+ #
209
+ # For example, if a <source>'s Change defines a media-query,
210
+ # {{ change.media }} will emit the plain value (e.g. `min-width: 800px`)
211
+ # and would typically be used in a template inside an explicit attribute key,
212
+ # e.g. `<source … media="{{ change.media }}"\>`.
213
+ #
214
+ # A template could instead call this drop via e.g. <source … {{ attr_media }}\>`
215
+ # to emit the same thing if a media-query is set but emit nothing if one isn't!
216
+ class ChangeAttrDrop < Liquid::Drop
217
+ def initialize(change)
218
+ @change = change
219
+ end
220
+ def liquid_method_missing(method)
221
+ # The underlying ChangeDrop is what responds to :attr, so we only
222
+ # need to respond to the Change keys in the same way ChangeDrop does.
223
+ value = @change&.send(method.to_sym)
224
+ # Return an empty String if there is no value, otherwise return `attr="value"`.
225
+ # Intentional leading-space in output so Liquid tags can abut in templates.
226
+ value.nil? ? ''.freeze : " #{method.to_str}=\"#{value.to_s}\""
227
+ end
228
+ end # Struct ChangeAttrDrop
229
+
230
+
231
+ # Wrap a Change in a Drop that our Element Liquid::Templates can use
232
+ # to emit either values-alone or keys-and-values for any attribute
233
+ # of any one variation ot a media file.
234
+ class ChangeDrop < Liquid::Drop
235
+ def initialize(change)
236
+ @change = change
237
+ end
238
+ def initialism
239
+ @initialism ||= @change.type&.sub_type&.to_s.split(MIME::Type::SUB_TYPE_SEPARATORS)[0].upcase
240
+ end
241
+ def attr
242
+ # Use a ChangeAttrDrop to avoid emitting keys for empty values.
243
+ # It will respond to its own Change-key method just like we do.
244
+ @attr ||= Cooltrainer::ChangeAttrDrop.new(@change)
245
+ end
246
+ def liquid_method_missing(method)
247
+ # Liquid will send us String keys only, so translate them
248
+ # to Symbols when looking up in the Change Struct.
249
+ # Don't explicitly call :to_s before returning,
250
+ # because we might be returning an Array.
251
+ @change&.send(method.to_sym) || super
252
+ end
253
+ end # Struct ChangeDrop
254
+
255
+ end
@@ -0,0 +1,5 @@
1
+ <a>
2
+ {%- for child in children %}
3
+ {{ child }}
4
+ {%- endfor -%}
5
+ </a>
@@ -0,0 +1 @@
1
+ <a href="{{ change.dir }}{{ change.name }}{{ fragment }}">{{ change.name }} [{{ change.initialism }}]</a>
@@ -0,0 +1 @@
1
+ <embed src="{{ change.dir }}{{ change.name }}{{ fragment }}"{{ change.attr.type }}{{ change.attr.width }}{{ change.attr.height }}/>
@@ -0,0 +1 @@
1
+ <img src="{{ change.dir }}{{ change.name }}"{{ change.attr.alt }}{{ change.attr.title }}{{ change.attr.loading }}/>
@@ -0,0 +1,5 @@
1
+ <object data="{{ change.dir }}{{ change.name }}{{ fragment }}"{{ change.attr.type }}{{ change.attr.width }}{{ change.attr.height }}>
2
+ {%- for child in children %}
3
+ {{ child }}
4
+ {%- endfor -%}
5
+ </object>
@@ -0,0 +1,15 @@
1
+ {% assign img_child = nil -%}
2
+ <picture>
3
+ {% for child in children %}
4
+ {%- assign test_img = child | slice: 1, 3 -%}
5
+ {%- if test_img == 'img' -%}
6
+ {%- assign img_child = child -%}
7
+ {%- else -%}
8
+ {{ child }}
9
+ {%- endif -%}
10
+ {%- endfor -%}
11
+ {%- unless img_child -%}
12
+ {%- assign img_child = '<img/>' -%}
13
+ {%- endunless -%}
14
+ {{ img_child }}
15
+ </picture>
@@ -0,0 +1,48 @@
1
+ require 'distorted-jekyll/liquid_liquid'
2
+ require 'distorted/modular_technology/vips/save'
3
+ require 'distorted/media_molecule'
4
+
5
+ module Jekyll; end
6
+ module Jekyll::DistorteD; end
7
+ module Jekyll::DistorteD::LiquidLiquid; end
8
+ module Jekyll::DistorteD::LiquidLiquid::Picture
9
+
10
+
11
+ # Returns a CSS media query String for a full-size Image outer_limit allowing btowsers,
12
+ # to properly consider its <source> alongside any generated resized versions of the same Image.
13
+ # https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries
14
+ def self.full_size_media_query(change, vips_image)
15
+ # This is kinda hacky, but use the Setting loader to look for `:outer_limits`
16
+ # of this Type that are larger than this `vips_image` since we won't have visibility of
17
+ # other `changes` from this Class instance method.
18
+ larger_than_us = Jekyll::DistorteD::the_setting_sun(:outer_limits, *(change.type.settings_paths))
19
+ .map { |l| l.fetch(:width, nil)} # Look for a :width key in every outer limit.
20
+ .compact # Discard any limits that don't define :width.
21
+ .keep_if { |w| w > vips_image.width } # Discard any limits whose :width is smaller than us.
22
+ # Add an additional `max` constraint to the media query if this `vips_image`
23
+ # is not the largest one in the <picture>.
24
+ # This effectively makes this <source> useless since so few user-agents will ever
25
+ # have a viewport the exact pixel width of this image, but for our use case
26
+ # it's better to have a useless media query than no media query since it will
27
+ # make browsers pick a better variation than this one.
28
+ return "(min-width: #{vips_image.width}px)#{" and (max-width: #{vips_image.width}px)" unless larger_than_us.empty?}"
29
+ end
30
+
31
+ # Returns an anonymous method that generates a <source> tag for Image output Types.
32
+ def self.render_picture_source
33
+ @@render_picture_source = lambda { |change|
34
+ # Fill in missing CSS media queries for any original-size (tag == null) Change that lacks one.
35
+ if change.width.nil? and not change.type.sub_type.include?('svg'.freeze)
36
+ change.width = to_vips_image.width
37
+ end
38
+ Cooltrainer::ElementalCreation.new(:picture_source, change, parents: Array[:anchor, :picture])
39
+ }
40
+ end
41
+
42
+ # Lots of MediaMolecules will want to render image representations of various media_types,
43
+ # so define a render method once for every Vips-supported Type that can be included/shared.
44
+ Cooltrainer::DistorteD::IMPLANTATION(:OUTER_LIMITS, Cooltrainer::DistorteD::Technology::Vips::Save).each_key { |type|
45
+ define_method(type.distorted_template_method, Jekyll::DistorteD::LiquidLiquid::Picture::render_picture_source)
46
+ }
47
+
48
+ end
@@ -0,0 +1 @@
1
+ <source srcset="{{ change.dir }}{{ change.basename }}{{ change.extname }}{% if change.width %} {{ change.width }}w{% endif %}{% for break_width in change.breaks %}, {{ change.dir }}{{ change.basename }}-{{ break_width }}{{ change.extname }} {{ break_width }}w{% endfor %}"{{ change.attr.type }}{{ change.attr.media }}/>
@@ -0,0 +1,5 @@
1
+ <div class="{{ dan }}">
2
+ {%- for child in children %}
3
+ {{ child }}
4
+ {%endfor%}
5
+ </div>
@@ -0,0 +1,5 @@
1
+ <video controls>
2
+ {% for child in children %}
3
+ {{ child }}
4
+ {%- endfor -%}
5
+ </video>
@@ -0,0 +1 @@
1
+ <source src="{{ change.dir }}{{ change.name }}"{{ change.attr.type }}{{ change.attr.media }}/>
@@ -0,0 +1,310 @@
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 flatten_attributes(el, type = :img)
219
+ matched = {}
220
+
221
+ if el.is_a? Enumerable
222
+ # Support an Array of elements...
223
+ el.each {
224
+ |child| matched.merge!(flatten_attributes(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.merge!(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.merge!(flatten_attributes(child, type))
240
+ }
241
+ end
242
+ end
243
+ matched
244
+ end
245
+
246
+ # Geenerates a DistorteD Liquid tag String given a Hash of element attributes.
247
+ # Examples:
248
+ # {% distorted rpg-ra11.nfo alt="HellMarch INTENSIFIES" title="C&C RA2:YR NoCD NFO" encoding="IBM437" crop="none" %}
249
+ # {% distorted DistorteD.png alt="DistorteD logo" title="This is so cool" crop="none" loading="lazy" %}
250
+ # The :additional_defaults Hash contains attribute values to set
251
+ # iff those attributes have no user-given value.
252
+ def distorted(attributes, additional_defaults: {})
253
+ "{% distorted #{additional_defaults.transform_keys{ |k|
254
+ # We will end up with a Hash of String-keys and String-values,
255
+ # so make sure override-defaults are String-keyed too.
256
+ k.to_s
257
+ }.merge(attributes).select{ |k, v|
258
+ # Filter out empty values, e.g. from an unfilled title/alt field
259
+ # in a Markdown image.
260
+ not v.empty?
261
+ }.map{ |k, v|
262
+ k.to_s + '="'.freeze + v.to_s + '"'.freeze
263
+ }.join(' '.freeze)} %}"
264
+ end
265
+
266
+ # Kramdown entry point
267
+ def convert(el)
268
+ # The parsed "images" may also be audio, video, or some other
269
+ # media type. There is only one Markdown image tag, however.
270
+ imgs = children(el, :img)
271
+
272
+ # Enable conceptual-grouping (BLOCKS) mode if the count of list item
273
+ # elements matches the count of image elements in our
274
+ # chunk of Markdown. Technically I should check to make sure each
275
+ # image is the child of one of those list items,
276
+ # but this is way easier until I (hopefully never) find a parsing
277
+ # corner-case where this doesn't hold up.
278
+ lists = children(el, :li)
279
+ list_imgs = lists.map{|li| children(li, :img)}.flatten
280
+
281
+ case lists.count
282
+ when 0..1
283
+ # Render one (1) image/video/whatever. This behavior is the same
284
+ # regardless if the image is in a single-item list or just by itself.
285
+ distorted(
286
+ flatten_attributes(imgs.first),
287
+ additional_defaults: {:crop => 'none'.freeze},
288
+ )
289
+ else
290
+ # Render a conceptual group (DD::BLOCKS)
291
+
292
+ if imgs.count != list_imgs.count
293
+ # Sanity check :img count vs :img-in-:li count.
294
+ # We should support the corner case where the regex matches
295
+ # multiple consecutive lines, but with mixed list item status,
296
+ # e.g. a solo image abuts a conceptual group and gets globbed
297
+ # into a single match.
298
+ # For now, however:
299
+ raise "MD->img regex returned an unequal number of listed and unlisted tags."
300
+ end
301
+
302
+ "{% distort -%}\n#{list_imgs.map{ |img|
303
+ distorted(flatten_attributes(img))
304
+ }.join("\n")}\n{% enddistort %}"
305
+ end
306
+ end
307
+
308
+ end
309
+ end
310
+ end