jekyll-theme-zer0 1.26.0 → 1.27.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/CHANGELOG.md +172 -1
- data/README.md +10 -27
- data/_data/authors.yml +4 -3
- data/_data/backlog.yml +28 -0
- data/_data/features.yml +65 -18
- data/_data/i18n/languages.yml +36 -0
- data/_data/theme-manifest.yml +0 -2
- data/_data/ui-text.yml +36 -246
- data/_includes/README.md +4 -0
- data/_includes/components/author-avatar-url.html +4 -2
- data/_includes/components/env-switcher.html +3 -1
- data/_includes/components/language-toggle.html +81 -0
- data/_includes/components/search-modal.html +2 -2
- data/_includes/components/shortcuts-modal.html +1 -1
- data/_includes/components/translation-notice.html +27 -0
- data/_includes/content/intro.html +16 -15
- data/_includes/core/footer.html +9 -4
- data/_includes/core/head.html +9 -0
- data/_includes/core/header.html +9 -4
- data/_includes/core/hreflang.html +33 -0
- data/_includes/core/i18n.html +36 -0
- data/_includes/navigation/breadcrumbs.html +1 -1
- data/_includes/navigation/navbar.html +5 -4
- data/_includes/navigation/sidebar-right.html +3 -2
- data/_includes/navigation/unified-drawer.html +1 -1
- data/_layouts/article.html +7 -1
- data/_layouts/default.html +5 -3
- data/_layouts/news.html +4 -2
- data/_layouts/root.html +14 -8
- data/_layouts/section.html +4 -2
- data/_sass/core/_obsidian.scss +9 -1
- data/_sass/layouts/_navbar-extras.scss +6 -1
- data/assets/js/obsidian-graph.js +5 -1
- data/scripts/README.md +20 -26
- data/scripts/bin/audit-consumer +1 -1
- data/scripts/bin/manifest +0 -1
- data/scripts/bin/sync-plugins +0 -1
- data/scripts/dev/rasterize-svg.js +65 -0
- data/scripts/features/generate-preview-images +49 -1390
- data/scripts/features/install-preview-generator +55 -33
- data/scripts/install/README.md +9 -20
- data/scripts/install/ai/prompts/wizard.system.md +8 -17
- data/scripts/lib/README.md +1 -5
- data/scripts/lib/install/deploy/README.md +3 -9
- data/scripts/lib/preview_generator.py +2261 -1341
- data/scripts/translate.rb +1114 -0
- metadata +9 -3
- data/_plugins/preview_image_generator.rb +0 -351
data/_includes/core/head.html
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- seo.html: SEO meta tags and Open Graph data
|
|
20
20
|
- google-analytics.html: Google Analytics tracking
|
|
21
21
|
- google-tag-manager-head.html: GTM head section
|
|
22
|
+
- posthog.html: PostHog product analytics (opt-in via site.posthog.enabled)
|
|
22
23
|
|
|
23
24
|
Performance Notes:
|
|
24
25
|
- Scripts loaded in head for immediate availability
|
|
@@ -93,6 +94,10 @@ window.MathJax = {
|
|
|
93
94
|
Outputs custom og:image with assets_prefix normalisation and non-Google site verification. -->
|
|
94
95
|
{% include content/seo.html %}
|
|
95
96
|
|
|
97
|
+
<!-- hreflang alternates for machine-generated translations (no-op when the
|
|
98
|
+
page has none) — see _data/i18n/manifest.yml + scripts/translate.rb -->
|
|
99
|
+
{% include core/hreflang.html %}
|
|
100
|
+
|
|
96
101
|
<!-- ========================== -->
|
|
97
102
|
<!-- STRUCTURED DATA MARKUP -->
|
|
98
103
|
<!-- ========================== -->
|
|
@@ -127,6 +132,10 @@ window.MathJax = {
|
|
|
127
132
|
<!-- Only load in production AND when an ID is configured; the include also guards against dev hostnames -->
|
|
128
133
|
{% if jekyll.environment == "production" and site.google_analytics %}{% include analytics/google-analytics.html %}{% endif %}
|
|
129
134
|
|
|
135
|
+
<!-- PostHog analytics - Configured via the `posthog:` block in _config.yml -->
|
|
136
|
+
<!-- Only load in production AND when explicitly enabled; the include itself also respects Do Not Track -->
|
|
137
|
+
{% if jekyll.environment == "production" and site.posthog.enabled %}{% include analytics/posthog.html %}{% endif %}
|
|
138
|
+
|
|
130
139
|
|
|
131
140
|
<!-- ================================ -->
|
|
132
141
|
<!-- CSS FRAMEWORKS AND LIBRARIES -->
|
data/_includes/core/header.html
CHANGED
|
@@ -41,9 +41,11 @@
|
|
|
41
41
|
<!-- ================================ -->
|
|
42
42
|
<!-- SKIP TO CONTENT LINK -->
|
|
43
43
|
<!-- ================================ -->
|
|
44
|
+
<!-- UI strings resolve per page language (core/i18n.html sets `ui`) -->
|
|
45
|
+
{%- include core/i18n.html -%}
|
|
44
46
|
<!-- Accessibility: allows keyboard users to skip navigation -->
|
|
45
47
|
<a href="#main-content" class="visually-hidden-focusable position-absolute top-0 start-0 z-3 m-3 btn btn-primary">
|
|
46
|
-
Skip to main content
|
|
48
|
+
{{ ui.skip_main_content | default: "Skip to main content" }}
|
|
47
49
|
</a>
|
|
48
50
|
|
|
49
51
|
<header id="navbar" class="container-fluid text-center fixed-top" role="banner">
|
|
@@ -149,17 +151,20 @@
|
|
|
149
151
|
<!-- UTILITY CONTROLS -->
|
|
150
152
|
<!-- ========================== -->
|
|
151
153
|
<div class="navbar-utility-controls d-flex align-items-center flex-shrink-0" role="group" aria-label="Utility controls">
|
|
154
|
+
<!-- Language Toggle (renders only when translation.languages is set) -->
|
|
155
|
+
{% include components/language-toggle.html %}
|
|
156
|
+
|
|
152
157
|
<!-- Search Modal Toggle -->
|
|
153
158
|
<button
|
|
154
159
|
class="btn nav-search-button"
|
|
155
160
|
type="button"
|
|
156
161
|
data-search-toggle
|
|
157
|
-
aria-label="Open search"
|
|
162
|
+
aria-label="{{ ui.shortcuts_search | default: 'Open search' }}"
|
|
158
163
|
aria-haspopup="dialog"
|
|
159
|
-
title="Search
|
|
164
|
+
title="{{ ui.search_button_label | default: 'Search' }}"
|
|
160
165
|
>
|
|
161
166
|
<i class="{{site.default_icon}} bi-search" aria-hidden="true"></i>
|
|
162
|
-
<span class="nav-link-text d-none d-xl-inline ms-1">Search</span>
|
|
167
|
+
<span class="nav-link-text d-none d-xl-inline ms-1">{{ ui.search_button_label | default: "Search" }}</span>
|
|
163
168
|
</button>
|
|
164
169
|
|
|
165
170
|
<!-- Settings Offcanvas Toggle -->
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{% comment %} Feature: ZER0-078 {% endcomment %}
|
|
2
|
+
{%- comment -%}
|
|
3
|
+
Component: hreflang
|
|
4
|
+
Path: _includes/core/hreflang.html
|
|
5
|
+
Purpose: Emit <link rel="alternate" hreflang> tags for pages that have
|
|
6
|
+
machine-generated translations, so search engines associate
|
|
7
|
+
the English original with its /fr/ (etc.) alternates.
|
|
8
|
+
Params: none — reads page.lang / page.translation_source_url and the
|
|
9
|
+
generated _data/i18n/manifest.yml (scripts/translate.rb).
|
|
10
|
+
Depends on: site.translation.* config, site.data.i18n.manifest
|
|
11
|
+
Notes: Renders nothing when translation is disabled, the manifest is
|
|
12
|
+
absent, or the current page has no translations. x-default
|
|
13
|
+
always points at the English original.
|
|
14
|
+
{%- endcomment -%}
|
|
15
|
+
{%- if site.translation.enabled -%}
|
|
16
|
+
{%- assign _hl_source = site.translation.source_lang | default: 'en' -%}
|
|
17
|
+
{%- if page.lang and page.lang != _hl_source -%}
|
|
18
|
+
{%- assign _hl_src_url = page.translation_source_url -%}
|
|
19
|
+
{%- else -%}
|
|
20
|
+
{%- assign _hl_src_url = page.url -%}
|
|
21
|
+
{%- endif -%}
|
|
22
|
+
{%- assign _hl_entry = site.data.i18n.manifest.pages[_hl_src_url] -%}
|
|
23
|
+
{%- if _hl_src_url and _hl_entry %}
|
|
24
|
+
<link rel="alternate" hreflang="{{ _hl_source }}" href="{{ _hl_src_url | absolute_url }}">
|
|
25
|
+
{%- for _hl_lang in site.translation.languages -%}
|
|
26
|
+
{%- assign _hl_t = _hl_entry[_hl_lang] -%}
|
|
27
|
+
{%- if _hl_t %}
|
|
28
|
+
<link rel="alternate" hreflang="{{ _hl_lang }}" href="{{ _hl_t.url | absolute_url }}">
|
|
29
|
+
{%- endif -%}
|
|
30
|
+
{%- endfor %}
|
|
31
|
+
<link rel="alternate" hreflang="x-default" href="{{ _hl_src_url | absolute_url }}">
|
|
32
|
+
{%- endif -%}
|
|
33
|
+
{%- endif -%}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{% comment %} Feature: ZER0-078 {% endcomment %}
|
|
2
|
+
{%- comment -%}
|
|
3
|
+
Component: i18n
|
|
4
|
+
Path: _includes/core/i18n.html
|
|
5
|
+
Purpose: Resolve the active UI-string set into the `ui` variable,
|
|
6
|
+
per-page-language aware. English strings come from
|
|
7
|
+
_data/ui-text.yml (the human-maintained source of truth);
|
|
8
|
+
alternate languages come from _data/i18n/<lang>.yml, which is
|
|
9
|
+
GENERATED by scripts/translate.rb — never hand-edited.
|
|
10
|
+
Params: lang (string, optional) — overrides page.lang. REQUIRED when
|
|
11
|
+
the caller is rendered via {% include_cached %} (pass
|
|
12
|
+
lang=page.lang there) so each language gets its own cache
|
|
13
|
+
entry instead of sharing one page-invariant render.
|
|
14
|
+
Depends on: _data/ui-text.yml, _data/i18n/<lang>.yml (generated),
|
|
15
|
+
site.translation.* config
|
|
16
|
+
Notes: Liquid assigns made inside an include are visible to the
|
|
17
|
+
caller, so after {% include core/i18n.html %} simply use
|
|
18
|
+
{{ ui.some_key | default: "English fallback" }}. Missing
|
|
19
|
+
languages or keys degrade to English/literal defaults.
|
|
20
|
+
{%- endcomment -%}
|
|
21
|
+
{%- comment -%}
|
|
22
|
+
Resolve using the FULL configured language code (e.g. "fr", "pt-BR"), not a
|
|
23
|
+
2-char slice: scripts/translate.rb writes _data/i18n/<code>.yml and sets
|
|
24
|
+
page.lang to that exact code, so a regional code like "pt-BR" must look up
|
|
25
|
+
site.data.i18n["pt-BR"] — slicing to "pt" would silently miss the file and
|
|
26
|
+
fall back to English.
|
|
27
|
+
{%- endcomment -%}
|
|
28
|
+
{%- assign _i18n_lang = include.lang | default: page.lang | default: site.translation.source_lang | default: site.locale | default: 'en' -%}
|
|
29
|
+
{%- assign _i18n_source = site.translation.source_lang | default: 'en' -%}
|
|
30
|
+
{%- assign ui = site.data.ui-text[site.locale] | default: site.data.ui-text.en -%}
|
|
31
|
+
{%- unless _i18n_lang == _i18n_source -%}
|
|
32
|
+
{%- assign _i18n_generated = site.data.i18n[_i18n_lang] -%}
|
|
33
|
+
{%- if _i18n_generated -%}
|
|
34
|
+
{%- assign ui = _i18n_generated -%}
|
|
35
|
+
{%- endif -%}
|
|
36
|
+
{%- endunless -%}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
===================================================================
|
|
15
15
|
{%- endcomment -%}
|
|
16
16
|
{% if page.url != "/" and site.breadcrumbs %}
|
|
17
|
-
{%-
|
|
17
|
+
{%- include core/i18n.html -%}
|
|
18
18
|
{%- assign label_root = ui.breadcrumbs_root | default: "Home" -%}
|
|
19
19
|
{%- assign label_aria = ui.breadcrumbs_aria | default: "Breadcrumb" -%}
|
|
20
20
|
{%- assign crumbs = page.url | remove:'/index.html' | split: '/' -%}
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
-->
|
|
9
9
|
|
|
10
10
|
<!-- Navigation Links - Responsive Offcanvas (inline at lg+, offcanvas below) -->
|
|
11
|
+
{%- include core/i18n.html -%}
|
|
11
12
|
<div class="offcanvas-lg offcanvas-end" tabindex="-1" id="bdNavbar" aria-labelledby="mainNavOffcanvasLabel">
|
|
12
13
|
|
|
13
14
|
<!-- Main Navigation Header - Offcanvas -->
|
|
14
15
|
<div class="offcanvas-header border-bottom">
|
|
15
|
-
<h2 class="offcanvas-title h5 mb-0" id="mainNavOffcanvasLabel">Main Navigation</h2>
|
|
16
|
+
<h2 class="offcanvas-title h5 mb-0" id="mainNavOffcanvasLabel">{{ ui.main_nav_label | default: "Main Navigation" }}</h2>
|
|
16
17
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close navigation menu" data-bs-target="#bdNavbar"></button>
|
|
17
18
|
</div>
|
|
18
19
|
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
<li class="nav-item d-lg-none">
|
|
25
26
|
<a class="nav-link" href="{{ '/' | relative_url }}" {% if page.url == '/' %}aria-current="page"{% endif %}>
|
|
26
27
|
<i class="{{site.default_icon}} bi-house" aria-hidden="true"></i>
|
|
27
|
-
Home
|
|
28
|
+
{{ ui.home_label | default: "Home" }}
|
|
28
29
|
</a>
|
|
29
30
|
</li>
|
|
30
31
|
{%- comment -%}
|
|
@@ -143,7 +144,7 @@
|
|
|
143
144
|
aria-haspopup="dialog"
|
|
144
145
|
>
|
|
145
146
|
<i class="{{site.default_icon}} bi-search" aria-hidden="true"></i>
|
|
146
|
-
Search
|
|
147
|
+
{{ ui.search_button_label | default: "Search" }}
|
|
147
148
|
</button>
|
|
148
149
|
</li>
|
|
149
150
|
<li class="nav-item d-lg-none">
|
|
@@ -156,7 +157,7 @@
|
|
|
156
157
|
aria-haspopup="dialog"
|
|
157
158
|
>
|
|
158
159
|
<i class="{{site.default_icon}} bi-gear" aria-hidden="true"></i>
|
|
159
|
-
Settings
|
|
160
|
+
{{ ui.settings_button_label | default: "Settings" }}
|
|
160
161
|
</button>
|
|
161
162
|
</li>
|
|
162
163
|
</ul>
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
includes: nav_list.html, content/toc.html
|
|
6
6
|
-->
|
|
7
7
|
|
|
8
|
+
{%- include core/i18n.html -%}
|
|
8
9
|
<!-- FOUC guard: apply hidden TOC preference before paint -->
|
|
9
10
|
<script>
|
|
10
11
|
try {
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
<!-- Right sidebar container -->
|
|
18
19
|
<div class="offcanvas-lg offcanvas-end" tabindex="-1" id="tocContents" aria-labelledby="tocLabel">
|
|
19
20
|
<div class="offcanvas-header">
|
|
20
|
-
<h2 class="offcanvas-title h5 mb-0" id="tocLabel">Table of Contents</h2>
|
|
21
|
+
<h2 class="offcanvas-title h5 mb-0" id="tocLabel">{{ ui.toc_title | default: "Table of Contents" }}</h2>
|
|
21
22
|
<div class="d-flex align-items-center gap-1">
|
|
22
23
|
<button type="button"
|
|
23
24
|
class="btn btn-sm btn-link text-body-secondary bd-toc-visibility-toggle d-lg-none px-2"
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
<div class="mb-3 d-flex align-items-center justify-content-between gap-2">
|
|
38
39
|
<strong class="d-flex align-items-center mb-0">
|
|
39
40
|
<i class="bi bi-file-text me-2" aria-hidden="true"></i>
|
|
40
|
-
On this page
|
|
41
|
+
{{ ui.toc_label | default: "On this page" }}
|
|
41
42
|
</strong>
|
|
42
43
|
<button type="button"
|
|
43
44
|
class="btn btn-sm btn-link text-body-secondary bd-toc-visibility-toggle d-none d-lg-inline-flex flex-shrink-0 px-1"
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
===================================================================
|
|
24
24
|
{%- endcomment -%}
|
|
25
25
|
{% if site.navigation.unified_mobile_drawer %}
|
|
26
|
-
{%-
|
|
26
|
+
{%- include core/i18n.html -%}
|
|
27
27
|
<div class="offcanvas offcanvas-start zer0-unified-drawer d-lg-none"
|
|
28
28
|
tabindex="-1"
|
|
29
29
|
id="zer0UnifiedDrawer"
|
data/_layouts/article.html
CHANGED
|
@@ -15,6 +15,9 @@ layout: default
|
|
|
15
15
|
Front Matter Variables:
|
|
16
16
|
- post_type: Article type (standard, featured, breaking, opinion, review, tutorial, listicle, interview)
|
|
17
17
|
- sidebar: Override sidebar visibility (true/false)
|
|
18
|
+
- show_hero: Opt into the top-of-article hero image (page.preview) for any
|
|
19
|
+
post_type without changing layout/sidebar/badges (true; featured and
|
|
20
|
+
breaking posts show the hero automatically)
|
|
18
21
|
|
|
19
22
|
Post Type Variations:
|
|
20
23
|
- standard (default): Normal article with sidebar
|
|
@@ -221,12 +224,15 @@ layout: default
|
|
|
221
224
|
preview image is set. Breaking stories were previously skipped because the
|
|
222
225
|
condition checked only post_type == "featured"; since breaking is mapped
|
|
223
226
|
before featured (see top of file), breaking+featured posts showed no hero.
|
|
227
|
+
Any other post type can opt in with `show_hero: true` in front matter —
|
|
228
|
+
this flips only the hero, leaving sidebar, typography, and badges at their
|
|
229
|
+
post_type defaults (issue #303).
|
|
224
230
|
Nested ifs keep the "only when page.preview exists" guard intact (Liquid has
|
|
225
231
|
no parentheses, so a flat `a or b and c` would misgroup).
|
|
226
232
|
{% endcomment %}
|
|
227
233
|
{% assign show_hero = false %}
|
|
228
234
|
{% if page.preview %}
|
|
229
|
-
{% if post_type == "featured" or post_type == "breaking" %}
|
|
235
|
+
{% if post_type == "featured" or post_type == "breaking" or page.show_hero %}
|
|
230
236
|
{% assign show_hero = true %}
|
|
231
237
|
{% endif %}
|
|
232
238
|
{% endif %}
|
data/_layouts/default.html
CHANGED
|
@@ -86,8 +86,10 @@ layout: root
|
|
|
86
86
|
<!-- ================================ -->
|
|
87
87
|
<!-- MAIN CONTENT AREA -->
|
|
88
88
|
<!-- ================================ -->
|
|
89
|
-
<!-- Primary content section with scroll spy for table of contents navigation
|
|
90
|
-
|
|
89
|
+
<!-- Primary content section with scroll spy for table of contents navigation.
|
|
90
|
+
Not a <main>: root.html already provides the single <main id="main-content">
|
|
91
|
+
landmark; a nested <main> here would duplicate it (issue #299). -->
|
|
92
|
+
<div class="bd-main order-1" data-bs-spy="scroll" data-bs-target="#TableOfContents" data-bs-offset="100" data-bs-smooth-scroll="true">
|
|
91
93
|
|
|
92
94
|
{%- comment -%}
|
|
93
95
|
Page introduction: title, breadcrumbs, metadata.
|
|
@@ -129,5 +131,5 @@ layout: root
|
|
|
129
131
|
{% include content/backlinks.html %}
|
|
130
132
|
{% endif %}
|
|
131
133
|
</div>
|
|
132
|
-
</
|
|
134
|
+
</div>
|
|
133
135
|
</div>
|
data/_layouts/news.html
CHANGED
|
@@ -78,7 +78,9 @@ source: "https://getbootstrap.com/docs/5.3/examples/blog/#"
|
|
|
78
78
|
</div>
|
|
79
79
|
</div>
|
|
80
80
|
|
|
81
|
-
<main
|
|
81
|
+
<!-- Not a <main>: root.html already provides the single <main id="main-content">
|
|
82
|
+
landmark; a nested <main> here would duplicate it (issue #299). -->
|
|
83
|
+
<div class="container">
|
|
82
84
|
|
|
83
85
|
{% if section_style == "magazine" %}
|
|
84
86
|
<!-- ================================ -->
|
|
@@ -578,7 +580,7 @@ source: "https://getbootstrap.com/docs/5.3/examples/blog/#"
|
|
|
578
580
|
</div>
|
|
579
581
|
</section>
|
|
580
582
|
|
|
581
|
-
</
|
|
583
|
+
</div>
|
|
582
584
|
|
|
583
585
|
<style>
|
|
584
586
|
.hover-lift {
|
data/_layouts/root.html
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
{%- else -%}
|
|
47
47
|
{%- assign _bs_theme = 'dark' -%}
|
|
48
48
|
{%- endif -%}
|
|
49
|
-
<html lang="{{ site.locale | slice: 0,2 | default: 'en' }}" class="no-js" data-bs-theme="{{ _bs_theme }}" data-color-mode-default="{{ _cm }}" data-theme-skin="{{ site.theme_skin | default: 'dark' }}" data-zer0-bg="{{ site.theme_background.enabled | default: true }}">
|
|
49
|
+
<html lang="{{ page.lang | default: site.locale | slice: 0, 2 | default: 'en' }}" class="no-js" data-bs-theme="{{ _bs_theme }}" data-color-mode-default="{{ _cm }}" data-theme-skin="{{ site.theme_skin | default: 'dark' }}" data-zer0-bg="{{ site.theme_background.enabled | default: true }}">
|
|
50
50
|
<head>
|
|
51
51
|
<!-- =============================================== -->
|
|
52
52
|
<!-- HEAD SECTION: Meta tags, styles, and SEO setup -->
|
|
@@ -82,11 +82,13 @@
|
|
|
82
82
|
Enable with `navigation.unified_mobile_drawer: true` in _config.yml. -->
|
|
83
83
|
{% include navigation/unified-drawer.html %}
|
|
84
84
|
|
|
85
|
-
<!-- Search modal (site-wide) —
|
|
86
|
-
|
|
85
|
+
<!-- Search modal (site-wide) — cached once per build PER LANGUAGE
|
|
86
|
+
(the lang param keys the include-cache entry; page.lang is nil
|
|
87
|
+
on English pages, so they all still share a single render). -->
|
|
88
|
+
{% include_cached components/search-modal.html lang=page.lang %}
|
|
87
89
|
|
|
88
|
-
<!-- Keyboard shortcuts help modal (triggered by `?`) —
|
|
89
|
-
{% include_cached components/shortcuts-modal.html %}
|
|
90
|
+
<!-- Keyboard shortcuts help modal (triggered by `?`) — cached per language. -->
|
|
91
|
+
{% include_cached components/shortcuts-modal.html lang=page.lang %}
|
|
90
92
|
|
|
91
93
|
<!-- Settings offcanvas (Appearance / Site / Developer tabs) -->
|
|
92
94
|
{% include components/info-section.html %}
|
|
@@ -98,10 +100,14 @@
|
|
|
98
100
|
<!-- MAIN CONTENT AREA -->
|
|
99
101
|
<!-- ======================== -->
|
|
100
102
|
<!-- Skip-link target: keep a single, consistent #main-content anchor site-wide.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
This is the ONE <main> landmark per page — child layouts (default/section/
|
|
104
|
+
news) must use non-landmark wrappers (<div>) inside it (issue #299) — so
|
|
105
|
+
assistive tech, search engines, and AI content extractors can isolate the
|
|
103
106
|
primary content from the surrounding header/nav/footer chrome. -->
|
|
104
107
|
<main id="main-content">
|
|
108
|
+
<!-- Machine-translation disclosure — renders only on generated
|
|
109
|
+
translations (page.machine_translated); no-op elsewhere. -->
|
|
110
|
+
{%- include components/translation-notice.html -%}
|
|
105
111
|
{{ content }}
|
|
106
112
|
</main>
|
|
107
113
|
|
|
@@ -116,7 +122,7 @@
|
|
|
116
122
|
fresh: jekyll-include-cache stores in memory only (Jekyll 3.x has no
|
|
117
123
|
Jekyll::Cache) and clears on every build's :pre_render hook — it never
|
|
118
124
|
persists to .jekyll-cache. -->
|
|
119
|
-
{%- include_cached core/footer.html -%}
|
|
125
|
+
{%- include_cached core/footer.html lang=page.lang -%}
|
|
120
126
|
{%- include core/footer-fabs.html -%}
|
|
121
127
|
|
|
122
128
|
<!-- Privacy-compliant cookie consent banner — page-invariant. -->
|
data/_layouts/section.html
CHANGED
|
@@ -156,7 +156,9 @@ layout: root
|
|
|
156
156
|
<!-- ================================ -->
|
|
157
157
|
<!-- MAIN CONTENT AREA -->
|
|
158
158
|
<!-- ================================ -->
|
|
159
|
-
<main
|
|
159
|
+
<!-- Not a <main>: root.html already provides the single <main id="main-content">
|
|
160
|
+
landmark; a nested <main> here would duplicate it (issue #299). -->
|
|
161
|
+
<div class="col-lg-9 col-xl-10 section-layout-main">
|
|
160
162
|
|
|
161
163
|
<!-- Section Header -->
|
|
162
164
|
<header class="section-header mb-4">
|
|
@@ -526,7 +528,7 @@ layout: root
|
|
|
526
528
|
</div>
|
|
527
529
|
</aside>
|
|
528
530
|
|
|
529
|
-
</
|
|
531
|
+
</div>
|
|
530
532
|
</div>
|
|
531
533
|
</div>
|
|
532
534
|
|
data/_sass/core/_obsidian.scss
CHANGED
|
@@ -208,11 +208,19 @@
|
|
|
208
208
|
// in _sass/theme/_backgrounds.scss (specificity 0,5,0) and forces
|
|
209
209
|
// `position: relative; z-index: 1` — dropping the FAB into page flow at the
|
|
210
210
|
// bottom. An id (1,0,0) wins, mirroring `#aiChatToggle`. See ai-chat.html.
|
|
211
|
+
//
|
|
212
|
+
// Slot 2 on the left-side FAB stack: sits above the page-feedback FAB
|
|
213
|
+
// (slot 1 = var(--zer0-space-fab-offset)) — same stacking formula as
|
|
214
|
+
// .bd-toc-fab above #backToTopBtn on the right side.
|
|
211
215
|
#obsidianLocalGraphFab.obsidian-local-graph-fab {
|
|
212
216
|
position: fixed;
|
|
213
217
|
left: var(--zer0-space-fab-offset, 1rem);
|
|
214
218
|
right: auto;
|
|
215
|
-
bottom:
|
|
219
|
+
bottom: calc(
|
|
220
|
+
var(--zer0-space-fab-offset, 1rem) +
|
|
221
|
+
var(--zer0-space-fab-size, 3.5rem) +
|
|
222
|
+
var(--zer0-space-fab-gap, 0.75rem)
|
|
223
|
+
);
|
|
216
224
|
z-index: var(--zer0-layer-fab-local-graph);
|
|
217
225
|
|
|
218
226
|
&[hidden] {
|
|
@@ -80,7 +80,12 @@
|
|
|
80
80
|
&.bd-sidebar-fab--restore,
|
|
81
81
|
html.bd-sidebar-pref-hidden &.bd-sidebar-fab {
|
|
82
82
|
display: block !important;
|
|
83
|
-
|
|
83
|
+
// Stay at slot 2 (above the feedback FAB at slot 1) so neither overlaps.
|
|
84
|
+
bottom: calc(
|
|
85
|
+
var(--zer0-space-fab-offset, 1rem) +
|
|
86
|
+
var(--zer0-space-fab-size, 3.5rem) +
|
|
87
|
+
var(--zer0-space-fab-gap, 0.75rem)
|
|
88
|
+
);
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
@media (min-width: 992px) {
|
data/assets/js/obsidian-graph.js
CHANGED
|
@@ -28,7 +28,11 @@
|
|
|
28
28
|
'use strict';
|
|
29
29
|
|
|
30
30
|
var CONTAINER_ID = 'obsidian-graph';
|
|
31
|
-
|
|
31
|
+
// Index URL resolution mirrors obsidian-wiki-links.js: the baseurl-aware
|
|
32
|
+
// value js-cdn.html emits (OBSIDIAN_CONFIG.wikiIndexUrl), then the legacy
|
|
33
|
+
// per-page override global, then a <base>-relative fallback.
|
|
34
|
+
var INDEX_URL = ((window.OBSIDIAN_CONFIG || {}).wikiIndexUrl ||
|
|
35
|
+
window.OBSIDIAN_WIKI_INDEX_URL ||
|
|
32
36
|
((document.querySelector('base') || {}).href || '/') + 'assets/data/wiki-index.json');
|
|
33
37
|
|
|
34
38
|
function normalize(value) {
|
data/scripts/README.md
CHANGED
|
@@ -18,7 +18,7 @@ scripts/
|
|
|
18
18
|
│ ├── git.sh # Git operations
|
|
19
19
|
│ ├── changelog.sh # Changelog generation
|
|
20
20
|
│ ├── gem.sh # Gem build/publish
|
|
21
|
-
│ └── preview_generator.py #
|
|
21
|
+
│ └── preview_generator.py # Preview-image engine (Claude orchestrates; ZER0-004)
|
|
22
22
|
├── features/ # Feature-specific scripts
|
|
23
23
|
│ ├── generate-preview-images # AI preview image generator
|
|
24
24
|
│ ├── pixelate-preview-images # Shrink preview banners (pixelate + PNG-8)
|
|
@@ -65,10 +65,7 @@ Build the gem without the full release workflow.
|
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
#### `bin/validate`
|
|
68
|
-
Run preflight validation before refactors, pull requests, and releases. The
|
|
69
|
-
quick path validates repository files, version consistency, YAML parsing, active
|
|
70
|
-
configuration contracts, config-file classification, and navigation data before
|
|
71
|
-
the Docker/local build stages run.
|
|
68
|
+
Run preflight validation before refactors, pull requests, and releases. The quick path validates repository files, version consistency, YAML parsing, active configuration contracts, config-file classification, and navigation data before the Docker/local build stages run.
|
|
72
69
|
|
|
73
70
|
```bash
|
|
74
71
|
./scripts/bin/validate [options]
|
|
@@ -115,22 +112,23 @@ Options:
|
|
|
115
112
|
--dry-run Preview without changes
|
|
116
113
|
--collection TYPE Generate for specific collection (posts, docs, etc.)
|
|
117
114
|
-f, --file PATH Process specific file
|
|
118
|
-
--provider PROVIDER
|
|
115
|
+
--provider PROVIDER Renderer (openai, xai, stability, gemini, local)
|
|
116
|
+
--prompt-engine ENG claude analyzes the article (default) | template
|
|
117
|
+
--review ENG claude reviews the render (default) | none
|
|
119
118
|
--assets-prefix Custom assets path prefix (default: /assets)
|
|
120
119
|
--no-auto-prefix Disable automatic path prefixing
|
|
121
120
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
Renderers (Claude orchestrates all of them — analysis + review via
|
|
122
|
+
CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY / logged-in `claude` CLI):
|
|
123
|
+
openai - OpenAI gpt-image-2 / DALL-E, default (requires OPENAI_API_KEY)
|
|
125
124
|
xai - xAI Grok image generation (requires XAI_API_KEY)
|
|
125
|
+
stability - Stability AI (requires STABILITY_API_KEY)
|
|
126
|
+
gemini - Google Gemini (requires GEMINI_API_KEY)
|
|
127
|
+
local - Deterministic template SVG/PNG (no API key)
|
|
126
128
|
```
|
|
127
129
|
|
|
128
130
|
#### `pixelate-preview-images`
|
|
129
|
-
Pixelate + palette-quantize the preview banners so they are dramatically smaller
|
|
130
|
-
files while retaining the retro pixel-art look. Pure Python stdlib (no
|
|
131
|
-
ImageMagick / Pillow / pngquant needed) — it downsamples the image and reduces
|
|
132
|
-
it to an indexed PNG-8 palette. Typical savings on the AI-generated banners are
|
|
133
|
-
~90% (e.g. a 2.7 MB banner becomes ~230 KB).
|
|
131
|
+
Pixelate + palette-quantize the preview banners so they are dramatically smaller files while retaining the retro pixel-art look. Pure Python stdlib (no ImageMagick / Pillow / pngquant needed) — it downsamples the image and reduces it to an indexed PNG-8 palette. Typical savings on the AI-generated banners are ~90% (e.g. a 2.7 MB banner becomes ~230 KB).
|
|
134
132
|
|
|
135
133
|
```bash
|
|
136
134
|
# Preview the savings for every banner (no changes), 4 workers:
|
|
@@ -157,10 +155,7 @@ Options (forwarded to scripts/features/pixelate_images.py):
|
|
|
157
155
|
-j, --jobs N Parallel worker processes (default: 1)
|
|
158
156
|
```
|
|
159
157
|
|
|
160
|
-
With no path argument it processes `preview_images.output_dir` from
|
|
161
|
-
`_config.yml` (default `assets/images/previews`). Non-PNG and 16-bit/interlaced
|
|
162
|
-
inputs are skipped gracefully. The engine has a built-in `--selftest`, exercised
|
|
163
|
-
by `scripts/test/lib/test_pixelate_images.sh`.
|
|
158
|
+
With no path argument it processes `preview_images.output_dir` from `_config.yml` (default `assets/images/previews`). Non-PNG and 16-bit/interlaced inputs are skipped gracefully. The engine has a built-in `--selftest`, exercised by `scripts/test/lib/test_pixelate_images.sh`.
|
|
164
159
|
|
|
165
160
|
#### `install-preview-generator`
|
|
166
161
|
Install the preview image generator feature.
|
|
@@ -254,7 +249,7 @@ These are sourced by other scripts, not executed directly:
|
|
|
254
249
|
- `git.sh` - Git operations (tags, commits, branches)
|
|
255
250
|
- `changelog.sh` - Changelog generation from commits
|
|
256
251
|
- `gem.sh` - Gem build/publish operations
|
|
257
|
-
- `preview_generator.py` -
|
|
252
|
+
- `preview_generator.py` - Preview-image engine (Claude analyze/review + openai/xai/stability/gemini/local renderers)
|
|
258
253
|
|
|
259
254
|
### Test Suites (scripts/test/)
|
|
260
255
|
|
|
@@ -272,8 +267,7 @@ These are sourced by other scripts, not executed directly:
|
|
|
272
267
|
|
|
273
268
|
## Backward Compatibility
|
|
274
269
|
|
|
275
|
-
Legacy script paths (e.g., `./scripts/build.sh`) are maintained as thin wrappers
|
|
276
|
-
that forward to the canonical locations in `bin/`, `utils/`, `features/`, or `test/`.
|
|
270
|
+
Legacy script paths (e.g., `./scripts/build.sh`) are maintained as thin wrappers that forward to the canonical locations in `bin/`, `utils/`, `features/`, or `test/`.
|
|
277
271
|
|
|
278
272
|
## Development Workflow
|
|
279
273
|
|
|
@@ -538,15 +532,15 @@ The script is designed to integrate with AI agents for automated content managem
|
|
|
538
532
|
3. **Front Matter Updates**: Automatically updates the markdown file with the new preview path
|
|
539
533
|
4. **Idempotent**: Won't regenerate images that already exist (unless `--force`)
|
|
540
534
|
|
|
541
|
-
###
|
|
535
|
+
### Engine
|
|
542
536
|
|
|
543
|
-
|
|
537
|
+
All logic lives in the single-file Python engine `scripts/lib/preview_generator.py` (the shell entry points are thin wrappers around it):
|
|
544
538
|
|
|
545
539
|
```bash
|
|
546
|
-
# Install
|
|
547
|
-
|
|
540
|
+
# Install the one dependency
|
|
541
|
+
pip3 install pyyaml
|
|
548
542
|
|
|
549
|
-
# Run
|
|
543
|
+
# Run the engine directly (same flags as the wrapper)
|
|
550
544
|
python3 scripts/lib/preview_generator.py --collection posts --dry-run
|
|
551
545
|
```
|
|
552
546
|
|
data/scripts/bin/audit-consumer
CHANGED
|
@@ -190,7 +190,7 @@ if [[ ${#THEMABLE_DIRS[@]} -eq 0 ]]; then
|
|
|
190
190
|
THEMABLE_DIRS=("_layouts" "_includes" "_sass" "assets/css" "assets/js")
|
|
191
191
|
fi
|
|
192
192
|
if [[ ${#REQUIRED_PLUGIN_FILES[@]} -eq 0 && ${#OPTIONAL_PLUGIN_FILES[@]} -eq 0 ]]; then
|
|
193
|
-
REQUIRED_PLUGIN_FILES=("_plugins/
|
|
193
|
+
REQUIRED_PLUGIN_FILES=("_plugins/obsidian_links.rb")
|
|
194
194
|
OPTIONAL_PLUGIN_FILES=("_plugins/admin_page_urls.rb" "_plugins/content_statistics_generator.rb" "_plugins/theme_version.rb")
|
|
195
195
|
fi
|
|
196
196
|
|
data/scripts/bin/manifest
CHANGED
data/scripts/bin/sync-plugins
CHANGED
|
@@ -172,7 +172,6 @@ fi
|
|
|
172
172
|
# Default hardcoded fallback if manifest has no plugin entries at all
|
|
173
173
|
if [[ ${#REQUIRED_PLUGINS[@]} -eq 0 && ${#OPTIONAL_PLUGINS[@]} -eq 0 ]]; then
|
|
174
174
|
REQUIRED_PLUGINS=(
|
|
175
|
-
"_plugins/preview_image_generator.rb"
|
|
176
175
|
"_plugins/obsidian_links.rb"
|
|
177
176
|
)
|
|
178
177
|
OPTIONAL_PLUGINS=(
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Feature: ZER0-004
|
|
2
|
+
// SVG → PNG rasterizer for the preview-image engine (scripts/lib/preview_generator.py).
|
|
3
|
+
// Last resort in the rasterizer chain (rsvg-convert → inkscape → magick → THIS):
|
|
4
|
+
// uses the repo's Playwright devDependency (the same Chromium that powers the
|
|
5
|
+
// visual test tiers) so contributors need no native image tooling installed.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// node scripts/dev/rasterize-svg.js <in.svg> <out.png> [width] [height]
|
|
9
|
+
//
|
|
10
|
+
// Exit codes:
|
|
11
|
+
// 0 PNG written
|
|
12
|
+
// 1 rasterization failed (Chromium error, unreadable SVG, …)
|
|
13
|
+
// 2 usage error
|
|
14
|
+
// 3 @playwright/test not resolvable (caller should fall through / keep .svg)
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const [svgPath, pngPath, widthArg, heightArg] = process.argv.slice(2);
|
|
19
|
+
if (!svgPath || !pngPath) {
|
|
20
|
+
console.error('Usage: node scripts/dev/rasterize-svg.js <in.svg> <out.png> [width] [height]');
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
const width = parseInt(widthArg, 10) || 1536;
|
|
24
|
+
const height = parseInt(heightArg, 10) || 1024;
|
|
25
|
+
|
|
26
|
+
let chromium;
|
|
27
|
+
try {
|
|
28
|
+
({ chromium } = require('@playwright/test'));
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.error('rasterize-svg: @playwright/test not resolvable from ' + process.cwd());
|
|
31
|
+
process.exit(3);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
(async () => {
|
|
35
|
+
const svg = fs.readFileSync(path.resolve(svgPath), 'utf8');
|
|
36
|
+
const browser = await chromium.launch();
|
|
37
|
+
try {
|
|
38
|
+
const ctx = await browser.newContext({
|
|
39
|
+
viewport: { width, height },
|
|
40
|
+
deviceScaleFactor: 1,
|
|
41
|
+
});
|
|
42
|
+
// Defense-in-depth: banner SVG must be self-contained, so block ALL
|
|
43
|
+
// network fetches — an external reference that slipped past the engine's
|
|
44
|
+
// sanitizer becomes a no-op instead of an SSRF/tracking request.
|
|
45
|
+
await ctx.route('**/*', (route) => route.abort());
|
|
46
|
+
const page = await ctx.newPage();
|
|
47
|
+
// Inline the SVG in a zero-margin shim and stretch it to the viewport so the
|
|
48
|
+
// screenshot is exactly width×height regardless of the SVG's own attributes.
|
|
49
|
+
await page.setContent(
|
|
50
|
+
`<!DOCTYPE html><html><head><style>
|
|
51
|
+
html,body{margin:0;padding:0;width:${width}px;height:${height}px;overflow:hidden;background:#000}
|
|
52
|
+
svg{display:block;width:${width}px;height:${height}px}
|
|
53
|
+
</style></head><body>${svg}</body></html>`,
|
|
54
|
+
{ waitUntil: 'load', timeout: 30000 }
|
|
55
|
+
);
|
|
56
|
+
await page.screenshot({ path: path.resolve(pngPath), fullPage: false });
|
|
57
|
+
await ctx.close();
|
|
58
|
+
console.log('wrote', pngPath);
|
|
59
|
+
} finally {
|
|
60
|
+
await browser.close();
|
|
61
|
+
}
|
|
62
|
+
})().catch((err) => {
|
|
63
|
+
console.error('rasterize-svg: ' + (err && err.message ? err.message : err));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
});
|