jekyll-image-links 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,395 @@
1
+ require "cgi"
2
+ require "digest"
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Jekyll
7
+ module ImageLinks
8
+ class MapRenderer
9
+ PORTABLE_FIGURE = /<figure\b[^>]*\bdata-jil-map="true"[^>]*>[\s\S]*?<\/figure>/i
10
+ PORTABLE_IMAGE = /<img\b[^>]*\bclass="[^"]*\bjil-map-image\b[^"]*"[^>]*\/?>(?:\s*<script\b[^>]*\bclass="jil-regions-data"[^>]*>[\s\S]*?<\/script>)?/i
11
+ REGIONS_SCRIPT = /<script\b[^>]*\bclass="jil-regions-data"[^>]*>([\s\S]*?)<\/script>/i
12
+
13
+ class << self
14
+ def portable_markup?(html)
15
+ return true if html.include?('data-jil-map="true"')
16
+
17
+ html.scan(PORTABLE_IMAGE).any? { |fragment| portable_image?(fragment) }
18
+ end
19
+
20
+ def enhance_html(html, site:, page:, cfg:)
21
+ return html unless portable_markup?(html)
22
+
23
+ html = html.gsub(PORTABLE_FIGURE) do |figure_html|
24
+ enhance_portable_markup(figure_html, site: site, page: page, cfg: cfg)
25
+ rescue StandardError => e
26
+ Jekyll.logger.warn("jekyll-image-links:", "Failed to enhance portable figure: #{e.message}")
27
+ figure_html
28
+ end
29
+
30
+ html.gsub(PORTABLE_IMAGE) do |image_html|
31
+ next image_html unless portable_image?(image_html)
32
+
33
+ enhance_portable_markup(image_html, site: site, page: page, cfg: cfg)
34
+ rescue StandardError => e
35
+ Jekyll.logger.warn("jekyll-image-links:", "Failed to enhance portable image map: #{e.message}")
36
+ image_html
37
+ end
38
+ end
39
+
40
+ def render_portable_figure(attrs, site:, page: nil, liquid_context: nil, cfg: {}, regions_body: nil)
41
+ src = attrs["src"].to_s
42
+ title = attrs["title"]
43
+ alt = attrs["alt"] || title
44
+ regions_file = attrs["file"]
45
+ viewer = attrs["viewer"]
46
+ inline = attrs["inline"]
47
+ labels = attrs["labels"]
48
+
49
+ resolved_src = resolve_url(src, site: site, page: page, liquid_context: liquid_context)
50
+ map_source = load_map_source(
51
+ site: site,
52
+ regions_file: regions_file,
53
+ regions_body: regions_body
54
+ )
55
+ native_from_source = native_dimensions_present?(map_source)
56
+ display_style = build_display_style(attrs, native_from_source: native_from_source)
57
+
58
+ regions_attr = regions_file ? %( data-jil-regions="#{escape_attr(regions_file)}") : ""
59
+ viewer_attr = %( data-jil-viewer="#{viewer}") unless viewer.nil? || viewer.empty?
60
+ inline_attr = %( data-jil-inline="#{inline}") unless inline.nil? || inline.empty?
61
+ labels_attr = %( data-jil-labels="#{labels}") unless labels.nil? || labels.empty?
62
+ title_attr = title ? %( data-jil-title="#{escape_attr(title)}") : ""
63
+
64
+ regions_script = ""
65
+ if regions_body && !regions_body.strip.empty? && regions_file.to_s.strip.empty?
66
+ regions_script = %(\n<script type="application/yaml" class="jil-regions-data">#{regions_body.strip}\n</script>)
67
+ end
68
+
69
+ img_markup = render_img_attrs(
70
+ src: resolved_src,
71
+ alt: alt,
72
+ native_width: native_from_source ? nil : attrs["width"],
73
+ native_height: native_from_source ? nil : attrs["height"]
74
+ )
75
+ style_attr = display_style.empty? ? "" : %( style="#{escape_attr(display_style)}")
76
+
77
+ <<~HTML.strip
78
+ <img
79
+ class="jil-map-image"
80
+ #{img_markup}#{style_attr}
81
+ loading="lazy"#{title_attr}#{regions_attr}#{viewer_attr}#{inline_attr}#{labels_attr}
82
+ />#{regions_script}
83
+ HTML
84
+ end
85
+
86
+ def render_interactive(map_data, viewer:, inline:, labels:, alt:, display_style: nil)
87
+ map_json = JSON.generate(map_data)
88
+ id = "jil-map-#{Digest::MD5.hexdigest(map_json)[0, 8]}"
89
+ title = map_data["title"]
90
+ caption_html = title ? %(<div class="jil-caption">#{escape_html(title)}</div>) : ""
91
+ img_attrs = render_img_attrs(
92
+ src: map_data["src"],
93
+ alt: alt,
94
+ native_width: nil,
95
+ native_height: nil
96
+ )
97
+ host_class = "jil-map-host"
98
+ host_class += " jil-height-constrained" if display_style.to_s.match?(/(?:^|;|\s)max-height\s*:/i)
99
+ host_style_attr = display_style.to_s.strip.empty? ? "" : %( style="#{escape_attr(display_style)}")
100
+
101
+ <<~HTML.strip
102
+ <div class="jil-figure" data-jil-image-map="true"><div class="#{host_class}" id="#{id}" data-jil-map="#{escape_attr(map_json)}" data-jil-viewer="#{viewer}" data-jil-inline="#{inline}" data-jil-labels="#{labels}"#{host_style_attr.empty? ? "" : " #{host_style_attr}"}><img class="jil-map-image" #{img_attrs} loading="lazy" /></div>#{caption_html}</div>
103
+ HTML
104
+ end
105
+
106
+ private
107
+
108
+ def enhance_portable_markup(markup_html, site:, page:, cfg:)
109
+ figure_tag = markup_html[/\A<figure\b[^>]*>/i]
110
+ img_tag = markup_html[/<img\b[^>]*>/i] || ""
111
+ container_tag = figure_tag || img_tag
112
+ container_attrs = parse_html_attrs(container_tag)
113
+ img_attrs = parse_html_attrs(img_tag)
114
+
115
+ merged = {
116
+ "src" => container_attrs["data-jil-src"] || img_attrs["src"],
117
+ "width" => container_attrs["data-jil-width"] || img_attrs["width"],
118
+ "height" => container_attrs["data-jil-height"] || img_attrs["height"],
119
+ "style" => img_attrs["style"],
120
+ "data-jil-max-width" => img_attrs["data-jil-max-width"],
121
+ "data-jil-max-height" => img_attrs["data-jil-max-height"],
122
+ "title" => container_attrs["data-jil-title"],
123
+ "alt" => img_attrs["alt"],
124
+ "file" => container_attrs["data-jil-regions"] || img_attrs["data-jil-regions"],
125
+ "viewer" => container_attrs["data-jil-viewer"] || img_attrs["data-jil-viewer"],
126
+ "inline" => container_attrs["data-jil-inline"] || img_attrs["data-jil-inline"],
127
+ "labels" => container_attrs["data-jil-labels"] || img_attrs["data-jil-labels"],
128
+ }
129
+
130
+ merged["title"] ||= img_attrs["data-jil-title"] || merged["alt"]
131
+
132
+ regions_body = markup_html[REGIONS_SCRIPT, 1]
133
+ render_interactive_from_attrs(merged, site: site, page: page, cfg: cfg, regions_body: regions_body)
134
+ end
135
+
136
+ def portable_image?(fragment)
137
+ return false unless fragment.match?(/\bclass="[^"]*\bjil-map-image\b/i)
138
+
139
+ fragment.include?("data-jil-regions=") ||
140
+ fragment.include?('data-jil-map="true"') ||
141
+ fragment.match?(REGIONS_SCRIPT)
142
+ end
143
+
144
+ def render_interactive_from_attrs(attrs, site:, page:, cfg:, regions_body: nil, liquid_context: nil)
145
+ src = resolve_url(attrs["src"], site: site, page: page, liquid_context: liquid_context)
146
+ title = attrs["title"]
147
+ alt = attrs["alt"] || title
148
+ viewer = parse_bool(attrs["viewer"], default: cfg.fetch("viewer_by_default", true))
149
+ inline = parse_bool(attrs["inline"], default: cfg.fetch("inline_by_default", true))
150
+ labels = parse_bool(attrs["labels"], default: cfg.fetch("labels_by_default", false))
151
+
152
+ map_source = load_map_source(
153
+ site: site,
154
+ regions_file: attrs["file"],
155
+ regions_body: regions_body
156
+ )
157
+ regions = map_source["regions"]
158
+ validate_regions!(regions)
159
+
160
+ width, height = resolve_native_dimensions(map_source, attrs)
161
+ validate_dimensions!(width, height, src)
162
+
163
+ native_from_source = native_dimensions_present?(map_source)
164
+ display_style = build_display_style(attrs, native_from_source: native_from_source)
165
+
166
+ map_data = {
167
+ "src" => src,
168
+ "width" => width,
169
+ "height" => height,
170
+ "title" => title,
171
+ "regions" => normalize_regions(regions, site: site, page: page, liquid_context: liquid_context),
172
+ }
173
+
174
+ render_interactive(
175
+ map_data,
176
+ viewer: viewer,
177
+ inline: inline,
178
+ labels: labels,
179
+ alt: alt,
180
+ display_style: display_style
181
+ )
182
+ end
183
+
184
+ def load_map_source(site:, regions_file:, regions_body:)
185
+ data =
186
+ if regions_file && !regions_file.to_s.strip.empty?
187
+ path = regions_file.to_s
188
+ full_path = Jekyll.sanitized_path(site.source, path)
189
+ raise ArgumentError, "image_map file not found: #{path}" unless File.file?(full_path)
190
+
191
+ YAML.safe_load(File.read(full_path), permitted_classes: [Date, Time, Symbol], aliases: true)
192
+ elsif regions_body.to_s.strip.empty?
193
+ {}
194
+ else
195
+ YAML.safe_load(regions_body, permitted_classes: [Date, Time, Symbol], aliases: true)
196
+ end
197
+
198
+ normalize_map_source(data)
199
+ end
200
+
201
+ def normalize_map_source(data)
202
+ case data
203
+ when Hash
204
+ {
205
+ "regions" => data["regions"] || data[:regions] || [],
206
+ "width" => data["width"] || data[:width],
207
+ "height" => data["height"] || data[:height],
208
+ }
209
+ when Array
210
+ { "regions" => data, "width" => nil, "height" => nil }
211
+ when nil
212
+ { "regions" => [], "width" => nil, "height" => nil }
213
+ else
214
+ raise ArgumentError, "image_map regions must be a YAML list or a mapping with regions:"
215
+ end
216
+ end
217
+
218
+ def native_dimensions_present?(map_source)
219
+ map_source["width"].to_i.positive? && map_source["height"].to_i.positive?
220
+ end
221
+
222
+ def resolve_native_dimensions(map_source, attrs)
223
+ width = map_source["width"].to_i
224
+ height = map_source["height"].to_i
225
+
226
+ if width <= 0 && attrs["width"].to_s.match?(/\A\d+\z/)
227
+ width = attrs["width"].to_i
228
+ end
229
+ if height <= 0 && attrs["height"].to_s.match?(/\A\d+\z/)
230
+ height = attrs["height"].to_i
231
+ end
232
+
233
+ [width, height]
234
+ end
235
+
236
+ def build_display_style(attrs, native_from_source:)
237
+ rules = {}
238
+
239
+ merge_style_rules!(rules, attrs["style"]) if attrs["style"]
240
+
241
+ [
242
+ ["data-jil-max-width", "max-width"],
243
+ ["data-jil-max-height", "max-height"],
244
+ ].each do |attr, prop|
245
+ value = attrs[attr]
246
+ rules[prop] = value if value && !value.to_s.strip.empty?
247
+ end
248
+
249
+ %w[width height].each do |dim|
250
+ value = attrs[dim].to_s.strip
251
+ next if value.empty?
252
+ next if !native_from_source && value.match?(/\A\d+\z/)
253
+
254
+ rules[dim] = format_css_size(value)
255
+ end
256
+
257
+ rules.map { |prop, value| "#{prop}: #{value}" }.join("; ")
258
+ end
259
+
260
+ def merge_style_rules!(rules, style)
261
+ style.to_s.split(";").each do |declaration|
262
+ prop, value = declaration.split(":", 2).map(&:strip)
263
+ next if prop.nil? || prop.empty? || value.nil? || value.empty?
264
+
265
+ rules[prop] = value
266
+ end
267
+ end
268
+
269
+ def format_css_size(value)
270
+ value = value.to_s.strip
271
+ return value if value.match?(/\A[\d.]+%\z/) || value.match?(/\A[\d.]+px\z/i) || value == "auto"
272
+
273
+ return "#{value}px" if value.match?(/\A\d+\z/)
274
+
275
+ value
276
+ end
277
+
278
+ def render_img_attrs(src:, alt:, native_width:, native_height:)
279
+ parts = [
280
+ %(src="#{escape_attr(src)}"),
281
+ %(alt="#{escape_attr(alt)}"),
282
+ ]
283
+
284
+ if native_width && native_height &&
285
+ native_width.to_s.match?(/\A\d+\z/) && native_height.to_s.match?(/\A\d+\z/)
286
+ parts << %(width="#{escape_attr(native_width)}")
287
+ parts << %(height="#{escape_attr(native_height)}")
288
+ end
289
+
290
+ parts.join(" ")
291
+ end
292
+
293
+ def validate_regions!(regions)
294
+ raise ArgumentError, "image_map requires at least one region" if regions.nil? || regions.empty?
295
+
296
+ regions.each_with_index do |region, index|
297
+ region = stringify_keys(region)
298
+ raise ArgumentError, "region #{index + 1} is missing href" if region["href"].to_s.strip.empty?
299
+ raise ArgumentError, "region #{index + 1} is missing points" unless region["points"].is_a?(Array) && !region["points"].empty?
300
+ end
301
+ end
302
+
303
+ def validate_dimensions!(width, height, src)
304
+ unless width.positive? && height.positive?
305
+ raise ArgumentError, "image_map requires native width and height in the YAML file or numeric width/height attributes"
306
+ end
307
+ raise ArgumentError, "image_map requires src attribute" if src.to_s.strip.empty?
308
+ end
309
+
310
+ def normalize_regions(regions, site:, page:, liquid_context: nil)
311
+ regions.map do |region|
312
+ region = stringify_keys(region)
313
+ {
314
+ "href" => resolve_url(region["href"], site: site, page: page, liquid_context: liquid_context),
315
+ "title" => region["title"] || region["label"] || region["name"],
316
+ "label" => region["label"] || region["title"] || region["name"],
317
+ "points" => normalize_points(region["points"]),
318
+ }
319
+ end
320
+ end
321
+
322
+ def normalize_points(points)
323
+ points.map do |point|
324
+ case point
325
+ when Array
326
+ [point[0].to_i, point[1].to_i]
327
+ when Hash
328
+ [(point["x"] || point[:x]).to_i, (point["y"] || point[:y]).to_i]
329
+ else
330
+ raise ArgumentError, "image_map points must be [x, y] pairs"
331
+ end
332
+ end
333
+ end
334
+
335
+ def resolve_url(url, site:, page: nil, liquid_context: nil)
336
+ return "" if url.nil?
337
+
338
+ url = Liquid::Template.parse(url.to_s).render(liquid_context).strip if liquid_context
339
+ url = url.to_s.strip
340
+ return url if url.start_with?("http://", "https://", "mailto:", "#")
341
+
342
+ baseurl = site.baseurl.to_s
343
+ baseurl = "" if baseurl == "/"
344
+
345
+ page_url = page_url_for(page)
346
+
347
+ if url.start_with?("/")
348
+ "#{baseurl}#{url}"
349
+ else
350
+ page_dir = page_url ? File.dirname(page_url) : ""
351
+ "#{baseurl}#{File.join(page_dir, url)}"
352
+ end
353
+ end
354
+
355
+ def page_url_for(page)
356
+ return page.url if page.respond_to?(:url) && page.url
357
+ return page["url"] if page.respond_to?(:[]) && page["url"]
358
+
359
+ nil
360
+ end
361
+
362
+ def parse_bool(value, default:)
363
+ return default if value.nil? || value.to_s.empty?
364
+ %w[true 1 yes on].include?(value.to_s.downcase)
365
+ end
366
+
367
+ def parse_html_attrs(tag_open)
368
+ attrs = {}
369
+ tag_open.scan(/([\w-]+)\s*=\s*"([^"]*)"/) { |key, value| attrs[key] = value }
370
+ tag_open.scan(/([\w-]+)\s*=\s*'([^']*)'/) { |key, value| attrs[key] = value }
371
+ attrs
372
+ end
373
+
374
+ def stringify_keys(value)
375
+ return value unless value.is_a?(Hash)
376
+
377
+ value.each_with_object({}) do |(key, val), out|
378
+ out[key.to_s] = val
379
+ end
380
+ end
381
+
382
+ def escape_attr(value)
383
+ value.to_s
384
+ .gsub("&", "&amp;")
385
+ .gsub('"', "&quot;")
386
+ .gsub("<", "&lt;")
387
+ end
388
+
389
+ def escape_html(value)
390
+ CGI.escapeHTML(value.to_s)
391
+ end
392
+ end
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,47 @@
1
+ module Jekyll
2
+ module ImageLinks
3
+ class ImageMapTag < Liquid::Block
4
+ def initialize(tag_name, markup, tokens)
5
+ super
6
+ @attrs = parse_attrs(markup)
7
+ end
8
+
9
+ def blank?
10
+ false
11
+ end
12
+
13
+ def render(context)
14
+ site = context.registers[:site]
15
+ cfg = (site.config["image_links"] || {})
16
+ regions_body = @attrs["file"] ? nil : super
17
+
18
+ figure = MapRenderer.render_portable_figure(
19
+ @attrs,
20
+ site: site,
21
+ page: context.registers[:page],
22
+ liquid_context: context,
23
+ cfg: cfg,
24
+ regions_body: regions_body
25
+ )
26
+
27
+ <<~HTML
28
+ {::nomarkdown}
29
+ #{figure}
30
+ {:/nomarkdown}
31
+ HTML
32
+ end
33
+
34
+ private
35
+
36
+ def parse_attrs(markup)
37
+ attrs = {}
38
+ markup.scan(/(\w+)\s*=\s*"([^"]*)"/) { |key, value| attrs[key] = value }
39
+ markup.scan(/(\w+)\s*=\s*'([^']*)'/) { |key, value| attrs[key] = value }
40
+ attrs["file"] ||= markup[/\Afile:\s*(\S+)/, 1] if markup.include?("file:")
41
+ attrs
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ Liquid::Template.register_tag("image_map", Jekyll::ImageLinks::ImageMapTag)
@@ -0,0 +1,5 @@
1
+ module Jekyll
2
+ module ImageLinks
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ require "jekyll"
2
+
3
+ require_relative "jekyll/image_links/version"
4
+ require_relative "jekyll/image_links/asset_file"
5
+ require_relative "jekyll/image_links/map_renderer"
6
+ require_relative "jekyll/image_links/generator"
7
+ require_relative "jekyll/image_links/hooks"
8
+ require_relative "jekyll/image_links/tags"
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-image-links
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - directsun
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jekyll
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.7'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.7'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5.0'
32
+ email: []
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - LICENSE
38
+ - README.md
39
+ - assets/jekyll-image-links/image_links.css
40
+ - assets/jekyll-image-links/image_links.js
41
+ - lib/jekyll-image-links.rb
42
+ - lib/jekyll/image_links/asset_file.rb
43
+ - lib/jekyll/image_links/generator.rb
44
+ - lib/jekyll/image_links/hooks.rb
45
+ - lib/jekyll/image_links/map_renderer.rb
46
+ - lib/jekyll/image_links/tags.rb
47
+ - lib/jekyll/image_links/version.rb
48
+ homepage: https://github.com/sunflowermans/image-links
49
+ licenses:
50
+ - MIT
51
+ metadata: {}
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '3.0'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubygems_version: 3.6.9
67
+ specification_version: 4
68
+ summary: Jekyll plugin for clickable polygon regions on images, inspired by 5etools
69
+ map viewer.
70
+ test_files: []