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 +4 -4
- data/assets/css/vitepress-overrides.css +1 -1
- data/lib/jekyll/vitepress_theme/hooks.rb +49 -20
- data/lib/jekyll/vitepress_theme/last_updated.rb +154 -0
- data/lib/jekyll/vitepress_theme/llms.rb +1 -2
- data/lib/jekyll/vitepress_theme/markdown_export.rb +636 -0
- data/lib/jekyll/vitepress_theme/seo.rb +1 -2
- data/lib/jekyll/vitepress_theme/version.rb +1 -1
- data/lib/jekyll-vitepress-theme.rb +2 -0
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0fa9bd9f39f9a07a853282a881aff54b24f4adde9c87f9c2f03b89bcafff8617
|
|
4
|
+
data.tar.gz: 19d2551ffa4497e178771471091d3655dd0678e6cbcd823e79eefd6c617546ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b5041e3e3a4019083374bba8ead0db79724322337d945605168214d3e430ac225c925a2b41040154431fd55da644fdcf0f42c2058daf99d326903f1ea3a77c61
|
|
7
|
+
data.tar.gz: 87e47129bd88e563ba4187118029c1de8311a7662cc7233432a05a8dc3911013b84c0b930bb582c79dd88bf23ad4d570326b9d93756db22016720f36eed38466
|
|
@@ -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,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.
|
|
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-
|
|
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: '
|
|
59
|
+
version: '4.1'
|
|
60
60
|
- - "<"
|
|
61
61
|
- !ruby/object:Gem::Version
|
|
62
|
-
version: '
|
|
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: '
|
|
69
|
+
version: '4.1'
|
|
70
70
|
- - "<"
|
|
71
71
|
- !ruby/object:Gem::Version
|
|
72
|
-
version: '
|
|
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
|