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 +4 -4
- data/README.md +11 -0
- data/_includes/copy_page_button.html +1 -1
- data/_includes/head.html +1 -31
- data/_includes/nav.html +13 -1
- data/_includes/seo.html +78 -0
- data/_layouts/default.html +7 -5
- data/assets/css/vitepress-overrides.css +10 -0
- data/lib/jekyll/vitepress_theme/hooks.rb +68 -6
- 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,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
|
-
{%
|
|
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>{{
|
|
119
|
+
<span>{{ version_current_label | default: 'Version' }}</span>
|
|
108
120
|
<span class="vpi-chevron-down text-icon"></span>
|
|
109
121
|
</span>
|
|
110
122
|
</button>
|
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">
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
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-
|
|
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: []
|