distorted-jekyll 0.5.4 → 0.7.0

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