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 +4 -4
- data/_includes/copy_page_button.html +2 -2
- data/assets/css/vitepress-overrides.css +0 -6
- data/assets/js/vitepress-theme.js +12 -0
- data/lib/jekyll/vitepress_theme/hooks.rb +48 -0
- data/lib/jekyll/vitepress_theme/markdown_export.rb +637 -0
- data/lib/jekyll/vitepress_theme/version.rb +1 -1
- data/lib/jekyll-vitepress-theme.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fe14ff0d5fdef3e958e33fdb1d0af6325209f095c352b5c7b09e42cc4da38316
|
|
4
|
+
data.tar.gz: cc2bc43f7102cde80e9dc6b54dbe8834febca38d906dde8aa47b1835275c2068
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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,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.
|
|
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-
|
|
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
|