jekyll-vitepress-theme 1.9.1 → 1.10.1

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: e183c8f8f2ffa9c7a63a0bd5e581f47cd41e377c3271331cdb709ba318157360
4
- data.tar.gz: 57afd8252f5ef2dec3c09a1b26051a2c6b13f26867148f0cc2f6efd8876a6744
3
+ metadata.gz: 0fa9bd9f39f9a07a853282a881aff54b24f4adde9c87f9c2f03b89bcafff8617
4
+ data.tar.gz: 19d2551ffa4497e178771471091d3655dd0678e6cbcd823e79eefd6c617546ef
5
5
  SHA512:
6
- metadata.gz: 8aec92127430303041ff8337187776d67bd5e779a77b50263ffc4a756df212bc847987002c9a824966bee7ce3d5293439eefbd3b3ba9ff63bffd52a8d258bfa5
7
- data.tar.gz: abb74bb84cf29cf52d4de84832ec2e6e78d6e55be445c51aea4ef3c7029f8fe21cc3cfdbdf2aaae5a98d0340fffa7f6303279e9aae4ae79b119bd047e9709cd4
6
+ metadata.gz: b5041e3e3a4019083374bba8ead0db79724322337d945605168214d3e430ac225c925a2b41040154431fd55da644fdcf0f42c2058daf99d326903f1ea3a77c61
7
+ data.tar.gz: 87e47129bd88e563ba4187118029c1de8311a7662cc7233432a05a8dc3911013b84c0b930bb582c79dd88bf23ad4d570326b9d93756db22016720f36eed38466
@@ -985,7 +985,7 @@ body.vp-search-open {
985
985
  height: 100%;
986
986
  padding: 18px;
987
987
  object-fit: contain;
988
- filter: drop-shadow(-2px 4px 6px rgba(0, 0, 0, 0.2));
988
+ filter: drop-shadow(-2px 4px 6px rgb(0, 0, 0, 0.2));
989
989
  transform: translate(-50%, -50%);
990
990
  }
991
991
 
@@ -3,7 +3,7 @@ require 'rouge'
3
3
 
4
4
  module Jekyll
5
5
  module VitePressTheme
6
- # rubocop:disable Metrics/ModuleLength
6
+ # rubocop:disable-next Metrics/ModuleLength
7
7
  module Sidebar
8
8
  module_function
9
9
 
@@ -243,7 +243,6 @@ module Jekyll
243
243
  )
244
244
  end
245
245
  end
246
- # rubocop:enable Metrics/ModuleLength
247
246
 
248
247
  module SearchIndex
249
248
  module_function
@@ -319,24 +318,6 @@ module Jekyll
319
318
  end
320
319
  end
321
320
 
322
- module LastUpdated
323
- module_function
324
-
325
- def source_file_time(site, path)
326
- return nil unless path
327
-
328
- source_path = if Pathname.new(path).absolute?
329
- path
330
- else
331
- File.join(site.source, path)
332
- end
333
-
334
- return nil unless File.file?(source_path)
335
-
336
- File.mtime(source_path).utc
337
- end
338
- end
339
-
340
321
  module RougeStyles
341
322
  module_function
342
323
 
@@ -475,6 +456,54 @@ module Jekyll
475
456
  end
476
457
 
477
458
  def resolved_markdown(item, payload)
459
+ markdown = MarkdownExport.convert(rendered_markdown(item, payload))
460
+ hero = hero_markdown(item)
461
+ return markdown if hero.empty?
462
+
463
+ markdown.to_s.strip.empty? ? "#{hero}\n" : "#{hero}\n\n#{markdown}"
464
+ end
465
+
466
+ # A home page keeps its headline, tagline, and calls to action in the
467
+ # hero front matter rather than in the page content.
468
+ def hero_markdown(item)
469
+ data = item.respond_to?(:data) && item.data.is_a?(Hash) ? item.data : {}
470
+ hero = data['hero']
471
+ return '' unless hero.is_a?(Hash)
472
+
473
+ [hero_name(hero, data['title']), inline_markdown(hero['text']), inline_markdown(hero['tagline']),
474
+ hero_actions(item, hero['actions'])].reject(&:empty?).join("\n\n")
475
+ end
476
+
477
+ def hero_name(hero, title)
478
+ name = inline_markdown(hero['name'])
479
+ name.empty? || name == inline_markdown(title) ? '' : "**#{name}**"
480
+ end
481
+
482
+ def inline_markdown(text)
483
+ MarkdownExport.convert(text.to_s).strip
484
+ end
485
+
486
+ def hero_actions(item, actions)
487
+ return '' unless actions.is_a?(Array)
488
+
489
+ actions.filter_map do |action|
490
+ next unless action.is_a?(Hash)
491
+
492
+ text = inline_markdown(action['text'])
493
+ link = action['link'].to_s.strip
494
+ next if text.empty?
495
+
496
+ link.empty? ? text : "[#{text}](#{site_relative(item, link)})"
497
+ end.join(' · ')
498
+ end
499
+
500
+ def site_relative(item, link)
501
+ return link if link.include?('://') || link.start_with?('mailto:', 'tel:')
502
+
503
+ "#{item.site.config['baseurl'].to_s.sub(%r{/+\z}, '')}/#{link.sub(%r{\A/+}, '')}"
504
+ end
505
+
506
+ def rendered_markdown(item, payload)
478
507
  raw = item.content.to_s
479
508
  return raw unless item.respond_to?(:render_with_liquid?) && item.render_with_liquid?
480
509
 
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'pathname'
5
+
6
+ module Jekyll
7
+ module VitePressTheme
8
+ # Dates each page by the last commit that touched its source file, read
9
+ # from one `git log` pass per build. Files Git cannot date reliably
10
+ # (untracked, locally modified, outside the repository, or older than a
11
+ # shallow clone's history) use their modification time instead.
12
+ module LastUpdated
13
+ module_function
14
+
15
+ FIELD_SEPARATOR = "\x1e"
16
+
17
+ # Git's view of the files under a site source: commit times keyed by
18
+ # real path, plus the paths whose working copy differs from HEAD.
19
+ class GitIndex
20
+ def self.empty
21
+ new({}, [])
22
+ end
23
+
24
+ def initialize(times, modified)
25
+ @times = times
26
+ @modified = modified.to_h { |path| [path, true] }
27
+ end
28
+
29
+ def time_for(path)
30
+ return nil if @modified.key?(path)
31
+
32
+ @times[path]
33
+ end
34
+ end
35
+
36
+ def source_file_time(site, path)
37
+ source_path = resolved_source_path(site, path)
38
+ return nil unless source_path
39
+
40
+ git_index(site).time_for(source_path) || File.mtime(source_path).utc
41
+ end
42
+
43
+ def resolved_source_path(site, path)
44
+ return nil unless path
45
+
46
+ source_path = Pathname.new(path).absolute? ? path : File.join(site.source, path)
47
+ return nil unless File.file?(source_path)
48
+
49
+ File.realpath(source_path)
50
+ end
51
+
52
+ def git_index(site)
53
+ @git_indexes ||= {}
54
+ @git_indexes[site] ||= build_git_index(site.source)
55
+ end
56
+
57
+ def reset(site)
58
+ @git_indexes&.delete(site)
59
+ end
60
+
61
+ def build_git_index(source)
62
+ source = File.realpath(source)
63
+ root = repository_root(source)
64
+ return GitIndex.empty unless root
65
+
66
+ pathspec = relative_pathspec(source, root)
67
+ boundaries = shallow_boundaries(root)
68
+ warn_shallow if boundaries.any?
69
+
70
+ GitIndex.new(commit_times(root, pathspec, boundaries), modified_paths(root, pathspec))
71
+ rescue SystemCallError, ArgumentError => e
72
+ Jekyll.logger.debug('jekyll-vitepress-theme', "Git dates unavailable: #{e.message}")
73
+ GitIndex.empty
74
+ end
75
+
76
+ def repository_root(source)
77
+ root = git(source, 'rev-parse', '--show-toplevel').to_s.strip
78
+ root.empty? ? nil : File.realpath(root)
79
+ end
80
+
81
+ def relative_pathspec(source, root)
82
+ Pathname.new(source).relative_path_from(Pathname.new(root)).to_s
83
+ end
84
+
85
+ def commit_times(root, pathspec, boundaries)
86
+ log = git(root, 'log', '--no-renames', "--format=#{FIELD_SEPARATOR}%H %ct", '--name-only', '-z', '--', pathspec)
87
+ return {} unless log
88
+
89
+ log.split(FIELD_SEPARATOR).each_with_object({}) do |entry, times|
90
+ sha, time, files = parse_log_entry(entry)
91
+ next if files.empty? || boundaries.include?(sha)
92
+
93
+ files.each do |file|
94
+ path = File.join(root, file)
95
+ times[path] = time if times[path].nil? || time > times[path]
96
+ end
97
+ end
98
+ end
99
+
100
+ def parse_log_entry(entry)
101
+ header, files = entry.split("\0", 2)
102
+ sha, timestamp = header.to_s.split
103
+ return [sha, nil, []] if files.nil?
104
+
105
+ [sha, Time.at(Integer(timestamp)).utc, files.delete_prefix("\n").split("\0").reject(&:empty?)]
106
+ end
107
+
108
+ def modified_paths(root, pathspec)
109
+ status = git(root, 'status', '--porcelain=v1', '-z', '--untracked-files=no', '--', pathspec)
110
+ return [] unless status
111
+
112
+ entries = status.split("\0")
113
+ paths = []
114
+ until entries.empty?
115
+ entry = entries.shift
116
+ paths << File.join(root, entry[3..])
117
+ entries.shift if entry.start_with?('R', 'C')
118
+ end
119
+ paths
120
+ end
121
+
122
+ def shallow_boundaries(root)
123
+ return [] unless git(root, 'rev-parse', '--is-shallow-repository')&.strip == 'true'
124
+
125
+ shallow_file = git(root, 'rev-parse', '--git-path', 'shallow')&.strip
126
+ return [] unless shallow_file
127
+
128
+ shallow_file = File.expand_path(shallow_file, root)
129
+ return [] unless File.file?(shallow_file)
130
+
131
+ File.read(shallow_file).split
132
+ end
133
+
134
+ def warn_shallow
135
+ Jekyll.logger.warn(
136
+ 'jekyll-vitepress-theme',
137
+ 'Shallow Git clone: pages last changed before the clone\'s history use their file ' \
138
+ 'modification time for "Last updated". Fetch the full history (fetch-depth: 0) for accurate dates.'
139
+ )
140
+ end
141
+
142
+ def git(directory, *args)
143
+ output, status = Open3.capture2('git', '-C', directory, *args, err: File::NULL)
144
+ status.success? ? output : nil
145
+ rescue SystemCallError
146
+ nil
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ Jekyll::Hooks.register :site, :after_reset do |site|
153
+ Jekyll::VitePressTheme::LastUpdated.reset(site)
154
+ end
@@ -4,7 +4,7 @@ module Jekyll
4
4
  module VitePressTheme
5
5
  # Generates the proposed llms.txt index and its full-text companion from
6
6
  # the same canonical, indexable content used by the theme's SEO layer.
7
- # rubocop:disable Metrics/AbcSize, Metrics/ModuleLength
7
+ # rubocop:disable-next Metrics/AbcSize, Metrics/ModuleLength
8
8
  module LLMS
9
9
  module_function
10
10
 
@@ -231,6 +231,5 @@ module Jekyll
231
231
  value.is_a?(Hash) ? value : {}
232
232
  end
233
233
  end
234
- # rubocop:enable Metrics/AbcSize, Metrics/ModuleLength
235
234
  end
236
235
  end
@@ -0,0 +1,636 @@
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-next 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
+ end
636
+ end
@@ -10,7 +10,7 @@ module Jekyll
10
10
  # Generates the metadata shared by the HTML head, sitemap, and robots.txt.
11
11
  # Keeping those outputs on one canonical URL resolver prevents contradictory
12
12
  # indexing signals across theme consumers.
13
- # rubocop:disable Metrics/AbcSize, Metrics/ModuleLength
13
+ # rubocop:disable-next Metrics/AbcSize, Metrics/ModuleLength
14
14
  module SEO
15
15
  module_function
16
16
 
@@ -588,6 +588,5 @@ module Jekyll
588
588
  CGI.escapeHTML(value.to_s)
589
589
  end
590
590
  end
591
- # rubocop:enable Metrics/AbcSize, Metrics/ModuleLength
592
591
  end
593
592
  end
@@ -1,5 +1,5 @@
1
1
  module Jekyll
2
2
  module VitePressTheme
3
- VERSION = "1.9.1".freeze
3
+ VERSION = "1.10.1".freeze
4
4
  end
5
5
  end
@@ -1,5 +1,7 @@
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'
6
+ require_relative 'jekyll/vitepress_theme/last_updated'
5
7
  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.1
4
+ version: 1.10.1
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
@@ -56,20 +56,20 @@ dependencies:
56
56
  requirements:
57
57
  - - ">="
58
58
  - !ruby/object:Gem::Version
59
- version: '3.0'
59
+ version: '4.1'
60
60
  - - "<"
61
61
  - !ruby/object:Gem::Version
62
- version: '5.0'
62
+ version: '6.0'
63
63
  type: :runtime
64
64
  prerelease: false
65
65
  version_requirements: !ruby/object:Gem::Requirement
66
66
  requirements:
67
67
  - - ">="
68
68
  - !ruby/object:Gem::Version
69
- version: '3.0'
69
+ version: '4.1'
70
70
  - - "<"
71
71
  - !ruby/object:Gem::Version
72
- version: '5.0'
72
+ version: '6.0'
73
73
  description: Jekyll VitePress Theme brings VitePress-style documentation UX, search,
74
74
  structured metadata, and Jekyll-native extensions to Ruby static sites.
75
75
  email:
@@ -177,7 +177,9 @@ files:
177
177
  - assets/vendor/turbo.js
178
178
  - lib/jekyll-vitepress-theme.rb
179
179
  - lib/jekyll/vitepress_theme/hooks.rb
180
+ - lib/jekyll/vitepress_theme/last_updated.rb
180
181
  - lib/jekyll/vitepress_theme/llms.rb
182
+ - lib/jekyll/vitepress_theme/markdown_export.rb
181
183
  - lib/jekyll/vitepress_theme/seo.rb
182
184
  - lib/jekyll/vitepress_theme/version.rb
183
185
  homepage: https://jekyll-vitepress.dev