jekyll-image-links 0.2.0 → 0.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c93aabbb9cd425deaacd65bb022a89262b8799957d6817e58cd1a3ada3d640c
4
- data.tar.gz: fe69971d43751442a622ed8571760f0ed5ebce7f74aca6bfa13942e9ec6b4521
3
+ metadata.gz: 1d2fc33af0a1cda25f029a7a72d68e32eefa8f1fa412fdd4f69281fdb8676a29
4
+ data.tar.gz: 174005c6a08c2f617d953ebe2d874f324c3bfd0107436e93ff23f490bc623606
5
5
  SHA512:
6
- metadata.gz: 6e741d3e37b158ee40f071ea3fe8a833427e4a7321df076e3ecedb68ed6b67e48449be4764c105179364dc15c0c6465617bbbaedf123d8dc3e54e5bd6959196a
7
- data.tar.gz: a74db4de8774eea228b99baca8a3d237807bd7167aa5c1e242798a6c9ec693e29905eba853563c2711c09ed78f3d15bcb97673abe1e2dc3e86306849003214fb
6
+ metadata.gz: 6cab285f59d4c32b302686e4995113c2b8ceaba8bbeaf29d9ec2a329ffd2ded5b8de487f6b26f62049c25c9fd155a7d64665d5cd5f807966d6aefae15618670f
7
+ data.tar.gz: 3be18ab75e132c7c7ad4b054879fac3ebb27d72efaedc7b34be916765b049b4c27cc8c45648f3c819ec8bd55fa1df6fe06895adfb380cec51087c14fd74ca526
@@ -3,6 +3,12 @@
3
3
 
4
4
  const ROOT_SELECTOR = '[data-jil-root="true"]';
5
5
  const config = window.__JIL_CONFIG__ || {};
6
+ // Hosts that already have listeners on this element instance.
7
+ // data-jil-ready is not enough: a hover-popup preview clones the live map,
8
+ // so the attribute is copied, but click listeners are not. A WeakSet tracks
9
+ // the actual element. Clones are absent from it and get set up again, and
10
+ // entries disappear when the preview window is discarded.
11
+ const readyHosts = new WeakSet();
6
12
 
7
13
  function getHoverPopup() {
8
14
  const api = window.JekyllHoverPopup;
@@ -17,42 +23,225 @@
17
23
  return !!getHoverPopup();
18
24
  }
19
25
 
26
+ function logError(message, error) {
27
+ if (error) console.error(`[jil] ${message}`, error);
28
+ else console.error(`[jil] ${message}`);
29
+ }
30
+
31
+ function reportMapProblems(img, mapData) {
32
+ const src = mapData.src || img.getAttribute("src") || "image map";
33
+ const width = Number(mapData.width);
34
+ const height = Number(mapData.height);
35
+
36
+ if (!width || !height) {
37
+ logError(`${src}: map data is missing width or height`);
38
+ }
39
+
40
+ const regions = mapData.regions;
41
+ if (!Array.isArray(regions) || regions.length === 0) {
42
+ logError(`${src}: map data has no regions`);
43
+ }
44
+
45
+ const checkSize = () => {
46
+ const naturalWidth = img.naturalWidth;
47
+ const naturalHeight = img.naturalHeight;
48
+ if (!naturalWidth || !naturalHeight) {
49
+ logError(`${src}: image failed to load`);
50
+ return;
51
+ }
52
+ if (!width || !height) return;
53
+ if (naturalWidth === width && naturalHeight === height) return;
54
+ logError(`${src}: image is ${naturalWidth}×${naturalHeight} but map data is ${width}×${height}`);
55
+ };
56
+
57
+ img.addEventListener("error", () => {
58
+ logError(`${src}: image failed to load`);
59
+ });
60
+
61
+ if (img.complete) checkSize();
62
+ else img.addEventListener("load", checkSize);
63
+
64
+ (regions || []).forEach((region, index) => {
65
+ const name = region.title || region.href || `region ${index + 1}`;
66
+ const points = region.points;
67
+ if (!region.href) logError(`${src}: "${name}" is missing href`);
68
+ if (!Array.isArray(points) || points.length < 3) {
69
+ logError(`${src}: "${name}" needs at least 3 points`);
70
+ return;
71
+ }
72
+ if (!width || !height) return;
73
+
74
+ const xs = points.map((point) => point[0]);
75
+ const ys = points.map((point) => point[1]);
76
+ const outside = xs.some((x) => x < 0 || x > width) || ys.some((y) => y < 0 || y > height);
77
+ if (!outside) return;
78
+ logError(
79
+ `${src}: "${name}" is outside ${width}×${height} (x ${Math.min(...xs)}–${Math.max(...xs)}, y ${Math.min(...ys)}–${Math.max(...ys)})`
80
+ );
81
+ });
82
+ }
83
+
20
84
  function init() {
21
- document.querySelectorAll("[data-jil-map]").forEach(initMapHost);
85
+ document.querySelectorAll("img.jil-map-image[data-jil-regions]").forEach((img) => {
86
+ if (img.closest("[data-jil-map]")) return;
87
+ logError(
88
+ `${img.getAttribute("src") || "image"} was not enhanced (${img.getAttribute("data-jil-regions")})`
89
+ );
90
+ });
91
+
92
+ // Maps already in the page. Maps that appear later, such as inside a
93
+ // preview window, are handled by watchInsertedMaps.
94
+ initWithin(document);
95
+ }
96
+
97
+ // Set up every map host inside root. root may be the document (first load)
98
+ // or a node just inserted into a preview window.
99
+ function initWithin(root) {
100
+ if (!root || !root.querySelectorAll) return;
101
+
102
+ const hosts = [];
103
+ // The inserted node can be the host itself. querySelectorAll only looks
104
+ // at descendants, so include root when it is a map host.
105
+ if (root.nodeType === 1 && root.matches("[data-jil-map]")) hosts.push(root);
106
+ root.querySelectorAll("[data-jil-map]").forEach((host) => hosts.push(host));
107
+
108
+ hosts.forEach((host) => {
109
+ try {
110
+ initMapHost(host);
111
+ } catch (error) {
112
+ logError(`Failed to init ${host.id || "image map"}`, error);
113
+ }
114
+ });
115
+ }
116
+
117
+ // Page load only sees maps in the original document. Preview windows fetch
118
+ // or clone section HTML later, so watch for those nodes and set them up then.
119
+ function watchInsertedMaps() {
120
+ if (!document.body || typeof MutationObserver === "undefined") return;
121
+
122
+ const observer = new MutationObserver((mutations) => {
123
+ mutations.forEach((mutation) => {
124
+ mutation.addedNodes.forEach((node) => {
125
+ // Text nodes cannot contain a map host.
126
+ if (node.nodeType !== 1) return;
127
+ initWithin(node);
128
+ });
129
+ });
130
+ });
131
+
132
+ // subtree covers maps nested inside the preview's section wrapper,
133
+ // not only nodes appended directly to body.
134
+ observer.observe(document.body, { childList: true, subtree: true });
135
+ }
136
+
137
+ // Drop UI that was copied from an already-initialized map. Labels and the
138
+ // viewer button are created in JS, so a clone includes the elements but not
139
+ // their listeners. Removing them lets init add a fresh, working copy.
140
+ // Server-rendered markup has neither, so this is a no-op on first setup.
141
+ function stripGeneratedUi(host) {
142
+ host.querySelectorAll(".jil-label-layer").forEach((node) => node.remove());
143
+ const figure = host.closest(".jil-figure");
144
+ if (!figure) return;
145
+ // The toolbar is a sibling of the host, inserted after the caption.
146
+ figure.querySelectorAll(":scope > .jil-toolbar").forEach((node) => node.remove());
22
147
  }
23
148
 
24
149
  function initMapHost(host) {
25
- if (host.dataset.jilReady === "true") return;
150
+ // Skip hosts already bound in this document. Clones are different elements
151
+ // and fall through even when they carry data-jil-ready from the original.
152
+ if (readyHosts.has(host)) return;
153
+ readyHosts.add(host);
26
154
  host.dataset.jilReady = "true";
155
+ stripGeneratedUi(host);
156
+
157
+ let mapData;
158
+ try {
159
+ mapData = JSON.parse(host.dataset.jilMap);
160
+ } catch (error) {
161
+ logError(`Invalid map JSON on ${host.id || "image map"}`, error);
162
+ return;
163
+ }
27
164
 
28
- const mapData = JSON.parse(host.dataset.jilMap);
29
165
  const inline = host.dataset.jilInline !== "false";
30
166
  const viewer = host.dataset.jilViewer !== "false";
31
167
  const labels = host.dataset.jilLabels === "true";
32
168
  const img = host.querySelector(".jil-map-image");
33
169
 
34
- if (!img) return;
170
+ if (!img) {
171
+ logError(`${mapData.src || host.id || "image map"}: missing .jil-map-image`);
172
+ return;
173
+ }
35
174
 
175
+ reportMapProblems(img, mapData);
36
176
  if (labels) renderLabels(host, mapData, img);
37
177
  if (inline) bindInlineClicks(host, mapData, img);
38
178
  if (viewer) addViewerButton(host, mapData);
39
179
  }
40
180
 
181
+ // Keep overlay geometry in sync with the image, including inside a preview
182
+ // that is opened, resized, and then closed.
183
+ function bindLiveUpdates(host, img, update) {
184
+ const onLoad = () => update();
185
+ img.addEventListener("load", onLoad);
186
+
187
+ // Window listeners and a ResizeObserver would keep a closed preview's
188
+ // nodes alive. Drop them once the host has left the document.
189
+ const detach = () => {
190
+ window.removeEventListener("resize", onResize);
191
+ if (observer) observer.disconnect();
192
+ };
193
+
194
+ const onResize = () => {
195
+ if (!host.isConnected) {
196
+ detach();
197
+ return;
198
+ }
199
+ update();
200
+ };
201
+
202
+ let observer = null;
203
+ if (typeof ResizeObserver !== "undefined") {
204
+ observer = new ResizeObserver(() => {
205
+ // Closing a preview disconnects the host and fires this observer.
206
+ if (!host.isConnected) {
207
+ detach();
208
+ return;
209
+ }
210
+ update();
211
+ });
212
+ // Watch both: the host size follows the preview window, and the image
213
+ // size follows its own load and CSS.
214
+ observer.observe(host);
215
+ observer.observe(img);
216
+ }
217
+
218
+ window.addEventListener("resize", onResize);
219
+ // A cached image may already be complete, so the load event never fires.
220
+ if (img.complete) update();
221
+ }
222
+
223
+ function bindOverlayGeometry(overlay, host, mapData, img) {
224
+ bindLiveUpdates(host, img, () => {
225
+ const geom = getMapGeometry(img, mapData);
226
+ if (!geom || !geom.width || !geom.height) return;
227
+ overlay.style.left = `${geom.offsetX}px`;
228
+ overlay.style.top = `${geom.offsetY}px`;
229
+ overlay.style.width = `${geom.width}px`;
230
+ overlay.style.height = `${geom.height}px`;
231
+ });
232
+ }
233
+
41
234
  function renderLabels(host, mapData, img) {
42
235
  const overlay = document.createElement("div");
43
236
  overlay.className = "jil-label-layer";
44
237
  host.appendChild(overlay);
238
+ bindOverlayGeometry(overlay, host, mapData, img);
45
239
 
46
- const update = () => {
240
+ bindLiveUpdates(host, img, () => {
47
241
  overlay.innerHTML = "";
48
242
  const geom = getMapGeometry(img, mapData);
49
243
  if (!geom || !geom.width || !geom.height) return;
50
244
 
51
- overlay.style.left = `${geom.offsetX}px`;
52
- overlay.style.top = `${geom.offsetY}px`;
53
- overlay.style.width = `${geom.width}px`;
54
- overlay.style.height = `${geom.height}px`;
55
-
56
245
  mapData.regions.forEach((region) => {
57
246
  const center = polygonCenter(region.points);
58
247
  if (!center) return;
@@ -71,16 +260,7 @@
71
260
  link.title = region.title || label;
72
261
  overlay.appendChild(link);
73
262
  });
74
- };
75
-
76
- if (img.complete) update();
77
- img.addEventListener("load", update);
78
- window.addEventListener("resize", update);
79
- if (typeof ResizeObserver !== "undefined") {
80
- const observer = new ResizeObserver(update);
81
- observer.observe(host);
82
- observer.observe(img);
83
- }
263
+ });
84
264
  }
85
265
 
86
266
  function bindInlineClicks(host, mapData, img) {
@@ -329,6 +509,9 @@
329
509
  state.image = image;
330
510
  fitZoom();
331
511
  };
512
+ image.onerror = () => {
513
+ logError(`${mapData.src || "viewer image"}: viewer image failed to load`);
514
+ };
332
515
  image.src = mapData.src;
333
516
 
334
517
  return {
@@ -354,13 +537,22 @@
354
537
  const jhp = useHoverPopup() ? getHoverPopup() : null;
355
538
  if (jhp) {
356
539
  event.preventDefault();
357
- await jhp.openLink({
358
- href: region.href,
359
- title: region.title || region.label || region.href,
360
- clientX: event.clientX,
361
- clientY: event.clientY,
362
- isPermanent: true,
363
- });
540
+ try {
541
+ await jhp.openLink({
542
+ href: region.href,
543
+ title: region.title || region.label || region.href,
544
+ clientX: event.clientX,
545
+ clientY: event.clientY,
546
+ isPermanent: true,
547
+ });
548
+ } catch (error) {
549
+ logError(`Failed to open ${region.href}`, error);
550
+ }
551
+ return;
552
+ }
553
+
554
+ if (!region.href) {
555
+ logError("Region is missing href");
364
556
  return;
365
557
  }
366
558
 
@@ -478,9 +670,18 @@
478
670
  }
479
671
 
480
672
  function start() {
481
- if (!document.querySelector(ROOT_SELECTOR)) return;
673
+ if (!document.querySelector(ROOT_SELECTOR)) {
674
+ logError("image_links.js loaded without data-jil-root");
675
+ return;
676
+ }
482
677
 
483
- const run = () => init();
678
+ const run = () => {
679
+ // Observe before the first pass so a preview inserted during init is
680
+ // not missed. Init itself does not add map hosts, so this does not
681
+ // set the same map up twice.
682
+ watchInsertedMaps();
683
+ init();
684
+ };
484
685
 
485
686
  if (document.querySelector('[data-hover-popup-root="true"]') && !getHoverPopup()) {
486
687
  window.setTimeout(run, 0);
@@ -17,7 +17,10 @@ module Jekyll
17
17
  html = MapRenderer.enhance_html(html, site: site, page: doc, cfg: cfg)
18
18
  doc.output = inject_assets(html, assets_path: assets_path, site: site, cfg: cfg)
19
19
  rescue StandardError => e
20
- Jekyll.logger.warn("jekyll-image-links:", "Failed to process #{doc.relative_path}: #{e.class}: #{e.message}")
20
+ Jekyll.logger.error(
21
+ "jekyll-image-links:",
22
+ "Failed to process #{doc.relative_path}: #{e.class}: #{e.message}"
23
+ )
21
24
  end
22
25
  end
23
26
  end
@@ -23,7 +23,7 @@ module Jekyll
23
23
  html = html.gsub(PORTABLE_FIGURE) do |figure_html|
24
24
  enhance_portable_markup(figure_html, site: site, page: page, cfg: cfg)
25
25
  rescue StandardError => e
26
- Jekyll.logger.warn("jekyll-image-links:", "Failed to enhance portable figure: #{e.message}")
26
+ log_error("#{page_label(page)} #{markup_context(figure_html)}: #{e.class}: #{e.message}")
27
27
  figure_html
28
28
  end
29
29
 
@@ -32,7 +32,7 @@ module Jekyll
32
32
 
33
33
  enhance_portable_markup(image_html, site: site, page: page, cfg: cfg)
34
34
  rescue StandardError => e
35
- Jekyll.logger.warn("jekyll-image-links:", "Failed to enhance portable image map: #{e.message}")
35
+ log_error("#{page_label(page)} #{markup_context(image_html)}: #{e.class}: #{e.message}")
36
36
  image_html
37
37
  end
38
38
  end
@@ -170,6 +170,8 @@ module Jekyll
170
170
  "title" => title,
171
171
  "regions" => normalize_regions(regions, site: site, page: page, liquid_context: liquid_context),
172
172
  }
173
+ warn_if_regions_out_of_bounds!(map_data["regions"], width, height, src)
174
+ warn_if_image_dimensions_differ!(site, page, attrs["src"], width, height)
173
175
 
174
176
  render_interactive(
175
177
  map_data,
@@ -300,6 +302,170 @@ module Jekyll
300
302
  end
301
303
  end
302
304
 
305
+ def warn_if_regions_out_of_bounds!(regions, width, height, src)
306
+ regions.each do |region|
307
+ points = region["points"] || []
308
+ next if points.empty?
309
+
310
+ xs = points.map { |point| point[0] }
311
+ ys = points.map { |point| point[1] }
312
+ outside = xs.any? { |x| x.negative? || x > width } || ys.any? { |y| y.negative? || y > height }
313
+ next unless outside
314
+
315
+ name = region["title"] || region["href"]
316
+ log_error(
317
+ "#{src}: region \"#{name}\" is outside #{width}×#{height} (x #{xs.min}–#{xs.max}, y #{ys.min}–#{ys.max})"
318
+ )
319
+ end
320
+ end
321
+
322
+ def warn_if_image_dimensions_differ!(site, page, src, width, height)
323
+ path = local_image_path(site, page, src)
324
+ if path.nil?
325
+ log_error("#{src}: image file not found") unless remote_url?(src)
326
+ return
327
+ end
328
+
329
+ actual = read_image_size(path)
330
+ return unless actual
331
+
332
+ actual_width, actual_height = actual
333
+ return if actual_width == width && actual_height == height
334
+
335
+ log_error("#{src}: image file is #{actual_width}×#{actual_height} but map data is #{width}×#{height}")
336
+ end
337
+
338
+ def local_image_path(site, page, src)
339
+ return nil if src.to_s.strip.empty? || remote_url?(src)
340
+
341
+ path = src.to_s
342
+ baseurl = site.baseurl.to_s
343
+ baseurl = "" if baseurl == "/"
344
+ path = path.delete_prefix(baseurl) if !baseurl.empty? && (path == baseurl || path.start_with?("#{baseurl}/"))
345
+
346
+ candidates = [path]
347
+ if !path.start_with?("/") && page&.respond_to?(:relative_path) && page.relative_path
348
+ candidates << File.join(File.dirname(page.relative_path), path)
349
+ end
350
+
351
+ candidates.each do |candidate|
352
+ full_path = Jekyll.sanitized_path(site.source, candidate)
353
+ return full_path if File.file?(full_path)
354
+ end
355
+ nil
356
+ end
357
+
358
+ def remote_url?(src)
359
+ src.to_s.start_with?("http://", "https://", "data:")
360
+ end
361
+
362
+ def read_image_size(path)
363
+ File.open(path, "rb") do |file|
364
+ header = file.read(32)
365
+ return nil unless header && header.bytesize >= 24
366
+
367
+ header = header.b
368
+ if header.start_with?("\x89PNG\r\n\x1A\n".b)
369
+ return [header[16, 4].unpack1("N"), header[20, 4].unpack1("N")]
370
+ end
371
+ if header.start_with?("GIF87a".b, "GIF89a".b)
372
+ return header[6, 4].unpack("v2")
373
+ end
374
+ if header.start_with?("RIFF".b) && header[8, 4] == "WEBP".b
375
+ return read_webp_size(file, header)
376
+ end
377
+ return read_jpeg_size(file) if header.start_with?("\xFF\xD8".b)
378
+ end
379
+ nil
380
+ rescue StandardError => e
381
+ log_error("#{path}: could not read image dimensions: #{e.class}: #{e.message}")
382
+ nil
383
+ end
384
+
385
+ def read_webp_size(file, header)
386
+ format = header[12, 4]
387
+ if format == "VP8X".b
388
+ file.seek(24)
389
+ data = file.read(6)
390
+ return nil unless data&.bytesize == 6
391
+
392
+ bytes = data.bytes
393
+ width = 1 + (bytes[0] | (bytes[1] << 8) | (bytes[2] << 16))
394
+ height = 1 + (bytes[3] | (bytes[4] << 8) | (bytes[5] << 16))
395
+ [width, height]
396
+ elsif format == "VP8 ".b
397
+ file.seek(26)
398
+ data = file.read(4)
399
+ return nil unless data&.bytesize == 4
400
+
401
+ width = data[0, 2].unpack1("v") & 0x3fff
402
+ height = data[2, 2].unpack1("v") & 0x3fff
403
+ [width, height]
404
+ elsif format == "VP8L".b
405
+ file.seek(21)
406
+ data = file.read(4)
407
+ return nil unless data&.bytesize == 4
408
+
409
+ b0, b1, b2, b3 = data.bytes
410
+ width = 1 + (((b1 & 0x3F) << 8) | b0)
411
+ height = 1 + (((b3 & 0x0F) << 10) | (b2 << 2) | ((b1 & 0xC0) >> 6))
412
+ [width, height]
413
+ end
414
+ end
415
+
416
+ def read_jpeg_size(file)
417
+ file.seek(2)
418
+ loop do
419
+ byte = file.read(1)
420
+ return nil unless byte == "\xFF".b
421
+
422
+ marker = nil
423
+ loop do
424
+ byte = file.read(1)
425
+ return nil unless byte
426
+ next if byte == "\xFF".b
427
+
428
+ marker = byte.ord
429
+ break
430
+ end
431
+ next if marker == 0xD8 || marker == 0xD9
432
+ return nil if marker == 0xDA
433
+
434
+ length_bytes = file.read(2)
435
+ return nil unless length_bytes&.bytesize == 2
436
+
437
+ length = length_bytes.unpack1("n")
438
+ return nil if length < 2
439
+
440
+ if [0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF].include?(marker)
441
+ data = file.read(5)
442
+ return nil unless data&.bytesize == 5
443
+
444
+ _precision, height, width = data.unpack("Cnn")
445
+ return [width, height]
446
+ end
447
+
448
+ file.seek(length - 2, IO::SEEK_CUR)
449
+ end
450
+ end
451
+
452
+ def page_label(page)
453
+ return page.relative_path if page.respond_to?(:relative_path) && page.relative_path
454
+ return page.path if page.respond_to?(:path) && page.path
455
+
456
+ "page"
457
+ end
458
+
459
+ def markup_context(markup)
460
+ src = markup[/src="([^"]*)"/, 1]
461
+ regions = markup[/data-jil-regions="([^"]*)"/, 1]
462
+ [regions, src].compact.reject(&:empty?).join(" ")
463
+ end
464
+
465
+ def log_error(message)
466
+ Jekyll.logger.error("jekyll-image-links:", message)
467
+ end
468
+
303
469
  def validate_dimensions!(width, height, src)
304
470
  unless width.positive? && height.positive?
305
471
  raise ArgumentError, "image_map requires native width and height in the YAML file or numeric width/height attributes"
@@ -15,14 +15,22 @@ module Jekyll
15
15
  cfg = (site.config["image_links"] || {})
16
16
  regions_body = @attrs["file"] ? nil : super
17
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
- )
18
+ begin
19
+ figure = MapRenderer.render_portable_figure(
20
+ @attrs,
21
+ site: site,
22
+ page: context.registers[:page],
23
+ liquid_context: context,
24
+ cfg: cfg,
25
+ regions_body: regions_body
26
+ )
27
+ rescue StandardError => e
28
+ page = context.registers[:page]
29
+ page_label = page.respond_to?(:relative_path) ? page.relative_path : "page"
30
+ src = @attrs["src"] || @attrs["file"]
31
+ Jekyll.logger.error("jekyll-image-links:", "#{page_label} #{src}: #{e.class}: #{e.message}")
32
+ raise
33
+ end
26
34
 
27
35
  <<~HTML
28
36
  {::nomarkdown}
@@ -1,5 +1,5 @@
1
1
  module Jekyll
2
2
  module ImageLinks
3
- VERSION = "0.2.0"
3
+ VERSION = "0.3.1"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-image-links
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - directsun