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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +4 -3
- data/lib/asciidoctor/extensions/asciidoctor_kroki/cache.rb +85 -0
- data/lib/asciidoctor/extensions/asciidoctor_kroki/extension.rb +87 -25
- data/lib/asciidoctor/extensions/asciidoctor_kroki/preprocess.rb +212 -0
- data/lib/asciidoctor/extensions/asciidoctor_kroki/version.rb +1 -1
- data/lib/asciidoctor/extensions/asciidoctor_kroki.rb +2 -0
- data/spec/asciidoctor_kroki_cache_spec.rb +134 -0
- data/spec/asciidoctor_kroki_diagram_spec.rb +97 -0
- data/spec/asciidoctor_kroki_processor_spec.rb +12 -0
- data/spec/asciidoctor_kroki_spec.rb +137 -21
- data/spec/plantuml_preprocessor_spec.rb +282 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a83bcb9eccbf3e0def0e928c6e62c7a054c721b96fc3bf755520f60d9117e48d
|
|
4
|
+
data.tar.gz: 33698f6396dedc31df4d06a7d410e72fb53feaa2829c100d657b9898764935e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4337469119534bd62fd38bb06bedb50958b62302b52868e0e94b747fb19e97d585fb59c2ad18a794a92f0fdd0624cdae00fdec95f5f740a0bc2f439c561a2db1
|
|
7
|
+
data.tar.gz: 39a922f2c39e331a7756f1034603c4a7e3036ead01b97c5e812c29111bb897ff7fee304e3b35a495f7903e23ca7d29940dcffc6018ea41336b39d419d7df46f9
|
data/Gemfile.lock
CHANGED
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
|
|
25
|
+
import { register } from 'asciidoctor-kroki'
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
|
345
|
-
#
|
|
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
|
|
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,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
|