jekyll-mermaid-prebuild 0.4.0 → 0.6.0

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: ad9dfff47c169ef73600de5d11407d536e0b16003ad4297e6bab55470aded883
4
- data.tar.gz: 214c1fe4e08e38927c85415aab82eaa542820c07ee5aa4d356e045799d4f34bc
3
+ metadata.gz: 1a6f44826b0694bbba4cea5d5b0272301e8909003a9b182eb340042c80454ebb
4
+ data.tar.gz: 9f632d879d948c8030edc5365bc06bc75bd36809da017fd35c6f20ee5b252b0a
5
5
  SHA512:
6
- metadata.gz: 53beefcd83cc729cc7aea99d55ca7a322b7858c616784f57dc2556ccb4b03ab740643ebe03a72c2a57acf754d197d2d8a911ccacc03b909a408631e4af25feee
7
- data.tar.gz: f9f97e2f5c6539994ff13612c22d06717f817f19cfec5ff471b4dc7cdcd474fe931fa277e6ba5ec4cc93226b1c0c999b7ca16e16a583aac188abc057c4113b1e
6
+ metadata.gz: 88b628146a89abbc660bda67b254653a1e77b74b63d55c4ba882a2abe600804129fe29405df0a720457bfbbcbf19b14e7d7841fea322835274deffdcd21c3e67
7
+ data.tar.gz: 3ce40596559ad01778c875cf599b693f42ee1477c70ab178f0f94ff4e735bbe1e1392d68430f2c3985b61ca82b1d8851f9e292715a501a10faf1c640d9dd001e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0](https://github.com/Texarkanine/jekyll-mermaid-prebuild/compare/v0.5.0...v0.6.0) (2026-07-19)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * **test:** add Mutant mutation testing with 100% kill ([#44](https://github.com/Texarkanine/jekyll-mermaid-prebuild/issues/44))
9
+
10
+ ### Features
11
+
12
+ * **test:** add Mutant mutation testing with 100% kill ([#44](https://github.com/Texarkanine/jekyll-mermaid-prebuild/issues/44)) ([e86a82a](https://github.com/Texarkanine/jekyll-mermaid-prebuild/commit/e86a82a6eba5dc0fc037ff0f3451d12708a4f629))
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **deps-dev:** migrate SimpleCov add_filter to skip ([#41](https://github.com/Texarkanine/jekyll-mermaid-prebuild/issues/41)) ([#42](https://github.com/Texarkanine/jekyll-mermaid-prebuild/issues/42)) ([bc068f0](https://github.com/Texarkanine/jekyll-mermaid-prebuild/commit/bc068f0268e2d974a061a1474d0973306d860f8e))
18
+
19
+ ## [0.5.0](https://github.com/Texarkanine/jekyll-mermaid-prebuild/compare/v0.4.0...v0.5.0) (2026-03-22)
20
+
21
+
22
+ ### Features
23
+
24
+ * support "dark mode" (resolves [#11](https://github.com/Texarkanine/jekyll-mermaid-prebuild/issues/11)) ([#20](https://github.com/Texarkanine/jekyll-mermaid-prebuild/issues/20)) ([1ae9322](https://github.com/Texarkanine/jekyll-mermaid-prebuild/commit/1ae93225d9f6089d3d85ce18d06f44310fea3895))
25
+
3
26
  ## [0.4.0](https://github.com/Texarkanine/jekyll-mermaid-prebuild/compare/v0.3.2...v0.4.0) (2026-03-22)
4
27
 
5
28
 
data/README.md CHANGED
@@ -94,6 +94,12 @@ Add to your `_config.yml`:
94
94
  mermaid_prebuild:
95
95
  enabled: true # default: true
96
96
  output_dir: assets/svg # default: assets/svg
97
+ prefers-color-scheme:
98
+ mode: light # light (default) | dark | auto — see [Color scheme](#color-scheme-mermaid-theme)
99
+ # Optional: override mmdc’s root SVG background (defaults: light=white, dark=black)
100
+ # background-color:
101
+ # light: white
102
+ # dark: black
97
103
  postprocessing:
98
104
  text_centering: true # default: true
99
105
  overflow_protection: true # default: true
@@ -108,6 +114,7 @@ mermaid_prebuild:
108
114
  |--------|---------|-------------|
109
115
  | `enabled` | `true` | Enable/disable the plugin |
110
116
  | `output_dir` | `assets/svg` | Directory for generated SVG files |
117
+ | `prefers-color-scheme` | see below | **Hash** with `mode` (`light` / `dark` / `auto`) and optional `background-color` for chart backgrounds. See [Color scheme](#color-scheme-mermaid-theme). |
111
118
 
112
119
  #### `postprocessing` group
113
120
 
@@ -120,6 +127,31 @@ All cross-browser rendering fixes live under the `postprocessing:` key. Each can
120
127
  | `edge_label_padding` | `0` | Extra SVG user units added to edge-label `<foreignObject>` widths after mmdc (off when `0`, `false`, or omitted). Applies to all diagram types. See [Cross-browser text rendering fixes](#cross-browser-text-rendering-fixes). |
121
128
  | `emoji_width_compensation` | `{}` | Map of diagram types to booleans; see [Emoji width compensation](#emoji-width-compensation) below. |
122
129
 
130
+ ### Color scheme (Mermaid theme)
131
+
132
+ Under `mermaid_prebuild`, the **`prefers-color-scheme`** key (hyphenated, like the CSS media feature) must be a **YAML mapping**. Use `mode` for the Mermaid theme / HTML behavior. Optionally nest **`background-color`** (same spelling as in CSS) with `light` / `dark` slots for each chart variant’s root SVG fill (mmdc always emits `background-color: white` on the root `<svg>`; the plugin rewrites that per file).
133
+
134
+ ```yaml
135
+ mermaid_prebuild:
136
+ prefers-color-scheme:
137
+ mode: auto
138
+ background-color:
139
+ light: white # default if omitted
140
+ dark: black # default if omitted
141
+ ```
142
+
143
+ | `mode` | Behavior |
144
+ |--------|----------|
145
+ | `light` | One SVG per diagram using Mermaid’s default (light) theme. |
146
+ | `dark` | One SVG per diagram using mmdc’s dark theme (`mmdc -t dark`). |
147
+ | `auto` | Two SVGs per diagram: `{digest}.svg` (light) and `{digest}-dark.svg` (dark). The embedded HTML uses two links with a small inline stylesheet so only the variant matching the visitor’s `prefers-color-scheme` is shown. **Build cost:** each diagram runs `mmdc` twice until both files are cached. |
148
+
149
+ **Chart backgrounds:** Values are injected verbatim into the root `<svg style="...">` as the CSS token after `background-color:` (for example `white`, `black`, `#fff0aa`, `rgb(0,0,0)`, `transparent`). They are validated conservatively: values containing quotes, angle brackets, backticks, semicolons, backslashes, or control characters are rejected and the default for that slot is used, with a warning. Keep values short (max 256 characters). **Do not** put raw double quotes or HTML in these strings.
150
+
151
+ **Transparency:** The standard CSS keyword **`transparent`** is supported and is the usual choice when you want that variant’s chart to show the page behind it (for example `dark: transparent` on a dark-themed site). Fully transparent RGBA such as `rgba(0, 0, 0, 0)` is also valid if you prefer explicit alpha.
152
+
153
+ If `prefers-color-scheme` is not a hash (for example a bare string), the plugin uses `mode: light` and default backgrounds and logs a warning. Unknown `mode` values fall back to `light` with a warning. Omitting `mode` is treated as `light`.
154
+
123
155
  ### Cross-browser text rendering fixes
124
156
 
125
157
  When mmdc renders a diagram, headless Chromium measures text with `getBoundingClientRect()` and sets each `<foreignObject>` to exactly that width. If the viewing browser (different OS, different fonts) renders the same text at a different width, labels can clip or shift. The plugin can apply some fixes to every generated SVG (all configurable under `postprocessing:`):
@@ -179,7 +211,7 @@ Generated SVGs are cached in `.jekyll-cache/jekyll-mermaid-prebuild/`. The cache
179
211
  - Modified diagrams are automatically regenerated
180
212
  - Different diagrams with different content get different cache keys
181
213
  - Enabling or disabling emoji width compensation for a diagram type invalidates cache for that content (keys include compensated source when applicable)
182
- - Changing `edge_label_padding`, `text_centering`, or `overflow_protection` invalidates cache keys
214
+ - Changing `edge_label_padding`, `text_centering`, `overflow_protection`, `prefers-color-scheme` mode, or chart background colors invalidates cache keys
183
215
 
184
216
  To clear the cache:
185
217
 
@@ -35,11 +35,13 @@ Gem::Specification.new do |spec|
35
35
  spec.add_dependency "jekyll", ">= 4.0", "< 5.0"
36
36
 
37
37
  # Development dependencies
38
+ spec.add_development_dependency "mutant", "~> 0.16"
39
+ spec.add_development_dependency "mutant-rspec", "~> 0.16"
38
40
  spec.add_development_dependency "rake", "~> 13.3"
39
41
  spec.add_development_dependency "rspec", "~> 3.13"
40
42
  spec.add_development_dependency "rubocop", "~> 1.81"
41
43
  spec.add_development_dependency "rubocop-rake", "~> 0.7"
42
44
  spec.add_development_dependency "rubocop-rspec", "~> 3.8"
43
- spec.add_development_dependency "simplecov", "~> 0.22"
44
- spec.add_development_dependency "simplecov-cobertura", "~> 3.1"
45
+ spec.add_development_dependency "simplecov", "~> 1.0"
46
+ spec.add_development_dependency "simplecov-cobertura", "~> 4.0"
45
47
  end
@@ -5,8 +5,19 @@ module JekyllMermaidPrebuild
5
5
  class Configuration
6
6
  DEFAULT_OUTPUT_DIR = "assets/svg"
7
7
  CACHE_DIR = ".jekyll-cache/jekyll-mermaid-prebuild"
8
+ DEFAULT_CHART_BG_LIGHT = "white"
9
+ DEFAULT_CHART_BG_DARK = "black"
10
+ # Parsed mode when PCS is omitted, invalid, or empty (not the YAML string "light").
11
+ DEFAULT_PREFERS_COLOR_SCHEME_MODE = :light
12
+ MAX_CHART_BACKGROUND_LENGTH = 256
13
+ INVALID_CHART_BACKGROUND = /[\x00-\x1f"'<>;`\\]/
8
14
 
9
- attr_reader :output_dir, :text_centering, :overflow_protection, :edge_label_padding, :emoji_width_compensation
15
+ # YAML keys under mermaid_prebuild — hyphenated to align with CSS (@media (prefers-color-scheme), background-color).
16
+ PREFERS_COLOR_SCHEME_YAML_KEY = "prefers-color-scheme"
17
+ BACKGROUND_COLOR_YAML_KEY = "background-color"
18
+
19
+ attr_reader :output_dir, :text_centering, :overflow_protection, :edge_label_padding, :emoji_width_compensation,
20
+ :prefers_color_scheme, :chart_background_light, :chart_background_dark
10
21
 
11
22
  # Initialize configuration from Jekyll site
12
23
  #
@@ -15,6 +26,8 @@ module JekyllMermaidPrebuild
15
26
  config = site.config["mermaid_prebuild"] || {}
16
27
  @output_dir = parse_output_dir(config["output_dir"])
17
28
  @enabled = config.fetch("enabled", true)
29
+ pcs_raw = config[PREFERS_COLOR_SCHEME_YAML_KEY]
30
+ parse_prefers_color_scheme(pcs_raw)
18
31
 
19
32
  pp = config["postprocessing"] || {}
20
33
  @text_centering = pp.fetch("text_centering", true)
@@ -37,7 +50,99 @@ module JekyllMermaidPrebuild
37
50
  CACHE_DIR
38
51
  end
39
52
 
40
- private
53
+ # Parse the prefers-color-scheme block (see PREFERS_COLOR_SCHEME_YAML_KEY) from a Hash only
54
+ # (mode + optional background-color map). Non-Hash values fall back to :light and default
55
+ # backgrounds with a warning.
56
+ #
57
+ # @param value [Object] raw site config value
58
+ # @return [void]
59
+ def parse_prefers_color_scheme(value)
60
+ if !value.nil? && !value.is_a?(Hash)
61
+ Jekyll.logger.warn(
62
+ "MermaidPrebuild:",
63
+ "Invalid #{PREFERS_COLOR_SCHEME_YAML_KEY} (expected a Hash); " \
64
+ "using light mode and default backgrounds"
65
+ )
66
+ end
67
+
68
+ mode_raw = config_hash_fetch(value, "mode")
69
+ @prefers_color_scheme = normalize_prefers_mode(mode_raw)
70
+
71
+ bg_container = config_hash_fetch(value, BACKGROUND_COLOR_YAML_KEY)
72
+ light_raw = config_hash_fetch(bg_container, "light")
73
+ dark_raw = config_hash_fetch(bg_container, "dark")
74
+ @chart_background_light = coerce_chart_background(light_raw, DEFAULT_CHART_BG_LIGHT, "light")
75
+ @chart_background_dark = coerce_chart_background(dark_raw, DEFAULT_CHART_BG_DARK, "dark")
76
+ end
77
+
78
+ # @param raw [Object]
79
+ # @return [Symbol] :light, :dark, or :auto
80
+ def normalize_prefers_mode(raw)
81
+ s = raw.to_s.strip.downcase
82
+ return DEFAULT_PREFERS_COLOR_SCHEME_MODE if s.empty?
83
+
84
+ case s
85
+ when "light" then :light
86
+ when "dark" then :dark
87
+ when "auto" then :auto
88
+ else
89
+ Jekyll.logger.warn(
90
+ "MermaidPrebuild:",
91
+ "Invalid #{PREFERS_COLOR_SCHEME_YAML_KEY} mode #{raw.inspect}; using light"
92
+ )
93
+ DEFAULT_PREFERS_COLOR_SCHEME_MODE
94
+ end
95
+ end
96
+
97
+ # Read a config key from a Hash (string key as in YAML, or matching Symbol).
98
+ #
99
+ # @param hash [Hash]
100
+ # @param key [String] exact key (e.g. "mode", "background-color", "light")
101
+ # @return [Object, nil]
102
+ def config_hash_fetch(hash, key)
103
+ return nil unless hash.is_a?(Hash)
104
+
105
+ hash.fetch(key) { hash.fetch(key.to_sym, nil) }
106
+ end
107
+
108
+ # @param value [Object] raw color string or nil (use default)
109
+ # @param default [String] fallback literal
110
+ # @param label [String] "light" or "dark" for logging
111
+ # @return [String] frozen sanitized CSS fragment
112
+ def coerce_chart_background(value, default, label)
113
+ return finalize_background(default) if value.nil?
114
+
115
+ str = value.to_s.strip
116
+ if str.empty?
117
+ Jekyll.logger.warn "MermaidPrebuild:",
118
+ "Invalid chart background (#{label}): empty string; using #{default.inspect}"
119
+ return finalize_background(default)
120
+ end
121
+
122
+ if str.length > MAX_CHART_BACKGROUND_LENGTH
123
+ Jekyll.logger.warn "MermaidPrebuild:",
124
+ "Invalid chart background (#{label}): value too long; using #{default.inspect}"
125
+ return finalize_background(default)
126
+ end
127
+
128
+ if chart_background_invalid?(str)
129
+ Jekyll.logger.warn "MermaidPrebuild:",
130
+ "Invalid chart background (#{label}): disallowed characters; using #{default.inspect}"
131
+ return finalize_background(default)
132
+ end
133
+
134
+ str.freeze
135
+ end
136
+
137
+ def chart_background_invalid?(value)
138
+ INVALID_CHART_BACKGROUND.match?(value)
139
+ end
140
+
141
+ # @param value [String]
142
+ # @return [String] frozen copy
143
+ def finalize_background(value)
144
+ value.to_s.dup.freeze
145
+ end
41
146
 
42
147
  # Returns a frozen Hash of diagram type (string) => boolean. Non-hash values are rejected → {}.
43
148
  #
@@ -46,7 +151,7 @@ module JekyllMermaidPrebuild
46
151
  def parse_emoji_width_compensation(value)
47
152
  return {}.freeze unless value.is_a?(Hash)
48
153
 
49
- result = value.transform_keys(&:to_s).transform_values { |v| [true, false].include?(v) ? v : false }
154
+ result = value.transform_keys(&:to_s).transform_values { |v| v == true }
50
155
  result.freeze
51
156
  end
52
157
 
@@ -56,18 +161,15 @@ module JekyllMermaidPrebuild
56
161
  dir = dir.strip
57
162
  return DEFAULT_OUTPUT_DIR if dir.empty?
58
163
 
59
- dir.gsub(%r{^/+|/+$}, "")
164
+ dir.split("/").reject(&:empty?).join("/")
60
165
  end
61
166
 
62
167
  # @param value [Object] raw config (numeric or off)
63
168
  # @return [Numeric] non-negative padding in SVG user units; 0 means disabled
64
169
  def parse_edge_label_padding(value)
65
- return 0 if value.nil? || value == false
66
-
67
- num = value.is_a?(Numeric) ? value : nil
68
- return 0 unless num
170
+ return 0 unless value.is_a?(Numeric)
69
171
 
70
- num.negative? ? 0 : num
172
+ value.negative? ? 0 : value
71
173
  end
72
174
  end
73
175
  end
@@ -5,13 +5,11 @@ require "digest"
5
5
  module JekyllMermaidPrebuild
6
6
  # Computes content-based digests for caching
7
7
  module DigestCalculator
8
- module_function
9
-
10
8
  # Generate a short digest for mermaid content
11
9
  #
12
10
  # @param content [String] mermaid diagram source
13
11
  # @return [String] 8-character hex digest
14
- def content_digest(content)
12
+ def self.content_digest(content)
15
13
  Digest::MD5.hexdigest(content)[0, 8]
16
14
  end
17
15
  end
@@ -4,8 +4,6 @@ module JekyllMermaidPrebuild
4
4
  # Stateless module: compensates for headless Chromium emoji width undermeasurement
5
5
  # by appending &nbsp; padding to emoji-containing node labels before mmdc renders.
6
6
  module EmojiCompensator
7
- module_function
8
-
9
7
  # Match a single Extended_Pictographic codepoint (emoji).
10
8
  EMOJI_RE = /\p{Extended_Pictographic}/
11
9
 
@@ -15,28 +13,22 @@ module JekyllMermaidPrebuild
15
13
  #
16
14
  # @param mermaid_source [String] raw Mermaid diagram source
17
15
  # @return [String, nil] diagram type keyword (e.g. "flowchart", "sequenceDiagram") or nil
18
- def detect_diagram_type(mermaid_source)
19
- return nil if !mermaid_source || mermaid_source.strip.empty?
16
+ def self.detect_diagram_type(mermaid_source)
17
+ return nil unless mermaid_source
20
18
 
21
19
  in_frontmatter = false
22
- frontmatter_delim_count = 0
23
20
 
24
21
  mermaid_source.each_line do |line|
25
22
  stripped = line.strip
26
- # Track YAML frontmatter
27
23
  if stripped == "---"
28
- frontmatter_delim_count += 1
29
- in_frontmatter = frontmatter_delim_count.odd?
24
+ in_frontmatter = !in_frontmatter
30
25
  next
31
26
  end
32
27
  next if in_frontmatter
33
28
  next if stripped.empty?
34
29
  next if stripped.start_with?("%%")
35
30
 
36
- # First token of first non-skipped line
37
- token = stripped.split(/\s+/, 2).first
38
- return nil if token.nil? || token.empty?
39
-
31
+ token = stripped[/\A\S+/]
40
32
  return token == "graph" ? "flowchart" : token
41
33
  end
42
34
 
@@ -49,10 +41,10 @@ module JekyllMermaidPrebuild
49
41
  # @param mermaid_source [String] Mermaid diagram source
50
42
  # @param diagram_type [String] result of detect_diagram_type
51
43
  # @return [String] possibly modified source
52
- def compensate(mermaid_source, diagram_type)
53
- return mermaid_source if diagram_type != "flowchart"
44
+ def self.compensate(mermaid_source, diagram_type)
45
+ return compensate_flowchart_labels(mermaid_source) if diagram_type == "flowchart"
54
46
 
55
- compensate_flowchart_labels(mermaid_source)
47
+ mermaid_source
56
48
  end
57
49
 
58
50
  BR_RE = %r{(<br\s*/?>)}i
@@ -62,7 +54,7 @@ module JekyllMermaidPrebuild
62
54
  #
63
55
  # @param text [String]
64
56
  # @return [Integer]
65
- def count_emoji(text)
57
+ def self.count_emoji(text)
66
58
  text.scan(EMOJI_RE).length
67
59
  end
68
60
 
@@ -71,46 +63,48 @@ module JekyllMermaidPrebuild
71
63
  #
72
64
  # @param text [String]
73
65
  # @return [Integer]
74
- def visual_length(text)
66
+ def self.visual_length(text)
75
67
  text.length + count_emoji(text)
76
68
  end
77
69
 
78
70
  # Pad the longest line in a label if it contains emoji.
79
71
  # Splits on <br/> variants, finds the visually longest line, and only
80
72
  # pads that line (shorter lines center naturally in the wider container).
81
- # Returns content unchanged if the longest line has no emoji.
73
+ # When no padding is applied, returns the same String object (identity),
74
+ # not merely an equal copy.
82
75
  #
83
76
  # @param content [String] raw label text (may contain <br/> line breaks)
84
77
  # @return [String] possibly padded label text
85
- def pad_label_content(content)
86
- parts = content.split(BR_RE)
87
- line_indices = (0...parts.length).step(2).to_a
88
- return content if line_indices.empty?
89
-
90
- longest_part_idx = line_indices.max_by { |i| visual_length(parts[i]) }
91
- longest_line = parts[longest_part_idx]
92
- n = count_emoji(longest_line)
93
- return content unless n.positive?
94
-
95
- parts[longest_part_idx] = "#{longest_line}#{NBSP * (n * 2)}"
96
- parts.join
78
+ def self.pad_label_content(content)
79
+ segments = content.split(BR_RE)
80
+ # Even indices are text lines; odd indices are the captured <br> delimiters.
81
+ line_pairs = segments.each_with_index.select { |_segment, index| index.even? }
82
+ return content if line_pairs.empty?
83
+
84
+ # max_by keeps the first of equal-length ties — pad that one index only.
85
+ longest_line, longest_idx = line_pairs.max_by { |segment, _index| visual_length(segment) }
86
+ emoji_count = count_emoji(longest_line)
87
+ return content unless emoji_count.positive?
88
+
89
+ segments[longest_idx] = "#{longest_line}#{NBSP * (emoji_count * 2)}"
90
+ segments.join
97
91
  end
98
92
 
99
- def compensate_flowchart_labels(source)
100
- result = source.dup
93
+ def self.compensate_flowchart_labels(source)
94
+ result = source
101
95
 
102
96
  [
103
- [/\["(.*?)"\]/m, '["', '"]'],
104
- [/\['(.*?)'\]/m, "['", "']"],
105
- [/\("(.*?)"\)/m, '("', '")'],
106
- [/\{"(.*?)"\}/m, '{"', '"}']
97
+ [/\["(.+?)"\]/m, '["', '"]'],
98
+ [/\['(.+?)'\]/m, "['", "']"],
99
+ [/\("(.+?)"\)/m, '("', '")'],
100
+ [/\{"(.+?)"\}/m, '{"', '"}']
107
101
  ].each do |regex, open_str, close_str|
108
102
  result = result.gsub(regex) do
109
103
  "#{open_str}#{pad_label_content(Regexp.last_match(1))}#{close_str}"
110
104
  end
111
105
  end
112
106
 
113
- result.gsub(%r{\[/"((?:[^"\\]|\\.)*)"/\]}m) do
107
+ result.gsub(%r{\[/"((?:[^"\\]|\\.)+?)"/\]}) do
114
108
  "[/\"#{pad_label_content(Regexp.last_match(1))}\"/]"
115
109
  end
116
110
  end
@@ -18,29 +18,21 @@ module JekyllMermaidPrebuild
18
18
  #
19
19
  # @param mermaid_source [String] mermaid diagram definition
20
20
  # @param cache_key [String] digest for caching
21
- # @param diagram_type [String, nil] from EmojiCompensator.detect_diagram_type (e.g. "block")
22
- # @return [String, nil] path to cached SVG file or nil on failure
23
- def generate(mermaid_source, cache_key, diagram_type: nil)
24
- cache_path = File.join(@config.cache_dir, "#{cache_key}.svg")
25
-
26
- # Return cached file if it exists
27
- return cache_path if File.exist?(cache_path)
28
-
29
- # Ensure cache directory exists
30
- FileUtils.mkdir_p(@config.cache_dir)
31
-
32
- # Generate SVG using mmdc
33
- success = MmdcWrapper.render(mermaid_source, cache_path)
34
- return nil unless success
35
-
36
- post_process_svg(cache_path, diagram_type)
37
-
38
- cache_path
21
+ # @return [Hash{String => String}, nil] stem (cache filename without .svg) => absolute cache path, or nil on failure
22
+ def generate(mermaid_source, cache_key)
23
+ case @config.prefers_color_scheme
24
+ when :light
25
+ generate_one(mermaid_source, cache_key, theme: :default)
26
+ when :dark
27
+ generate_one(mermaid_source, cache_key, theme: :dark)
28
+ when :auto
29
+ generate_auto(mermaid_source, cache_key)
30
+ end
39
31
  end
40
32
 
41
- # Build SVG URL from cache key
33
+ # Build SVG URL from cache key (stem, e.g. digest or digest-dark)
42
34
  #
43
- # @param cache_key [String] the cache key
35
+ # @param cache_key [String] the cache stem
44
36
  # @return [String] URL path to SVG
45
37
  def build_svg_url(cache_key)
46
38
  "/#{@config.output_dir}/#{cache_key}.svg"
@@ -48,9 +40,27 @@ module JekyllMermaidPrebuild
48
40
 
49
41
  # Build figure HTML for a diagram
50
42
  #
51
- # @param svg_url [String] URL to the SVG file
43
+ # @param svg_url [String] URL to the light (or only) SVG file
44
+ # @param dark_url [String, nil] when set (auto mode), second link + CSS toggle for prefers-color-scheme
52
45
  # @return [String] HTML figure element
53
- def build_figure_html(svg_url)
46
+ def build_figure_html(svg_url, dark_url: nil)
47
+ if dark_url
48
+ return <<~HTML
49
+ <figure class="mermaid-diagram">
50
+ <style>
51
+ .mermaid-diagram__light { display: inline; }
52
+ .mermaid-diagram__dark { display: none; }
53
+ @media (prefers-color-scheme: dark) {
54
+ .mermaid-diagram__light { display: none; }
55
+ .mermaid-diagram__dark { display: inline; }
56
+ }
57
+ </style>
58
+ <a class="mermaid-diagram__light" href="#{svg_url}"><img src="#{svg_url}" alt="Mermaid Diagram"></a>
59
+ <a class="mermaid-diagram__dark" href="#{dark_url}"><img src="#{dark_url}" alt="Mermaid Diagram"></a>
60
+ </figure>
61
+ HTML
62
+ end
63
+
54
64
  <<~HTML
55
65
  <figure class="mermaid-diagram">
56
66
  <a href="#{svg_url}"><img src="#{svg_url}" alt="Mermaid Diagram"></a>
@@ -60,17 +70,54 @@ module JekyllMermaidPrebuild
60
70
 
61
71
  private
62
72
 
63
- def post_process_svg(cache_path, _diagram_type)
73
+ # @return [Hash{String => String}, nil]
74
+ def generate_one(mermaid_source, stem, theme:)
75
+ cache_path = File.join(@config.cache_dir, "#{stem}.svg")
76
+ return { stem => cache_path } if File.exist?(cache_path)
77
+
78
+ FileUtils.mkdir_p(@config.cache_dir)
79
+ return nil unless MmdcWrapper.render(mermaid_source, cache_path, theme: theme)
80
+
81
+ bg = theme == :dark ? @config.chart_background_dark : @config.chart_background_light
82
+ post_process_svg(cache_path, root_background: bg)
83
+ { stem => cache_path }
84
+ end
85
+
86
+ # @return [Hash{String => String}, nil]
87
+ def generate_auto(mermaid_source, cache_key)
88
+ light_stem = cache_key
89
+ dark_stem = "#{cache_key}-dark"
90
+ light_path = File.join(@config.cache_dir, "#{light_stem}.svg")
91
+ dark_path = File.join(@config.cache_dir, "#{dark_stem}.svg")
92
+
93
+ FileUtils.mkdir_p(@config.cache_dir)
94
+
95
+ unless File.exist?(light_path)
96
+ return nil unless MmdcWrapper.render(mermaid_source, light_path, theme: :default)
97
+
98
+ post_process_svg(light_path, root_background: @config.chart_background_light)
99
+ end
100
+
101
+ unless File.exist?(dark_path)
102
+ return nil unless MmdcWrapper.render(mermaid_source, dark_path, theme: :dark)
103
+
104
+ post_process_svg(dark_path, root_background: @config.chart_background_dark)
105
+ end
106
+
107
+ { light_stem => light_path, dark_stem => dark_path }
108
+ end
109
+
110
+ def post_process_svg(cache_path, root_background:)
64
111
  raw = File.read(cache_path)
65
112
  svg = raw
66
113
 
67
114
  svg = SvgPostProcessor.ensure_text_centering(svg) if @config.text_centering
68
115
  svg = SvgPostProcessor.ensure_foreignobject_overflow(svg) if @config.overflow_protection
116
+ svg = SvgPostProcessor.apply_root_svg_background(svg, root_background)
69
117
 
70
- pad = @config.edge_label_padding
71
- svg = SvgPostProcessor.apply(svg, padding: pad) if pad.is_a?(Numeric) && pad.positive?
118
+ svg = SvgPostProcessor.apply(svg, padding: @config.edge_label_padding)
72
119
 
73
- File.write(cache_path, svg) if svg != raw
120
+ File.write(cache_path, svg) unless svg.eql?(raw)
74
121
  end
75
122
  end
76
123
  end
@@ -5,15 +5,13 @@ require "fileutils"
5
5
  module JekyllMermaidPrebuild
6
6
  # Jekyll hook integration
7
7
  module Hooks
8
- module_function
9
-
10
8
  # Copy generated SVGs to _site directory
11
9
  #
12
10
  # @param site [Jekyll::Site] the Jekyll site
13
11
  # @param config [Configuration] plugin configuration
14
12
  # @param svgs [Hash] cache_key => cached_path mapping
15
- def copy_svgs_to_site(site, config, svgs)
16
- return unless svgs && !svgs.empty?
13
+ def self.copy_svgs_to_site(site, config, svgs)
14
+ return if svgs.nil? || svgs.empty?
17
15
 
18
16
  dest_dir = File.join(site.dest, config.output_dir)
19
17
  FileUtils.mkdir_p(dest_dir)
@@ -34,110 +32,110 @@ module JekyllMermaidPrebuild
34
32
  end
35
33
 
36
34
  # Log helpful error message for Puppeteer issues
37
- def log_puppeteer_error
38
- Jekyll.logger.error "MermaidPrebuild:", "=" * 60
35
+ def self.log_puppeteer_error
39
36
  Jekyll.logger.error "MermaidPrebuild:", "mmdc failed: Puppeteer cannot launch headless Chrome"
40
- Jekyll.logger.error "MermaidPrebuild:", ""
41
- Jekyll.logger.error "MermaidPrebuild:", "This usually means missing system libraries."
42
- Jekyll.logger.error "MermaidPrebuild:", "On Debian/Ubuntu/WSL, install with:"
43
- Jekyll.logger.error "MermaidPrebuild:", ""
44
- Jekyll.logger.error "MermaidPrebuild:", " sudo apt-get update"
45
- Jekyll.logger.error "MermaidPrebuild:", " sudo apt-get install -y libgbm1 libasound2 libatk1.0-0 \\"
46
- Jekyll.logger.error "MermaidPrebuild:", " libatk-bridge2.0-0 libcups2 libdrm2 libxcomposite1 \\"
47
- Jekyll.logger.error "MermaidPrebuild:", " libxdamage1 libxfixes3 libxrandr2 libxkbcommon0 \\"
48
- Jekyll.logger.error "MermaidPrebuild:", " libpango-1.0-0 libcairo2 libnss3 libnspr4"
49
- Jekyll.logger.error "MermaidPrebuild:", ""
50
- Jekyll.logger.error "MermaidPrebuild:", "See: https://pptr.dev/troubleshooting"
51
- Jekyll.logger.error "MermaidPrebuild:", "=" * 60
37
+ Jekyll.logger.error "MermaidPrebuild:", "Install system libraries (see https://pptr.dev/troubleshooting)"
52
38
  end
53
39
 
54
- # Register Jekyll hooks
55
- def register
56
- # Initialize and check mmdc after site data is loaded
57
- Jekyll::Hooks.register :site, :post_read do |site|
58
- config = Configuration.new(site)
59
- site.data["mermaid_prebuild_config"] = config
40
+ # Initialize plugin state after site data is loaded (:post_read)
41
+ #
42
+ # @param site [Jekyll::Site] the Jekyll site
43
+ def self.initialize_system(site)
44
+ config = Configuration.new(site)
45
+ site.data["mermaid_prebuild_config"] = config
46
+
47
+ unless config.enabled?
48
+ Jekyll.logger.info "MermaidPrebuild:", "Disabled in configuration"
49
+ site.data["mermaid_prebuild_enabled"] = false
50
+ return
51
+ end
60
52
 
61
- unless config.enabled?
62
- Jekyll.logger.info "MermaidPrebuild:", "Disabled in configuration"
63
- site.data["mermaid_prebuild_enabled"] = false
64
- next
65
- end
53
+ status = MmdcWrapper.check_status
54
+
55
+ case status
56
+ when :ok
57
+ Jekyll.logger.info "MermaidPrebuild:", "Initialized (mmdc #{MmdcWrapper.version || "unknown version"})"
58
+ Jekyll.logger.info "MermaidPrebuild:", "Output directory: #{config.output_dir}"
59
+ site.data["mermaid_prebuild_enabled"] = true
60
+ generator = Generator.new(config)
61
+ site.data["mermaid_prebuild_generator"] = generator
62
+ site.data["mermaid_prebuild_processor"] = Processor.new(config, generator)
63
+ site.data["mermaid_prebuild_svgs"] = {}
64
+ when :not_found
65
+ Jekyll.logger.warn "MermaidPrebuild:", "mmdc not found - mermaid diagrams will not be converted"
66
+ Jekyll.logger.warn "MermaidPrebuild:", "Install with: npm install -g @mermaid-js/mermaid-cli"
67
+ site.data["mermaid_prebuild_enabled"] = false
68
+ when :puppeteer_error
69
+ log_puppeteer_error
70
+ site.data["mermaid_prebuild_enabled"] = false
71
+ else
72
+ Jekyll.logger.warn "MermaidPrebuild:", "mmdc check failed with unknown error"
73
+ site.data["mermaid_prebuild_enabled"] = false
74
+ end
75
+ end
76
+
77
+ # Process documents/pages before rendering (:pre_render)
78
+ #
79
+ # @param site [Jekyll::Site] the Jekyll site
80
+ def self.process_site(site)
81
+ return unless site.data["mermaid_prebuild_enabled"]
82
+
83
+ processor = site.data["mermaid_prebuild_processor"]
84
+ total_count = 0
85
+
86
+ site.documents.each do |document|
87
+ next unless document.content
66
88
 
67
- status = MmdcWrapper.check_status
68
-
69
- case status
70
- when :ok
71
- Jekyll.logger.info "MermaidPrebuild:", "Initialized (mmdc #{MmdcWrapper.version || "unknown version"})"
72
- Jekyll.logger.info "MermaidPrebuild:", "Output directory: #{config.output_dir}"
73
- site.data["mermaid_prebuild_enabled"] = true
74
- site.data["mermaid_prebuild_generator"] = Generator.new(config)
75
- site.data["mermaid_prebuild_processor"] = Processor.new(config, site.data["mermaid_prebuild_generator"])
76
- site.data["mermaid_prebuild_svgs"] = {}
77
- when :not_found
78
- Jekyll.logger.warn "MermaidPrebuild:", "mmdc not found - mermaid diagrams will not be converted"
79
- Jekyll.logger.warn "MermaidPrebuild:", "Install with: npm install -g @mermaid-js/mermaid-cli"
80
- site.data["mermaid_prebuild_enabled"] = false
81
- when :puppeteer_error
82
- log_puppeteer_error
83
- site.data["mermaid_prebuild_enabled"] = false
84
- else
85
- Jekyll.logger.warn "MermaidPrebuild:", "mmdc check failed with unknown error"
86
- site.data["mermaid_prebuild_enabled"] = false
89
+ processed, count, svgs = processor.process_content(document.content, site)
90
+ if count.positive?
91
+ document.content = processed
92
+ site.data["mermaid_prebuild_svgs"].merge!(svgs)
93
+ Jekyll.logger.info "MermaidPrebuild:", "Converted #{count} diagram(s) in #{document.relative_path}"
94
+ total_count += count
87
95
  end
96
+ rescue StandardError => e
97
+ Jekyll.logger.error "MermaidPrebuild:", "Error processing #{document.relative_path}: #{e.message}"
88
98
  end
89
99
 
90
- # Process all documents/pages BEFORE rendering
91
- Jekyll::Hooks.register :site, :pre_render do |site|
92
- next unless site.data["mermaid_prebuild_enabled"]
93
-
94
- processor = site.data["mermaid_prebuild_processor"]
95
- total_count = 0
96
-
97
- # Process all documents
98
- site.documents.each do |document|
99
- next unless document.content
100
-
101
- processed, count, svgs = processor.process_content(document.content, site)
102
- if count.positive?
103
- document.content = processed
104
- site.data["mermaid_prebuild_svgs"].merge!(svgs)
105
- Jekyll.logger.info "MermaidPrebuild:", "Converted #{count} diagram(s) in #{document.relative_path}"
106
- total_count += count
107
- end
108
- rescue StandardError => e
109
- Jekyll.logger.error "MermaidPrebuild:", "Error processing #{document.relative_path}: #{e.message}"
110
- end
100
+ site.pages.each do |page|
101
+ next unless page.content
111
102
 
112
- # Process all pages
113
- site.pages.each do |page|
114
- next unless page.content
115
-
116
- processed, count, svgs = processor.process_content(page.content, site)
117
- if count.positive?
118
- page.content = processed
119
- site.data["mermaid_prebuild_svgs"].merge!(svgs)
120
- Jekyll.logger.info "MermaidPrebuild:", "Converted #{count} diagram(s) in #{page.relative_path}"
121
- total_count += count
122
- end
123
- rescue StandardError => e
124
- Jekyll.logger.error "MermaidPrebuild:", "Error processing page: #{e.message}"
103
+ processed, count, svgs = processor.process_content(page.content, site)
104
+ if count.positive?
105
+ page.content = processed
106
+ site.data["mermaid_prebuild_svgs"].merge!(svgs)
107
+ Jekyll.logger.info "MermaidPrebuild:", "Converted #{count} diagram(s) in #{page.relative_path}"
108
+ total_count += count
125
109
  end
126
-
127
- Jekyll.logger.info "MermaidPrebuild:", "Total: #{total_count} diagram(s) converted" if total_count.positive?
110
+ rescue StandardError => e
111
+ Jekyll.logger.error "MermaidPrebuild:", "Error processing #{page.relative_path}: #{e.message}"
128
112
  end
129
113
 
130
- # Copy SVGs to _site after write
131
- Jekyll::Hooks.register :site, :post_write do |site|
132
- next unless site.data["mermaid_prebuild_enabled"]
114
+ Jekyll.logger.info "MermaidPrebuild:", "Total: #{total_count} diagram(s) converted" if total_count.positive?
115
+ end
133
116
 
134
- config = site.data["mermaid_prebuild_config"]
135
- svgs = site.data["mermaid_prebuild_svgs"]
136
- copy_svgs_to_site(site, config, svgs)
137
- end
117
+ # Copy SVGs into _site after write (:post_write)
118
+ #
119
+ # @param site [Jekyll::Site] the Jekyll site
120
+ def self.copy_generated_svgs(site)
121
+ return unless site.data["mermaid_prebuild_enabled"]
122
+
123
+ config = site.data["mermaid_prebuild_config"]
124
+ svgs = site.data["mermaid_prebuild_svgs"]
125
+ copy_svgs_to_site(site, config, svgs)
138
126
  end
139
127
  end
140
128
  end
141
129
 
142
- # Register hooks when loaded
143
- JekyllMermaidPrebuild::Hooks.register
130
+ # Register Jekyll hooks when loaded
131
+ Jekyll::Hooks.register :site, :post_read do |site|
132
+ JekyllMermaidPrebuild::Hooks.initialize_system(site)
133
+ end
134
+
135
+ Jekyll::Hooks.register :site, :pre_render do |site|
136
+ JekyllMermaidPrebuild::Hooks.process_site(site)
137
+ end
138
+
139
+ Jekyll::Hooks.register :site, :post_write do |site|
140
+ JekyllMermaidPrebuild::Hooks.copy_generated_svgs(site)
141
+ end
@@ -6,39 +6,41 @@ require "tempfile"
6
6
  module JekyllMermaidPrebuild
7
7
  # Wrapper for mmdc (mermaid CLI) commands
8
8
  module MmdcWrapper
9
- module_function
10
-
11
9
  # Check if mmdc is available in PATH
12
10
  #
13
11
  # @return [Boolean] true if mmdc is available
14
- def available?
15
- return @available if defined?(@available)
12
+ MMDC_COMMAND = "mmdc"
13
+
14
+ def self.available?
15
+ return @available if instance_variable_defined?(:@available)
16
16
 
17
- @available = command_exists?("mmdc")
17
+ @available = command_exists?(MMDC_COMMAND)
18
18
  end
19
19
 
20
20
  # Check if a command exists in PATH (cross-platform)
21
21
  #
22
22
  # @param cmd [String] command name
23
23
  # @return [Boolean] true if command found
24
- def command_exists?(cmd)
24
+ def self.command_exists?(cmd)
25
+ return false if cmd.nil? || cmd.empty?
26
+
25
27
  cmd_name = Gem.win_platform? ? "#{cmd}.exe" : cmd
26
28
  path_dirs = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
27
29
 
28
30
  path_dirs.any? do |dir|
29
31
  executable = File.join(dir, cmd_name)
30
- File.executable?(executable)
32
+ File.file?(executable) && File.executable?(executable)
31
33
  end
32
34
  end
33
35
 
34
36
  # Get mmdc version
35
37
  #
36
38
  # @return [String, nil] version string or nil
37
- def version
38
- return @version if defined?(@version)
39
+ def self.version
40
+ return @version if instance_variable_defined?(:@version)
39
41
 
40
- output, status = Open3.capture2e("mmdc", "--version")
41
- @version = status.success? ? output.strip : nil
42
+ output, status = Open3.capture2e(MMDC_COMMAND, "--version")
43
+ @version = output.strip if status.success?
42
44
  rescue StandardError
43
45
  @version = nil
44
46
  end
@@ -46,8 +48,8 @@ module JekyllMermaidPrebuild
46
48
  # Check mmdc status (availability and Puppeteer)
47
49
  #
48
50
  # @return [Symbol] :ok, :not_found, :puppeteer_error, or :unknown_error
49
- def check_status
50
- return @check_status if defined?(@check_status)
51
+ def self.check_status
52
+ return @check_status if instance_variable_defined?(:@check_status)
51
53
 
52
54
  unless available?
53
55
  @check_status = :not_found
@@ -58,25 +60,26 @@ module JekyllMermaidPrebuild
58
60
  end
59
61
 
60
62
  # Reset cached status (useful for testing)
61
- def reset_cache!
62
- remove_instance_variable(:@available) if defined?(@available)
63
- remove_instance_variable(:@version) if defined?(@version)
64
- remove_instance_variable(:@check_status) if defined?(@check_status)
63
+ def self.reset_cache!
64
+ remove_instance_variable(:@available) if instance_variable_defined?(:@available)
65
+ remove_instance_variable(:@version) if instance_variable_defined?(:@version)
66
+ remove_instance_variable(:@check_status) if instance_variable_defined?(:@check_status)
65
67
  end
66
68
 
67
69
  # Test mmdc with a minimal diagram to verify Puppeteer works
68
70
  #
69
71
  # @return [Symbol] :ok, :puppeteer_error, or :unknown_error
70
- def test_render
71
- input = Tempfile.new(["test", ".mmd"])
72
- output = Tempfile.new(["test", ".svg"])
73
-
72
+ def self.test_render
73
+ input = Tempfile.new(["", ".mmd"])
74
+ output = Tempfile.new(["", ".svg"])
74
75
  begin
75
76
  input.write("graph TD\nA-->B")
76
77
  input.close
77
78
  output.close # Close before mmdc writes (Windows file locking)
78
79
 
79
- _stdout, stderr, status = Open3.capture3("mmdc", "-i", input.path, "-o", output.path, "-e", "svg")
80
+ _stdout, stderr, status = Open3.capture3(
81
+ MMDC_COMMAND, "-i", input.path, "-o", output.path, "-e", "svg"
82
+ )
80
83
 
81
84
  if status.success?
82
85
  :ok
@@ -86,8 +89,8 @@ module JekyllMermaidPrebuild
86
89
  :unknown_error
87
90
  end
88
91
  ensure
89
- input.close! rescue nil # rubocop:disable Style/RescueModifier
90
- output.close! rescue nil # rubocop:disable Style/RescueModifier
92
+ input.unlink
93
+ output.unlink
91
94
  end
92
95
  end
93
96
 
@@ -95,15 +98,22 @@ module JekyllMermaidPrebuild
95
98
  #
96
99
  # @param mermaid_source [String] mermaid diagram definition
97
100
  # @param output_path [String] path to write SVG file
101
+ ALLOWED_RENDER_THEMES = %i[default dark].freeze
102
+
103
+ # @param theme [Symbol] :default (mermaid default theme) or :dark (mmdc -t dark)
98
104
  # @return [Boolean] true if successful
99
- def render(mermaid_source, output_path)
105
+ # @raise [ArgumentError] if theme is not supported
106
+ def self.render(mermaid_source, output_path, theme: :default)
107
+ raise ArgumentError, "unsupported mmdc theme #{theme.inspect}" unless ALLOWED_RENDER_THEMES.include?(theme)
108
+
100
109
  input_file = Tempfile.new(["mermaid", ".mmd"])
101
110
 
102
111
  begin
103
112
  input_file.write(mermaid_source)
104
113
  input_file.close
105
114
 
106
- cmd = ["mmdc", "-i", input_file.path, "-o", output_path, "-e", "svg"]
115
+ cmd = [MMDC_COMMAND, "-i", input_file.path, "-o", output_path, "-e", "svg"]
116
+ cmd += ["-t", "dark"] if theme == :dark
107
117
  _stdout, _stderr, status = Open3.capture3(*cmd)
108
118
 
109
119
  status.success?
@@ -116,7 +126,7 @@ module JekyllMermaidPrebuild
116
126
  # Supports both backtick (```) and tilde (~~~) fences with 3+ characters
117
127
  #
118
128
  # @return [Regexp] pattern matching mermaid code blocks
119
- def mermaid_fence_pattern
129
+ def self.mermaid_fence_pattern
120
130
  /
121
131
  ^(`{3,}|~{3,})mermaid\s*\n # Opening fence: 3+ backticks or tildes, then 'mermaid'
122
132
  (.*?) # Content (captured, non-greedy)
@@ -22,7 +22,7 @@ module JekyllMermaidPrebuild
22
22
  # @param _site [Jekyll::Site] the Jekyll site (unused, kept for API compatibility)
23
23
  # @return [Array<String, Integer, Hash>] [processed_content, count, svgs_to_copy]
24
24
  def process_content(content, _site = nil)
25
- return [content, 0, {}] unless content
25
+ return [nil, 0, {}] unless content
26
26
 
27
27
  converted_count = 0
28
28
  svgs_to_copy = {}
@@ -36,21 +36,23 @@ module JekyllMermaidPrebuild
36
36
  result = convert_block(block)
37
37
  next unless result
38
38
 
39
+ result => { svgs:, html: }
40
+ block => { start:, end: block_end }
39
41
  converted_count += 1
40
- svgs_to_copy[result[:cache_key]] = result[:cached_path]
41
- processed[block[:start]...block[:end]] = result[:html]
42
+ svgs_to_copy.merge!(svgs)
43
+ processed[start...block_end] = html
42
44
  end
43
45
 
44
46
  [processed, converted_count, svgs_to_copy]
45
47
  end
46
48
 
47
- private
48
-
49
49
  # @param source [String] mermaid passed to mmdc (after optional emoji compensation)
50
- # @param diagram_type [String, nil]
51
50
  # @return [String] input to MD5 for cache key
52
- def digest_string_for_cache(source, _diagram_type)
51
+ def digest_string_for_cache(source)
53
52
  parts = [source]
53
+ parts << "pcs=#{@config.prefers_color_scheme}"
54
+ parts << "bgL=#{@config.chart_background_light}"
55
+ parts << "bgD=#{@config.chart_background_dark}"
54
56
  parts << "tc=#{@config.text_centering}" unless @config.text_centering
55
57
  parts << "op=#{@config.overflow_protection}" unless @config.overflow_protection
56
58
  pad = @config.edge_label_padding
@@ -61,23 +63,30 @@ module JekyllMermaidPrebuild
61
63
  # Convert a single mermaid block to SVG
62
64
  #
63
65
  # @param block [Hash] block info with :content key
64
- # @return [Hash, nil] {cache_key:, cached_path:, html:} or nil if failed
66
+ # @return [Hash, nil] {:svgs, :html} or nil if failed
65
67
  def convert_block(block)
66
68
  mermaid_source = block[:content]
67
69
  diagram_type = EmojiCompensator.detect_diagram_type(mermaid_source)
68
- source_for_render = if diagram_type && @config.emoji_width_compensation[diagram_type]
70
+ source_for_render = if @config.emoji_width_compensation[diagram_type]
69
71
  EmojiCompensator.compensate(mermaid_source, diagram_type)
70
72
  else
71
73
  mermaid_source
72
74
  end
73
- digest_input = digest_string_for_cache(source_for_render, diagram_type)
75
+ digest_input = digest_string_for_cache(source_for_render)
74
76
  cache_key = DigestCalculator.content_digest(digest_input)
75
- cached_path = @generator.generate(source_for_render, cache_key, diagram_type: diagram_type)
77
+ paths = @generator.generate(source_for_render, cache_key)
78
+
79
+ return nil if paths.nil? || paths.empty?
76
80
 
77
- return nil unless cached_path
81
+ light_url = @generator.build_svg_url(cache_key)
82
+ html = if @config.prefers_color_scheme == :auto
83
+ dark_url = @generator.build_svg_url("#{cache_key}-dark")
84
+ @generator.build_figure_html(light_url, dark_url: dark_url)
85
+ else
86
+ @generator.build_figure_html(light_url)
87
+ end
78
88
 
79
- svg_url = @generator.build_svg_url(cache_key)
80
- { cache_key: cache_key, cached_path: cached_path, html: @generator.build_figure_html(svg_url) }
89
+ { svgs: paths, html: html }
81
90
  end
82
91
 
83
92
  # Find all top-level mermaid code blocks (not nested inside other fences)
@@ -85,13 +94,14 @@ module JekyllMermaidPrebuild
85
94
  # @param content [String] markdown content
86
95
  # @return [Array<Hash>] array of {start:, end:, content:} for each top-level mermaid block
87
96
  def find_top_level_mermaid_blocks(content)
88
- state = { blocks: [], fence_stack: [], current_mermaid: nil, position: 0 }
97
+ blocks = []
98
+ state = { blocks: blocks, fence_stack: [], position: 0 }
89
99
 
90
- content.lines.each do |line|
100
+ content.lines do |line|
91
101
  process_line(line, state)
92
102
  end
93
103
 
94
- state[:blocks]
104
+ blocks
95
105
  end
96
106
 
97
107
  # Process a single line for fence detection
@@ -102,8 +112,9 @@ module JekyllMermaidPrebuild
102
112
  match = line.match(FENCE_OPENER)
103
113
  if match
104
114
  handle_fence_line(line, line_start, match, state)
105
- elsif state[:current_mermaid]
106
- state[:current_mermaid][:content_lines] << line
115
+ elsif (current_mermaid = state[:current_mermaid])
116
+ content_lines = current_mermaid[:content_lines]
117
+ content_lines << line
107
118
  end
108
119
  end
109
120
 
@@ -111,7 +122,7 @@ module JekyllMermaidPrebuild
111
122
  def handle_fence_line(line, line_start, match, state)
112
123
  fence_chars = match[1]
113
124
  language = match[2]
114
- fence_type = fence_chars[0]
125
+ fence_type = fence_chars.include?("~") ? "~" : "`"
115
126
  fence_length = fence_chars.length
116
127
 
117
128
  if state[:current_mermaid]
@@ -126,11 +137,14 @@ module JekyllMermaidPrebuild
126
137
  # Handle fence line while inside a mermaid block
127
138
  def handle_line_in_mermaid(line, fence_chars, fence_type, fence_length, state)
128
139
  cm = state[:current_mermaid]
140
+ blocks = state[:blocks]
141
+ position = state[:position]
142
+ content_lines = cm[:content_lines]
129
143
  if fence_type == cm[:fence_type] && fence_length == cm[:fence_length] && line.strip == fence_chars
130
- state[:blocks] << { start: cm[:start], end: state[:position], content: cm[:content_lines].join }
144
+ blocks << { start: cm[:start], end: position, content: content_lines.join }
131
145
  state[:current_mermaid] = nil
132
146
  else
133
- cm[:content_lines] << line
147
+ content_lines << line
134
148
  end
135
149
  end
136
150
 
@@ -146,12 +160,13 @@ module JekyllMermaidPrebuild
146
160
 
147
161
  # Handle fence line while inside a non-mermaid fence
148
162
  def handle_line_in_nested_fence(line, fence_type, fence_length, state)
149
- top_fence_length, top_fence_type = state[:fence_stack].last
163
+ fence_stack = state[:fence_stack]
164
+ top_fence_length, top_fence_type = fence_stack.last
150
165
 
151
- if fence_type == top_fence_type && fence_length >= top_fence_length && line.strip.match?(/^[`~]+$/)
152
- state[:fence_stack].pop
166
+ if fence_type == top_fence_type && fence_length >= top_fence_length && line.strip.match?(/\A[`~]+\z/)
167
+ fence_stack.pop
153
168
  else
154
- state[:fence_stack].push([fence_length, fence_type])
169
+ fence_stack.push([fence_length, fence_type])
155
170
  end
156
171
  end
157
172
  end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JekyllMermaidPrebuild
4
- # Post-processes mmdc-generated SVGs to fix cross-browser rendering issues.
4
+ # Post-processes mmdc-generated SVGs to fix rendering issues.
5
5
  #
6
- # Three independent fixes:
6
+ # Four independent fixes:
7
7
  # 1. Text centering: Mermaid's CSS `text-align: center` targets SVG `<g>` elements where it
8
8
  # has no effect on HTML inside `<foreignObject>`. We inject a CSS rule so that foreignObject
9
9
  # content centers correctly regardless of text measurement differences between the generating
@@ -14,9 +14,10 @@ module JekyllMermaidPrebuild
14
14
  # 3. Edge label padding: Widens edge-label `<foreignObject>` widths in any diagram type to
15
15
  # prevent clipping when the viewing browser renders text wider than headless Chromium measured.
16
16
  # Opt-in via `postprocessing.edge_label_padding` config.
17
+ # 4. Root SVG background: mmdc always emits `background-color: white` on the root `<svg>` regardless
18
+ # of theme. The plugin replaces that token with configurable CSS color values for light and
19
+ # dark variants (defaults white / black) so charts match page background in both modes.
17
20
  module SvgPostProcessor
18
- module_function
19
-
20
21
  # Opening sequence produced by mmdc for block edge labels (deterministic minified output).
21
22
  EDGE_LABEL_FOREIGN_OBJECT_RE = /
22
23
  (<g\sclass="edgeLabel"[^>]*><g\sclass="label"[^>]*><foreignObject)
@@ -27,9 +28,8 @@ module JekyllMermaidPrebuild
27
28
  # @param svg_string [String] full SVG document from mmdc
28
29
  # @param padding [Numeric] user units to add to each matching foreignObject width (must be positive)
29
30
  # @return [String] possibly widened SVG, or the original string on no-op / error
30
- def apply(svg_string, padding:)
31
- return svg_string unless svg_string.is_a?(String)
32
- return svg_string unless padding.is_a?(Numeric) && padding.positive?
31
+ def self.apply(svg_string, padding:)
32
+ return svg_string unless padding.positive?
33
33
 
34
34
  apply_edge_label_padding(svg_string, padding)
35
35
  rescue StandardError
@@ -46,10 +46,8 @@ module JekyllMermaidPrebuild
46
46
  #
47
47
  # @param svg_string [String] full SVG document from mmdc
48
48
  # @return [String] SVG with overflow rule injected, or original on no-op / error
49
- def ensure_foreignobject_overflow(svg_string)
50
- return svg_string unless svg_string.is_a?(String)
49
+ def self.ensure_foreignobject_overflow(svg_string)
51
50
  return svg_string if svg_string.include?(OVERFLOW_RULE)
52
- return svg_string unless svg_string.include?("</style>")
53
51
 
54
52
  svg_string.sub("</style>", "#{OVERFLOW_RULE}</style>")
55
53
  rescue StandardError
@@ -65,17 +63,33 @@ module JekyllMermaidPrebuild
65
63
  #
66
64
  # @param svg_string [String] full SVG document from mmdc
67
65
  # @return [String] SVG with centering rule injected, or original on no-op / error
68
- def ensure_text_centering(svg_string)
69
- return svg_string unless svg_string.is_a?(String)
66
+ def self.ensure_text_centering(svg_string)
70
67
  return svg_string if svg_string.include?(CENTERING_RULE)
71
- return svg_string unless svg_string.include?("</style>")
72
68
 
73
69
  svg_string.sub("</style>", "#{CENTERING_RULE}</style>")
74
70
  rescue StandardError
75
71
  svg_string
76
72
  end
77
73
 
78
- def apply_edge_label_padding(svg_string, padding)
74
+ # Replace `background-color: white` on the root <svg> style attribute with a caller-supplied
75
+ # CSS color (already sanitized by Configuration). Idempotent when mmdc output no longer contains
76
+ # the white token or the value already matches.
77
+ #
78
+ # @param svg_string [String] full SVG document from mmdc
79
+ # @param css_background [String] literal after `background-color:` (e.g. "black", "#fff0aa")
80
+ # @return [String] SVG with updated root background, or original on no-op / error
81
+ def self.apply_root_svg_background(svg_string, css_background)
82
+ return svg_string unless css_background.is_a?(String) && !css_background.empty?
83
+
84
+ svg_string.sub(
85
+ /(<svg\b[^>]+\bstyle="[^"]*?)background-color:\s*white;?/,
86
+ "\\1background-color: #{css_background};"
87
+ )
88
+ rescue StandardError
89
+ svg_string
90
+ end
91
+
92
+ def self.apply_edge_label_padding(svg_string, padding)
79
93
  svg_string.gsub(EDGE_LABEL_FOREIGN_OBJECT_RE) do
80
94
  prefix = Regexp.last_match(1)
81
95
  attrs = Regexp.last_match(2)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JekyllMermaidPrebuild
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-mermaid-prebuild
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Texarkanine
@@ -29,6 +29,34 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '5.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: mutant
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '0.16'
39
+ type: :development
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '0.16'
46
+ - !ruby/object:Gem::Dependency
47
+ name: mutant-rspec
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '0.16'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '0.16'
32
60
  - !ruby/object:Gem::Dependency
33
61
  name: rake
34
62
  requirement: !ruby/object:Gem::Requirement
@@ -105,28 +133,28 @@ dependencies:
105
133
  requirements:
106
134
  - - "~>"
107
135
  - !ruby/object:Gem::Version
108
- version: '0.22'
136
+ version: '1.0'
109
137
  type: :development
110
138
  prerelease: false
111
139
  version_requirements: !ruby/object:Gem::Requirement
112
140
  requirements:
113
141
  - - "~>"
114
142
  - !ruby/object:Gem::Version
115
- version: '0.22'
143
+ version: '1.0'
116
144
  - !ruby/object:Gem::Dependency
117
145
  name: simplecov-cobertura
118
146
  requirement: !ruby/object:Gem::Requirement
119
147
  requirements:
120
148
  - - "~>"
121
149
  - !ruby/object:Gem::Version
122
- version: '3.1'
150
+ version: '4.0'
123
151
  type: :development
124
152
  prerelease: false
125
153
  version_requirements: !ruby/object:Gem::Requirement
126
154
  requirements:
127
155
  - - "~>"
128
156
  - !ruby/object:Gem::Version
129
- version: '3.1'
157
+ version: '4.0'
130
158
  description: Jekyll plugin that converts mermaid diagram code blocks to SVG files
131
159
  at build time using the mmdc CLI. Eliminates the need for client-side mermaid.js
132
160
  (~2MB) by generating static SVG files with intelligent caching.