jekyll-theme-zer0 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 398f5967ae3eedcc43ca6b6ef344d62207ab1d36a457203ab7023dd926a9742b
4
- data.tar.gz: b4a234aef6bbb109ae6410a46d2c48a292fdd048fa47729bbe6ac728c92f20e1
3
+ metadata.gz: 0ae8ec5b12734cd267a46b1b5c26ed95f7d55b004e6dd9a52111a477f6bbc395
4
+ data.tar.gz: 84ff1429cdd6504bef5977c44ff88ff9078ffdb2dcd98ea39304c10f939daceb
5
5
  SHA512:
6
- metadata.gz: 12f0348d5fff7c3f3c2219dcd873e25363a4af52f8df636841955e3b69a7b4860816de2d543ba4e067f887fc5ae4f181f4a9db2df50dbf84fb64a1806b084c06
7
- data.tar.gz: e97964b79d62031aac4f469ab561e6bbb96fd61b17c56aa71a1418d72a588a0723f3b85be38a189f4e502d13886911da6a862d255c2e75b962b58ae51d00bb2a
6
+ metadata.gz: 4c194515ed9cbf3590d46b6c887b9a27c94bbdfb4a5e841417ae759e25b59bd5bf0612846ce20927361daefa42b23459152336db6fb22874ce56b05c87d0203b
7
+ data.tar.gz: d0469bd560f3ddfc264b22e0fd4b7f7ffed2b9c1a7f524f3975b5a574a7a2ae0c5c8bf53c25db53eec1bb4201e21078f95a45de46e3dd742569171ca15d29421
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.1] - 2026-04-28
4
+
5
+ ### Changed
6
+ - Version bump: patch release
7
+
8
+ ### Commits in this release
9
+ - 9830d3d refactor(obsidian): extract full graph include and sync docs (#82)
10
+
11
+
3
12
  ## [1.4.0] - 2026-04-25
4
13
 
5
14
  ### Changed
@@ -48,6 +57,9 @@
48
57
 
49
58
  ## [Unreleased]
50
59
 
60
+ ### Changed
61
+ - **Docker/Jekyll build performance** — Reduced repeated full-page Liquid scans in the footer, settings offcanvas, and cookie consent includes; cached preview image checks during generation; skipped server-side Obsidian rewrites for documents without Obsidian syntax; and changed Docker dev startup to run `bundle install` only when `bundle check` reports missing dependencies. The profiled Docker build improved from 119.2s to 86.8s in local validation.
62
+
51
63
  ### Added
52
64
  - **Development Automation**: Added `scripts/bin/validate` and `scripts/validate`
53
65
  as the canonical preflight validation command for repository files, version
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  title: zer0-mistakes
3
3
  sub-title: AI-Native Jekyll Theme
4
4
  description: AI-native Jekyll theme for GitHub Pages — Docker-first development, AI-powered installation, multi-agent integration (Copilot, Codex, Cursor, Claude), AI preview-image generation, and AIEO content optimization with Bootstrap 5.3.
5
- version: 1.4.0
5
+ version: 1.4.1
6
6
  layout: landing
7
7
  tags:
8
8
  - jekyll
@@ -20,7 +20,7 @@ categories:
20
20
  - bootstrap
21
21
  - ai-tooling
22
22
  created: 2024-02-10T23:51:11.480Z
23
- lastmod: 2026-04-25T19:55:07.000Z
23
+ lastmod: 2026-04-28T21:04:09.000Z
24
24
  draft: false
25
25
  permalink: /
26
26
  slug: zer0
@@ -1138,7 +1138,7 @@ git push origin feature/awesome-feature
1138
1138
 
1139
1139
  | Metric | Value |
1140
1140
  |--------|-------|
1141
- | **Current Version** | 1.4.0 ([RubyGems](https://rubygems.org/gems/jekyll-theme-zer0), [CHANGELOG](/CHANGELOG)) |
1141
+ | **Current Version** | 1.4.1 ([RubyGems](https://rubygems.org/gems/jekyll-theme-zer0), [CHANGELOG](/CHANGELOG)) |
1142
1142
  | **Documented Features** | 43 ([Feature Registry](https://github.com/bamr87/zer0-mistakes/blob/main/_data/features.yml)) |
1143
1143
  | **Setup Time** | 2-5 minutes ([install.sh benchmarks](https://github.com/bamr87/zer0-mistakes/blob/main/install.sh)) |
1144
1144
  | **Documentation Pages** | 70+ ([browse docs](/pages/)) |
@@ -1189,6 +1189,6 @@ And these AI partners that make zer0-mistakes truly AI-native:
1189
1189
 
1190
1190
  **Built with ❤️ — and a little help from our AI partners — for the Jekyll community**
1191
1191
 
1192
- **v1.4.0** • [Changelog](CHANGELOG.md) • [License](LICENSE) • [Contributing](CONTRIBUTING.md) • [AI Agent Guide](AGENTS.md)
1192
+ **v1.4.1** • [Changelog](CHANGELOG.md) • [License](LICENSE) • [Contributing](CONTRIBUTING.md) • [AI Agent Guide](AGENTS.md)
1193
1193
 
1194
1194
 
@@ -59,9 +59,8 @@ Configuration: Uses site.posthog settings from _config.yml
59
59
  </p>
60
60
  <p class="mb-2 mb-lg-0 small text-white-50">
61
61
  This website uses cookies and similar technologies to enhance your browsing experience, analyze traffic, and provide personalized content.
62
- {% assign _pp = site.html_pages | where: "url", "/privacy-policy/" | first %}
63
- {% unless _pp %}{% assign _pp = site.html_pages | where: "url", "/privacy-policy" | first %}{% endunless %}
64
- {% if _pp or site.privacy_policy_url %}
62
+ {% assign cookie_page_urls = site.html_pages | map: "url" | join: "|" | prepend: "|" | append: "|" %}
63
+ {% if site.privacy_policy_url or cookie_page_urls contains "|/privacy-policy/|" or cookie_page_urls contains "|/privacy-policy|" %}
65
64
  <a href="{{ site.privacy_policy_url | default: '/privacy-policy/' | relative_url }}" class="text-white text-decoration-underline">Learn more in our Privacy Policy</a>.
66
65
  {% endif %}
67
66
  </p>
@@ -22,6 +22,7 @@
22
22
  -->
23
23
 
24
24
  {% include components/env-detect.html %}
25
+ {% assign info_section_page_urls = site.html_pages | map: "url" | join: "|" | prepend: "|" | append: "|" %}
25
26
  <!-- Settings Offcanvas -->
26
27
  <div class="offcanvas offcanvas-end" tabindex="-1" id="info-section" aria-labelledby="infoSectionLabel">
27
28
 
@@ -111,9 +112,12 @@
111
112
  <!-- Admin Quick Links — only render links to pages that actually exist
112
113
  in the build (prevents 404s on bare-minimum / remote-theme sites
113
114
  that haven't created the admin pages). -->
114
- {% assign _cfg_page = site.html_pages | where: "url", "/about/config/" | first %}
115
- {% assign _theme_page = site.html_pages | where: "url", "/about/settings/theme/" | first %}
116
- {% assign _nav_page = site.html_pages | where: "url", "/about/settings/navigation/" | first %}
115
+ {% assign _cfg_page = false %}
116
+ {% assign _theme_page = false %}
117
+ {% assign _nav_page = false %}
118
+ {% if info_section_page_urls contains "|/about/config/|" %}{% assign _cfg_page = true %}{% endif %}
119
+ {% if info_section_page_urls contains "|/about/settings/theme/|" %}{% assign _theme_page = true %}{% endif %}
120
+ {% if info_section_page_urls contains "|/about/settings/navigation/|" %}{% assign _nav_page = true %}{% endif %}
117
121
  {% if _cfg_page or _theme_page or _nav_page %}
118
122
  <div class="mb-3">
119
123
  <h6 class="text-body-secondary small text-uppercase fw-semibold mb-2">
@@ -145,7 +149,8 @@
145
149
  {% include components/env-switcher.html %}
146
150
 
147
151
  <!-- Admin Link — only when target page exists -->
148
- {% assign _env_page = site.html_pages | where: "url", "/about/settings/environment/" | first %}
152
+ {% assign _env_page = false %}
153
+ {% if info_section_page_urls contains "|/about/settings/environment/|" %}{% assign _env_page = true %}{% endif %}
149
154
  {% if _env_page %}
150
155
  <div class="mt-4 pt-3 border-top">
151
156
  <a href="{{ '/about/settings/environment/' | relative_url }}" class="btn btn-outline-secondary btn-sm w-100">
@@ -31,6 +31,7 @@
31
31
  -->
32
32
 
33
33
  <footer class="bd-footer border-top" role="contentinfo">
34
+ {% assign footer_page_urls = site.html_pages | map: "url" | join: "|" | prepend: "|" | append: "|" %}
34
35
  <!-- Powered by Row -->
35
36
  <div class="container-xl my-3">
36
37
  <ul class="nav justify-content-end list-unstyled d-flex align-items-center flex-wrap" aria-label="Powered by technologies">
@@ -100,8 +101,8 @@
100
101
  {% assign _parts = entry | split: "," %}
101
102
  {% assign _label = _parts[0] %}
102
103
  {% assign _url = _parts[1] %}
103
- {% assign _page = site.html_pages | where: "url", _url | first %}
104
- {% if _page %}
104
+ {% assign _needle = "|" | append: _url | append: "|" %}
105
+ {% if footer_page_urls contains _needle %}
105
106
  <li><a href="{{ _url | relative_url }}" class="text-light text-decoration-none">{{ _label }}</a></li>
106
107
  {% endif %}
107
108
  {% endfor %}
@@ -169,14 +170,14 @@
169
170
  <div class="col-md-4 text-md-end">
170
171
  <ul class="list-inline mb-0">
171
172
  {% comment %} Only render policy links whose pages exist (or that are explicitly configured). {% endcomment %}
172
- {% assign _privacy = site.html_pages | where: "url", "/privacy-policy/" | first %}
173
- {% unless _privacy %}{% assign _privacy = site.html_pages | where: "url", "/privacy-policy" | first %}{% endunless %}
174
- {% if _privacy or site.privacy_policy_url %}
173
+ {% assign _privacy_needle = "|/privacy-policy/|" %}
174
+ {% assign _privacy_alt_needle = "|/privacy-policy|" %}
175
+ {% if site.privacy_policy_url or footer_page_urls contains _privacy_needle or footer_page_urls contains _privacy_alt_needle %}
175
176
  <li class="list-inline-item"><a href="{{ site.privacy_policy_url | default: '/privacy-policy' | relative_url }}" class="text-light text-decoration-none">Privacy Policy</a></li>
176
177
  {% endif %}
177
- {% assign _terms = site.html_pages | where: "url", "/terms-of-service/" | first %}
178
- {% unless _terms %}{% assign _terms = site.html_pages | where: "url", "/terms-of-service" | first %}{% endunless %}
179
- {% if _terms or site.terms_of_service_url %}
178
+ {% assign _terms_needle = "|/terms-of-service/|" %}
179
+ {% assign _terms_alt_needle = "|/terms-of-service|" %}
180
+ {% if site.terms_of_service_url or footer_page_urls contains _terms_needle or footer_page_urls contains _terms_alt_needle %}
180
181
  <li class="list-inline-item"><a href="{{ site.terms_of_service_url | default: '/terms-of-service' | relative_url }}" class="text-light text-decoration-none">Terms of Service</a></li>
181
182
  {% endif %}
182
183
  <li class="list-inline-item">
@@ -48,12 +48,6 @@
48
48
  <!-- Scrollable content area with dynamic navigation options -->
49
49
  <div class="offcanvas-body overflow-auto">
50
50
 
51
- <!-- ========================== -->
52
- <!-- LOCAL GRAPH WIDGET -->
53
- <!-- ========================== -->
54
- <!-- Mini Obsidian-style local graph of the current page + neighbors -->
55
- {% include navigation/local-graph.html %}
56
-
57
51
  <!-- ========================== -->
58
52
  <!-- AUTO MODE: Collection Docs -->
59
53
  <!-- ========================== -->
@@ -0,0 +1,170 @@
1
+ <style>
2
+ /* Scoped to the graph page so we don't leak into other docs. */
3
+ #obsidian-graph {
4
+ width: 100%;
5
+ height: 82vh;
6
+ min-height: 620px;
7
+ border: 1px solid var(--bs-border-color, #dee2e6);
8
+ border-radius: var(--bs-border-radius-lg, .5rem);
9
+ background: var(--bs-tertiary-bg, #f8f9fa);
10
+ box-shadow: 0 1px 2px rgba(0, 0, 0, .04), 0 4px 12px rgba(0, 0, 0, .04);
11
+ position: relative;
12
+ overflow: hidden;
13
+ }
14
+ #obsidian-graph-stats {
15
+ display: flex;
16
+ flex-wrap: wrap;
17
+ gap: .375rem;
18
+ }
19
+ #obsidian-graph-stats .badge {
20
+ font-size: .8125rem;
21
+ font-weight: 500;
22
+ padding: .35rem .6rem;
23
+ }
24
+ .obsidian-graph-toolbar {
25
+ display: flex;
26
+ flex-wrap: wrap;
27
+ gap: .5rem .75rem;
28
+ align-items: center;
29
+ margin: .75rem 0;
30
+ padding: .625rem .75rem;
31
+ background: var(--bs-tertiary-bg, #f8f9fa);
32
+ border: 1px solid var(--bs-border-color, #dee2e6);
33
+ border-radius: var(--bs-border-radius, .375rem);
34
+ }
35
+ .obsidian-graph-toolbar .form-control {
36
+ max-width: 280px;
37
+ flex: 1 1 200px;
38
+ }
39
+ .obsidian-graph-toolbar .form-check {
40
+ margin-bottom: 0;
41
+ }
42
+ #obsidian-graph-status {
43
+ flex-basis: 100%;
44
+ font-size: .8125rem;
45
+ color: var(--bs-secondary-color, #6c757d);
46
+ min-height: 1.25rem;
47
+ }
48
+ .obsidian-graph-legend {
49
+ display: flex;
50
+ flex-wrap: wrap;
51
+ gap: .5rem;
52
+ margin-top: .75rem;
53
+ font-size: .8125rem;
54
+ }
55
+ .obsidian-graph-legend > span {
56
+ display: inline-flex;
57
+ align-items: center;
58
+ gap: .375rem;
59
+ padding: .25rem .55rem;
60
+ background: var(--bs-body-bg, #fff);
61
+ border: 1px solid var(--bs-border-color, #dee2e6);
62
+ border-radius: 999px;
63
+ color: var(--bs-body-color, #212529);
64
+ }
65
+ .obsidian-graph-legend .swatch {
66
+ display: inline-block;
67
+ width: .7rem;
68
+ height: .7rem;
69
+ border-radius: 50%;
70
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, .15);
71
+ }
72
+ .obsidian-graph-legend .swatch-broken {
73
+ border: 1.5px dashed #dc3545;
74
+ background: transparent !important;
75
+ box-shadow: none;
76
+ }
77
+ .obsidian-graph-tips {
78
+ margin-top: .75rem;
79
+ padding: .625rem .75rem;
80
+ font-size: .8125rem;
81
+ color: var(--bs-secondary-color, #6c757d);
82
+ background: var(--bs-tertiary-bg, #f8f9fa);
83
+ border-left: 3px solid var(--bs-primary, #0d6efd);
84
+ border-radius: .25rem;
85
+ }
86
+ .obsidian-graph-tips strong {
87
+ color: var(--bs-body-color, #212529);
88
+ }
89
+ </style>
90
+
91
+ # Obsidian Graph View
92
+
93
+ A live, force-directed map of every page on this site and every
94
+ `[[wiki-link]]` between them. This is the rendered-site equivalent of
95
+ Obsidian's local graph view — built from the same
96
+ [`assets/data/wiki-index.json`]({{ "/assets/data/wiki-index.json" | relative_url }})
97
+ that powers the [client-side resolver]({{ "/docs/obsidian/syntax-reference/" | relative_url }})
98
+ and [backlinks panel]({{ "/docs/obsidian/syntax-reference/#backlinks-panel" | relative_url }}).
99
+
100
+ <div id="obsidian-graph-stats" class="mb-2" aria-live="polite"></div>
101
+
102
+ <div class="obsidian-graph-toolbar">
103
+ <input type="search"
104
+ class="form-control form-control-sm"
105
+ id="obsidian-graph-search"
106
+ placeholder="Filter nodes by title…"
107
+ aria-label="Filter graph nodes by title" />
108
+ <button type="button"
109
+ class="btn btn-outline-secondary btn-sm"
110
+ id="obsidian-graph-fit">
111
+ <i class="bi bi-arrows-fullscreen" aria-hidden="true"></i>
112
+ Reset view
113
+ </button>
114
+ <div class="form-check form-switch">
115
+ <input class="form-check-input"
116
+ type="checkbox"
117
+ role="switch"
118
+ id="obsidian-graph-orphans" />
119
+ <label class="form-check-label" for="obsidian-graph-orphans">
120
+ Show orphans
121
+ </label>
122
+ </div>
123
+ <span id="obsidian-graph-status" role="status"></span>
124
+ </div>
125
+
126
+ <div id="obsidian-graph" role="img" aria-label="Site knowledge graph"></div>
127
+
128
+ <div class="obsidian-graph-legend" aria-label="Graph legend">
129
+ <span><span class="swatch" style="background:#0d6efd"></span>Posts</span>
130
+ <span><span class="swatch" style="background:#198754"></span>Docs</span>
131
+ <span><span class="swatch" style="background:#6f42c1"></span>Notes</span>
132
+ <span><span class="swatch" style="background:#d63384"></span>Notebooks</span>
133
+ <span><span class="swatch" style="background:#fd7e14"></span>Quickstart</span>
134
+ <span><span class="swatch" style="background:#6c757d"></span>Pages</span>
135
+ <span><span class="swatch swatch-broken"></span>Broken links</span>
136
+ </div>
137
+
138
+ <div class="obsidian-graph-tips">
139
+ <strong>Tips:</strong> click a node to open the page · ⌘/Ctrl-click to
140
+ open in a new tab · drag to reposition · scroll to zoom · hover to
141
+ highlight a node's neighborhood · type in the search box to filter.
142
+ </div>
143
+
144
+ ## How it's built
145
+
146
+ | Piece | File |
147
+ | --- | --- |
148
+ | Build-time index (nodes + outgoing edges) | [`assets/data/wiki-index.json`]({{ "/assets/data/wiki-index.json" | relative_url }}) (Liquid template at [`assets/data/wiki-index.json`](https://github.com/bamr87/zer0-mistakes/blob/main/assets/data/wiki-index.json)) |
149
+ | Renderer | `assets/js/obsidian-graph.js` |
150
+ | Layout engine | [cytoscape.js](https://js.cytoscape.org/) (loaded from CDN, only on this page) |
151
+
152
+ Outgoing edges come from the same `[[…]]` syntax the resolver handles —
153
+ unresolved targets show up as dashed red nodes so you can find dangling
154
+ links at a glance. The graph is regenerated every Jekyll build; nothing
155
+ runs client-side except cytoscape's force layout.
156
+
157
+ <!-- Cytoscape.js (only loaded on this page). -->
158
+ <script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.0/dist/cytoscape.min.js"
159
+ integrity="sha384-kpMsYllYzyaWU69Piok08rPNktpnjqAoDMdB00fjqUkEk3lkuUbSuwJ+oXrjvN6B"
160
+ crossorigin="anonymous"
161
+ defer></script>
162
+ <script src="{{ '/assets/js/obsidian-graph.js' | relative_url }}" defer></script>
163
+
164
+ ## See also
165
+
166
+ - [[Obsidian Vault Integration]]
167
+ - [[Obsidian Syntax Reference]]
168
+ - [[Obsidian Authoring Workflow]]
169
+ - [[Getting Started with the Obsidian Vault]]
170
+ - [[Obsidian Integration Troubleshooting]]
@@ -416,10 +416,15 @@ module Jekyll
416
416
 
417
417
  def rewrite_document(doc)
418
418
  return unless index
419
+ return unless needs_rewrite?(doc.content)
419
420
 
420
421
  converter = Converter.new(nil, index, config)
421
422
  doc.content = converter.convert(doc.content, current_url: doc.url)
422
423
  end
424
+
425
+ def needs_rewrite?(content)
426
+ content.include?('[[') || content.include?('> [!') || content.match?(Converter::INLINE_TAG_RE)
427
+ end
423
428
  end
424
429
  end
425
430
  end
@@ -47,6 +47,9 @@ module Jekyll
47
47
 
48
48
  # Check if a document has a preview image defined
49
49
  def self.has_preview?(doc)
50
+ cached = cached_preview_entry(doc)
51
+ return cached['has_preview'] unless cached.nil?
52
+
50
53
  preview = doc.data['preview']
51
54
  return false if preview.nil? || preview.to_s.strip.empty?
52
55
 
@@ -95,6 +98,9 @@ module Jekyll
95
98
 
96
99
  # Get the preview image path for a document
97
100
  def self.preview_path(doc)
101
+ cached = cached_preview_entry(doc)
102
+ return cached['path'] unless cached.nil?
103
+
98
104
  preview = doc.data['preview']
99
105
  return nil if preview.nil? || preview.to_s.strip.empty?
100
106
 
@@ -113,36 +119,86 @@ module Jekyll
113
119
 
114
120
  # Get list of documents missing preview images
115
121
  def self.missing_previews(site)
122
+ build_index(site)['missing']
123
+ end
124
+
125
+ def self.build_index(site)
126
+ cached = site.data['preview_image_index']
127
+ return cached if cached
128
+
116
129
  config = self.config(site)
130
+ index = {}
117
131
  missing = []
118
-
119
- config['collections'].each do |collection_name|
132
+
133
+ preview_documents(site, config).each do |doc, collection_name|
134
+ key = preview_cache_key(doc)
135
+ next if index.key?(key)
136
+
137
+ preview = doc.data['preview']
138
+ normalized_path = nil
139
+ has_preview = false
140
+
141
+ if preview && !preview.to_s.strip.empty? && (preview.match?(/\.(png|jpe?g|gif|svg|webp)$/i) || preview.start_with?('http'))
142
+ normalized_path = preview.start_with?('http') ? preview : normalize_preview_path(preview, config)
143
+ has_preview = preview.start_with?('http') || preview_file_exists?(site, normalized_path, config)
144
+ end
145
+
146
+ index[key] = {
147
+ 'has_preview' => has_preview,
148
+ 'path' => normalized_path,
149
+ 'collection' => collection_name
150
+ }
151
+
152
+ next if has_preview
153
+
154
+ missing << {
155
+ 'path' => doc.relative_path,
156
+ 'title' => doc.data['title'] || File.basename(doc.relative_path),
157
+ 'collection' => collection_name
158
+ }
159
+ end
160
+
161
+ site.data['preview_image_index'] = {
162
+ 'documents' => index,
163
+ 'missing' => missing
164
+ }
165
+ end
166
+
167
+ def self.cached_preview_entry(doc)
168
+ return nil unless doc.respond_to?(:site) && doc.site && doc.site.data['preview_image_index']
169
+
170
+ doc.site.data['preview_image_index']['documents'][preview_cache_key(doc)]
171
+ end
172
+
173
+ def self.preview_documents(site, config)
174
+ docs = []
175
+ Array(config['collections']).each do |collection_name|
120
176
  collection = site.collections[collection_name]
121
177
  next unless collection
122
-
123
- collection.docs.each do |doc|
124
- unless has_preview?(doc)
125
- missing << {
126
- 'path' => doc.relative_path,
127
- 'title' => doc.data['title'] || File.basename(doc.relative_path),
128
- 'collection' => collection_name
129
- }
130
- end
131
- end
178
+
179
+ collection.docs.each { |doc| docs << [doc, collection_name] }
132
180
  end
133
-
134
- # Also check posts (which are special in Jekyll)
135
- site.posts.docs.each do |doc|
136
- unless has_preview?(doc)
137
- missing << {
138
- 'path' => doc.relative_path,
139
- 'title' => doc.data['title'] || File.basename(doc.relative_path),
140
- 'collection' => 'posts'
141
- }
142
- end
181
+
182
+ site.posts.docs.each { |doc| docs << [doc, 'posts'] } if site.respond_to?(:posts) && site.posts
183
+ docs
184
+ end
185
+
186
+ def self.preview_cache_key(doc)
187
+ doc.respond_to?(:relative_path) ? doc.relative_path : doc.path
188
+ end
189
+
190
+ def self.preview_file_exists?(site, normalized_preview, config)
191
+ candidates = []
192
+ if normalized_preview.start_with?('/')
193
+ candidates << File.join(site.source, normalized_preview.sub(/^\//, ''))
194
+ candidates << File.join(site.source, 'assets', normalized_preview.sub(/^\//, ''))
195
+ else
196
+ candidates << File.join(site.source, 'assets', normalized_preview)
197
+ candidates << File.join(site.source, normalized_preview)
198
+ candidates << File.join(site.source, config['output_dir'], normalized_preview)
143
199
  end
144
-
145
- missing.uniq { |m| m['path'] }
200
+
201
+ candidates.any? { |path| File.exist?(path) }
146
202
  end
147
203
 
148
204
  # Generate a preview image filename from document
@@ -264,7 +320,8 @@ module Jekyll
264
320
  return unless config['enabled']
265
321
 
266
322
  # Store missing previews in site data for access in templates
267
- site.data['preview_images_missing'] = PreviewImageGenerator.missing_previews(site)
323
+ preview_index = PreviewImageGenerator.build_index(site)
324
+ site.data['preview_images_missing'] = preview_index['missing']
268
325
  site.data['preview_images_config'] = config
269
326
 
270
327
  # Log status
@@ -36,14 +36,38 @@ sitemap: false
36
36
  "entries": [
37
37
  {%- for doc in all_docs -%}
38
38
  {%- assign basename = doc.path | split: "/" | last | replace: ".md", "" | replace: ".markdown", "" | replace: ".html", "" -%}
39
- {%- assign body = doc.content | default: "" | strip_html -%}
39
+ {%- assign body_source = doc.content | default: "" -%}
40
+ {%- assign body_without_fences = "" -%}
41
+ {%- assign fence_parts = body_source | split: "```" -%}
42
+ {%- for fence_part in fence_parts -%}
43
+ {%- assign fence_mod = forloop.index0 | modulo: 2 -%}
44
+ {%- if fence_mod == 0 -%}
45
+ {%- assign body_without_fences = body_without_fences | append: fence_part -%}
46
+ {%- endif -%}
47
+ {%- endfor -%}
48
+ {%- assign body_without_code = "" -%}
49
+ {%- assign inline_code_parts = body_without_fences | split: "`" -%}
50
+ {%- for inline_code_part in inline_code_parts -%}
51
+ {%- assign inline_code_mod = forloop.index0 | modulo: 2 -%}
52
+ {%- if inline_code_mod == 0 -%}
53
+ {%- assign body_without_code = body_without_code | append: inline_code_part -%}
54
+ {%- endif -%}
55
+ {%- endfor -%}
56
+ {%- assign body = body_without_code | strip_html -%}
40
57
  {%- assign body_excerpt = body | strip_newlines | truncate: 240 -%}
58
+ {%- assign aliases = "" | split: "," -%}
59
+ {%- if doc.aliases -%}
60
+ {%- assign aliases_blob = doc.aliases | join: "||" -%}
61
+ {%- assign aliases = aliases_blob | split: "||" -%}
62
+ {%- endif -%}
41
63
  {%- comment -%}
42
64
  Extract outgoing wiki-link targets so the graph view
43
65
  (assets/js/obsidian-graph.js) can render edges without re-parsing
44
- every page client-side. We mask `![[…]]` embeds first so they don't
45
- also get split as `[[]]`, then split on `[[`, take everything
46
- before `]]`, and strip the optional `|alias` and `#anchor` parts.
66
+ every page client-side. Fenced and inline code are removed first so
67
+ Bash `[[ ... ]]` tests and literal examples don't become graph edges.
68
+ We mask `![[…]]` embeds so they don't also get split as `[[…]]`, then
69
+ strip optional `|alias` and `#anchor` / `^block` parts. Operator-heavy
70
+ targets are discarded as table examples rather than Obsidian page names.
47
71
  {%- endcomment -%}
48
72
  {%- assign masked = body | replace: "![[", "@@OBSEMBED@@" -%}
49
73
  {%- assign chunks = masked | split: "[[" -%}
@@ -52,7 +76,21 @@ sitemap: false
52
76
  {%- assign target_raw = chunk | split: "]]" | first -%}
53
77
  {%- if target_raw and target_raw != "" -%}
54
78
  {%- assign target = target_raw | split: "|" | first | split: "#" | first | split: "^" | first | strip | downcase -%}
55
- {%- if target != "" and target.size < 200 -%}
79
+ {%- assign target_is_code = false -%}
80
+ {%- assign first_char = target | slice: 0 -%}
81
+ {%- if target contains "$" or target contains "==" or target contains "!=" or target contains "=~" -%}
82
+ {%- assign target_is_code = true -%}
83
+ {%- endif -%}
84
+ {%- if target contains "&&" or target contains "||" or target contains "&amp;&amp;" or target contains "&lt;" or target contains "&gt;" -%}
85
+ {%- assign target_is_code = true -%}
86
+ {%- endif -%}
87
+ {%- if target contains " -eq " or target contains " -ne " or target contains " -lt " or target contains " -le " or target contains " -gt " or target contains " -ge " -%}
88
+ {%- assign target_is_code = true -%}
89
+ {%- endif -%}
90
+ {%- if first_char == "-" or target contains "{" or target contains "}" or target contains "(" or target contains ")" or target contains '"' or target contains "'" -%}
91
+ {%- assign target_is_code = true -%}
92
+ {%- endif -%}
93
+ {%- if target != "" and target.size < 200 and target_is_code == false -%}
56
94
  {%- assign outgoing = outgoing | push: target -%}
57
95
  {%- endif -%}
58
96
  {%- endif -%}
@@ -65,7 +103,7 @@ sitemap: false
65
103
  "collection": {{ doc.collection | default: nil | jsonify }},
66
104
  "tags": {{ doc.tags | default: empty | jsonify }},
67
105
  "categories": {{ doc.categories | default: empty | jsonify }},
68
- "aliases": {{ doc.aliases | default: empty | jsonify }},
106
+ "aliases": {{ aliases | jsonify }},
69
107
  "outgoing": {{ outgoing | jsonify }},
70
108
  "excerpt": {{ body_excerpt | jsonify }}
71
109
  }{% unless forloop.last %},{% endunless %}
@@ -184,7 +184,6 @@
184
184
  var cy = window.cytoscape({
185
185
  container: container,
186
186
  elements: elements,
187
- wheelSensitivity: 0.2,
188
187
  minZoom: 0.1,
189
188
  maxZoom: 4,
190
189
  style: [
@@ -277,7 +277,6 @@
277
277
  var cy = window.cytoscape({
278
278
  container: container,
279
279
  elements: elements,
280
- wheelSensitivity: 0.2,
281
280
  minZoom: 0.3,
282
281
  maxZoom: 3,
283
282
  autoungrabify: false,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-theme-zer0
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Amr Abdel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-25 00:00:00.000000000 Z
11
+ date: 2026-04-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jekyll
@@ -169,6 +169,7 @@ files:
169
169
  - _includes/navigation/sidebar-folders.html
170
170
  - _includes/navigation/sidebar-left.html
171
171
  - _includes/navigation/sidebar-right.html
172
+ - _includes/obsidian/full-graph.html
172
173
  - _includes/search-data.json
173
174
  - _includes/setup/wizard.html
174
175
  - _includes/stats/README.md