asciidoctor-kroki 1.0.1 → 2.0.0.rc.2

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: 5f18e9ffde3538c375578dce0fee86bff48e8ec402214943afdfd078eda7f4f9
4
- data.tar.gz: a3f46683cdc4200a94805ef5308b371c20df3c5514a810ceb78fff3846bbd0e4
3
+ metadata.gz: a83bcb9eccbf3e0def0e928c6e62c7a054c721b96fc3bf755520f60d9117e48d
4
+ data.tar.gz: 33698f6396dedc31df4d06a7d410e72fb53feaa2829c100d657b9898764935e8
5
5
  SHA512:
6
- metadata.gz: 13db244902d8df19c0efa1b8703ac017d86ad8ba4012177e0fdd1ca558b665dcd74bfd9348a54a491733f60839f1a302281f56b5bd9a4f4904957ff031479975
7
- data.tar.gz: 6bc5a8e87883a976af9dd7889fedc79f6630453d65f6420717a6c3062c1e999d41d372573ced78fa2311c945d89066633c79392bb26fc655244debf18217d7d9
6
+ metadata.gz: 4337469119534bd62fd38bb06bedb50958b62302b52868e0e94b747fb19e97d585fb59c2ad18a794a92f0fdd0624cdae00fdec95f5f740a0bc2f439c561a2db1
7
+ data.tar.gz: 39a922f2c39e331a7756f1034603c4a7e3036ead01b97c5e812c29111bb897ff7fee304e3b35a495f7903e23ca7d29940dcffc6018ea41336b39d419d7df46f9
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- asciidoctor-kroki (1.0.1)
4
+ asciidoctor-kroki (2.0.0.rc.2)
5
5
  asciidoctor (~> 2.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -22,10 +22,11 @@ Register and use the extension:
22
22
 
23
23
  ```js
24
24
  import { convert, Extensions } from '@asciidoctor/core'
25
- import asciidoctorKroki from 'asciidoctor-kroki'
25
+ import { register } from 'asciidoctor-kroki'
26
26
 
27
- asciidoctorKroki.register(Extensions)
28
- console.log(await convert('[graphviz]\n....\ndigraph G { Hello->World }\n....'))
27
+ const registry = Extensions.create()
28
+ register(registry)
29
+ console.log(await convert('[graphviz]\n....\ndigraph G { Hello->World }\n....', { extension_registry: registry }))
29
30
  ```
30
31
 
31
32
  For installation instructions for Node.js, Browser, Ruby, and Antora, as well as the full configuration reference, see the [documentation](https://docs.asciidoctor.org/kroki-extension/latest).
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+
7
+ module AsciidoctorExtensions
8
+ # Persistent, content-addressed cache for fetched diagrams, independent of the output
9
+ # directory. Ports src/cache.js so both language bindings behave the same way: the cache
10
+ # key is derived from the diagram request itself (server URL, type, format, encoded source,
11
+ # options), not from the output file name, so it also survives builds that wipe the output
12
+ # directory between runs (e.g. Antora) and correctly detects unchanged content for diagrams
13
+ # with a stable, user-defined name (see #90, #113).
14
+ module KrokiCache
15
+ VALID_CACHE_MODES = ['', 'true', 'false', 'refresh'].freeze
16
+
17
+ class << self
18
+ # Resolves the persistent cache directory.
19
+ # Uses the `kroki-cache-dir` attribute when set; otherwise defaults to the XDG cache
20
+ # directory (`$XDG_CACHE_HOME/kroki` or `~/.cache/kroki`).
21
+ def resolve_cache_dir(doc)
22
+ configured = doc.attr('kroki-cache-dir')
23
+ return configured if configured && !configured.empty?
24
+
25
+ xdg_cache_home = ENV['XDG_CACHE_HOME'] || File.join(Dir.home, '.cache')
26
+ File.join(xdg_cache_home, 'kroki')
27
+ end
28
+
29
+ # Resolves the `kroki-cache` attribute into a cache mode.
30
+ #
31
+ # Recognised values are: unset (defaults to enabled), `` (set with no value, e.g.
32
+ # `:kroki-cache:`) and `true` (both enabled), `false` (disabled), and `refresh` (enabled,
33
+ # but bypasses cached reads and re-fetches + updates the cache). Any other value is
34
+ # invalid: it is logged and treated as if unset.
35
+ #
36
+ # @return [Hash{Symbol => Boolean}] with keys :enabled and :refresh
37
+ def resolve_cache_mode(doc, logger)
38
+ raw = doc.attr('kroki-cache')
39
+ return { enabled: true, refresh: false } if raw.nil?
40
+
41
+ value = raw.to_s.strip.downcase
42
+ unless VALID_CACHE_MODES.include?(value)
43
+ logger.warn "Invalid value '#{raw}' for kroki-cache attribute. The value must be either: " \
44
+ "'true', 'false' or 'refresh'. Proceeding using: 'true'."
45
+ return { enabled: true, refresh: false }
46
+ end
47
+ { enabled: value != 'false', refresh: value == 'refresh' }
48
+ end
49
+
50
+ # Computes the content-addressed cache key for a diagram.
51
+ #
52
+ # Deliberately host-dependent: the server URL is part of the key because two Kroki
53
+ # servers are not guaranteed to render the same source identically (they may run
54
+ # different versions of the underlying diagram libraries). Options are sorted so the
55
+ # key does not depend on their insertion order.
56
+ def content_key(kroki_diagram, server_url)
57
+ sorted_opts = kroki_diagram.opts.sort_by { |k, _| k.to_s }
58
+ material = [server_url, kroki_diagram.type, kroki_diagram.format, kroki_diagram.encode, sorted_opts.to_json].join('/')
59
+ Digest::SHA256.hexdigest(material)
60
+ end
61
+
62
+ # Whether a diagram is already present in the cache.
63
+ def exists_in_cache?(cache_dir, key, format)
64
+ File.exist?(cache_file_path(cache_dir, key, format))
65
+ end
66
+
67
+ # Reads a cached diagram.
68
+ def read_from_cache(cache_dir, key, format)
69
+ File.read(cache_file_path(cache_dir, key, format), mode: 'rb')
70
+ end
71
+
72
+ # Writes a diagram to the cache, creating the cache directory if needed.
73
+ def write_to_cache(cache_dir, key, format, contents)
74
+ FileUtils.mkdir_p(cache_dir)
75
+ File.write(cache_file_path(cache_dir, key, format), contents, mode: 'wb')
76
+ end
77
+
78
+ private
79
+
80
+ def cache_file_path(cache_dir, key, format)
81
+ File.join(cache_dir, "#{key}.#{format}")
82
+ end
83
+ end
84
+ end
85
+ end
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'cgi'
4
+ require 'pathname'
4
5
  require 'asciidoctor/extensions' unless RUBY_ENGINE == 'opal'
6
+ require_relative 'preprocess'
7
+ require_relative 'cache'
5
8
 
6
9
  # Asciidoctor extensions
7
10
  #
@@ -79,7 +82,7 @@ module AsciidoctorExtensions
79
82
  logger.error message_with_context "Failed to read #{diagram_type} file: #{path}. #{e}.", source_location: parent.document.reader.cursor_at_mark
80
83
  return create_block(parent, 'paragraph', unresolved_block_macro_message(diagram_type, path), {})
81
84
  end
82
- KrokiProcessor.process(self, parent, attrs, diagram_type, diagram_text, @logger)
85
+ KrokiProcessor.process(self, parent, attrs, diagram_type, diagram_text, @logger, resource_path: path)
83
86
  end
84
87
 
85
88
  protected
@@ -154,12 +157,14 @@ module AsciidoctorExtensions
154
157
 
155
158
  TEXT_FORMATS = %w[txt atxt utxt].freeze
156
159
  BUILTIN_ATTRIBUTES = %w[target width height format fallback link float align role caption title cloaked-context subs].freeze
160
+ PLANTUML_TYPES = %i[plantuml c4plantuml].freeze
157
161
 
158
162
  class << self
159
163
  # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
160
- def process(processor, parent, attrs, diagram_type, diagram_text, logger)
164
+ def process(processor, parent, attrs, diagram_type, diagram_text, logger, resource_path: nil)
161
165
  doc = parent.document
162
166
  diagram_text = prepend_plantuml_config(diagram_text, diagram_type, doc, logger)
167
+ diagram_text = preprocess_plantuml_includes(diagram_text, diagram_type, doc, resource_path, logger)
163
168
  # If "subs" attribute is specified, substitute accordingly.
164
169
  # Be careful not to specify "specialcharacters" or your diagram code won't be valid anymore!
165
170
  if (subs = attrs['subs'])
@@ -190,7 +195,7 @@ module AsciidoctorExtensions
190
195
  block = processor.create_block(parent, 'literal', text_content, attrs)
191
196
  else
192
197
  attrs['alt'] = alt
193
- attrs['target'] = create_image_src(doc, kroki_diagram, kroki_client, logger)
198
+ apply_image_src(attrs, create_image_src(doc, kroki_diagram, kroki_client, logger))
194
199
  block = processor.create_image_block(parent, attrs)
195
200
  end
196
201
  block.title = title if title
@@ -201,10 +206,13 @@ module AsciidoctorExtensions
201
206
 
202
207
  private
203
208
 
209
+ # Prepends the kroki-plantuml-include file content to the diagram text. Unlike the
210
+ # !include directives resolved by preprocess_plantuml_includes below, this path is jailed
211
+ # to the document's safe-mode boundaries via normalize_system_path, since the attribute
212
+ # value (unlike an !include target written by the diagram's author) may come from outside
213
+ # the diagram itself (e.g. a document-wide default set by a build script).
204
214
  def prepend_plantuml_config(diagram_text, diagram_type, doc, logger)
205
- if diagram_type == :plantuml && doc.safe < ::Asciidoctor::SafeMode::SECURE && doc.attr?('kroki-plantuml-include')
206
- # REMIND: this behaves different than the JS version
207
- # Once we have a preprocessor for Ruby, the value should be added in the diagram source as "!include #{plantuml_include}"
215
+ if PLANTUML_TYPES.include?(diagram_type) && doc.safe < ::Asciidoctor::SafeMode::SECURE && doc.attr?('kroki-plantuml-include')
208
216
  plantuml_include_path = doc.normalize_system_path(doc.attr('kroki-plantuml-include'))
209
217
  if ::File.readable? plantuml_include_path
210
218
  config = File.read(plantuml_include_path)
@@ -217,6 +225,18 @@ module AsciidoctorExtensions
217
225
  diagram_text
218
226
  end
219
227
 
228
+ # Resolves !include/!include_once/!include_many/!includeurl/!includesub directives found in
229
+ # PlantUML/C4-PlantUML diagram text (including text prepended above), searching resource_path's
230
+ # own directory and then each directory in kroki-plantuml-include-paths, in order. Unlike
231
+ # kroki-plantuml-include above, this mirrors the JavaScript extension's preprocessor and is
232
+ # not jailed to the safe-mode boundary (see src/preprocess.js) — only gated by safe mode itself.
233
+ def preprocess_plantuml_includes(diagram_text, diagram_type, doc, resource_path, logger)
234
+ return diagram_text unless PLANTUML_TYPES.include?(diagram_type) && doc.safe < ::Asciidoctor::SafeMode::SECURE
235
+
236
+ include_paths = doc.attr('kroki-plantuml-include-paths')
237
+ PlantUmlPreprocessor.preprocess(diagram_text, resource_path, include_paths, logger)
238
+ end
239
+
220
240
  def get_alt(attrs)
221
241
  if (title = attrs['title'])
222
242
  title
@@ -265,11 +285,27 @@ module AsciidoctorExtensions
265
285
  format
266
286
  end
267
287
 
288
+ def apply_image_src(attrs, image_src)
289
+ attrs['target'] = image_src[:target]
290
+ attrs['imagesdir'] = image_src[:imagesdir] if image_src[:imagesdir]
291
+ end
292
+
268
293
  def create_image_src(doc, kroki_diagram, kroki_client, logger)
269
294
  if doc.attr('kroki-fetch-diagram') && doc.safe < ::Asciidoctor::SafeMode::SECURE
270
- kroki_diagram.save(output_dir_path(doc), kroki_client, generated_files(doc), logger)
295
+ images_output_dir = output_dir_path(doc)
296
+ diagram_name = kroki_diagram.save(images_output_dir, kroki_client, generated_files(doc), logger,
297
+ cache_dir: KrokiCache.resolve_cache_dir(doc), cache_mode: KrokiCache.resolve_cache_mode(doc, logger))
298
+ # The converter resolves the image target against the document's `imagesdir`
299
+ # attribute, which only matches where we actually wrote the file when
300
+ # `imagesoutdir` is unset. Overriding `imagesdir` on this image node (rather
301
+ # than on the document) tells the converter exactly where to find this one
302
+ # file, without disturbing other images in the document (asciidoctor/asciidoctor#3660).
303
+ # As of this writing that core change is merged to `main` but not yet in a
304
+ # released gem (still absent from 2.0.26); until it ships, Asciidoctor's
305
+ # `image_uri` ignores the node-level attribute and this is a harmless no-op.
306
+ { target: diagram_name, imagesdir: relative_images_dir(doc, images_output_dir) }
271
307
  else
272
- kroki_diagram.get_diagram_uri(server_url(doc))
308
+ { target: kroki_diagram.get_diagram_uri(server_url(doc)) }
273
309
  end
274
310
  end
275
311
 
@@ -294,15 +330,21 @@ module AsciidoctorExtensions
294
330
  end
295
331
 
296
332
  def output_dir_path(doc)
297
- images_dir = doc.attr('imagesdir', '')
298
- if (images_output_dir = doc.attr('imagesoutdir'))
299
- images_output_dir
300
- # the nested document logic will become obsolete once https://github.com/asciidoctor/asciidoctor/commit/7edc9da023522be67b17e2a085d72e056703a438 is released
301
- elsif (out_dir = doc.attr('outdir') || (doc.nested? ? doc.parent_document : doc).options[:to_dir])
302
- File.join(out_dir, images_dir)
303
- else
304
- File.join(doc.base_dir, images_dir)
305
- end
333
+ images_output_dir = doc.attr('imagesoutdir')
334
+ return images_output_dir if images_output_dir
335
+
336
+ File.join(output_dir(doc), doc.attr('imagesdir', ''))
337
+ end
338
+
339
+ # the nested document logic will become obsolete once https://github.com/asciidoctor/asciidoctor/commit/7edc9da023522be67b17e2a085d72e056703a438 is released
340
+ def output_dir(doc)
341
+ doc.attr('outdir') || (doc.nested? ? doc.parent_document : doc).options[:to_dir] || doc.base_dir
342
+ end
343
+
344
+ def relative_images_dir(doc, images_output_dir)
345
+ from = Pathname.new(File.expand_path(output_dir(doc)))
346
+ to = Pathname.new(File.expand_path(images_output_dir))
347
+ to.relative_path_from(from).to_s
306
348
  end
307
349
  end
308
350
  end
@@ -333,7 +375,10 @@ module AsciidoctorExtensions
333
375
  ([Zlib::Deflate.deflate(@text, 9)].pack 'm0').tr '+/', '-_'
334
376
  end
335
377
 
336
- def save(output_dir_path, kroki_client, generated_files = nil, logger = nil)
378
+ # @param cache_dir [String, nil] persistent cache directory (see KrokiCache.resolve_cache_dir); required when cache_mode[:enabled]
379
+ # @param cache_mode [Hash] {enabled:, refresh:} (see KrokiCache.resolve_cache_mode); disabled by default so callers that
380
+ # don't pass it (e.g. specs exercising #save directly) keep the pre-cache behaviour
381
+ def save(output_dir_path, kroki_client, generated_files = nil, logger = nil, cache_dir: nil, cache_mode: { enabled: false, refresh: false })
337
382
  diagram_url = get_diagram_uri(kroki_client.server_url)
338
383
  # An explicit name is used verbatim so links stay stable across content changes;
339
384
  # otherwise the name is content-addressed so anonymous diagrams don't collide (see #451).
@@ -341,26 +386,43 @@ module AsciidoctorExtensions
341
386
  diagram_name = named ? "#{@target}.#{@format}" : "diag-#{Digest::SHA256.hexdigest diagram_url}.#{@format}"
342
387
  file_path = File.join(output_dir_path, diagram_name)
343
388
  if named
344
- # A stable file may exist from a previous build with stale content, so always
345
- # re-fetch and overwrite. Warn when the same name is reused for a different diagram.
389
+ # A stable file may exist from a previous build with stale content, so it cannot be
390
+ # trusted by name alone: go through fetch_diagram, which re-fetches only when the
391
+ # persistent cache doesn't already have this exact content (see #90). Warn when the
392
+ # same name is reused for a diagram with different content.
346
393
  warn_on_name_clash(generated_files, diagram_name, diagram_url, logger)
347
394
  generated_files[diagram_name] = diagram_url if generated_files
348
- fetch_and_write(output_dir_path, file_path, kroki_client)
395
+ fetch_and_write(output_dir_path, file_path, kroki_client, cache_dir, cache_mode)
349
396
  elsif !File.exist?(file_path)
350
- # Content-addressed name: an existing file necessarily has identical content.
351
- fetch_and_write(output_dir_path, file_path, kroki_client)
397
+ # Content-addressed name: an existing output file necessarily has identical content.
398
+ fetch_and_write(output_dir_path, file_path, kroki_client, cache_dir, cache_mode)
352
399
  end
353
400
  diagram_name
354
401
  end
355
402
 
356
403
  private
357
404
 
358
- def fetch_and_write(output_dir_path, file_path, kroki_client)
359
- contents = kroki_client.get_image(self, image_encoding)
405
+ def fetch_and_write(output_dir_path, file_path, kroki_client, cache_dir, cache_mode)
406
+ contents = fetch_diagram(kroki_client, cache_dir, cache_mode)
360
407
  FileUtils.mkdir_p(output_dir_path)
361
408
  File.write(file_path, contents, mode: 'wb')
362
409
  end
363
410
 
411
+ # Fetches from Kroki, going through the persistent cache (see cache.rb) when enabled. The
412
+ # cache is keyed on the diagram's actual content, not on the output file name, so it also
413
+ # survives builds that wipe the output directory (e.g. Antora) and correctly detects
414
+ # unchanged content for named diagrams (see #90, #113).
415
+ def fetch_diagram(kroki_client, cache_dir, cache_mode)
416
+ return kroki_client.get_image(self, image_encoding) unless cache_mode[:enabled]
417
+
418
+ key = KrokiCache.content_key(self, kroki_client.server_url)
419
+ return KrokiCache.read_from_cache(cache_dir, key, @format) if !cache_mode[:refresh] && KrokiCache.exists_in_cache?(cache_dir, key, @format)
420
+
421
+ fetched = kroki_client.get_image(self, image_encoding)
422
+ KrokiCache.write_to_cache(cache_dir, key, @format, fetched)
423
+ fetched
424
+ end
425
+
364
426
  def warn_on_name_clash(generated_files, diagram_name, diagram_url, logger)
365
427
  return unless generated_files && logger
366
428
 
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'uri'
5
+
6
+ module AsciidoctorExtensions
7
+ # Resolves PlantUML `!include` directives (and friends) in diagram text before it is sent to
8
+ # the Kroki server, mirroring the JavaScript/Node.js extension's preprocessor (src/preprocess.js)
9
+ # so both language bindings behave the same way. See http://plantuml.com/en/preprocessing.
10
+ #
11
+ # Unlike the JS extension (which also supports Antora resource IDs and a pluggable virtual
12
+ # filesystem), this Ruby port only needs to resolve plain local paths and http(s) URLs, since
13
+ # the Ruby gem is not used with Antora.
14
+ # rubocop:disable Metrics/ModuleLength
15
+ module PlantUmlPreprocessor
16
+ PLANTUML_BLOCK_RX = /@startuml\r?\n([\s\S]*?)\r?\n@enduml/.freeze
17
+ INCLUDE_LINE_RX = /^\s*!(include(?:_many|_once|url|sub)?)\s+(.*)/.freeze
18
+
19
+ class << self
20
+ # @param diagram_text [String] the raw diagram source
21
+ # @param resource_path [String, nil] absolute path (or URL) of the file the diagram text was
22
+ # read from, used to resolve relative !include directives; nil for a diagram written
23
+ # directly in the AsciiDoc source (resolved relative to the current working directory,
24
+ # like plain !include paths without a match in kroki-plantuml-include-paths)
25
+ # @param include_paths [String, nil] the kroki-plantuml-include-paths attribute value
26
+ # (a list of directories separated by File::PATH_SEPARATOR)
27
+ # @param logger [#info] logger used to report includes that were skipped, not raised
28
+ # @return [String] the diagram text with !include directives resolved
29
+ def preprocess(diagram_text, resource_path, include_paths, logger)
30
+ resource = resource_path ? parse_resource(resource_path) : { dir: '', path: nil }
31
+ paths = include_paths.to_s.empty? ? [] : include_paths.split(::File::PATH_SEPARATOR)
32
+ diagram_text = preprocess_includes(diagram_text, resource, [], [], paths, logger)
33
+ remove_tags(diagram_text)
34
+ end
35
+
36
+ private
37
+
38
+ def preprocess_includes(diagram_text, resource, include_once, include_stack, include_paths, logger)
39
+ inside_comment_block = false
40
+ # -1 keeps trailing empty segments (a diagram ending in \n must not lose it), matching
41
+ # JavaScript's String#split, which (unlike Ruby's default) never drops trailing empties.
42
+ lines = diagram_text.split("\n", -1).map do |line|
43
+ result = line
44
+ if !inside_comment_block && (match = INCLUDE_LINE_RX.match(line))
45
+ substituted = process_include_line(match, resource, include_once, include_stack, include_paths, logger)
46
+ result = substituted unless substituted.nil?
47
+ end
48
+ inside_comment_block = true if line.include?("/'")
49
+ inside_comment_block = false if inside_comment_block && line.include?("'/")
50
+ result
51
+ end
52
+ lines.join("\n")
53
+ end
54
+
55
+ def process_include_line(match, resource, include_once, include_stack, include_paths, logger)
56
+ include_directive = match[1].downcase
57
+ target = parse_target(match[2])
58
+ # Intentionally unlimited split (matches the JS split(!) semantics): a second '!' beyond
59
+ # the sub name is silently discarded, same as upstream.
60
+ url_sub = target[:url].split('!')
61
+ trailing_content = target[:comment]
62
+ url = url_sub[0].gsub('\\ ', ' ').sub(/\s+\z/, '')
63
+ sub = url_sub[1]
64
+
65
+ read_result = read_include(url, resource, include_paths, include_stack, logger)
66
+ return nil if read_result[:skip]
67
+
68
+ check_include_once(read_result[:file_path], include_once) if include_directive == 'include_once'
69
+
70
+ text = extract_text(read_result[:text], include_directive, sub)
71
+ include_stack.push(read_result[:file_path])
72
+ text = preprocess_includes(text, parse_resource(read_result[:file_path]), include_once, include_stack, include_paths, logger)
73
+ include_stack.pop
74
+
75
+ trailing_content.empty? ? text : "#{text} #{trailing_content}"
76
+ end
77
+
78
+ def extract_text(text, include_directive, sub)
79
+ return text_or_first_block(text) if sub.nil? || sub.empty?
80
+ return text_from_sub(text, sub) if include_directive == 'includesub'
81
+
82
+ index = /\A\d+\z/.match?(sub) ? sub.to_i : nil
83
+ index ? text_from_index(text, index) : text_from_id(text, sub)
84
+ end
85
+
86
+ # Splits the rest of an !include line into the target path/URL and any trailing PlantUML
87
+ # comment (# line comment or /' block comment), which must be preserved verbatim.
88
+ def parse_target(value)
89
+ (3...value.length).each do |i|
90
+ char = value[i]
91
+ return { url: value[0...(i - 1)].strip, comment: value[i..] } if char == '#' && value[i - 1] == ' ' && value[i - 2] != '\\'
92
+ return { url: value[0...(i - 1)].strip, comment: value[(i - 1)..] } if char == "'" && value[i - 1] == '/' && value[i - 2] != '\\'
93
+ end
94
+ { url: value, comment: '' }
95
+ end
96
+
97
+ def read_include(url, resource, include_paths, include_stack, logger)
98
+ if url.start_with?('<')
99
+ # A standard library include cannot be resolved locally but might be resolved by the Kroki server.
100
+ logger.info("Skipping preprocessing of PlantUML standard library include '#{url}'")
101
+ return { skip: true, text: '', file_path: url }
102
+ end
103
+ raise "Preprocessing of PlantUML include failed, because recursive reading already included referenced file '#{url}'" if include_stack.include?(url)
104
+
105
+ if remote_url?(url)
106
+ read_and_rescue(url, url, 'remote', logger)
107
+ else
108
+ file_path = resolve_include_file(url, resource, include_paths)
109
+ raise "Preprocessing of PlantUML include failed, because recursive reading already included referenced file '#{file_path}'" if include_stack.include?(file_path)
110
+
111
+ read_and_rescue(file_path, file_path, 'local', logger)
112
+ end
113
+ end
114
+
115
+ def read_and_rescue(path, file_path, kind, logger)
116
+ { skip: false, text: read_resource(path), file_path: file_path }
117
+ rescue StandardError => e
118
+ # Includes a file that cannot be found but might be resolved by the Kroki server (see #60).
119
+ logger.info("Skipping preprocessing of PlantUML include, because reading the referenced #{kind} file '#{file_path}' caused an error:\n#{e}")
120
+ { skip: true, text: '', file_path: file_path }
121
+ end
122
+
123
+ def resolve_include_file(include_file, resource, include_paths)
124
+ # When the including file was itself fetched from a remote URL, resolve a relative
125
+ # include against that URL so it can be fetched remotely as well, instead of being
126
+ # (incorrectly) looked up on the local file system.
127
+ return ::URI.join(resource[:path], include_file).to_s if resource[:path].is_a?(::String) && remote_url?(resource[:path])
128
+
129
+ ([resource[:dir]] + include_paths).each do |dir|
130
+ candidate = join_paths(dir, include_file)
131
+ return candidate if ::File.exist?(candidate)
132
+ end
133
+ include_file
134
+ end
135
+
136
+ def check_include_once(file_path, include_once)
137
+ if include_once.include?(file_path)
138
+ raise "Preprocessing of PlantUML include failed, because including multiple times referenced file '#{file_path}' with '!include_once' guard"
139
+ end
140
+
141
+ include_once.push(file_path)
142
+ end
143
+
144
+ def text_from_sub(text, sub)
145
+ regex = /!startsub\s+#{::Regexp.escape(sub)}(?:\r\n|\n)([\s\S]*?)(?:\r\n|\n)!endsub/
146
+ text.scan(regex).flatten.join("\n")
147
+ end
148
+
149
+ def text_from_id(text, id)
150
+ regex = /@startuml\(id=#{::Regexp.escape(id)}\)(?:\r\n|\n)([\s\S]*?)(?:\r\n|\n)@enduml/
151
+ text.scan(regex).flatten.join("\n")
152
+ end
153
+
154
+ def text_from_index(text, index)
155
+ blocks = text.scan(PLANTUML_BLOCK_RX).flatten
156
+ blocks[index] || ''
157
+ end
158
+
159
+ def text_or_first_block(text)
160
+ match = PLANTUML_BLOCK_RX.match(text)
161
+ match ? match[1] : text
162
+ end
163
+
164
+ # Removes all plantuml tags (@startuml/@enduml) from the diagram. It's possible to have more
165
+ # than one diagram in a single file in the cli version of plantuml. This does not work for
166
+ # the server, so recent plantuml versions remove the tags before processing the diagram. We
167
+ # don't want to rely on the server to handle this, so we remove the tags here before sending
168
+ # the diagram to the server.
169
+ #
170
+ # Some diagrams have special tags (i.e. @startmindmap for mindmap) - these are mandatory, so
171
+ # we can't do much about them.
172
+ def remove_tags(diagram_text)
173
+ return diagram_text if diagram_text.nil? || diagram_text.empty?
174
+
175
+ diagram_text.gsub(/^\s*@(?:startuml|enduml).*\n?/, '')
176
+ end
177
+
178
+ def remote_url?(str)
179
+ str.start_with?('http://', 'https://')
180
+ end
181
+
182
+ def read_resource(path)
183
+ if remote_url?(path)
184
+ require 'open-uri'
185
+ ::OpenURI.open_uri(path, &:read)
186
+ else
187
+ ::File.read(path, mode: 'rb:utf-8:utf-8')
188
+ end
189
+ end
190
+
191
+ def parse_resource(file_path)
192
+ if remote_url?(file_path)
193
+ { dir: nil, path: file_path }
194
+ else
195
+ { dir: ::File.dirname(file_path), path: file_path }
196
+ end
197
+ end
198
+
199
+ # Joins path segments the way Node's path.posix.join does: empty segments contribute
200
+ # nothing (so a blank resource dir doesn't force everything into an absolute path), and the
201
+ # result is lexically normalized (. and .. segments resolved) so that two different textual
202
+ # routes to the same file compare equal for cycle detection.
203
+ def join_paths(*parts)
204
+ segments = parts.compact.map(&:to_s).reject(&:empty?)
205
+ return '' if segments.empty?
206
+
207
+ ::Pathname.new(segments.join('/')).cleanpath.to_s
208
+ end
209
+ end
210
+ end
211
+ # rubocop:enable Metrics/ModuleLength
212
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Asciidoctor
4
4
  module AsciidoctorKroki
5
- VERSION = '1.0.1'
5
+ VERSION = '2.0.0.rc.2'
6
6
  end
7
7
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'asciidoctor/extensions' unless RUBY_ENGINE == 'opal'
4
4
  require_relative 'asciidoctor_kroki/version'
5
+ require_relative 'asciidoctor_kroki/preprocess'
6
+ require_relative 'asciidoctor_kroki/cache'
5
7
  require_relative 'asciidoctor_kroki/extension'
6
8
 
7
9
  Asciidoctor::Extensions.register do