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 +4 -4
- data/README.md +9 -6
- data/_includes/copy_page_button.html +1 -1
- data/_includes/head.html +1 -31
- data/_includes/seo.html +78 -0
- data/_layouts/default.html +7 -5
- data/lib/jekyll/vitepress_theme/hooks.rb +13 -3
- data/lib/jekyll/vitepress_theme/llms.rb +236 -0
- data/lib/jekyll/vitepress_theme/seo.rb +593 -0
- data/lib/jekyll/vitepress_theme/version.rb +1 -1
- data/lib/jekyll-vitepress-theme.rb +2 -0
- metadata +28 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3f4205e18a7cb5f642be4e0a827a024f488030143e11a11816123ba53792757
|
|
4
|
+
data.tar.gz: 53e6432abfd64ab03aef151445484a1c5852f13ed55880f1481d9a541ed1bf83
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
###
|
|
82
|
+
### SEO is built in
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
{%
|
|
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/seo.html
ADDED
|
@@ -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 %}
|
data/_layouts/default.html
CHANGED
|
@@ -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
|
-
{%
|
|
37
|
-
{%
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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
|
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.
|
|
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-
|
|
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
|
|
54
|
-
|
|
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-
|
|
212
|
+
summary: A polished, SEO-ready VitePress-style documentation theme for Jekyll.
|
|
190
213
|
test_files: []
|