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.
- checksums.yaml +4 -4
- data/LICENSE +661 -0
- data/README.md +7 -11
- data/lib/distorted-jekyll.rb +75 -0
- data/lib/distorted-jekyll/13th-style.css +79 -0
- data/lib/distorted-jekyll/13th-style.rb +58 -0
- data/lib/distorted-jekyll/_config_default.yml +63 -0
- data/lib/distorted-jekyll/blocks.rb +16 -0
- data/lib/distorted-jekyll/invoker.rb +234 -0
- data/lib/distorted-jekyll/liquid_liquid.rb +255 -0
- data/lib/distorted-jekyll/liquid_liquid/anchor.liquid +5 -0
- data/lib/distorted-jekyll/liquid_liquid/anchor_inline.liquid +1 -0
- data/lib/distorted-jekyll/liquid_liquid/embed.liquid +1 -0
- data/lib/distorted-jekyll/liquid_liquid/img.liquid +1 -0
- data/lib/distorted-jekyll/liquid_liquid/object.liquid +5 -0
- data/lib/distorted-jekyll/liquid_liquid/picture.liquid +15 -0
- data/lib/distorted-jekyll/liquid_liquid/picture.rb +48 -0
- data/lib/distorted-jekyll/liquid_liquid/picture_source.liquid +1 -0
- data/lib/distorted-jekyll/liquid_liquid/root.liquid +5 -0
- data/lib/distorted-jekyll/liquid_liquid/video.liquid +5 -0
- data/lib/distorted-jekyll/liquid_liquid/video_source.liquid +1 -0
- data/lib/distorted-jekyll/md_injection.rb +310 -0
- data/lib/distorted-jekyll/media_molecule.rb +20 -0
- data/lib/distorted-jekyll/media_molecule/font.rb +21 -0
- data/lib/distorted-jekyll/media_molecule/image.rb +15 -0
- data/lib/distorted-jekyll/media_molecule/never_let_you_down.rb +28 -0
- data/lib/distorted-jekyll/media_molecule/pdf.rb +108 -0
- data/lib/distorted-jekyll/media_molecule/svg.rb +20 -0
- data/lib/distorted-jekyll/media_molecule/text.rb +23 -0
- data/lib/distorted-jekyll/media_molecule/video.rb +45 -0
- data/lib/distorted-jekyll/monkey_business/jekyll/cleaner.rb +121 -0
- data/lib/distorted-jekyll/static_state.rb +160 -0
- data/lib/distorted-jekyll/the_setting_sun.rb +179 -0
- 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 @@
|
|
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,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 @@
|
|
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
|