|
| 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