jekyll-vitepress-theme 1.9.0 → 1.10.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: d3f4205e18a7cb5f642be4e0a827a024f488030143e11a11816123ba53792757
4
- data.tar.gz: 53e6432abfd64ab03aef151445484a1c5852f13ed55880f1481d9a541ed1bf83
3
+ metadata.gz: fe14ff0d5fdef3e958e33fdb1d0af6325209f095c352b5c7b09e42cc4da38316
4
+ data.tar.gz: cc2bc43f7102cde80e9dc6b54dbe8834febca38d906dde8aa47b1835275c2068
5
5
  SHA512:
6
- metadata.gz: 14be8fc302967f0f9e625bb666adf40ab0af14e6f338981ede4e639d9acd47d9e3172248bf4ddc50be57bb17a51fae9b9c049f4b02bf07f5d631a1310ba974a8
7
- data.tar.gz: 7c51764e8ae51818ec881a2b25b82babd90c10f21153763171852b7925451ca08a5601e49c0497f0a8060f614c6710c731c3dee30245b01676e59150eb3bc770
6
+ metadata.gz: 62f45b83d078559a6065716a1d37170cf53043acbdc568760bef9c94e8c2dc90bc9bc37e070c1d50d4480d9abcc95f8c0ae7b286c7e4576a0ec312c35a2bce91
7
+ data.tar.gz: 8040cd095339494a138568eeeb021056985f43656c48db23ad4a00489a2a249fd259d79d299178ad2f19923ed79c02ecdc118dfe24f4c217daaa5fc10bd0366e
@@ -18,12 +18,12 @@
18
18
  <span class="copy-md-dropdown-desc">Copy page as Markdown for LLMs</span>
19
19
  </div>
20
20
  </button>
21
- <a class="copy-md-dropdown-item" href="{{ view_md_url | relative_url }}" target="_blank" rel="nofollow noreferrer">
21
+ <button class="copy-md-dropdown-item" type="button" data-action="view-markdown" data-url="{{ view_md_url | relative_url | escape }}">
22
22
  <span class="vpi-file-text copy-md-dropdown-icon"></span>
23
23
  <div class="copy-md-dropdown-text">
24
24
  <span class="copy-md-dropdown-title">View as Markdown</span>
25
25
  <span class="copy-md-dropdown-desc">View this page as plain text</span>
26
26
  </div>
27
- </a>
27
+ </button>
28
28
  </div>
29
29
  </div>
@@ -2112,12 +2112,6 @@ html.dark .only-light {
2112
2112
  background: var(--vp-c-bg-soft);
2113
2113
  }
2114
2114
 
2115
- a.copy-md-dropdown-item,
2116
- a.copy-md-dropdown-item:hover {
2117
- text-decoration: none;
2118
- color: var(--vp-c-text-1);
2119
- }
2120
-
2121
2115
  .copy-md-dropdown-icon {
2122
2116
  flex-shrink: 0;
2123
2117
  width: 18px;
@@ -1427,6 +1427,7 @@
1427
1427
  var toggle = document.querySelector('.copy-md-toggle');
1428
1428
  var dropdown = document.querySelector('.copy-md-dropdown');
1429
1429
  var dropdownCopy = dropdown ? dropdown.querySelector('[data-action="copy"]') : null;
1430
+ var dropdownView = dropdown ? dropdown.querySelector('[data-action="view-markdown"]') : null;
1430
1431
 
1431
1432
  function setCopyPageDropdownOpen(open) {
1432
1433
  if (!dropdown || !toggle) {
@@ -1478,6 +1479,16 @@
1478
1479
  copyPageMarkdown(copyMdBtn);
1479
1480
  });
1480
1481
  }
1482
+
1483
+ dropdownView = dropdown ? dropdown.querySelector('[data-action="view-markdown"]') : null;
1484
+ if (dropdownView && !dropdownView.hasAttribute('data-vp-bound')) {
1485
+ dropdownView.setAttribute('data-vp-bound', 'true');
1486
+ dropdownView.addEventListener('click', function () {
1487
+ var url = dropdownView.getAttribute('data-url');
1488
+ setCopyPageDropdownOpen(false);
1489
+ if (url) window.open(url, '_blank', 'noopener,noreferrer');
1490
+ });
1491
+ }
1481
1492
  }
1482
1493
 
1483
1494
  bindCopyPageControls();
@@ -2262,6 +2273,7 @@
2262
2273
  toggle = document.querySelector('.copy-md-toggle');
2263
2274
  dropdown = document.querySelector('.copy-md-dropdown');
2264
2275
  dropdownCopy = dropdown ? dropdown.querySelector('[data-action="copy"]') : null;
2276
+ dropdownView = dropdown ? dropdown.querySelector('[data-action="view-markdown"]') : null;
2265
2277
  bindCopyPageControls();
2266
2278
 
2267
2279
  addCopyButtons();
@@ -475,6 +475,54 @@ module Jekyll
475
475
  end
476
476
 
477
477
  def resolved_markdown(item, payload)
478
+ markdown = MarkdownExport.convert(rendered_markdown(item, payload))
479
+ hero = hero_markdown(item)
480
+ return markdown if hero.empty?
481
+
482
+ markdown.to_s.strip.empty? ? "#{hero}\n" : "#{hero}\n\n#{markdown}"
483
+ end
484
+
485
+ # A home page keeps its headline, tagline, and calls to action in the
486
+ # hero front matter rather than in the page content.
487
+ def hero_markdown(item)
488
+ data = item.respond_to?(:data) && item.data.is_a?(Hash) ? item.data : {}
489
+ hero = data['hero']
490
+ return '' unless hero.is_a?(Hash)
491
+
492
+ [hero_name(hero, data['title']), inline_markdown(hero['text']), inline_markdown(hero['tagline']),
493
+ hero_actions(item, hero['actions'])].reject(&:empty?).join("\n\n")
494
+ end
495
+
496
+ def hero_name(hero, title)
497
+ name = inline_markdown(hero['name'])
498
+ name.empty? || name == inline_markdown(title) ? '' : "**#{name}**"
499
+ end
500
+
501
+ def inline_markdown(text)
502
+ MarkdownExport.convert(text.to_s).strip
503
+ end
504
+
505
+ def hero_actions(item, actions)
506
+ return '' unless actions.is_a?(Array)
507
+
508
+ actions.filter_map do |action|
509
+ next unless action.is_a?(Hash)
510
+
511
+ text = inline_markdown(action['text'])
512
+ link = action['link'].to_s.strip
513
+ next if text.empty?
514
+
515
+ link.empty? ? text : "[#{text}](#{site_relative(item, link)})"
516
+ end.join(' · ')
517
+ end
518
+
519
+ def site_relative(item, link)
520
+ return link if link.include?('://') || link.start_with?('mailto:', 'tel:')
521
+
522
+ "#{item.site.config['baseurl'].to_s.sub(%r{/+\z}, '')}/#{link.sub(%r{\A/+}, '')}"
523
+ end
524
+
525
+ def rendered_markdown(item, payload)
478
526
  raw = item.content.to_s
479
527
  return raw unless item.respond_to?(:render_with_liquid?) && item.render_with_liquid?
480
528
 
@@ -0,0 +1,637 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'kramdown'
4
+ require 'set'
5
+ require 'strscan'
6
+
7
+ module Jekyll
8
+ module VitePressTheme
9
+ # Converts the HTML that a page's Markdown source carries, written inline or
10
+ # produced by includes and filters, into Markdown. The "Copy page" button,
11
+ # the exported .md files, and llms-full.txt all share it. Fenced code
12
+ # blocks and code spans pass through untouched, and a page without HTML
13
+ # comes back unchanged.
14
+ # rubocop:disable Metrics/AbcSize, Metrics/ModuleLength
15
+ module MarkdownExport
16
+ module_function
17
+
18
+ Element = Struct.new(:name, :attrs, :children, :parent)
19
+ Text = Struct.new(:text)
20
+
21
+ BLOCK_CODE = "\u0000B%d\u0000"
22
+ SPAN_CODE = "\u0000S%d\u0000"
23
+ BLOCK_CODE_PATTERN = /\u0000B\d+\u0000/
24
+ PLACEHOLDER_PATTERN = /\u0000([BS])(\d+)\u0000/
25
+ LINE_BREAK = "\u0001"
26
+
27
+ KNOWN_ELEMENTS = %w[
28
+ a abbr address article aside audio b bdi bdo blockquote body br button canvas caption cite code col colgroup
29
+ data dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2 h3 h4 h5 h6
30
+ head header hgroup hr html i iframe img input ins kbd label legend li link main map mark menu meta meter nav
31
+ noscript object ol optgroup option output p picture pre progress q s samp script search section select small
32
+ source span strike strong style sub summary sup svg table tbody td template textarea tfoot th thead time tr
33
+ track tt u ul var video wbr
34
+ ].to_set.freeze
35
+
36
+ VOID_ELEMENTS = %w[area base br col embed hr img input link meta source track wbr].to_set.freeze
37
+ RAW_TEXT_ELEMENTS = %w[script style textarea].to_set.freeze
38
+ DROPPED_ELEMENTS = %w[
39
+ audio button canvas embed fieldset head iframe input link map meta noscript object script select style svg
40
+ template textarea video
41
+ ].to_set.freeze
42
+ FORM_CONTROLS = %w[input select textarea].to_set.freeze
43
+
44
+ BLOCK_ELEMENTS = %w[
45
+ address article aside blockquote body caption dd details dialog div dl dt fieldset figcaption figure footer
46
+ form h1 h2 h3 h4 h5 h6 header hgroup hr html legend li main menu nav ol p pre search section summary table
47
+ tbody td tfoot th thead tr ul
48
+ ].to_set.freeze
49
+
50
+ IMPLIED_CLOSES = {
51
+ 'li' => %w[li],
52
+ 'dt' => %w[dt dd],
53
+ 'dd' => %w[dt dd],
54
+ 'tr' => %w[tr td th],
55
+ 'td' => %w[td th],
56
+ 'th' => %w[td th],
57
+ 'option' => %w[option]
58
+ }.freeze
59
+
60
+ EMPHASIS = {
61
+ 'em' => '*', 'i' => '*', 'cite' => '*', 'dfn' => '*', 'var' => '*',
62
+ 'strong' => '**', 'b' => '**',
63
+ 'del' => '~~', 's' => '~~', 'strike' => '~~'
64
+ }.freeze
65
+
66
+ CODE_ELEMENTS = %w[code kbd samp tt].to_set.freeze
67
+
68
+ TAG_PATTERN = %r{
69
+ <!--.*?-->
70
+ |
71
+ <(/)?([a-zA-Z][a-zA-Z0-9-]*)
72
+ ((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)
73
+ \s*(/)?>
74
+ }mx
75
+
76
+ ATTRIBUTE_PATTERN = %r{([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?}
77
+ ENTITY_PATTERN = /&(?:#(\d+)|#[xX](\h+)|([a-zA-Z][a-zA-Z0-9]*));/
78
+ IAL_LINE_PATTERN = /^[ \t]*\{:[^\n]*\}[ \t]*(?:\n|\z)/
79
+ FENCE_OPEN_PATTERN = /\A((?:[ \t]*>)*[ \t]*)(`{3,}|~{3,})(.*)\z/
80
+ LIST_ITEM_PATTERN = /\A(?:[-*+]|\d+[.)])[ \t]/
81
+ CODE_SPAN_PATTERN = /(?<!`)(`+)(?!`)((?:(?!\n[ \t]*\n).)+?)(?<!`)\1(?!`)/m
82
+
83
+ def convert(markdown)
84
+ source = markdown.to_s
85
+ codes = []
86
+ text = protect_code(source, codes)
87
+ return source unless html?(text) || text.match?(IAL_LINE_PATTERN)
88
+
89
+ output = render_markdown(parse(text).children, dedent: false, codes: codes)
90
+ restore_code(finish(output, source), codes)
91
+ end
92
+
93
+ def html?(text)
94
+ text.scan(TAG_PATTERN).any? { |_, name| name.nil? || known?(name) }
95
+ end
96
+
97
+ def known?(name)
98
+ name = name.downcase
99
+ KNOWN_ELEMENTS.include?(name) || name.include?('-')
100
+ end
101
+
102
+ def protect_code(source, codes)
103
+ output = +''
104
+ prose = +''
105
+ lines = source.lines
106
+ index = 0
107
+ while index < lines.length
108
+ line = lines[index]
109
+ match = line.chomp.match(FENCE_OPEN_PATTERN)
110
+ if match && !(match[2].start_with?('`') && match[3].include?('`'))
111
+ closing = closing_fence_index(lines, index, match[2])
112
+ output << protect_spans(prose, codes)
113
+ prose = +''
114
+ block = lines[index..closing].join
115
+ newline = block.end_with?("\n") ? "\n" : ''
116
+ output << code_title(lines[closing + 1], match[1], output)
117
+ output << register(codes, block.chomp, BLOCK_CODE) << newline
118
+ index = closing + 1
119
+ else
120
+ prose << line
121
+ index += 1
122
+ end
123
+ end
124
+ output << protect_spans(prose, codes)
125
+ end
126
+
127
+ def closing_fence_index(lines, start, fence)
128
+ closing = /\A(?:[ \t]*>)*[ \t]*#{Regexp.escape(fence[0])}{#{fence.length},}[ \t]*\z/
129
+ ((start + 1)...lines.length).find { |i| lines[i].chomp.match?(closing) } || (lines.length - 1)
130
+ end
131
+
132
+ # The IAL after a fence gives the code block its title bar, so the title
133
+ # moves to a line of its own before the fence when the IAL goes.
134
+ def code_title(line, prefix, output)
135
+ return '' unless line&.match?(IAL_LINE_PATTERN)
136
+
137
+ title = ial_value(line, 'data-title')
138
+ return '' if title.empty?
139
+
140
+ href = ial_value(line, 'data-href')
141
+ title = "[#{title}](#{href})" unless href.empty?
142
+ separator = output.empty? || output.end_with?("\n\n") ? '' : "#{prefix.rstrip}\n"
143
+ "#{separator}#{prefix}**#{title}**\n#{prefix.rstrip}\n"
144
+ end
145
+
146
+ def ial_value(line, name)
147
+ match = line.match(/(?:\A|[\s{:])#{Regexp.escape(name)}=(?:"([^"]*)"|'([^']*)')/)
148
+ match ? decode(match[1] || match[2]).strip : ''
149
+ end
150
+
151
+ def protect_spans(text, codes)
152
+ text.gsub(CODE_SPAN_PATTERN) { |span| register(codes, span, SPAN_CODE) }
153
+ end
154
+
155
+ def register(codes, code, format)
156
+ codes << code
157
+ format(format, codes.length - 1)
158
+ end
159
+
160
+ def restore_code(text, codes)
161
+ 8.times do
162
+ break unless text.include?("\u0000")
163
+
164
+ text = text.gsub(/^([ \t>]*)#{BLOCK_CODE_PATTERN.source}/) do |match|
165
+ prefix = Regexp.last_match(1)
166
+ code = codes[match[/\d+/].to_i]
167
+ prefix + code.gsub(/\n(?=(.?))/) { Regexp.last_match(1).empty? ? "\n" : "\n#{prefix}" }
168
+ end
169
+ text = text.gsub(PLACEHOLDER_PATTERN) { codes[Regexp.last_match(2).to_i] }
170
+ end
171
+ text
172
+ end
173
+
174
+ def finish(output, source)
175
+ text = output.gsub(/^[ \t]+$/, '')
176
+ .gsub(/\n{3,}/, "\n\n")
177
+ .sub(/\A\n+/, '')
178
+ .sub(/\s+\z/, '')
179
+ .gsub(LINE_BREAK, " \n")
180
+ source.end_with?("\n") && !text.empty? ? "#{text}\n" : text
181
+ end
182
+
183
+ # Parsing
184
+
185
+ def parse(text)
186
+ root = Element.new('#root', {}, [], nil)
187
+ stack = [root]
188
+ scanner = StringScanner.new(text)
189
+ while (consumed = scanner.scan_until(TAG_PATTERN))
190
+ tag = scanner.matched
191
+ closing, name, attributes, self_closing = scanner.captures
192
+ append_text(stack.last, consumed[0...-tag.length])
193
+ name = name&.downcase
194
+ next if name.nil?
195
+
196
+ if !known?(name)
197
+ append_text(stack.last, tag)
198
+ elsif closing
199
+ close_element(stack, name)
200
+ else
201
+ element = open_element(stack, name, parse_attributes(attributes))
202
+ if RAW_TEXT_ELEMENTS.include?(name)
203
+ append_text(element, raw_text(scanner, name))
204
+ stack.pop
205
+ elsif VOID_ELEMENTS.include?(name) || self_closing
206
+ stack.pop
207
+ end
208
+ end
209
+ end
210
+ append_text(stack.last, scanner.rest)
211
+ root
212
+ end
213
+
214
+ def raw_text(scanner, name)
215
+ consumed = scanner.scan_until(%r{</#{name}\s*>}i)
216
+ return consumed[0...-scanner.matched.length] if consumed
217
+
218
+ scanner.rest.tap { scanner.terminate }
219
+ end
220
+
221
+ def open_element(stack, name, attrs)
222
+ closes = IMPLIED_CLOSES.fetch(name, [])
223
+ stack.pop while stack.length > 1 && closes.include?(stack.last.name)
224
+ stack.pop if stack.length > 1 && stack.last.name == 'p' && BLOCK_ELEMENTS.include?(name)
225
+
226
+ element = Element.new(name, attrs, [], stack.last)
227
+ stack.last.children << element
228
+ stack << element
229
+ element
230
+ end
231
+
232
+ def close_element(stack, name)
233
+ index = stack.rindex { |element| element.name == name }
234
+ stack.slice!(index..) if index&.positive?
235
+ end
236
+
237
+ def append_text(parent, text)
238
+ parent.children << Text.new(text) unless text.nil? || text.empty?
239
+ end
240
+
241
+ def parse_attributes(source)
242
+ source.to_s.scan(ATTRIBUTE_PATTERN).to_h do |name, double, single, bare|
243
+ [name.downcase, decode(double || single || bare || '')]
244
+ end
245
+ end
246
+
247
+ # Rendering
248
+
249
+ def render_markdown(nodes, dedent:, codes:)
250
+ output = nodes.each_with_object(+'') do |node, out|
251
+ if node.is_a?(Text)
252
+ out << node.text.gsub(IAL_LINE_PATTERN, '')
253
+ elsif dropped?(node)
254
+ next
255
+ elsif block?(node)
256
+ out << "\n\n" << render_block(node, codes) << "\n\n"
257
+ else
258
+ unindent_paragraph_start(out)
259
+ out << render_inline(node, codes, breaks: true)
260
+ end
261
+ end
262
+ dedent ? dedent(output) : output
263
+ end
264
+
265
+ # Includes often indent the HTML they write, and Markdown would read a
266
+ # paragraph indented four spaces as code. Indentation that continues a
267
+ # list item stays.
268
+ def unindent_paragraph_start(out)
269
+ return unless out.match?(/(?:\A|\n[ \t]*\n)[ \t]+\z/)
270
+
271
+ previous = out.rstrip.lines.last.to_s
272
+ return if previous.match?(/\A[ \t]/) || previous.match?(LIST_ITEM_PATTERN)
273
+
274
+ out.sub!(/[ \t]+\z/, '')
275
+ end
276
+
277
+ def dedent(text)
278
+ lines = text.split("\n", -1)
279
+ indents = lines.drop(1).reject { |line| line.strip.empty? || line.match?(/\A[ \t]*#{BLOCK_CODE_PATTERN.source}/) }
280
+ .map { |line| line[/\A[ \t]*/].length }
281
+ indent = indents.min.to_i
282
+ return text if indent.zero?
283
+
284
+ lines.each_with_index.map { |line, i| i.zero? ? line : line.sub(/\A[ \t]{0,#{indent}}/, '') }.join("\n")
285
+ end
286
+
287
+ def render_flow(nodes, codes)
288
+ blocks = []
289
+ run = +''
290
+ previous = nil
291
+ flush = lambda do
292
+ inline = normalize_inline(run)
293
+ blocks << inline unless inline.empty?
294
+ run = +''
295
+ end
296
+
297
+ nodes.each do |node|
298
+ if node.is_a?(Text)
299
+ node.text.split(/(#{BLOCK_CODE_PATTERN.source})/).each do |piece|
300
+ if piece.match?(BLOCK_CODE_PATTERN)
301
+ flush.call
302
+ blocks << piece
303
+ else
304
+ run << inline_text_node(piece)
305
+ end
306
+ end
307
+ elsif dropped?(node)
308
+ next
309
+ elsif block?(node)
310
+ flush.call
311
+ block = render_block(node, codes)
312
+ blocks << block unless block.strip.empty?
313
+ else
314
+ append_inline(run, render_inline(node, codes, breaks: true), previous, node)
315
+ end
316
+ previous = node
317
+ end
318
+ flush.call
319
+ blocks.join("\n\n")
320
+ end
321
+
322
+ def render_block(element, codes)
323
+ return strong_block(element, codes) if class?(element, 'custom-block-title')
324
+
325
+ case element.name
326
+ when /\Ah([1-6])\z/
327
+ text = inline_text(element.children, codes, breaks: false)
328
+ text.empty? ? '' : "#{'#' * Regexp.last_match(1).to_i} #{text}"
329
+ when 'summary', 'dt', 'legend' then strong_block(element, codes)
330
+ when 'pre' then render_pre(element, codes)
331
+ when 'ul', 'ol', 'menu' then render_list(element, codes)
332
+ when 'li' then list_item(render_flow(element.children, codes), '- ')
333
+ when 'blockquote' then quote(render_flow(element.children, codes))
334
+ when 'table' then render_table(element, codes)
335
+ when 'hr' then '---'
336
+ when 'a' then render_block_link(element, codes)
337
+ else
338
+ body = if markdown_attribute?(element)
339
+ render_markdown(element.children, dedent: true, codes: codes)
340
+ else
341
+ render_flow(element.children, codes)
342
+ end
343
+ class?(element, 'custom-block') ? quote(body) : body
344
+ end
345
+ end
346
+
347
+ def strong_block(element, codes)
348
+ text = inline_text(element.children, codes, breaks: false)
349
+ text.empty? ? '' : "**#{text}**"
350
+ end
351
+
352
+ def render_inline(node, codes, breaks:)
353
+ return inline_text_node(node.text) if node.is_a?(Text)
354
+ return '' if dropped?(node)
355
+
356
+ name = node.name
357
+ return " #{inline_text(node.children, codes, breaks: breaks)} " if BLOCK_ELEMENTS.include?(name)
358
+
359
+ if name == 'a'
360
+ render_link(node, codes, breaks: breaks)
361
+ elsif CODE_ELEMENTS.include?(name)
362
+ code_span(text_content(node).gsub(/\s+/, ' ').strip)
363
+ elsif EMPHASIS.key?(name)
364
+ emphasize(render_children_inline(node, codes, breaks: breaks), EMPHASIS[name])
365
+ elsif name == 'br'
366
+ breaks ? LINE_BREAK : ' '
367
+ elsif name == 'img'
368
+ duplicate_image?(node) ? '' : node.attrs['alt'].to_s.strip
369
+ else
370
+ render_children_inline(node, codes, breaks: breaks)
371
+ end
372
+ end
373
+
374
+ def render_children_inline(element, codes, breaks:)
375
+ render_inline_nodes(element.children, codes, breaks: breaks)
376
+ end
377
+
378
+ def inline_text(nodes, codes, breaks:)
379
+ normalize_inline(render_inline_nodes(nodes, codes, breaks: breaks))
380
+ end
381
+
382
+ def render_inline_nodes(nodes, codes, breaks:)
383
+ previous = nil
384
+ nodes.each_with_object(+'') do |node, buffer|
385
+ append_inline(buffer, render_inline(node, codes, breaks: breaks), previous, node)
386
+ previous = node
387
+ end
388
+ end
389
+
390
+ # Sibling elements with no whitespace between them are laid out apart by
391
+ # CSS, such as a name and a job title in a flex row, so keep them apart.
392
+ def append_inline(buffer, text, previous, node)
393
+ separate = previous.is_a?(Element) && node.is_a?(Element) &&
394
+ buffer.match?(/[^\s#{LINE_BREAK}]\z/o) && text.match?(/\A[^\s#{LINE_BREAK}]/o)
395
+ buffer << ' ' if separate
396
+ buffer << text
397
+ end
398
+
399
+ def inline_text_node(text)
400
+ decode(text.gsub(/[ \t\r\n]+/, ' ')).tr("\u00A0", ' ')
401
+ end
402
+
403
+ def normalize_inline(text)
404
+ text.gsub(/[ \t\r\n]+/, ' ')
405
+ .gsub(/ ?#{LINE_BREAK} ?/o, LINE_BREAK)
406
+ .gsub(/\A[ #{LINE_BREAK}]+|[ #{LINE_BREAK}]+\z/o, '')
407
+ end
408
+
409
+ def render_link(element, codes, breaks:)
410
+ label = inline_text(element.children, codes, breaks: breaks)
411
+ label = accessible_name(element) if label.empty?
412
+ href = element.attrs['href'].to_s.strip
413
+ return label if href.empty? || label.empty?
414
+
415
+ "[#{label}](#{href})"
416
+ end
417
+
418
+ def render_block_link(element, codes)
419
+ body = render_flow(element.children, codes)
420
+ href = element.attrs['href'].to_s.strip
421
+ return body if href.empty?
422
+
423
+ label = accessible_name(element)
424
+ label = body.lines.first.to_s.sub(/\A[#*\s]+/, '').sub(/[*\s]+\z/, '') if label.empty?
425
+ [body, "[#{label}](#{href})"].reject(&:empty?).join("\n\n")
426
+ end
427
+
428
+ # An image repeats its neighbour when it is the dark-mode twin of the
429
+ # previous image or an avatar next to the name it shows.
430
+ def duplicate_image?(image)
431
+ alt = normalize_inline(image.attrs['alt'].to_s)
432
+ return false if alt.empty?
433
+
434
+ siblings = image.parent.children.reject { |child| child.is_a?(Text) && child.text.strip.empty? }
435
+ index = siblings.index { |child| child.equal?(image) }
436
+ previous = index.positive? ? siblings[index - 1] : nil
437
+ following = siblings[index + 1]
438
+
439
+ (previous.is_a?(Element) && previous.name == 'img' && normalize_inline(previous.attrs['alt'].to_s) == alt) ||
440
+ (following.is_a?(Element) && following.name != 'img' && normalize_inline(text_content(following)) == alt)
441
+ end
442
+
443
+ def accessible_name(element)
444
+ normalize_inline(element.attrs['aria-label'] || element.attrs['title'] || '')
445
+ end
446
+
447
+ def emphasize(text, marker)
448
+ core = text.strip
449
+ return text if core.empty?
450
+
451
+ leading = text.match?(/\A\s/) ? ' ' : ''
452
+ trailing = text.match?(/\s\z/) ? ' ' : ''
453
+ "#{leading}#{marker}#{core}#{marker}#{trailing}"
454
+ end
455
+
456
+ def code_span(text)
457
+ return '' if text.empty?
458
+
459
+ fence = '`' * (longest_run(text, '`') + 1)
460
+ padding = text.start_with?('`') || text.end_with?('`') ? ' ' : ''
461
+ "#{fence}#{padding}#{text}#{padding}#{fence}"
462
+ end
463
+
464
+ def render_pre(element, codes)
465
+ code = text_content(element).sub(/\A\n/, '').sub(/\s+\z/, '')
466
+ return '' if code.empty?
467
+
468
+ fence = '`' * [3, longest_run(code, '`') + 1].max
469
+ register(codes, "#{fence}#{language(element)}\n#{code}\n#{fence}", BLOCK_CODE)
470
+ end
471
+
472
+ def language(element)
473
+ candidates = element.children.grep(Element).select { |child| child.name == 'code' }
474
+ node = element
475
+ while node
476
+ candidates << node
477
+ node = node.parent
478
+ end
479
+ candidates.each do |candidate|
480
+ match = candidate.attrs['class'].to_s.match(/(?:\A|\s)(?:language|lang)-(\S+)/)
481
+ return match[1] if match
482
+ end
483
+ ''
484
+ end
485
+
486
+ def render_list(element, codes)
487
+ number = (element.attrs['start'] || 1).to_i
488
+ items = element.children.grep(Element).select { |child| child.name == 'li' && !dropped?(child) }
489
+ items.filter_map do |item|
490
+ body = render_flow(item.children, codes)
491
+ next if body.empty?
492
+
493
+ marker = element.name == 'ol' ? "#{number}. " : '- '
494
+ number += 1
495
+ list_item(body, marker)
496
+ end.join("\n")
497
+ end
498
+
499
+ def list_item(body, marker)
500
+ indent = ' ' * marker.length
501
+ body.lines.each_with_index.map do |line, index|
502
+ if index.zero? then "#{marker}#{line}"
503
+ elsif line.strip.empty? then line
504
+ else
505
+ "#{indent}#{line}"
506
+ end
507
+ end.join
508
+ end
509
+
510
+ def quote(body)
511
+ body = body.gsub(/^[ \t]+$/, '').gsub(/\n{3,}/, "\n\n").sub(/\A\n+/, '').sub(/\s+\z/, '')
512
+ body.lines.map do |line|
513
+ line.strip.empty? ? ">\n" : "> #{line}"
514
+ end.join.chomp
515
+ end
516
+
517
+ def render_table(element, codes)
518
+ rows = table_rows(element)
519
+ return render_flow(element.children, codes) unless tabular?(rows)
520
+
521
+ caption = element.children.grep(Element).find { |child| child.name == 'caption' && !dropped?(child) }
522
+ width = rows.map { |cells| cells.sum { |cell| colspan(cell) } }.max
523
+ lines = rows.map { |cells| table_row(cells, width, codes) }
524
+ lines.insert(1, "|#{' --- |' * width}")
525
+ table = lines.join("\n")
526
+ caption_text = caption ? inline_text(caption.children, codes, breaks: false) : ''
527
+ caption_text.empty? ? table : "#{caption_text}\n\n#{table}"
528
+ end
529
+
530
+ def table_rows(table)
531
+ rows = []
532
+ table.children.grep(Element).each do |child|
533
+ next if dropped?(child)
534
+
535
+ case child.name
536
+ when 'tr' then rows << child
537
+ when 'thead', 'tbody', 'tfoot'
538
+ rows.concat(child.children.grep(Element).select { |row| row.name == 'tr' && !dropped?(row) })
539
+ end
540
+ end
541
+ rows.map { |row| row.children.grep(Element).select { |cell| %w[td th].include?(cell.name) } }
542
+ .reject(&:empty?)
543
+ end
544
+
545
+ def tabular?(rows)
546
+ return false if rows.empty?
547
+
548
+ rows.flatten.none? do |cell|
549
+ cell.attrs['rowspan'].to_i > 1 || descendant?(cell) { |node| %w[table pre ul ol dl blockquote].include?(node.name) }
550
+ end
551
+ end
552
+
553
+ def table_row(cells, width, codes)
554
+ texts = cells.flat_map do |cell|
555
+ text = cell_text(cell, codes).gsub('|', '\\|')
556
+ text = "**#{text}**" if cells.length == 1 && colspan(cell) > 1 && !text.empty?
557
+ [text] + Array.new(colspan(cell) - 1, '')
558
+ end
559
+ texts.fill('', texts.length...width)
560
+ "|#{texts.map { |text| text.empty? ? ' ' : " #{text} " }.join('|')}|"
561
+ end
562
+
563
+ # Sortable tables label their columns with buttons, which are otherwise
564
+ # dropped, so a header cell falls back to its full text.
565
+ def cell_text(cell, codes)
566
+ text = inline_text(cell.children, codes, breaks: false)
567
+ return text unless text.empty? && cell.name == 'th'
568
+
569
+ normalize_inline(text_content(cell))
570
+ end
571
+
572
+ def colspan(cell)
573
+ [cell.attrs['colspan'].to_i, 1].max
574
+ end
575
+
576
+ # Queries
577
+
578
+ def dropped?(element)
579
+ DROPPED_ELEMENTS.include?(element.name) ||
580
+ element.attrs['aria-hidden'] == 'true' ||
581
+ (element.name == 'label' && control_label?(element)) ||
582
+ (element.name == 'dialog' && !element.attrs.key?('open'))
583
+ end
584
+
585
+ def control_label?(label)
586
+ label.children.any? do |child|
587
+ child.is_a?(Element) && (FORM_CONTROLS.include?(child.name) || control_label?(child))
588
+ end
589
+ end
590
+
591
+ def block?(element)
592
+ BLOCK_ELEMENTS.include?(element.name) || descendant?(element) { |node| BLOCK_ELEMENTS.include?(node.name) }
593
+ end
594
+
595
+ def descendant?(element, &block)
596
+ element.children.any? do |child|
597
+ child.is_a?(Element) && !dropped?(child) && (yield(child) || descendant?(child, &block))
598
+ end
599
+ end
600
+
601
+ def class?(element, name)
602
+ element.attrs['class'].to_s.split.include?(name)
603
+ end
604
+
605
+ def markdown_attribute?(element)
606
+ %w[1 block span].include?(element.attrs['markdown'])
607
+ end
608
+
609
+ def text_content(element)
610
+ element.children.map do |child|
611
+ child.is_a?(Text) ? decode(child.text) : text_content(child)
612
+ end.join
613
+ end
614
+
615
+ def longest_run(text, character)
616
+ text.scan(/#{Regexp.escape(character)}+/).map(&:length).max.to_i
617
+ end
618
+
619
+ def decode(text)
620
+ text.gsub(ENTITY_PATTERN) do |entity|
621
+ code_point = entity_code_point(Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3))
622
+ code_point ? [code_point].pack('U') : entity
623
+ end
624
+ end
625
+
626
+ def entity_code_point(decimal, hex, name)
627
+ return decimal.to_i if decimal
628
+ return hex.to_i(16) if hex
629
+
630
+ Kramdown::Utils::Entities.entity(name).code_point
631
+ rescue Kramdown::Error, RangeError
632
+ nil
633
+ end
634
+ end
635
+ # rubocop:enable Metrics/AbcSize, Metrics/ModuleLength
636
+ end
637
+ end
@@ -1,5 +1,5 @@
1
1
  module Jekyll
2
2
  module VitePressTheme
3
- VERSION = "1.9.0".freeze
3
+ VERSION = "1.10.0".freeze
4
4
  end
5
5
  end
@@ -1,5 +1,6 @@
1
1
  require 'jekyll'
2
2
  require_relative 'jekyll/vitepress_theme/version'
3
+ require_relative 'jekyll/vitepress_theme/markdown_export'
3
4
  require_relative 'jekyll/vitepress_theme/seo'
4
5
  require_relative 'jekyll/vitepress_theme/llms'
5
6
  require_relative 'jekyll/vitepress_theme/hooks'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-vitepress-theme
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.0
4
+ version: 1.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
11
+ date: 2026-10-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cgi
@@ -178,6 +178,7 @@ files:
178
178
  - lib/jekyll-vitepress-theme.rb
179
179
  - lib/jekyll/vitepress_theme/hooks.rb
180
180
  - lib/jekyll/vitepress_theme/llms.rb
181
+ - lib/jekyll/vitepress_theme/markdown_export.rb
181
182
  - lib/jekyll/vitepress_theme/seo.rb
182
183
  - lib/jekyll/vitepress_theme/version.rb
183
184
  homepage: https://jekyll-vitepress.dev