jekyll-vitepress-theme 1.8.0 → 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: 7ca0f243e32e2dd051ee2c8bd0c073ec5ab1d2469d05e606531df740508a036f
4
- data.tar.gz: caac258aed1a827394fdf8f6f239d81a6978b297567d5558c8433ead4504ded9
3
+ metadata.gz: d3f4205e18a7cb5f642be4e0a827a024f488030143e11a11816123ba53792757
4
+ data.tar.gz: 53e6432abfd64ab03aef151445484a1c5852f13ed55880f1481d9a541ed1bf83
5
5
  SHA512:
6
- metadata.gz: 04b1d2d66942c3c0e8277955be4936e268a8f7cbc0d3a818e6e7685ca7cc6b89f35944b252444ce708f6334a48e5d2a8cf62f55a3bcb707669c5fe33616db39d
7
- data.tar.gz: 3d72ee616cf769d5c242de1bd5a0de440187206ecece4574fab664a5d98fc12efb0597b8dc1e2995c356602ba66676e351ffb3112b1fbe3c04f532ceec431bf6
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,6 +79,16 @@ bundle install
78
79
  bundle exec jekyll serve --livereload
79
80
  ```
80
81
 
82
+ ### SEO is built in
83
+
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`.
91
+
81
92
  ## Screenshots
82
93
 
83
94
  | Home | Docs |
@@ -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 %}
data/_includes/nav.html CHANGED
@@ -103,8 +103,20 @@
103
103
  {% if versions and versions.items and versions.items.size > 0 %}
104
104
  <div class="VPVersionSelector" id="vp-version-selector">
105
105
  <button type="button" class="button" id="vp-version-button" aria-haspopup="true" aria-expanded="false" aria-controls="vp-version-menu">
106
+ {%- comment -%}
107
+ `current` names an item, by id or title, the way
108
+ version_link.html matches it. Show that item's title so a
109
+ manifest holding ids does not label the button "next".
110
+ {%- endcomment -%}
111
+ {%- assign version_current_label = versions.current -%}
112
+ {%- for version_item in versions.items -%}
113
+ {%- assign version_item_key = version_item.id | default: version_item.title -%}
114
+ {%- if version_item_key == versions.current or version_item.title == versions.current -%}
115
+ {%- assign version_current_label = version_item.title -%}
116
+ {%- endif -%}
117
+ {%- endfor -%}
106
118
  <span class="text">
107
- <span>{{ versions.current | default: 'Version' }}</span>
119
+ <span>{{ version_current_label | default: 'Version' }}</span>
108
120
  <span class="vpi-chevron-down text-icon"></span>
109
121
  </span>
110
122
  </button>
@@ -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">
@@ -329,6 +329,11 @@ body.vp-nav-screen-open .VPNav {
329
329
  width: 100%;
330
330
  height: 100%;
331
331
  fill: currentcolor;
332
+
333
+ /* These icons draw a 16-unit viewBox into a 13-14px box. Their paths touch
334
+ the viewBox edge, so at a fractional scale the SVG viewport clips the
335
+ outermost column and the shape looks flat on one side. */
336
+ overflow: visible;
332
337
  }
333
338
 
334
339
  .VPNavBarStarText,
@@ -1120,6 +1125,11 @@ body.vp-search-open {
1120
1125
  width: 100%;
1121
1126
  height: 100%;
1122
1127
  fill: currentcolor;
1128
+
1129
+ /* These icons draw a 16-unit viewBox into a 13-14px box. Their paths touch
1130
+ the viewBox edge, so at a fractional scale the SVG viewport clips the
1131
+ outermost column and the shape looks flat on one side. */
1132
+ overflow: visible;
1123
1133
  }
1124
1134
 
1125
1135
  .VPMetricButtonCount {
@@ -17,9 +17,26 @@ module Jekyll
17
17
  Jekyll.logger.warn('jekyll-vitepress-theme', "Sidebar hierarchy generation failed: #{e.message}")
18
18
  end
19
19
 
20
+ # A site with collection documents and no _data/sidebar.yml builds a page
21
+ # with an empty sidebar and no other complaint, which is a slow thing to
22
+ # notice. Overriding `data_dir` without carrying the file over is the
23
+ # usual cause.
24
+ def warn_missing_sidebar_data(site)
25
+ return if site.collections.values.all? { |collection| collection.docs.empty? }
26
+
27
+ Jekyll.logger.warn(
28
+ 'jekyll-vitepress-theme',
29
+ 'No _data/sidebar.yml found, so the sidebar will be empty. ' \
30
+ 'If you set a custom data_dir, copy the theme data files into it.'
31
+ )
32
+ end
33
+
20
34
  def generate(site)
21
35
  sidebar_groups = site.data['sidebar']
22
- return nil unless sidebar_groups.respond_to?(:each)
36
+ unless sidebar_groups.respond_to?(:each)
37
+ warn_missing_sidebar_data(site)
38
+ return nil
39
+ end
23
40
 
24
41
  groups = sidebar_groups.filter_map { |group| generated_group(site, group) }
25
42
 
@@ -384,7 +401,18 @@ module Jekyll
384
401
  module_function
385
402
 
386
403
  AUTO_VALUE = 'auto'.freeze
387
-
404
+ VERSION_PATTERN = /VERSION\s*=\s*['"]([^'"]+)['"]/
405
+
406
+ # Resolves `current: auto` to the version of the project being documented.
407
+ # Configure where to read it from:
408
+ #
409
+ # jekyll_vitepress:
410
+ # version:
411
+ # value: "2.0.0" # literal, wins over file
412
+ # file: lib/my_gem/version.rb # or a file holding VERSION = '...'
413
+ #
414
+ # With neither, it falls back to this theme's own version, which is only
415
+ # ever right for the theme's own documentation.
388
416
  def apply(site)
389
417
  versions = site.data['versions']
390
418
  return unless versions.is_a?(Hash)
@@ -392,11 +420,35 @@ module Jekyll
392
420
  current_value = versions['current'] || versions[:current]
393
421
  return unless auto_value?(current_value)
394
422
 
395
- versions['current'] = "v#{Jekyll::VitePressTheme::VERSION}"
423
+ versions['current'] = "v#{site_version(site) || Jekyll::VitePressTheme::VERSION}"
396
424
  rescue StandardError => e
397
425
  Jekyll.logger.warn('jekyll-vitepress-theme', "Version label resolution failed: #{e.message}")
398
426
  end
399
427
 
428
+ def site_version(site)
429
+ config = site.config.dig('jekyll_vitepress', 'version')
430
+ return nil unless config.is_a?(Hash)
431
+
432
+ literal = config['value'].to_s.strip
433
+ return normalize(literal) unless literal.empty?
434
+
435
+ version_from_file(site, config['file'])
436
+ end
437
+
438
+ def version_from_file(site, path)
439
+ return nil if path.to_s.strip.empty?
440
+
441
+ full_path = File.expand_path(path.to_s, site.source)
442
+ return nil unless File.file?(full_path)
443
+
444
+ match = File.read(full_path)[VERSION_PATTERN]
445
+ match && normalize(Regexp.last_match(1))
446
+ end
447
+
448
+ def normalize(value)
449
+ value.to_s.strip.sub(/\Av/, '')
450
+ end
451
+
400
452
  def auto_value?(value)
401
453
  value.to_s.strip.casecmp(AUTO_VALUE).zero?
402
454
  end
@@ -468,18 +520,28 @@ Jekyll::Hooks.register :site, :post_read do |site|
468
520
  Jekyll::VitePressTheme::RougeStyles.apply(site)
469
521
  Jekyll::VitePressTheme::Sidebar.apply(site)
470
522
  Jekyll::VitePressTheme::SearchIndex.apply(site)
523
+ Jekyll::VitePressTheme::SEO.apply(site)
524
+ Jekyll::VitePressTheme::LLMS.apply(site)
471
525
  end
472
526
 
473
527
  capture_page_state = lambda do |item, payload|
474
528
  if Jekyll::VitePressTheme::CopyPage.enabled?(item)
475
529
  raw = Jekyll::VitePressTheme::CopyPage.resolved_markdown(item, payload)
476
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)
477
532
  end
478
533
 
479
- 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)
480
539
 
481
- updated_at = Jekyll::VitePressTheme::LastUpdated.source_file_time(item.site, item.path)
482
- 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
483
545
  end
484
546
 
485
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.0".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.0
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-06-22 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: []