jekyll-vitepress-theme 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e8e0506d69bd7172baf0392133cc0782815c07784a747437be2b7a59503ca37
4
- data.tar.gz: 7037f54855dc45578def24ab7144a563f9211f7a441b110bfa56172cebdff91d
3
+ metadata.gz: d3f4205e18a7cb5f642be4e0a827a024f488030143e11a11816123ba53792757
4
+ data.tar.gz: 53e6432abfd64ab03aef151445484a1c5852f13ed55880f1481d9a541ed1bf83
5
5
  SHA512:
6
- metadata.gz: '08cad3bf3e61558a9ca8a9832f1ad95943289691c6832961405551f62d43399294a3f709f1e120a5a265a664c39ee46b3591a2e39dd20e3dc3ad92b3c603ea46'
7
- data.tar.gz: 2cedeabbc9f8be7ded0ed39164d7808bc63178e84a7ef06aa2da6bddcfd6262047490eff29eb36da1dcd80393c0aba1bc33cc4340a5358af02bc4373c02f568e
6
+ metadata.gz: 14be8fc302967f0f9e625bb666adf40ab0af14e6f338981ede4e639d9acd47d9e3172248bf4ddc50be57bb17a51fae9b9c049f4b02bf07f5d631a1310ba974a8
7
+ data.tar.gz: 7c51764e8ae51818ec881a2b25b82babd90c10f21153763171852b7925451ca08a5601e49c0497f0a8060f614c6710c731c3dee30245b01676e59150eb3bc770
data/README.md CHANGED
@@ -26,6 +26,7 @@ The unusual part is navigation. Jekyll VitePress uses Turbo Frames like a Rails
26
26
  - **[VitePress polish for Jekyll](https://jekyll-vitepress.dev/vitepress-parity/):** match VitePress for the docs homepage, sidebar, outline, search, dark mode, callouts, code blocks, and doc footers.
27
27
  - **Jekyll-native setup:** keep your Markdown, Liquid, YAML, and static hosting. Add the gem, set a few options, and publish.
28
28
  - **Fast docs navigation:** Turbo Frames update the content area while the nav, sidebar, and shell stay mounted.
29
+ - **Search and AI discovery by default:** ship canonical metadata, social cards, JSON-LD, nested breadcrumbs, sitemap, robots controls, `llms.txt`, and `llms-full.txt` without another discovery plugin.
29
30
  - **[More than VitePress](https://jekyll-vitepress.dev/extensions-to-vitepress/):** add GitHub Star and Sponsor buttons, RubyGems downloads, versions, labels, generated local search, and Copy Page/View as Markdown for LLM workflows.
30
31
  - **Static Ruby output:** build with Jekyll and deploy the generated HTML to GitHub Pages, any CDN, or any static host.
31
32
 
@@ -78,13 +79,15 @@ bundle install
78
79
  bundle exec jekyll serve --livereload
79
80
  ```
80
81
 
81
- ### Metadata and jekyll-seo-tag
82
+ ### SEO is built in
82
83
 
83
- The theme writes the page description, canonical link, Open Graph, and Twitter
84
- card tags itself. Calling `{% seo %}` as well emits a second copy of each, so
85
- drop it when you switch to this theme, or drop the theme's tags if you prefer
86
- jekyll-seo-tag's. Its JSON-LD has no equivalent here, so keep that plugin if
87
- you want structured data.
84
+ Every site using the theme gets one consistent set of search and social metadata, JSON-LD (`WebSite`, `WebPage` or `Article`, `Organization`/`Person`, image, and nested breadcrumbs), `sitemap.xml`, and `robots.txt`. Canonical overrides, `noindex`, language alternates, social images, authors, publishers, and webmaster verification tokens are configurable without another plugin.
85
+
86
+ Set at least `title`, `description`, and an absolute production `url` in `_config.yml`. The build warns about missing and duplicate metadata. Do not also render `{% seo %}` or enable another sitemap/robots generator; that creates duplicated or contradictory signals.
87
+
88
+ ### LLM discovery is built in
89
+
90
+ The theme also creates `/llms.txt`, a concise canonical documentation index, and `/llms-full.txt`, a single Markdown bundle containing every eligible page and collection document. Redirects, `404` pages, `noindex` pages, and external-canonical duplicates are excluded. Both files understand `baseurl` and can be configured or disabled without `jekyll-ai-visible-content`.
88
91
 
89
92
  ## Screenshots
90
93
 
@@ -18,7 +18,7 @@
18
18
  <span class="copy-md-dropdown-desc">Copy page as Markdown for LLMs</span>
19
19
  </div>
20
20
  </button>
21
- <a class="copy-md-dropdown-item" href="{{ view_md_url | relative_url }}" target="_blank" rel="noreferrer">
21
+ <a class="copy-md-dropdown-item" href="{{ view_md_url | relative_url }}" target="_blank" rel="nofollow noreferrer">
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>
data/_includes/head.html CHANGED
@@ -1,37 +1,7 @@
1
1
  <head>
2
2
  <meta charset="UTF-8">
3
3
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
4
- {% assign page_title = page.title | default: site.title %}
5
- {% capture full_title %}{% if page_title and page_title != site.title %}{{ page_title }} | {{ site.title }}{% else %}{{ site.title }}{% endif %}{% endcapture %}
6
- {% assign full_title = full_title | strip %}
7
- {% assign page_description = page.description | default: site.description %}
8
- {% assign canonical_url = page.canonical_url | default: page.url | absolute_url %}
9
- {% assign page_image = page.image %}
10
- {% assign seo_image = site.logo | default: '/assets/images/favicon/web-app-manifest-512x512.png' %}
11
- {% if page_image %}
12
- {% if page_image.src %}
13
- {% assign seo_image = page_image.src %}
14
- {% elsif page_image.path %}
15
- {% assign seo_image = page_image.path %}
16
- {% else %}
17
- {% assign seo_image = page_image %}
18
- {% endif %}
19
- {% endif %}
20
- {% assign seo_image_url = seo_image | absolute_url %}
21
- <title>{{ full_title }}</title>
22
- <meta name="description" content="{{ page_description | escape }}">
23
- <meta name="robots" content="index,follow,max-image-preview:large">
24
- <link rel="canonical" href="{{ canonical_url }}">
25
- <meta property="og:site_name" content="{{ site.title | escape }}">
26
- <meta property="og:title" content="{{ full_title | escape }}">
27
- <meta property="og:description" content="{{ page_description | escape }}">
28
- <meta property="og:url" content="{{ canonical_url }}">
29
- <meta property="og:type" content="{% if page.url == '/' %}website{% else %}article{% endif %}">
30
- <meta property="og:image" content="{{ seo_image_url }}">
31
- <meta name="twitter:card" content="summary_large_image">
32
- <meta name="twitter:title" content="{{ full_title | escape }}">
33
- <meta name="twitter:description" content="{{ page_description | escape }}">
34
- <meta name="twitter:image" content="{{ seo_image_url }}">
4
+ {% include seo.html %}
35
5
 
36
6
  {% assign theme = site.jekyll_vitepress %}
37
7
  {% assign branding = theme.branding %}
@@ -0,0 +1,78 @@
1
+ {% assign seo_config = site.jekyll_vitepress.seo %}
2
+ {% assign seo_enabled = true %}
3
+ {% if seo_config == false or seo_config.enabled == false or page.seo == false %}
4
+ {% assign seo_enabled = false %}
5
+ {% endif %}
6
+
7
+ {% if seo_enabled %}
8
+ {% assign seo = page._seo %}
9
+ {% if seo %}
10
+ <title>{{ seo.title | escape }}</title>
11
+ {% if seo.description %}<meta name="description" content="{{ seo.description | escape }}">{% endif %}
12
+ {% if seo.author.name %}<meta name="author" content="{{ seo.author.name | escape }}">{% endif %}
13
+ {% if seo.robots %}<meta name="robots" content="{{ seo.robots | escape }}">{% endif %}
14
+ {% if seo.canonical_url %}<link rel="canonical" href="{{ seo.canonical_url | escape }}">{% endif %}
15
+ {% if seo.sitemap_url %}<link rel="sitemap" type="application/xml" href="{{ seo.sitemap_url | escape }}">{% endif %}
16
+
17
+ <meta property="og:site_name" content="{{ site.title | default: site.jekyll_vitepress.branding.site_title | escape }}">
18
+ <meta property="og:title" content="{{ seo.page_title | escape }}">
19
+ {% if seo.description %}<meta property="og:description" content="{{ seo.description | escape }}">{% endif %}
20
+ {% if seo.canonical_url %}<meta property="og:url" content="{{ seo.canonical_url | escape }}">{% endif %}
21
+ <meta property="og:type" content="{{ seo.og_type }}">
22
+ <meta property="og:locale" content="{{ seo.locale | escape }}">
23
+ {% for alternate in seo.alternates %}
24
+ <meta property="og:locale:alternate" content="{{ alternate.locale | escape }}">
25
+ <link rel="alternate" hreflang="{{ alternate.hreflang | escape }}" href="{{ alternate.url | escape }}">
26
+ {% endfor %}
27
+ {% if seo.image.url %}
28
+ <meta property="og:image" content="{{ seo.image.url | escape }}">
29
+ {% if seo.image.url contains 'https://' %}<meta property="og:image:secure_url" content="{{ seo.image.url | escape }}">{% endif %}
30
+ {% if seo.image.alt %}<meta property="og:image:alt" content="{{ seo.image.alt | escape }}">{% endif %}
31
+ {% if seo.image.width %}<meta property="og:image:width" content="{{ seo.image.width }}">{% endif %}
32
+ {% if seo.image.height %}<meta property="og:image:height" content="{{ seo.image.height }}">{% endif %}
33
+ {% endif %}
34
+ {% if seo.og_type == 'article' %}
35
+ {% if seo.date_published %}<meta property="article:published_time" content="{{ seo.date_published }}">{% endif %}
36
+ {% if seo.date_modified %}<meta property="article:modified_time" content="{{ seo.date_modified }}">{% endif %}
37
+ {% if seo.author.url %}<meta property="article:author" content="{{ seo.author.url | escape }}">{% endif %}
38
+ {% if seo.facebook.publisher %}<meta property="article:publisher" content="{{ seo.facebook.publisher | escape }}">{% endif %}
39
+ {% endif %}
40
+ {% if seo.facebook.app_id %}<meta property="fb:app_id" content="{{ seo.facebook.app_id | escape }}">{% endif %}
41
+ {% if seo.facebook.admins %}<meta property="fb:admins" content="{{ seo.facebook.admins | escape }}">{% endif %}
42
+
43
+ <meta name="twitter:card" content="{{ seo.twitter.card | escape }}">
44
+ <meta name="twitter:title" content="{{ seo.page_title | escape }}">
45
+ {% if seo.description %}<meta name="twitter:description" content="{{ seo.description | escape }}">{% endif %}
46
+ {% if seo.image.url %}<meta name="twitter:image" content="{{ seo.image.url | escape }}">{% endif %}
47
+ {% if seo.image.alt %}<meta name="twitter:image:alt" content="{{ seo.image.alt | escape }}">{% endif %}
48
+ {% if seo.twitter.site %}<meta name="twitter:site" content="{{ seo.twitter.site | escape }}">{% endif %}
49
+ {% if seo.twitter.creator %}<meta name="twitter:creator" content="{{ seo.twitter.creator | escape }}">{% endif %}
50
+
51
+ {% for verification in seo.verifications %}
52
+ <meta name="{{ verification.name }}" content="{{ verification.content | escape }}">
53
+ {% endfor %}
54
+ {% if seo.json_ld %}<script type="application/ld+json">{{ seo.json_ld }}</script>{% endif %}
55
+ {% else %}
56
+ {% assign page_title = page.title | default: site.title | default: site.jekyll_vitepress.branding.site_title %}
57
+ {% capture full_title %}{% if page_title and page_title != site.title %}{{ page_title }} | {{ site.title }}{% else %}{{ site.title }}{% endif %}{% endcapture %}
58
+ {% assign page_description = page.description | default: site.description %}
59
+ {% assign canonical_url = page.canonical_url | default: page.url | absolute_url %}
60
+ {% assign page_image = page.image | default: site.logo %}
61
+ {% if page_image.src %}{% assign page_image = page_image.src %}{% endif %}
62
+ {% if page_image.path %}{% assign page_image = page_image.path %}{% endif %}
63
+ <title>{{ full_title | strip | escape }}</title>
64
+ {% if page_description %}<meta name="description" content="{{ page_description | strip_html | strip | escape }}">{% endif %}
65
+ <meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1">
66
+ {% if site.url %}<link rel="canonical" href="{{ canonical_url }}">{% endif %}
67
+ <meta property="og:site_name" content="{{ site.title | escape }}">
68
+ <meta property="og:title" content="{{ page_title | escape }}">
69
+ {% if page_description %}<meta property="og:description" content="{{ page_description | strip_html | strip | escape }}">{% endif %}
70
+ {% if site.url %}<meta property="og:url" content="{{ canonical_url }}">{% endif %}
71
+ <meta property="og:type" content="website">
72
+ {% if page_image %}<meta property="og:image" content="{{ page_image | absolute_url }}">{% endif %}
73
+ <meta name="twitter:card" content="{% if page_image %}summary_large_image{% else %}summary{% endif %}">
74
+ <meta name="twitter:title" content="{{ page_title | escape }}">
75
+ {% if page_description %}<meta name="twitter:description" content="{{ page_description | strip_html | strip | escape }}">{% endif %}
76
+ {% if page_image %}<meta name="twitter:image" content="{{ page_image | absolute_url }}">{% endif %}
77
+ {% endif %}
78
+ {% endif %}
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="{{ site.lang | default: 'en-US' }}">
2
+ <html lang="{{ page._seo.language | default: page.lang | default: page.locale | default: site.lang | default: site.locale | default: 'en-US' }}"{% if page.dir or site.dir %} dir="{{ page.dir | default: site.dir }}"{% endif %}>
3
3
  {% include head.html %}
4
4
  {% assign theme = site.jekyll_vitepress %}
5
5
  {% assign behavior = theme.behavior %}
@@ -32,10 +32,12 @@
32
32
 
33
33
  <div class="VPContent{% if has_sidebar %} has-sidebar{% endif %}{% if is_home %} is-home{% endif %}" id="VPContent">
34
34
  {% if has_sidebar %}
35
- {% assign frame_page_title = site.title %}
36
- {% if page.title and page.title != site.title %}
37
- {% capture frame_page_title %}{{ page.title }} | {{ site.title }}{% endcapture %}
38
- {% endif %}
35
+ {% assign frame_page_title = page._seo.title | default: site.title %}
36
+ {% unless page._seo.title %}
37
+ {% if page.title and page.title != site.title %}
38
+ {% capture frame_page_title %}{{ page.title }} | {{ site.title }}{% endcapture %}
39
+ {% endif %}
40
+ {% endunless %}
39
41
  <turbo-frame id="vp-content-frame" target="_top">
40
42
  <div id="vp-page-state" hidden data-title="{{ frame_page_title | strip | escape }}" data-url="{{ page.url | relative_url }}" data-collection="{{ page.collection | default: '' | escape }}"></div>
41
43
  <div class="VPDoc has-sidebar has-aside">
@@ -520,18 +520,28 @@ Jekyll::Hooks.register :site, :post_read do |site|
520
520
  Jekyll::VitePressTheme::RougeStyles.apply(site)
521
521
  Jekyll::VitePressTheme::Sidebar.apply(site)
522
522
  Jekyll::VitePressTheme::SearchIndex.apply(site)
523
+ Jekyll::VitePressTheme::SEO.apply(site)
524
+ Jekyll::VitePressTheme::LLMS.apply(site)
523
525
  end
524
526
 
525
527
  capture_page_state = lambda do |item, payload|
526
528
  if Jekyll::VitePressTheme::CopyPage.enabled?(item)
527
529
  raw = Jekyll::VitePressTheme::CopyPage.resolved_markdown(item, payload)
528
530
  item.data['_raw_markdown'] = Jekyll::VitePressTheme::CopyPage.with_title(raw, item.data['title'])
531
+ payload['page']['_raw_markdown'] = item.data['_raw_markdown'] if payload['page'].is_a?(Hash)
529
532
  end
530
533
 
531
- next if item.data.key?('last_updated_at')
534
+ unless item.data.key?('last_updated_at')
535
+ updated_at = Jekyll::VitePressTheme::LastUpdated.source_file_time(item.site, item.path)
536
+ item.data['last_updated_at'] = updated_at if updated_at
537
+ end
538
+ payload['page']['last_updated_at'] = item.data['last_updated_at'] if payload['page'].is_a?(Hash)
532
539
 
533
- updated_at = Jekyll::VitePressTheme::LastUpdated.source_file_time(item.site, item.path)
534
- item.data['last_updated_at'] = updated_at if updated_at
540
+ Jekyll::VitePressTheme::SEO.prepare(item)
541
+ if payload['page'].is_a?(Hash)
542
+ payload['page']['_seo'] = item.data['_seo']
543
+ payload['page']['_seo_disabled'] = item.data['_seo_disabled']
544
+ end
535
545
  end
536
546
 
537
547
  Jekyll::Hooks.register :documents, :pre_render, &capture_page_state
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module VitePressTheme
5
+ # Generates the proposed llms.txt index and its full-text companion from
6
+ # the same canonical, indexable content used by the theme's SEO layer.
7
+ # rubocop:disable Metrics/AbcSize, Metrics/ModuleLength
8
+ module LLMS
9
+ module_function
10
+
11
+ class GeneratedPage < Jekyll::PageWithoutAFile
12
+ def initialize(site, name, content)
13
+ super(site, site.source, '', name)
14
+ self.content = content
15
+ data['layout'] = nil
16
+ data['permalink'] = "/#{name}"
17
+ data['seo'] = false
18
+ data['sitemap'] = false
19
+ data['llms'] = false
20
+ end
21
+
22
+ def render_with_liquid?
23
+ false
24
+ end
25
+ end
26
+
27
+ def apply(site)
28
+ config = config_for(site)
29
+ return unless enabled?(config)
30
+
31
+ groups = content_groups(site)
32
+ return if groups.empty?
33
+
34
+ unless SEO.custom_output?(site, 'llms.txt')
35
+ site.pages << GeneratedPage.new(site, 'llms.txt', render_index(site, config, groups))
36
+ end
37
+
38
+ return unless full_text?(config) && !SEO.custom_output?(site, 'llms-full.txt')
39
+
40
+ site.pages << GeneratedPage.new(site, 'llms-full.txt', render_full(site, config, groups))
41
+ rescue StandardError => e
42
+ Jekyll.logger.warn('jekyll-vitepress-theme', "LLM discovery generation failed: #{e.message}")
43
+ end
44
+
45
+ def config_for(site)
46
+ theme_config = site.config['jekyll_vitepress']
47
+ return {} unless theme_config.is_a?(Hash)
48
+
49
+ value = theme_config.fetch('llms', {})
50
+ value == false ? false : hash_value(value)
51
+ end
52
+
53
+ def enabled?(config)
54
+ config != false && config.fetch('enabled', true) != false
55
+ end
56
+
57
+ def full_text?(config)
58
+ config.fetch('full', config.fetch('include_full_text', true)) != false
59
+ end
60
+
61
+ def content_groups(site)
62
+ eligible = SEO.html_items(site).select { |item| eligible?(item) }
63
+ used = {}.compare_by_identity
64
+ groups = []
65
+
66
+ home = eligible.find { |item| item.url == '/' }
67
+ add_group(groups, used, 'Overview', [home].compact)
68
+
69
+ sidebar_groups(site).each do |group|
70
+ docs = Array(group['docs']).select { |item| eligible.include?(item) }
71
+ add_group(groups, used, group['title'] || collection_title(group['collection']), docs)
72
+ end
73
+
74
+ remaining = eligible.reject { |item| used[item] }
75
+ remaining.group_by { |item| collection_label(item) }.each do |label, items|
76
+ title = label ? collection_title(label) : 'Pages'
77
+ add_group(groups, used, title, sorted_items(items))
78
+ end
79
+
80
+ groups
81
+ end
82
+
83
+ def eligible?(item)
84
+ return false if item.data['llms'] == false || item.data['redirect_to']
85
+ return false if ['/404.html', '/404/'].include?(item.url)
86
+
87
+ metadata = item.data['_seo']
88
+ robots = metadata&.dig('robots') || item.data['robots']
89
+ tokens = robots.to_s.downcase.split(/[\s,]+/)
90
+ return false if item.data['noindex'] == true || tokens.intersect?(%w[noindex none])
91
+
92
+ canonical = metadata&.dig('canonical_url')
93
+ self_url = SEO.absolute_url(item.site, item.url)
94
+ canonical.to_s.empty? || self_url.to_s.empty? || canonical == self_url
95
+ end
96
+
97
+ def render_index(site, config, groups)
98
+ lines = header_lines(site, config)
99
+ details = config['details'].to_s.strip
100
+ lines.push(details, '') unless details.empty?
101
+
102
+ if full_text?(config)
103
+ lines << '## Full Documentation'
104
+ lines << ''
105
+ lines << "- [Complete documentation](#{site_url(site, '/llms-full.txt')}): All canonical pages in one Markdown document."
106
+ lines << ''
107
+ end
108
+
109
+ groups.each do |group|
110
+ lines << "## #{plain_heading(group[:title])}"
111
+ lines << ''
112
+ group[:items].each do |item|
113
+ title = link_label(item_title(item))
114
+ entry = "- [#{title}](#{canonical_url(item)})"
115
+ description = item_description(item)
116
+ entry += ": #{description}" if description
117
+ lines << entry
118
+ end
119
+ lines << ''
120
+ end
121
+
122
+ "#{lines.join("\n").rstrip}\n"
123
+ end
124
+
125
+ def render_full(site, config, groups)
126
+ title = config['title'] || SEO.site_title(site) || 'Documentation'
127
+ description = config['description'] || site.config['description']
128
+ lines = ["# #{plain_heading(title)} — Full Documentation", '']
129
+ lines.push("> #{SEO.clean_text(description)}", '') if SEO.clean_text(description)
130
+
131
+ groups.flat_map { |group| group[:items] }.each do |item|
132
+ lines << '---'
133
+ lines << ''
134
+ lines << "# #{plain_heading(item_title(item))}"
135
+ lines << ''
136
+ lines << "Canonical URL: #{canonical_url(item)}"
137
+ lines << ''
138
+ content = strip_leading_title(raw_markdown(item, site))
139
+ content = item_description(item).to_s if content.empty?
140
+ lines << content
141
+ lines << ''
142
+ end
143
+
144
+ "#{lines.join("\n").rstrip}\n"
145
+ end
146
+
147
+ def header_lines(site, config)
148
+ title = config['title'] || SEO.site_title(site) || 'Documentation'
149
+ description = config['description'] || site.config['description']
150
+ lines = ["# #{plain_heading(title)}", '']
151
+ lines.push("> #{SEO.clean_text(description)}", '') if SEO.clean_text(description)
152
+ lines
153
+ end
154
+
155
+ def raw_markdown(item, site)
156
+ payload = site.site_payload.merge('page' => item.to_liquid)
157
+ raw = CopyPage.resolved_markdown(item, payload)
158
+ CopyPage.with_title(raw, item_title(item)).to_s.strip
159
+ end
160
+
161
+ def strip_leading_title(markdown)
162
+ markdown.to_s
163
+ .sub(/\A\s*#\s+[^\n]+\n+/, '')
164
+ .sub(%r{\A\s*<h1(?:\s[^>]*)?>.*?</h1>\s*}mi, '')
165
+ .sub(/\A\s*[^\n]+\n=+\s*\n+/, '')
166
+ .gsub(/\n{3,}/, "\n\n")
167
+ .strip
168
+ end
169
+
170
+ def canonical_url(item)
171
+ item.data.dig('_seo', 'canonical_url') || site_url(item.site, item.url)
172
+ end
173
+
174
+ def site_url(site, path)
175
+ SEO.absolute_url(site, path) || relative_url(site, path)
176
+ end
177
+
178
+ def relative_url(site, path)
179
+ baseurl = site.config['baseurl'].to_s.sub(%r{/+\z}, '')
180
+ path = "/#{path}" unless path.start_with?('/')
181
+ "#{baseurl}#{path}"
182
+ end
183
+
184
+ def item_title(item)
185
+ item.data.dig('_seo', 'page_title') || SEO.clean_text(item.data['title']) || item.url
186
+ end
187
+
188
+ def item_description(item)
189
+ SEO.clean_text(item.data.dig('_seo', 'description') || item.data['description'])
190
+ end
191
+
192
+ def sidebar_groups(site)
193
+ data = site.data['jekyll_vitepress_sidebar']
194
+ data.is_a?(Hash) ? Array(data['groups']) : []
195
+ end
196
+
197
+ def add_group(groups, used, title, items)
198
+ items = items.reject { |item| used[item] }
199
+ return if items.empty?
200
+
201
+ items.each { |item| used[item] = true }
202
+ groups << { title: title, items: items }
203
+ end
204
+
205
+ def sorted_items(items)
206
+ items.sort_by do |item|
207
+ order = item.data['nav_order']
208
+ [order.is_a?(Numeric) ? order : Float::INFINITY, item.url.to_s]
209
+ end
210
+ end
211
+
212
+ def collection_label(item)
213
+ return unless item.respond_to?(:collection) && item.collection
214
+
215
+ item.collection.label
216
+ end
217
+
218
+ def collection_title(label)
219
+ label.to_s.tr('_-', ' ').split.map(&:capitalize).join(' ')
220
+ end
221
+
222
+ def plain_heading(value)
223
+ SEO.clean_text(value).to_s.gsub(/[\r\n#]+/, ' ').strip
224
+ end
225
+
226
+ def link_label(value)
227
+ plain_heading(value).gsub(/([\[\]])/, '\\\1')
228
+ end
229
+
230
+ def hash_value(value)
231
+ value.is_a?(Hash) ? value : {}
232
+ end
233
+ end
234
+ # rubocop:enable Metrics/AbcSize, Metrics/ModuleLength
235
+ end
236
+ end
@@ -0,0 +1,593 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'json'
5
+ require 'time'
6
+ require 'uri'
7
+
8
+ module Jekyll
9
+ module VitePressTheme
10
+ # Generates the metadata shared by the HTML head, sitemap, and robots.txt.
11
+ # Keeping those outputs on one canonical URL resolver prevents contradictory
12
+ # indexing signals across theme consumers.
13
+ # rubocop:disable Metrics/AbcSize, Metrics/ModuleLength
14
+ module SEO
15
+ module_function
16
+
17
+ DEFAULT_ROBOTS = 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1'
18
+ ARTICLE_TYPES = %w[Article BlogPosting NewsArticle TechArticle].freeze
19
+ VERIFICATION_NAMES = {
20
+ 'google' => 'google-site-verification',
21
+ 'bing' => 'msvalidate.01',
22
+ 'yandex' => 'yandex-verification',
23
+ 'baidu' => 'baidu-site-verification',
24
+ 'facebook' => 'facebook-domain-verification'
25
+ }.freeze
26
+
27
+ class GeneratedPage < Jekyll::PageWithoutAFile
28
+ def initialize(site, name, content)
29
+ super(site, site.source, '', name)
30
+ self.content = content
31
+ data['layout'] = nil
32
+ data['sitemap'] = false
33
+ data['seo'] = false
34
+ end
35
+ end
36
+
37
+ def apply(site)
38
+ return unless enabled?(site)
39
+
40
+ items = html_items(site)
41
+ items.each { |item| prepare(item) }
42
+ warn_about_metadata(site, items)
43
+ generate_discovery_files(site, items)
44
+ rescue StandardError => e
45
+ Jekyll.logger.warn('jekyll-vitepress-theme', "SEO generation failed: #{e.message}")
46
+ end
47
+
48
+ def prepare(item)
49
+ return unless enabled?(item.site)
50
+
51
+ page_config = page_config(item)
52
+ if page_config == false
53
+ item.data['_seo_disabled'] = true
54
+ item.data.delete('_seo')
55
+ return
56
+ end
57
+
58
+ metadata = metadata_for(item, page_config)
59
+ item.data['_seo'] = metadata
60
+ item.data['sitemap'] = false unless sitemap_candidate?(item, metadata)
61
+ metadata
62
+ end
63
+
64
+ def enabled?(site)
65
+ config = config_for(site)
66
+ config != false && !(config.is_a?(Hash) && config['enabled'] == false)
67
+ end
68
+
69
+ def config_for(site)
70
+ theme_config = site.config['jekyll_vitepress']
71
+ return {} unless theme_config.is_a?(Hash)
72
+
73
+ value = theme_config.fetch('seo', {})
74
+ value == false ? false : hash_value(value)
75
+ end
76
+
77
+ def page_config(item)
78
+ value = item.data['seo']
79
+ return false if value == false
80
+
81
+ value.is_a?(Hash) ? value : {}
82
+ end
83
+
84
+ def metadata_for(item, page_config = page_config(item))
85
+ site = item.site
86
+ config = config_for(site)
87
+ page_title = clean_text(page_config['title'] || item.data['title'] || site_title(site))
88
+ full_title = full_title_for(page_title, site_title(site), config)
89
+ description = clean_text(page_config['description'] || item.data['description'] || site.config['description'])
90
+ canonical = canonical_url(site, page_config['canonical_url'] || item.data['canonical_url'] || item.url)
91
+ locale = locale_for(item)
92
+ image = image_for(item, page_config, config)
93
+ author = author_for(item, page_config)
94
+ schema_type = schema_type_for(item, page_config, config)
95
+ dates = dates_for(item, page_config)
96
+ robots = robots_for(item, page_config, config)
97
+ alternates = alternates_for(item, page_config)
98
+ publisher = publisher_for(site, config)
99
+
100
+ metadata = {
101
+ 'title' => full_title,
102
+ 'page_title' => page_title,
103
+ 'description' => description,
104
+ 'canonical_url' => canonical,
105
+ 'locale' => locale.tr('-', '_'),
106
+ 'language' => locale.tr('_', '-'),
107
+ 'robots' => robots,
108
+ 'og_type' => ARTICLE_TYPES.include?(schema_type) ? 'article' : 'website',
109
+ 'image' => image,
110
+ 'author' => author,
111
+ 'twitter' => twitter_for(item, author),
112
+ 'facebook' => hash_value(site.config['facebook']),
113
+ 'verifications' => verifications_for(site),
114
+ 'alternates' => alternates,
115
+ 'date_published' => dates['published'],
116
+ 'date_modified' => dates['modified'],
117
+ 'sitemap_url' => sitemap_enabled?(config) ? absolute_url(site, '/sitemap.xml') : nil
118
+ }
119
+ metadata['json_ld'] = json_ld_for(item, metadata, schema_type, publisher, config)
120
+ metadata
121
+ end
122
+
123
+ def full_title_for(page_title, title, config)
124
+ return title if page_title.to_s.empty?
125
+ return page_title if title.to_s.empty? || page_title == title
126
+
127
+ template = config.is_a?(Hash) ? config['title_template'] : nil
128
+ if template.to_s.include?(':page') || template.to_s.include?(':site')
129
+ clean_text(template.to_s.gsub(':page', page_title).gsub(':site', title))
130
+ else
131
+ separator = config.is_a?(Hash) ? config.fetch('title_separator', ' | ') : ' | '
132
+ "#{page_title}#{separator}#{title}"
133
+ end
134
+ end
135
+
136
+ def robots_for(item, page_config, config)
137
+ explicit = page_config['robots'] || item.data['robots']
138
+ return clean_text(explicit) if explicit && explicit != true
139
+
140
+ site_index = !config.is_a?(Hash) || config.fetch('index', true) != false
141
+ noindex = page_config['noindex'] == true || item.data['noindex'] == true || !site_index
142
+ nofollow = page_config['nofollow'] == true || item.data['nofollow'] == true
143
+ return "noindex,#{nofollow ? 'nofollow' : 'follow'}" if noindex
144
+
145
+ return DEFAULT_ROBOTS unless config.is_a?(Hash)
146
+
147
+ clean_text(config['robots']) || DEFAULT_ROBOTS
148
+ end
149
+
150
+ def image_for(item, page_config, config)
151
+ raw = page_config['image'] || item.data['image']
152
+ raw ||= config['image'] if config.is_a?(Hash)
153
+ raw ||= item.site.config['logo']
154
+ return nil if raw.nil? || raw == false
155
+
156
+ image = raw.is_a?(Hash) ? raw : { 'path' => raw }
157
+ path = image['path'] || image['src'] || image['url']
158
+ return nil if path.to_s.strip.empty?
159
+
160
+ {
161
+ 'url' => absolute_url(item.site, path),
162
+ 'alt' => clean_text(image['alt'] || item.data['image_alt'] || item.data['title'] || site_title(item.site)),
163
+ 'width' => positive_integer(image['width']),
164
+ 'height' => positive_integer(image['height'])
165
+ }.compact
166
+ end
167
+
168
+ def author_for(item, page_config)
169
+ value = page_config['author'] || item.data['author'] || item.site.config['author']
170
+ return nil if value.nil? || value == false
171
+
172
+ author = value.is_a?(Hash) ? value : { 'name' => value }
173
+ name = clean_text(author['name'] || author[:name])
174
+ return nil if name.to_s.empty?
175
+
176
+ {
177
+ 'name' => name,
178
+ 'url' => absolute_url(item.site, author['url'] || author[:url]),
179
+ 'twitter' => clean_text(author['twitter'] || author[:twitter])
180
+ }.compact
181
+ end
182
+
183
+ def publisher_for(site, config)
184
+ raw = config['publisher'] if config.is_a?(Hash)
185
+ return nil unless raw.is_a?(Hash)
186
+
187
+ name = clean_text(raw['name'] || site_title(site))
188
+ return nil if name.to_s.empty?
189
+
190
+ type = %w[Organization Person].include?(raw['type']) ? raw['type'] : 'Organization'
191
+ image = raw['logo'] || site.config['logo']
192
+ same_as = Array(raw['same_as'] || raw['sameAs']).filter_map do |url|
193
+ absolute_url(site, url)
194
+ end
195
+
196
+ {
197
+ 'type' => type,
198
+ 'name' => name,
199
+ 'url' => absolute_url(site, raw['url'] || '/'),
200
+ 'logo' => absolute_url(site, image.is_a?(Hash) ? image['path'] || image['src'] : image),
201
+ 'same_as' => same_as
202
+ }.compact
203
+ end
204
+
205
+ def twitter_for(item, author = nil)
206
+ site_twitter = hash_value(item.site.config['twitter'])
207
+ page_twitter = hash_value(item.data['twitter'])
208
+ creator = page_twitter['creator'] || page_twitter['username'] || author&.dig('twitter')
209
+
210
+ {
211
+ 'card' => page_twitter['card'] || site_twitter['card'] || 'summary_large_image',
212
+ 'site' => twitter_handle(site_twitter['username']),
213
+ 'creator' => twitter_handle(creator)
214
+ }.compact
215
+ end
216
+
217
+ def verifications_for(site)
218
+ values = hash_value(site.config['webmaster_verifications'])
219
+ values['google'] ||= site.config['google_site_verification']
220
+
221
+ VERIFICATION_NAMES.filter_map do |key, name|
222
+ content = clean_text(values[key])
223
+ { 'name' => name, 'content' => content } if content
224
+ end
225
+ end
226
+
227
+ def alternates_for(item, page_config)
228
+ raw = page_config['alternates'] || item.data['alternates']
229
+ Array(raw).filter_map do |alternate|
230
+ next unless alternate.is_a?(Hash)
231
+
232
+ language = clean_text(alternate['lang'] || alternate['hreflang'])
233
+ url = absolute_url(item.site, alternate['url'] || alternate['href'])
234
+ next if language.to_s.empty? || url.to_s.empty?
235
+
236
+ { 'hreflang' => language, 'url' => url, 'locale' => language.tr('-', '_') }
237
+ end
238
+ end
239
+
240
+ def dates_for(item, page_config)
241
+ page_theme = hash_value(item.data['jekyll_vitepress'])
242
+ published = page_config['date_published'] || item.data['date_published']
243
+ published ||= item.data['date'] if posts_document?(item)
244
+ modified = page_config['date_modified'] || item.data['date_modified'] || item.data['last_modified_at']
245
+ modified ||= page_theme['last_updated_at']
246
+
247
+ {
248
+ 'published' => xml_time(published),
249
+ 'modified' => xml_time(modified)
250
+ }.compact
251
+ end
252
+
253
+ def schema_type_for(item, page_config, config)
254
+ value = page_config['type']
255
+ value ||= config['page_type'] if config.is_a?(Hash)
256
+ value = 'WebPage' if value.to_s.empty? || item.url == '/'
257
+ clean_text(value)
258
+ end
259
+
260
+ def json_ld_for(item, metadata, schema_type, publisher, config)
261
+ return nil if config.is_a?(Hash) && config.dig('schema', 'enabled') == false
262
+ return nil if metadata['canonical_url'].to_s.empty?
263
+
264
+ site = item.site
265
+ root_url = absolute_url(site, '/')
266
+ website_id = "#{root_url}#website"
267
+ webpage_id = "#{metadata['canonical_url']}#webpage"
268
+ graph = []
269
+
270
+ if publisher
271
+ publisher_id = "#{root_url}#identity"
272
+ identity = {
273
+ '@type' => publisher['type'], '@id' => publisher_id,
274
+ 'name' => publisher['name'], 'url' => publisher['url']
275
+ }
276
+ identity['logo'] = { '@type' => 'ImageObject', 'url' => publisher['logo'] } if publisher['logo']
277
+ identity['sameAs'] = publisher['same_as'] unless publisher['same_as'].empty?
278
+ graph << identity
279
+ end
280
+
281
+ website = {
282
+ '@type' => 'WebSite', '@id' => website_id, 'url' => root_url,
283
+ 'name' => site_title(site), 'description' => clean_text(site.config['description']),
284
+ 'inLanguage' => metadata['language']
285
+ }.compact
286
+ website['publisher'] = { '@id' => "#{root_url}#identity" } if publisher
287
+ graph << website
288
+
289
+ if metadata['image']
290
+ graph << {
291
+ '@type' => 'ImageObject', '@id' => "#{metadata['image']['url']}#primaryimage",
292
+ 'url' => metadata['image']['url'], 'contentUrl' => metadata['image']['url'],
293
+ 'caption' => metadata['image']['alt'], 'width' => metadata['image']['width'],
294
+ 'height' => metadata['image']['height']
295
+ }.compact
296
+ end
297
+
298
+ article_type = ARTICLE_TYPES.include?(schema_type)
299
+ webpage = {
300
+ '@type' => article_type ? 'WebPage' : schema_type,
301
+ '@id' => webpage_id, 'url' => metadata['canonical_url'],
302
+ 'name' => metadata['page_title'],
303
+ 'description' => metadata['description'], 'isPartOf' => { '@id' => website_id },
304
+ 'inLanguage' => metadata['language'], 'datePublished' => metadata['date_published'],
305
+ 'dateModified' => metadata['date_modified']
306
+ }.compact
307
+ if metadata['image']
308
+ webpage['primaryImageOfPage'] = { '@id' => "#{metadata['image']['url']}#primaryimage" }
309
+ end
310
+
311
+ breadcrumbs = breadcrumb_graph(item, metadata['canonical_url'], root_url)
312
+ if breadcrumbs
313
+ webpage['breadcrumb'] = { '@id' => "#{metadata['canonical_url']}#breadcrumb" }
314
+ graph << breadcrumbs
315
+ end
316
+ graph << webpage
317
+
318
+ if article_type
319
+ article_id = "#{metadata['canonical_url']}#article"
320
+ webpage['mainEntity'] = { '@id' => article_id }
321
+ article = {
322
+ '@type' => schema_type, '@id' => article_id,
323
+ 'mainEntityOfPage' => { '@id' => webpage_id }, 'isPartOf' => { '@id' => website_id },
324
+ 'headline' => metadata['page_title'], 'description' => metadata['description'],
325
+ 'inLanguage' => metadata['language'], 'datePublished' => metadata['date_published'],
326
+ 'dateModified' => metadata['date_modified']
327
+ }.compact
328
+ article['image'] = { '@id' => "#{metadata['image']['url']}#primaryimage" } if metadata['image']
329
+ article['author'] = person_reference(metadata['author']) if metadata['author']
330
+ article['publisher'] = { '@id' => "#{root_url}#identity" } if publisher
331
+ graph << article
332
+ end
333
+
334
+ safe_json('@context' => 'https://schema.org', '@graph' => graph)
335
+ end
336
+
337
+ def breadcrumb_graph(item, canonical, root_url)
338
+ return nil if item.url == '/'
339
+
340
+ ancestors = breadcrumb_ancestors(item)
341
+ entries = [{ 'name' => site_title(item.site), 'item' => root_url }]
342
+ ancestors.each do |ancestor|
343
+ entries << { 'name' => clean_text(ancestor.data['title']), 'item' => absolute_url(item.site, ancestor.url) }
344
+ end
345
+ entries << { 'name' => clean_text(item.data['title'] || site_title(item.site)), 'item' => canonical }
346
+ entries.reject! { |entry| entry['name'].to_s.empty? || entry['item'].to_s.empty? }
347
+ return nil if entries.length < 2
348
+
349
+ {
350
+ '@type' => 'BreadcrumbList', '@id' => "#{canonical}#breadcrumb",
351
+ 'itemListElement' => entries.each_with_index.map do |entry, index|
352
+ { '@type' => 'ListItem', 'position' => index + 1, 'name' => entry['name'], 'item' => entry['item'] }
353
+ end
354
+ }
355
+ end
356
+
357
+ def breadcrumb_ancestors(item)
358
+ return [] unless item.respond_to?(:collection) && item.collection
359
+
360
+ docs = item.collection.docs
361
+ ancestors = []
362
+ current = item
363
+ seen = []
364
+ while (parent = Sidebar.parent_doc_for(current, docs)) && !seen.include?(parent)
365
+ seen << parent
366
+ ancestors.unshift(parent)
367
+ current = parent
368
+ end
369
+ ancestors
370
+ end
371
+
372
+ def generate_discovery_files(site, items)
373
+ config = config_for(site)
374
+ return unless config.is_a?(Hash)
375
+
376
+ site_url = absolute_url(site, '/')
377
+ if sitemap_enabled?(config) && !custom_output?(site, 'sitemap.xml') && absolute?(site_url)
378
+ site.pages << GeneratedPage.new(site, 'sitemap.xml', sitemap_xml(items))
379
+ end
380
+
381
+ return if config.fetch('robots_txt', true) == false || custom_output?(site, 'robots.txt')
382
+
383
+ site.pages << GeneratedPage.new(site, 'robots.txt', robots_txt(site, config, site_url))
384
+ end
385
+
386
+ def sitemap_xml(items)
387
+ rows = items.filter_map do |item|
388
+ metadata = item.data['_seo']
389
+ next unless sitemap_candidate?(item, metadata)
390
+
391
+ last_modified = metadata['date_modified']
392
+ last_modified ||= metadata['date_published'] if posts_document?(item)
393
+ [metadata['canonical_url'], last_modified]
394
+ end
395
+ rows.uniq!(&:first)
396
+ rows.sort_by!(&:first)
397
+
398
+ body = rows.map do |url, last_modified|
399
+ lastmod = last_modified ? "\n <lastmod>#{xml_escape(last_modified)}</lastmod>" : ''
400
+ " <url>\n <loc>#{xml_escape(url)}</loc>#{lastmod}\n </url>"
401
+ end.join("\n")
402
+
403
+ <<~XML
404
+ <?xml version="1.0" encoding="UTF-8"?>
405
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
406
+ #{body}
407
+ </urlset>
408
+ XML
409
+ end
410
+
411
+ def robots_txt(site, config, site_url)
412
+ index = config.fetch('index', true) != false
413
+ lines = ['User-agent: *', index ? 'Allow: /' : 'Disallow: /']
414
+ lines << 'Disallow: /*.md$' if index && CopyPage.site_enabled?(site)
415
+ additional = config['robots_txt_rules']
416
+ lines.push('', additional.to_s.strip) unless additional.to_s.strip.empty?
417
+ sitemap_url = absolute_url(site, '/sitemap.xml')
418
+ if sitemap_enabled?(config) && absolute?(site_url) && sitemap_url
419
+ lines.push('', "Sitemap: #{sitemap_url}")
420
+ end
421
+ "#{lines.join("\n")}\n"
422
+ end
423
+
424
+ def sitemap_candidate?(item, metadata)
425
+ return false unless metadata.is_a?(Hash)
426
+ return false if item.data['sitemap'] == false || item.data['redirect_to'] || item.data['_seo_disabled']
427
+ return false if ['/404.html', '/404/'].include?(item.url)
428
+
429
+ robot_tokens = metadata['robots'].to_s.downcase.split(',').map(&:strip)
430
+ return false if robot_tokens.intersect?(%w[noindex none])
431
+
432
+ canonical = metadata['canonical_url']
433
+ self_url = absolute_url(item.site, item.url)
434
+ absolute?(canonical) && canonical == self_url
435
+ end
436
+
437
+ def html_items(site)
438
+ pages = site.pages.select { |page| page.output_ext == '.html' }
439
+ docs = site.collections.values.reject { |collection| collection.metadata['output'] == false }.flat_map(&:docs)
440
+ (pages + docs).uniq
441
+ end
442
+
443
+ def custom_output?(site, name)
444
+ expected = "/#{name}"
445
+ (site.pages + site.static_files).any? do |item|
446
+ item.url == expected || (item.respond_to?(:relative_path) && item.relative_path == expected)
447
+ end
448
+ end
449
+
450
+ def warn_about_metadata(site, items)
451
+ root = absolute_url(site, '/')
452
+ unless absolute?(root)
453
+ Jekyll.logger.warn(
454
+ 'jekyll-vitepress-theme',
455
+ 'Set an absolute `url` in _config.yml to emit canonical URLs, JSON-LD, sitemap.xml, and robots.txt discovery.'
456
+ )
457
+ end
458
+
459
+ enabled_items = items.reject { |item| item.data['_seo_disabled'] }
460
+ warn_for_items('Missing SEO title', enabled_items.select { |item| item.data.dig('_seo', 'page_title').to_s.empty? })
461
+ warn_for_items('Missing SEO description', enabled_items.select { |item| item.data.dig('_seo', 'description').to_s.empty? })
462
+
463
+ duplicate_titles = enabled_items.group_by { |item| item.data.dig('_seo', 'title') }
464
+ .select { |title, matches| !title.to_s.empty? && matches.length > 1 }
465
+ duplicate_titles.each do |title, matches|
466
+ paths = matches.first(5).map { |item| item.path || item.url }.join(', ')
467
+ Jekyll.logger.warn('jekyll-vitepress-theme', "Duplicate SEO title '#{title}': #{paths}")
468
+ end
469
+
470
+ duplicate_descriptions = enabled_items.group_by { |item| item.data.dig('_seo', 'description') }
471
+ .select { |description, matches| !description.to_s.empty? && matches.length > 1 }
472
+ duplicate_descriptions.each do |description, matches|
473
+ paths = matches.first(5).map { |item| item.path || item.url }.join(', ')
474
+ Jekyll.logger.warn('jekyll-vitepress-theme', "Duplicate SEO description '#{description}': #{paths}")
475
+ end
476
+ end
477
+
478
+ def warn_for_items(label, items)
479
+ return if items.empty?
480
+
481
+ paths = items.first(5).map { |item| item.path || item.url }.join(', ')
482
+ suffix = items.length > 5 ? " (and #{items.length - 5} more)" : ''
483
+ Jekyll.logger.warn('jekyll-vitepress-theme', "#{label}: #{paths}#{suffix}")
484
+ end
485
+
486
+ def locale_for(item)
487
+ value = item.data['locale'] || item.data['lang'] || item.site.config['locale'] || item.site.config['lang'] || 'en-US'
488
+ clean_text(value) || 'en-US'
489
+ end
490
+
491
+ def site_title(site)
492
+ branding = site.config.dig('jekyll_vitepress', 'branding')
493
+ clean_text(site.config['title'] || (branding['site_title'] if branding.is_a?(Hash)))
494
+ end
495
+
496
+ def absolute_url(site, value)
497
+ return nil if value.nil? || value == false
498
+
499
+ string = value.to_s.strip
500
+ return nil if string.empty?
501
+ return string if absolute?(string)
502
+
503
+ base = site.config['url'].to_s.strip.sub(%r{/+\z}, '')
504
+ return nil if base.empty?
505
+
506
+ baseurl = site.config['baseurl'].to_s.strip
507
+ baseurl = '' if baseurl == '/'
508
+ baseurl = "/#{baseurl}" unless baseurl.empty? || baseurl.start_with?('/')
509
+ baseurl = baseurl.sub(%r{/+\z}, '')
510
+ path = "/#{string}" unless string.start_with?('/')
511
+ path ||= string
512
+ "#{base}#{baseurl}#{path}".sub(%r{/index\.html\z}, '/').sub(%r{/+\z}, '/')
513
+ end
514
+
515
+ def canonical_url(site, value)
516
+ url = absolute_url(site, value)
517
+ return nil unless url
518
+
519
+ uri = URI.parse(url)
520
+ uri.fragment = nil
521
+ uri.to_s
522
+ rescue URI::InvalidURIError
523
+ nil
524
+ end
525
+
526
+ def sitemap_enabled?(config)
527
+ config.fetch('index', true) != false && config.fetch('sitemap', true) != false
528
+ end
529
+
530
+ def absolute?(value)
531
+ uri = URI.parse(value.to_s)
532
+ uri.is_a?(URI::HTTP) && uri.host
533
+ rescue URI::InvalidURIError
534
+ false
535
+ end
536
+
537
+ def clean_text(value)
538
+ return nil if value.nil? || value == false
539
+
540
+ text = value.to_s.gsub(/\[([^\]]+)\]\([^)]+\)/, '\\1')
541
+ .gsub(/<[^>]*>/, ' ')
542
+ .gsub(/[`*~]/, '')
543
+ .gsub(/\s+/, ' ')
544
+ .strip
545
+ text = CGI.unescapeHTML(text)
546
+ text.empty? ? nil : text
547
+ end
548
+
549
+ def positive_integer(value)
550
+ integer = Integer(value, exception: false)
551
+ integer if integer&.positive?
552
+ end
553
+
554
+ def twitter_handle(value)
555
+ handle = clean_text(value)
556
+ handle ? "@#{handle.delete_prefix('@')}" : nil
557
+ end
558
+
559
+ def hash_value(value)
560
+ value.is_a?(Hash) ? value : {}
561
+ end
562
+
563
+ def posts_document?(item)
564
+ item.respond_to?(:collection) && item.collection&.label == 'posts'
565
+ end
566
+
567
+ def person_reference(author)
568
+ value = { '@type' => 'Person', 'name' => author['name'] }
569
+ value['url'] = author['url'] if author['url']
570
+ value
571
+ end
572
+
573
+ def xml_time(value)
574
+ return nil if value.nil? || value == false
575
+ return value if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
576
+
577
+ time = value.respond_to?(:to_time) ? value.to_time : Time.parse(value.to_s)
578
+ time.xmlschema
579
+ rescue ArgumentError, TypeError
580
+ nil
581
+ end
582
+
583
+ def safe_json(value)
584
+ JSON.generate(value).gsub('<', '\\u003c').gsub('>', '\\u003e').gsub('&', '\\u0026')
585
+ end
586
+
587
+ def xml_escape(value)
588
+ CGI.escapeHTML(value.to_s)
589
+ end
590
+ end
591
+ # rubocop:enable Metrics/AbcSize, Metrics/ModuleLength
592
+ end
593
+ end
@@ -1,5 +1,5 @@
1
1
  module Jekyll
2
2
  module VitePressTheme
3
- VERSION = "1.8.1".freeze
3
+ VERSION = "1.9.0".freeze
4
4
  end
5
5
  end
@@ -1,3 +1,5 @@
1
1
  require 'jekyll'
2
2
  require_relative 'jekyll/vitepress_theme/version'
3
+ require_relative 'jekyll/vitepress_theme/seo'
4
+ require_relative 'jekyll/vitepress_theme/llms'
3
5
  require_relative 'jekyll/vitepress_theme/hooks'
metadata CHANGED
@@ -1,15 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-vitepress-theme
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.1
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carmine Paolino
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-13 00:00:00.000000000 Z
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: cgi
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0.3'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '1.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '0.3'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
13
33
  - !ruby/object:Gem::Dependency
14
34
  name: jekyll
15
35
  requirement: !ruby/object:Gem::Requirement
@@ -50,8 +70,8 @@ dependencies:
50
70
  - - "<"
51
71
  - !ruby/object:Gem::Version
52
72
  version: '5.0'
53
- description: Jekyll VitePress Theme brings the VitePress default theme look and behavior
54
- to Jekyll sites.
73
+ description: Jekyll VitePress Theme brings VitePress-style documentation UX, search,
74
+ structured metadata, and Jekyll-native extensions to Ruby static sites.
55
75
  email:
56
76
  - carmine@paolino.me
57
77
  executables: []
@@ -78,6 +98,7 @@ files:
78
98
  - _includes/nav_social_links.html
79
99
  - _includes/rubygems_downloads_button.html
80
100
  - _includes/search.html
101
+ - _includes/seo.html
81
102
  - _includes/sidebar.html
82
103
  - _includes/sidebar_items.html
83
104
  - _includes/version_link.html
@@ -156,6 +177,8 @@ files:
156
177
  - assets/vendor/turbo.js
157
178
  - lib/jekyll-vitepress-theme.rb
158
179
  - lib/jekyll/vitepress_theme/hooks.rb
180
+ - lib/jekyll/vitepress_theme/llms.rb
181
+ - lib/jekyll/vitepress_theme/seo.rb
159
182
  - lib/jekyll/vitepress_theme/version.rb
160
183
  homepage: https://jekyll-vitepress.dev
161
184
  licenses:
@@ -186,5 +209,5 @@ requirements: []
186
209
  rubygems_version: 3.5.22
187
210
  signing_key:
188
211
  specification_version: 4
189
- summary: A VitePress-like docs theme for Jekyll.
212
+ summary: A polished, SEO-ready VitePress-style documentation theme for Jekyll.
190
213
  test_files: []