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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +172 -1
  3. data/README.md +10 -27
  4. data/_data/authors.yml +4 -3
  5. data/_data/backlog.yml +28 -0
  6. data/_data/features.yml +65 -18
  7. data/_data/i18n/languages.yml +36 -0
  8. data/_data/theme-manifest.yml +0 -2
  9. data/_data/ui-text.yml +36 -246
  10. data/_includes/README.md +4 -0
  11. data/_includes/components/author-avatar-url.html +4 -2
  12. data/_includes/components/env-switcher.html +3 -1
  13. data/_includes/components/language-toggle.html +81 -0
  14. data/_includes/components/search-modal.html +2 -2
  15. data/_includes/components/shortcuts-modal.html +1 -1
  16. data/_includes/components/translation-notice.html +27 -0
  17. data/_includes/content/intro.html +16 -15
  18. data/_includes/core/footer.html +9 -4
  19. data/_includes/core/head.html +9 -0
  20. data/_includes/core/header.html +9 -4
  21. data/_includes/core/hreflang.html +33 -0
  22. data/_includes/core/i18n.html +36 -0
  23. data/_includes/navigation/breadcrumbs.html +1 -1
  24. data/_includes/navigation/navbar.html +5 -4
  25. data/_includes/navigation/sidebar-right.html +3 -2
  26. data/_includes/navigation/unified-drawer.html +1 -1
  27. data/_layouts/article.html +7 -1
  28. data/_layouts/default.html +5 -3
  29. data/_layouts/news.html +4 -2
  30. data/_layouts/root.html +14 -8
  31. data/_layouts/section.html +4 -2
  32. data/_sass/core/_obsidian.scss +9 -1
  33. data/_sass/layouts/_navbar-extras.scss +6 -1
  34. data/assets/js/obsidian-graph.js +5 -1
  35. data/scripts/README.md +20 -26
  36. data/scripts/bin/audit-consumer +1 -1
  37. data/scripts/bin/manifest +0 -1
  38. data/scripts/bin/sync-plugins +0 -1
  39. data/scripts/dev/rasterize-svg.js +65 -0
  40. data/scripts/features/generate-preview-images +49 -1390
  41. data/scripts/features/install-preview-generator +55 -33
  42. data/scripts/install/README.md +9 -20
  43. data/scripts/install/ai/prompts/wizard.system.md +8 -17
  44. data/scripts/lib/README.md +1 -5
  45. data/scripts/lib/install/deploy/README.md +3 -9
  46. data/scripts/lib/preview_generator.py +2261 -1341
  47. data/scripts/translate.rb +1114 -0
  48. metadata +9 -3
  49. data/_plugins/preview_image_generator.rb +0 -351
@@ -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 -->
@@ -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 site content"
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
- {%- assign ui = site.data.ui-text[site.locale] | default: site.data.ui-text.en -%}
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
- {%- assign ui = site.data.ui-text[site.locale] | default: site.data.ui-text.en -%}
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"
@@ -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 %}
@@ -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
- <main class="bd-main order-1" data-bs-spy="scroll" data-bs-target="#TableOfContents" data-bs-offset="100" data-bs-smooth-scroll="true">
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
- </main>
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 class="container">
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
- </main>
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) — page-invariant, cached once per build. -->
86
- {% include_cached components/search-modal.html %}
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 `?`) — page-invariant. -->
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
- Semantic <main> landmark (consistent with the default/section/news layouts)
102
- so assistive tech, search engines, and AI content extractors can isolate the
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. -->
@@ -156,7 +156,9 @@ layout: root
156
156
  <!-- ================================ -->
157
157
  <!-- MAIN CONTENT AREA -->
158
158
  <!-- ================================ -->
159
- <main class="col-lg-9 col-xl-10 section-layout-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
- </main>
531
+ </div>
530
532
  </div>
531
533
  </div>
532
534
 
@@ -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: var(--zer0-space-fab-offset, 1rem);
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
- bottom: var(--zer0-space-fab-offset, 1rem);
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) {
@@ -28,7 +28,11 @@
28
28
  'use strict';
29
29
 
30
30
  var CONTAINER_ID = 'obsidian-graph';
31
- var INDEX_URL = (window.OBSIDIAN_WIKI_INDEX_URL ||
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 # Python preview image generator
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 Use specific AI provider (openai, stability, xai)
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
- AI Providers:
123
- openai - OpenAI DALL-E (requires OPENAI_API_KEY)
124
- stability - Stability AI (requires STABILITY_API_KEY)
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&nbsp;MB banner becomes ~230&nbsp;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&nbsp;MB banner becomes ~230&nbsp;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` - Python preview image generator
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
- ### Python Alternative
535
+ ### Engine
542
536
 
543
- A Python version is available at `scripts/lib/preview_generator.py` with additional features:
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 dependencies
547
- pip install openai pyyaml requests
540
+ # Install the one dependency
541
+ pip3 install pyyaml
548
542
 
549
- # Run Python version
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
 
@@ -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/preview_image_generator.rb" "_plugins/obsidian_links.rb")
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
@@ -67,7 +67,6 @@ THEME_DATA_PATHS=(
67
67
  # Plugins that remote_theme consumers MUST vendor locally (GitHub Pages won't
68
68
  # load plugins from a remote theme).
69
69
  REQUIRED_PLUGIN_PATHS=(
70
- "_plugins/preview_image_generator.rb"
71
70
  "_plugins/obsidian_links.rb"
72
71
  )
73
72
 
@@ -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
+ });