Skip to content

Commit 324abc5

Browse files
cachescrubberclaudeahus1
authored
Resolve Antora example$ Kroki diagram targets and show placeholders for diagrams that cannot render (#2112)
Inside an Antora module, plantuml::example$model.puml[] and other Kroki block macros now resolve their target through the plugin's Antora resource resolution (AntoraReferenceAdapter.resolveAntoraResourcePath, prepended as resolve_target_path in kroki-antora.rb). The diagram file itself keeps plain relative PlantUML includes, which the asciidoctor-kroki 2.0 preprocessor resolves against the resolved file, so every .puml stays valid for the PlantUML CLI and the PlantUML integration plugin. A Kroki diagram the preview cannot render shows a placeholder naming the diagram type and the reason, with a link that opens the source in the editor, instead of an error paragraph, a broken image or a silently degraded diagram: an unreadable or unresolvable target, a local !include still present after preprocessing (the Kroki server would drop it and render the rest), or a kroki-error block from the Kroki extension. The document attribute kroki-preview-placeholder shows placeholders for all Kroki diagrams without contacting the server. Ruby-only: the browser fetches the image, so a transport failure still shows as a broken image. Tests: resource id resolution on the antoraModule fixture, and an end-to-end render of a temporary Antora module (relative include, nested ../../ include, unresolvable target, missing include file, placeholder attribute). Relates to #516 Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Alexander Schwartz <alexander.schwartz@gmx.net>
1 parent f81d850 commit 324abc5

8 files changed

Lines changed: 440 additions & 1 deletion

File tree

‎CHANGELOG.adoc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This document provides a high-level view of the changes introduced by release.
99

1010
=== 0.45.7
1111

12+
- Resolve Antora `example$` resource ids as Kroki diagram block macro targets inside an Antora module, and show a placeholder with an open-in-editor link for Kroki diagrams the preview cannot render (unresolvable target, missing include file) instead of an error paragraph or a silently degraded diagram (#516)
1213
- Update the embedded Kroki extension to asciidoctor-kroki 2.0.0.rc.3: PlantUML `!include`, `!includesub`, `!include_once`, `!include_many` and `!includeurl` directives (relative to the diagram file, plus `kroki-plantuml-include-paths`) are now resolved before the diagram is sent to Kroki, a failed diagram no longer aborts the page, and block macro targets resolve relative to the document (#516)
1314

1415
=== 0.45.6

‎doc/users-guide/modules/ROOT/pages/features/advanced/antora.adoc‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ For this it searches all available `antora.yml` files in the current project.
5050

5151
For `include::[]` macros the plugin supports the Antora families like `partial$`, `example$` and `page$`.
5252

53+
When Kroki is enabled, the plugin also resolves an `example$` resource id used as the target of a diagram
54+
block macro, `+plantuml::example$dir/file.puml[]+`; includes inside the diagram file are plain relative
55+
PlantUML includes, resolved by the Kroki extension.
56+
5357
[NOTE]
5458
====
5559
Using Antora page families and includes works in the preview only for trusted project and when IDE is running in the default UNSAFE mode.

‎doc/users-guide/modules/ROOT/pages/features/preview/diagrams.adoc‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,30 @@ and inlined as well, only `+<stdlib>+` includes are passed through to the server
123123
Diagrams written inline in a `[plantuml]` block have no file location, so only absolute paths and the
124124
`kroki-plantuml-include-paths` directories work for their includes.
125125

126+
=== Antora resource ids as Kroki diagram targets
127+
128+
When editing inside an Antora module with Kroki enabled, a diagram source can be referenced by its Antora
129+
resource id, `+plantuml::example$order-model.puml[]+`, and the plugin resolves it to the file in the
130+
module's `examples` directory (plugin version 0.45.7+).
131+
This is the form the Antora site renders through the asciidoctor-kroki JavaScript extension, so page and
132+
preview agree.
133+
Includes inside the `.puml` file stay plain relative PlantUML includes (see above): resource ids belong in
134+
the AsciiDoc page, not in the diagram source, so every `.puml` remains a valid standalone diagram for the
135+
PlantUML CLI or the PlantUML integration plugin.
136+
137+
=== Placeholders for Kroki diagrams that cannot be rendered
138+
139+
A Kroki diagram can fail in the preview when the block macro's target cannot be resolved or read, or when
140+
one of its `+!include+` files does not exist.
141+
In both cases the preview shows a placeholder box instead of an error paragraph or a silently incomplete
142+
diagram (the Kroki server drops an include it cannot read and renders the rest).
143+
The box names the diagram type and the reason, and offers an *Open* link that opens the diagram source in
144+
the IDE editor -- where the PlantUML integration plugin, if installed, previews the file on its own.
145+
146+
To show placeholders for all Kroki diagrams without contacting the server, for example while editing a
147+
page with many diagrams, set the document attribute `kroki-preview-placeholder` (in the document header,
148+
via the plugin's attribute settings, or via an `xref:features/advanced/asciidoctorconfig-file.adoc[.asciidoctorconfig]` file).
149+
126150
== Using embedded Mermaid support
127151

128152
As an experimental option in plugin version 0.38.1+, there's an embedded Mermaid support that is embedded in the plugin and will render Mermaid diagrams within the IDE without external helpers.

‎src/main/java/org/asciidoc/intellij/AsciiDocWrapper.java‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,23 @@ private Asciidoctor initWithExtensions(List<String> extensions, boolean springRe
440440
}
441441
asciidoctor.rubyExtensionRegistry().loadClass(is);
442442
}
443+
// Antora support for Kroki: resolve example$ resource ids used as diagram block macro targets
444+
// (no-op outside an Antora module). Must load after kroki-extension.rb as it prepends onto its classes.
445+
try (InputStream is = this.getClass().getResourceAsStream("/kroki-antora.rb")) {
446+
if (is == null) {
447+
throw new RuntimeException("unable to load script kroki-antora.rb");
448+
}
449+
asciidoctor.rubyExtensionRegistry().loadClass(is);
450+
}
451+
// Placeholders for Kroki diagrams the preview cannot render (unresolvable target, missing include
452+
// file), linking to the diagram source instead of failing the page or rendering a silently degraded
453+
// diagram. Must load after kroki-antora.rb.
454+
try (InputStream is = this.getClass().getResourceAsStream("/kroki-placeholder.rb")) {
455+
if (is == null) {
456+
throw new RuntimeException("unable to load script kroki-placeholder.rb");
457+
}
458+
asciidoctor.rubyExtensionRegistry().loadClass(is);
459+
}
443460
}
444461

445462
if (format.backend.equals("html5") && asciiDocApplicationSettings.getAsciiDocPreviewSettings().isEnableBuiltInMermaid()) {

‎src/main/java/org/asciidoc/intellij/asciidoc/AntoraReferenceAdapter.java‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,54 @@ public static void convertVideo(RubyObject node) {
6262
convertAntora(node, "video");
6363
}
6464

65+
/**
66+
* Resolve an Antora resource identifier (for example {@code example$diagram.puml}, {@code partial$x.puml})
67+
* to an absolute path on the local file system, relative to the current Antora module.
68+
* <p>
69+
* Used by the Kroki extension ({@code kroki-antora.rb}) to resolve diagram block-macro targets and
70+
* PlantUML {@code !include} directives, which Asciidoctor does not resolve on its own (see
71+
* <a href="https://github.com/asciidoctor/asciidoctor-intellij-plugin/issues/516">#516</a>).
72+
*
73+
* @param target an Antora resource id, possibly with a family prefix
74+
* @return the absolute file system path, or {@code null} if not in an Antora module, not a resource id,
75+
* a URL, or not resolvable (callers should then fall back to the unmodified target)
76+
*/
77+
public static String resolveAntoraResourcePath(String target) {
78+
if (antoraModuleDir == null || target == null || target.isEmpty()) {
79+
return null;
80+
}
81+
if (URL_PREFIX_PATTERN.matcher(target).find()) {
82+
return null;
83+
}
84+
// Antora may present a family-only resource id in its empty-module form, e.g. ":example$path";
85+
// a leading colon means "current module", so strip it before resolving.
86+
String normalized = target.startsWith(":") ? target.substring(1) : target;
87+
Matcher matcher = ANTORA_PREFIX_AND_FAMILY_PATTERN.matcher(normalized);
88+
if (!matcher.find()) {
89+
// no Antora family/prefix (e.g. a plain relative path or a PlantUML standard library include) -> leave to caller
90+
return null;
91+
}
92+
if (isExpandedWindowsPath(matcher, normalized)) {
93+
// probably an already expanded windows path name (c:/...)
94+
return null;
95+
}
96+
VirtualFile sourceDir = LocalFileSystem.getInstance().findFileByIoFile(fileBaseDir);
97+
// diagram sources are commonly kept in the example family; the explicit prefix in the target wins anyway
98+
List<String> replaced = AsciiDocUtil.replaceAntoraPrefix(project, antoraModuleDir, sourceDir, normalized, "example");
99+
if (replaced.isEmpty() || (replaced.size() == 1 && replaced.get(0).equals(normalized))) {
100+
// unable to replace
101+
return null;
102+
}
103+
return replaced.get(0);
104+
}
105+
106+
// The ANTORA_PREFIX_AND_FAMILY_PATTERN also matches an already-expanded Windows path (e.g. "c:/..."),
107+
// which must not be treated as an Antora resource id.
108+
private static boolean isExpandedWindowsPath(Matcher matcher, String target) {
109+
return matcher.group().length() == 2 && matcher.group().charAt(1) == ':'
110+
&& target.length() > 2 && target.charAt(2) == '/';
111+
}
112+
65113
@SuppressWarnings("checkstyle:MethodLength")
66114
public static void convertAntora(RubyObject node, String type) {
67115
if (antoraModuleDir != null) {
@@ -95,7 +143,7 @@ public static void convertAntora(RubyObject node, String type) {
95143
}
96144
Matcher matcher = ANTORA_PREFIX_AND_FAMILY_PATTERN.matcher(target);
97145
if (matcher.find()) {
98-
if (matcher.group().length() == 2 && matcher.group().charAt(1) == ':' && target.length() > 2 && target.charAt(2) == '/') {
146+
if (isExpandedWindowsPath(matcher, target)) {
99147
// if the second character is a colon, this is probably an already expanded windows path name
100148
return;
101149
}

‎src/main/resources/kroki-antora.rb‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# frozen_string_literal: true
2+
require 'java'
3+
4+
# Antora support for the Kroki extension (loaded right after kroki-extension.rb, only when Kroki is enabled).
5+
#
6+
# Inside an Antora module a diagram source is referenced by resource id, e.g.
7+
#
8+
# plantuml::example$order-model.puml[]
9+
#
10+
# Asciidoctor and the embedded Kroki extension do not understand resource ids, so the block macro target is
11+
# resolved to a local path here, delegating to the Java side (AntoraReferenceAdapter#resolveAntoraResourcePath),
12+
# which reuses the plugin's existing Antora module resolution. Outside an Antora module, or for a target that
13+
# is not a resource id, the resolver returns nil and the default resolution (relative to the document) applies.
14+
#
15+
# Includes *inside* a .puml file are plain relative PlantUML includes (`!include layout/colors.puml`), resolved
16+
# against the diagram file by the Kroki extension's own preprocessor - exactly as the asciidoctor-kroki
17+
# JavaScript extension does for the Antora site. Nothing Antora-specific is needed for them.
18+
#
19+
# See https://github.com/asciidoctor/asciidoctor-intellij-plugin/issues/516
20+
module AsciidoctorExtensions
21+
module AntoraKroki
22+
module_function
23+
24+
# Resolve an Antora resource id to an absolute local path, or nil if not resolvable / not a resource id.
25+
def resolve(target)
26+
org.asciidoc.intellij.asciidoc.AntoraReferenceAdapter.resolveAntoraResourcePath(target)
27+
rescue StandardError
28+
nil
29+
end
30+
end
31+
32+
module AntoraKrokiTarget
33+
def resolve_target_path(parent, target)
34+
AntoraKroki.resolve(target) || super
35+
end
36+
end
37+
38+
class KrokiBlockMacroProcessor
39+
prepend AntoraKrokiTarget
40+
end
41+
end
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# frozen_string_literal: true
2+
require 'cgi'
3+
4+
# Placeholders for Kroki diagrams that cannot be rendered in the preview (loaded after kroki-extension.rb and
5+
# kroki-antora.rb, only when Kroki is the selected diagram renderer).
6+
#
7+
# A diagram fails in the preview when its block-macro target cannot be resolved or read, when it still contains
8+
# a local `!include` after the Kroki extension's preprocessing (the referenced file does not exist; the Kroki
9+
# server has no file system and PlantUML there silently drops such a line and renders a degraded diagram, e.g.
10+
# without the shared layout), or when the Kroki extension itself reports an error (a `kroki-error` block).
11+
# Instead of an error paragraph or a silently incomplete diagram, the preview shows a placeholder box naming
12+
# the diagram and the reason, with a link that opens the source in the IDE editor - where the PlantUML
13+
# integration plugin, if installed, previews it on its own.
14+
#
15+
# 1. KrokiBlockMacroProcessor#process and KrokiBlockProcessor#process are wrapped: an unreadable target
16+
# yields the placeholder directly; a `kroki-error` block returned by the Kroki extension is turned into
17+
# the placeholder carrying its message.
18+
# 2. PlantUmlPreprocessor.preprocess is wrapped: a local `!include` (not `<stdlib>`, not http(s)) still
19+
# present after preprocessing raises UnresolvedIncludeError, which the Kroki extension reports as a
20+
# `kroki-error` block -> placeholder, so the server never renders a silently degraded diagram.
21+
# 3. Setting the document attribute `kroki-preview-placeholder` (e.g. via the plugin's attribute settings)
22+
# shows placeholders for all Kroki diagrams without contacting the server at all.
23+
#
24+
# The image itself is fetched by the preview's browser, so a transport failure (server unreachable, URI too
25+
# long) still shows as a broken image; that case is intentionally not handled here.
26+
#
27+
# See https://github.com/asciidoctor/asciidoctor-intellij-plugin/issues/516
28+
module AsciidoctorExtensions
29+
# Raised before a PlantUML diagram with a local include the Kroki server cannot resolve is sent.
30+
class UnresolvedIncludeError < StandardError; end
31+
32+
module KrokiPlaceholder
33+
STYLE = 'border: 1px dashed #999; border-radius: 4px; padding: 0.6em 0.9em; margin: 0.5em 0; ' \
34+
'font-family: sans-serif; font-size: 0.9em; background: rgba(128,128,128,0.08);'
35+
LOCAL_INCLUDE_RX = /^\s*!include(?:_many|_once|sub|url)?\s+(\S+)/i
36+
37+
module_function
38+
39+
def placeholder_only?(doc)
40+
doc.attr?('kroki-preview-placeholder')
41+
end
42+
43+
# Local include targets (relative paths) left in a diagram: the server cannot read them.
44+
def unresolved_includes(diagram_text)
45+
diagram_text.each_line.filter_map do |line|
46+
next unless (m = LOCAL_INCLUDE_RX.match(line))
47+
48+
target = m[1].split('!').first
49+
next if target.start_with?('<', 'http://', 'https://')
50+
51+
target
52+
end.uniq
53+
end
54+
55+
def error_block?(block)
56+
block.respond_to?(:role) && block.role.to_s.split.include?('kroki-error')
57+
end
58+
59+
def file_link(path)
60+
return nil if path.nil? || path.empty? || path.start_with?('http://', 'https://')
61+
62+
p = path.to_s.tr('\\', '/')
63+
p = "/#{p}" unless p.start_with?('/')
64+
"file://#{p}"
65+
end
66+
67+
# Builds the placeholder block. `source` is the resolved local path of the diagram source (or nil for an
68+
# inline block), `message` the reason, `text` an optional diagram excerpt (inline blocks).
69+
def block(processor, parent, diagram_type, source, message, text = nil)
70+
e = ->(s) { CGI.escapeHTML(s.to_s) }
71+
html = +"<div class=\"kroki-placeholder\" style=\"#{STYLE}\" data-kroki-type=\"#{e[diagram_type]}\">"
72+
html << "<div><strong>#{e[diagram_type]} diagram not rendered</strong></div>"
73+
html << "<div class=\"kroki-placeholder-message\">#{e[message]}</div>" if message && !message.empty?
74+
if (link = file_link(source))
75+
html << "<div><a href=\"#{e[link]}\" class=\"kroki-placeholder-open\">Open #{e[::File.basename(source)]}</a></div>"
76+
elsif text
77+
html << "<pre class=\"kroki-placeholder-source\" style=\"margin:0.4em 0 0; white-space: pre-wrap;\">#{e[text]}</pre>"
78+
end
79+
html << '</div>'
80+
processor.create_block(parent, :pass, html, {}, content_model: :raw)
81+
end
82+
83+
# The result of the Kroki extension: a placeholder if it reported an error, the result itself otherwise.
84+
# For a block macro the error block is a paragraph carrying the message; for a delimited block it is the
85+
# original listing/literal block, i.e. its source is the diagram text (shown as the excerpt).
86+
def finish(processor, parent, result, diagram_type, source)
87+
return result unless error_block?(result)
88+
89+
if result.context.to_s == 'paragraph' # the Kroki extension creates it with a String context
90+
message = result.source.to_s.lines.first.to_s.strip
91+
text = nil
92+
else
93+
message = 'Diagram could not be rendered'
94+
text = result.source.to_s
95+
end
96+
message = 'Diagram could not be rendered' if message.empty?
97+
block(processor, parent, diagram_type, source, message, text)
98+
end
99+
end
100+
101+
# Block macro form: plantuml::example$model.puml[]
102+
module KrokiPlaceholderBlockMacro
103+
def process(parent, target, attrs)
104+
diagram_type = @name
105+
resolved_target = parent.apply_subs(target, [:attributes])
106+
path = resolve_target_path(parent, resolved_target)
107+
remote = path && (path.start_with?('http://') || path.start_with?('https://'))
108+
unless path && (remote || ::File.readable?(path))
109+
# no file to open: name the macro and, if the target resolved to a path, where the file was expected
110+
message = "Cannot read #{diagram_type}::#{resolved_target}[]"
111+
message += " (resolved to #{path})" if path && path != resolved_target
112+
return KrokiPlaceholder.block(self, parent, diagram_type, nil, message)
113+
end
114+
if KrokiPlaceholder.placeholder_only?(parent.document)
115+
return KrokiPlaceholder.block(self, parent, diagram_type, path, 'Preview placeholders enabled (kroki-preview-placeholder)')
116+
end
117+
KrokiPlaceholder.finish(self, parent, super, diagram_type, remote ? nil : path)
118+
end
119+
end
120+
121+
# Delimited block form: [plantuml] ---- ... ----
122+
module KrokiPlaceholderBlock
123+
def process(parent, reader, attrs)
124+
diagram_type = @name
125+
if KrokiPlaceholder.placeholder_only?(parent.document)
126+
# the only path that reads the text itself; otherwise the reader is left to the Kroki extension
127+
return KrokiPlaceholder.block(self, parent, diagram_type, nil, 'Preview placeholders enabled (kroki-preview-placeholder)', reader.string)
128+
end
129+
KrokiPlaceholder.finish(self, parent, super, diagram_type, nil)
130+
end
131+
end
132+
133+
# After the Kroki extension resolved what it could, a remaining local include means a missing file.
134+
module KrokiPlaceholderPreprocessor
135+
def preprocess(diagram_text, resource_path, include_paths, logger)
136+
text = super
137+
unresolved = KrokiPlaceholder.unresolved_includes(text)
138+
unless unresolved.empty?
139+
raise UnresolvedIncludeError, "Cannot resolve PlantUML include#{'s' if unresolved.size > 1}: #{unresolved.join(', ')}"
140+
end
141+
text
142+
end
143+
end
144+
145+
module PlantUmlPreprocessor
146+
class << self
147+
prepend KrokiPlaceholderPreprocessor
148+
end
149+
end
150+
151+
class KrokiBlockMacroProcessor
152+
prepend KrokiPlaceholderBlockMacro
153+
end
154+
155+
class KrokiBlockProcessor
156+
prepend KrokiPlaceholderBlock
157+
end
158+
end

0 commit comments

Comments
 (0)