jekyll-theme-zer0 1.27.0 → 1.29.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +812 -13
  3. data/_data/backlog.yml +356 -2
  4. data/_data/consumers.yml +252 -0
  5. data/_data/features.yml +234 -5
  6. data/_data/i18n/fr.yml +103 -0
  7. data/_data/i18n/manifest.yml +1529 -0
  8. data/_data/ingredient_densities.yml +122 -0
  9. data/_data/navigation/docs.yml +2 -0
  10. data/_data/navigation/main.yml +22 -0
  11. data/_data/recipe_courses.yml +64 -0
  12. data/_data/series.yml +19 -0
  13. data/_data/theme-manifest.yml +387 -277
  14. data/_data/ui-text.yml +8 -0
  15. data/_includes/README.md +36 -3
  16. data/_includes/analytics/google-tag-manager-body.html +11 -2
  17. data/_includes/analytics/google-tag-manager-head.html +16 -6
  18. data/_includes/analytics/posthog.html +33 -4
  19. data/_includes/components/abc-letter.html +43 -0
  20. data/_includes/components/background-customizer.html +2 -2
  21. data/_includes/components/book-card.html +42 -0
  22. data/_includes/components/book-nav.html +80 -0
  23. data/_includes/components/book-plate.html +31 -0
  24. data/_includes/components/book-toc.html +47 -0
  25. data/_includes/components/bookshelf.html +68 -0
  26. data/_includes/components/card-grid.html +60 -0
  27. data/_includes/components/cookie-consent.html +4 -4
  28. data/_includes/components/data-card.html +95 -0
  29. data/_includes/components/halfmoon.html +5 -1
  30. data/_includes/components/info-section.html +8 -3
  31. data/_includes/components/language-toggle.html +168 -21
  32. data/_includes/components/page-feedback.html +65 -2
  33. data/_includes/components/page-views-init.html +55 -0
  34. data/_includes/components/page-views.html +33 -0
  35. data/_includes/components/recipe-card.html +67 -0
  36. data/_includes/components/recipe-duration.html +50 -0
  37. data/_includes/components/recipe-grams.html +58 -0
  38. data/_includes/components/recipe-index.html +96 -0
  39. data/_includes/components/recipe-ingredients.html +90 -0
  40. data/_includes/components/recipe-meta.html +96 -0
  41. data/_includes/components/recipe-nutrition.html +57 -0
  42. data/_includes/components/recipe-qty.html +73 -0
  43. data/_includes/components/recipe-ratio.html +151 -0
  44. data/_includes/components/recipe-scaler.html +73 -0
  45. data/_includes/components/recipe-steps.html +86 -0
  46. data/_includes/components/recipe-temp.html +45 -0
  47. data/_includes/components/search-modal.html +2 -2
  48. data/_includes/components/shortcuts-modal.html +3 -0
  49. data/_includes/components/theme-controls-bar.html +10 -2
  50. data/_includes/components/theme-customizer.html +8 -2
  51. data/_includes/components/theme-info.html +8 -1
  52. data/_includes/content/jsonld-software.html +22 -3
  53. data/_includes/content/seo.html +9 -3
  54. data/_includes/core/color-mode-init.html +13 -4
  55. data/_includes/core/favicon.html +46 -0
  56. data/_includes/core/footer.html +18 -16
  57. data/_includes/core/head.html +21 -0
  58. data/_includes/core/header.html +7 -4
  59. data/_includes/custom/body-end.html +18 -0
  60. data/_includes/custom/body-start.html +17 -0
  61. data/_includes/custom/footer.html +18 -0
  62. data/_includes/custom/head.html +18 -0
  63. data/_includes/navigation/local-graph.html +28 -2
  64. data/_includes/navigation/nav-tree.html +3 -3
  65. data/_includes/navigation/section-sidebar.html +93 -11
  66. data/_includes/navigation/sidebar-config.html +21 -0
  67. data/_includes/navigation/sidebar-left.html +2 -1
  68. data/_includes/navigation/sidebar-nav.html +4 -0
  69. data/_includes/navigation/sidebar-pagetree.html +150 -0
  70. data/_includes/navigation/sidebar-right.html +2 -1
  71. data/_includes/navigation/unified-drawer.html +8 -2
  72. data/_includes/obsidian/full-graph.html +165 -136
  73. data/_includes/setup/wizard.html +173 -86
  74. data/_includes/stats/stats-metrics.html +2 -2
  75. data/_layouts/404.html +260 -0
  76. data/_layouts/README.md +4 -0
  77. data/_layouts/admin.html +2 -2
  78. data/_layouts/article.html +5 -0
  79. data/_layouts/book-abc.html +106 -0
  80. data/_layouts/book-story.html +91 -0
  81. data/_layouts/book.html +113 -0
  82. data/_layouts/collection.html +17 -5
  83. data/_layouts/cookbook.html +88 -0
  84. data/_layouts/default.html +4 -4
  85. data/_layouts/home.html +5 -2
  86. data/_layouts/landing.html +9 -0
  87. data/_layouts/news.html +155 -31
  88. data/_layouts/recipe.html +274 -0
  89. data/_layouts/root.html +38 -5
  90. data/_layouts/section.html +96 -25
  91. data/_sass/components/_book.scss +423 -0
  92. data/_sass/components/_callout.scss +1 -1
  93. data/_sass/components/_footer.scss +37 -1
  94. data/_sass/components/_page-views.scss +36 -0
  95. data/_sass/components/_recipe.scss +506 -0
  96. data/_sass/components/_setup-wizard.scss +235 -0
  97. data/_sass/components/_ui-enhancements.scss +6 -6
  98. data/_sass/core/_navbar.scss +263 -15
  99. data/_sass/core/_obsidian.scss +286 -6
  100. data/_sass/layouts/_landing.scss +2 -2
  101. data/_sass/theme/_backgrounds.scss +21 -8
  102. data/_sass/tokens/_color.scss +6 -0
  103. data/_sass/tokens/_index.scss +2 -0
  104. data/_sass/tokens/_radius.scss +21 -0
  105. data/_sass/tokens/_typography.scss +4 -0
  106. data/_sass/utilities/_focus.scss +14 -0
  107. data/assets/css/main.scss +4 -0
  108. data/assets/js/auto-hide-nav.js +5 -1
  109. data/assets/js/halfmoon.js +26 -0
  110. data/assets/js/modules/navigation/navbar.js +55 -0
  111. data/assets/js/obsidian-graph.js +702 -264
  112. data/assets/js/obsidian-local-graph.js +161 -54
  113. data/assets/js/page-views.js +372 -0
  114. data/assets/js/recipe-scaler.js +501 -0
  115. data/assets/js/search-modal.js +14 -1
  116. data/assets/js/setup-wizard.js +322 -20
  117. data/scripts/README.md +29 -0
  118. data/scripts/bin/audit-consumer +39 -7
  119. data/scripts/bin/giscus-discussions +213 -14
  120. data/scripts/bin/manifest +66 -16
  121. data/scripts/bin/validate +5 -1
  122. data/scripts/ci/agent_review_result.py +164 -0
  123. data/scripts/ci/test_agent_review_result.py +172 -0
  124. data/scripts/design-system-check.rb +170 -0
  125. data/scripts/install/README.md +47 -6
  126. data/scripts/install/ai/client.sh +302 -93
  127. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  128. data/scripts/install/ai/wizard.sh +10 -5
  129. data/scripts/install/apply.sh +7 -3
  130. data/scripts/install/cli.sh +54 -4
  131. data/scripts/install/config.sh +167 -0
  132. data/scripts/install/doctor.sh +38 -0
  133. data/scripts/install/plan.sh +10 -0
  134. data/scripts/install/spec.sh +15 -7
  135. data/scripts/install/template.sh +4 -0
  136. data/scripts/lib/audit.sh +42 -2
  137. data/scripts/lint-liquid-raw.rb +137 -0
  138. data/scripts/propagate.rb +277 -0
  139. data/scripts/test/lib/run_tests.sh +2 -1
  140. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  141. data/scripts/translate.rb +161 -15
  142. metadata +56 -2
@@ -0,0 +1,274 @@
1
+ ---
2
+ # Feature: ZER0-084
3
+ layout: default
4
+ ---
5
+ {%- comment -%}
6
+ Layout: recipe.html
7
+ Extends: default.html
8
+ Purpose: One recipe — headnote, fact bar, serving scaler, ingredients,
9
+ equipment, method, ratio table, notes, and nutrition, all driven by
10
+ structured front matter rather than hand-written markdown.
11
+ Front matter required: layout, title
12
+ Front matter optional (the whole cookbook schema):
13
+ description, image/preview, course, cookbook, cuisine, difficulty,
14
+ yield: {amount, unit, singular}, times: {prep, cook, rest, rest_label,
15
+ total}, oven: {temp_f, temp_c, mode}, ratio, ratio_basis, equipment,
16
+ ingredients, steps, notes, nutrition, source, source_url, scaler (false to
17
+ hide the controls), units (false to hide the unit switch)
18
+ Notes: Every amount is rendered as authored, so the page is complete and
19
+ correct without JavaScript; assets/js/recipe-scaler.js only rewrites
20
+ the numbers once a reader asks it to. Machine-readable data is emitted
21
+ once, as JSON-LD — the itemprops inside the components are inert
22
+ annotations unless a consumer adds their own itemscope.
23
+ {%- endcomment -%}
24
+
25
+ {%- assign _rl_id = page.slug | default: "recipe" -%}
26
+ {%- assign _rl_course_meta = site.data.recipe_courses[page.course] -%}
27
+ {%- assign _rl_cookbook = site.recipes | where: "layout", "cookbook" | where: "cookbook", page.cookbook | first -%}
28
+ {%- assign _rl_image = page.image | default: page.preview -%}
29
+ {%- assign _rl_show_scaler = true -%}
30
+ {%- if page.scaler == false -%}{%- assign _rl_show_scaler = false -%}{%- endif -%}
31
+
32
+ <article class="recipe" data-recipe data-recipe-id="{{ _rl_id }}">
33
+
34
+ <!-- ================================ -->
35
+ <!-- HEADER -->
36
+ <!-- ================================ -->
37
+ <header class="recipe-header">
38
+ {%- if _rl_cookbook or _rl_course_meta -%}
39
+ <p class="recipe-eyebrow">
40
+ {%- if _rl_cookbook -%}
41
+ <a href="{{ _rl_cookbook.url | relative_url }}"><i class="bi bi-journal-bookmark me-1" aria-hidden="true"></i>{{ _rl_cookbook.title }}</a>
42
+ {%- endif -%}
43
+ {%- if _rl_cookbook and _rl_course_meta %} <span class="mx-1" aria-hidden="true">•</span> {% endif -%}
44
+ {%- if _rl_course_meta -%}{{ _rl_course_meta.title | escape }}{%- endif -%}
45
+ </p>
46
+ {%- endif -%}
47
+
48
+ <h1 id="page-title" class="recipe-title">{{ page.title }}</h1>
49
+
50
+ {%- if page.description -%}
51
+ <p class="lead recipe-description">{{ page.description }}</p>
52
+ {%- endif -%}
53
+
54
+ {%- if page.source or page.author -%}
55
+ <p class="recipe-byline text-body-secondary small">
56
+ {%- if page.author %}By {{ page.author }}{% endif -%}
57
+ {%- if page.author and page.source %} <span class="mx-1" aria-hidden="true">•</span> {% endif -%}
58
+ {%- if page.source -%}
59
+ Adapted from
60
+ {%- if page.source_url %} <a href="{{ page.source_url }}" rel="noopener">{{ page.source | escape }}</a>
61
+ {%- else %} {{ page.source | escape }}{% endif -%}
62
+ {%- endif -%}
63
+ </p>
64
+ {%- endif -%}
65
+
66
+ {%- if _rl_image -%}
67
+ <figure class="recipe-hero-figure">
68
+ {%- include components/preview-image.html src=_rl_image alt=page.title class="recipe-hero-image" loading="eager" -%}
69
+ {%- if page.image_caption -%}
70
+ <figcaption class="small text-body-secondary mt-2">{{ page.image_caption | escape }}</figcaption>
71
+ {%- endif -%}
72
+ </figure>
73
+ {%- endif -%}
74
+ </header>
75
+
76
+ <!-- ================================ -->
77
+ <!-- HEADNOTE (the markdown body) -->
78
+ <!-- ================================ -->
79
+ {%- assign _rl_body = content | strip -%}
80
+ {%- if _rl_body != "" -%}
81
+ <div class="recipe-headnote">
82
+ {{ content }}
83
+ </div>
84
+ {%- endif -%}
85
+
86
+ <!-- ================================ -->
87
+ <!-- FACT BAR + SCALER -->
88
+ <!-- ================================ -->
89
+ {%- include components/recipe-meta.html -%}
90
+
91
+ {%- if _rl_show_scaler -%}
92
+ {%- include components/recipe-scaler.html recipe_id=_rl_id units=page.units -%}
93
+ <noscript>
94
+ <p class="recipe-noscript small text-body-secondary">
95
+ <i class="bi bi-info-circle me-1" aria-hidden="true"></i>Serving scaling and unit conversion need JavaScript.
96
+ Every amount below is shown exactly as the recipe was written.
97
+ </p>
98
+ </noscript>
99
+ {%- endif -%}
100
+
101
+ <!-- ================================ -->
102
+ <!-- INGREDIENTS + METHOD -->
103
+ <!-- ================================ -->
104
+ <div class="row g-4 g-lg-5 recipe-body">
105
+ <div class="col-lg-5">
106
+ {%- include components/recipe-ingredients.html id_prefix=_rl_id -%}
107
+
108
+ {%- if page.equipment and page.equipment.size > 0 -%}
109
+ <section class="recipe-equipment">
110
+ <h2 class="recipe-section-heading">
111
+ <i class="bi bi-tools me-2" aria-hidden="true"></i>Equipment
112
+ </h2>
113
+ <ul class="recipe-equipment-list list-unstyled">
114
+ {%- for _rl_e in page.equipment -%}
115
+ <li><i class="bi bi-dot" aria-hidden="true"></i>{{ _rl_e | escape }}</li>
116
+ {%- endfor -%}
117
+ </ul>
118
+ </section>
119
+ {%- endif -%}
120
+ </div>
121
+
122
+ <div class="col-lg-7">
123
+ {%- include components/recipe-steps.html -%}
124
+ </div>
125
+ </div>
126
+
127
+ <!-- ================================ -->
128
+ <!-- RATIO / FORMULA -->
129
+ <!-- ================================ -->
130
+ {%- include components/recipe-ratio.html -%}
131
+
132
+ <!-- ================================ -->
133
+ <!-- NOTES -->
134
+ <!-- ================================ -->
135
+ {%- if page.notes and page.notes.size > 0 -%}
136
+ <section class="recipe-notes">
137
+ <h2 class="recipe-section-heading">
138
+ <i class="bi bi-lightbulb me-2" aria-hidden="true"></i>Cook's notes
139
+ </h2>
140
+ <ul class="recipe-notes-list">
141
+ {%- for _rl_note in page.notes -%}
142
+ <li>{{ _rl_note | markdownify | remove: "<p>" | remove: "</p>" }}</li>
143
+ {%- endfor -%}
144
+ </ul>
145
+ </section>
146
+ {%- endif -%}
147
+
148
+ <!-- ================================ -->
149
+ <!-- NUTRITION -->
150
+ <!-- ================================ -->
151
+ {%- include components/recipe-nutrition.html -%}
152
+
153
+ <!-- ================================ -->
154
+ <!-- COMMENTS -->
155
+ <!-- ================================ -->
156
+ {%- if page.comments and site.giscus.enabled -%}
157
+ <section class="post-comments mt-5 pt-4 border-top" id="comments">
158
+ <h2 class="h4 mb-4"><i class="bi bi-chat-dots me-2" aria-hidden="true"></i>Comments</h2>
159
+ {%- include content/giscus.html -%}
160
+ </section>
161
+ {%- endif -%}
162
+ </article>
163
+
164
+ {%- comment -%}
165
+ ================================================================
166
+ STRUCTURED DATA — schema.org/Recipe as JSON-LD.
167
+ Emitted once, here, so search engines see exactly one Recipe entity.
168
+ ================================================================
169
+ {%- endcomment -%}
170
+ {%- assign _rl_prep = page.times.prep | default: 0 -%}
171
+ {%- assign _rl_cook = page.times.cook | default: 0 -%}
172
+ {%- assign _rl_rest = page.times.rest | default: 0 -%}
173
+ {%- if page.times.total -%}
174
+ {%- assign _rl_total = page.times.total -%}
175
+ {%- else -%}
176
+ {%- assign _rl_total = _rl_prep | plus: _rl_cook | plus: _rl_rest -%}
177
+ {%- endif -%}
178
+ {%- capture _rl_prep_iso -%}{%- include components/recipe-duration.html minutes=_rl_prep format="iso" -%}{%- endcapture -%}
179
+ {%- capture _rl_cook_iso -%}{%- include components/recipe-duration.html minutes=_rl_cook format="iso" -%}{%- endcapture -%}
180
+ {%- capture _rl_total_iso -%}{%- include components/recipe-duration.html minutes=_rl_total format="iso" -%}{%- endcapture -%}
181
+ {%- assign _rl_flat = "" | split: "" -%}
182
+ {%- for _rl_entry in page.ingredients -%}
183
+ {%- if _rl_entry.items -%}
184
+ {%- for _rl_i in _rl_entry.items -%}{%- assign _rl_flat = _rl_flat | push: _rl_i -%}{%- endfor -%}
185
+ {%- else -%}
186
+ {%- assign _rl_flat = _rl_flat | push: _rl_entry -%}
187
+ {%- endif -%}
188
+ {%- endfor -%}
189
+ {%- assign _rl_flat_steps = "" | split: "" -%}
190
+ {%- for _rl_entry in page.steps -%}
191
+ {%- if _rl_entry.items -%}
192
+ {%- for _rl_s in _rl_entry.items -%}{%- assign _rl_flat_steps = _rl_flat_steps | push: _rl_s -%}{%- endfor -%}
193
+ {%- else -%}
194
+ {%- assign _rl_flat_steps = _rl_flat_steps | push: _rl_entry -%}
195
+ {%- endif -%}
196
+ {%- endfor -%}
197
+ <script type="application/ld+json">
198
+ {
199
+ "@context": "https://schema.org",
200
+ "@type": "Recipe",
201
+ "name": {{ page.title | jsonify }},
202
+ "url": {{ page.url | absolute_url | jsonify }}
203
+ {%- if page.description %},
204
+ "description": {{ page.description | strip_html | strip | jsonify }}
205
+ {%- endif -%}
206
+ {%- if _rl_image %},
207
+ "image": {{ _rl_image | absolute_url | jsonify }}
208
+ {%- endif -%}
209
+ {%- if page.author %},
210
+ "author": { "@type": "Person", "name": {{ page.author | jsonify }} }
211
+ {%- endif -%}
212
+ {%- if page.date %},
213
+ "datePublished": {{ page.date | date_to_xmlschema | jsonify }}
214
+ {%- endif -%}
215
+ {%- assign _rl_prep_iso = _rl_prep_iso | strip -%}
216
+ {%- if _rl_prep_iso != "" %},
217
+ "prepTime": {{ _rl_prep_iso | jsonify }}
218
+ {%- endif -%}
219
+ {%- assign _rl_cook_iso = _rl_cook_iso | strip -%}
220
+ {%- if _rl_cook_iso != "" %},
221
+ "cookTime": {{ _rl_cook_iso | jsonify }}
222
+ {%- endif -%}
223
+ {%- assign _rl_total_iso = _rl_total_iso | strip -%}
224
+ {%- if _rl_total_iso != "" %},
225
+ "totalTime": {{ _rl_total_iso | jsonify }}
226
+ {%- endif -%}
227
+ {%- if page.yield.amount %},
228
+ {%- capture _rl_yield_text %}{{ page.yield.amount }} {{ page.yield.unit | default: "servings" }}{% endcapture %}
229
+ "recipeYield": {{ _rl_yield_text | strip | jsonify }}
230
+ {%- endif -%}
231
+ {%- if _rl_course_meta %},
232
+ "recipeCategory": {{ _rl_course_meta.title | jsonify }}
233
+ {%- endif -%}
234
+ {%- if page.cuisine %},
235
+ "recipeCuisine": {{ page.cuisine | jsonify }}
236
+ {%- endif -%}
237
+ {%- if page.tags and page.tags.size > 0 %},
238
+ "keywords": {{ page.tags | join: ", " | jsonify }}
239
+ {%- endif -%}
240
+ {%- if _rl_flat.size > 0 %},
241
+ "recipeIngredient": [
242
+ {%- assign _rl_first = true -%}
243
+ {%- for _rl_i in _rl_flat -%}
244
+ {%- if _rl_i.item -%}
245
+ {%- capture _rl_line %}{% if _rl_i.qty %}{{ _rl_i.qty }} {% endif %}{% if _rl_i.unit %}{{ _rl_i.unit }} {% endif %}{{ _rl_i.item }}{% if _rl_i.prep %}, {{ _rl_i.prep }}{% endif %}{% endcapture -%}
246
+ {%- unless _rl_first %},{% endunless %}{%- assign _rl_first = false -%}
247
+ {{ _rl_line | strip | jsonify }}
248
+ {%- endif -%}
249
+ {%- endfor -%}
250
+ ]
251
+ {%- endif -%}
252
+ {%- if _rl_flat_steps.size > 0 %},
253
+ "recipeInstructions": [
254
+ {%- assign _rl_first = true -%}
255
+ {%- for _rl_s in _rl_flat_steps -%}
256
+ {%- if _rl_s.text -%}{%- assign _rl_stext = _rl_s.text -%}{%- else -%}{%- assign _rl_stext = _rl_s -%}{%- endif -%}
257
+ {%- assign _rl_stext = _rl_stext | strip_html | strip -%}
258
+ {%- if _rl_stext != "" -%}
259
+ {%- unless _rl_first %},{% endunless %}{%- assign _rl_first = false -%}
260
+ { "@type": "HowToStep"{% if _rl_s.title %}, "name": {{ _rl_s.title | jsonify }}{% endif %}, "text": {{ _rl_stext | jsonify }} }
261
+ {%- endif -%}
262
+ {%- endfor -%}
263
+ ]
264
+ {%- endif -%}
265
+ {%- if page.nutrition.calories %},
266
+ {%- capture _rl_serving_size %}1 {{ page.yield.singular | default: "serving" }}{% endcapture %}
267
+ "nutrition": {
268
+ "@type": "NutritionInformation",
269
+ "servingSize": {{ _rl_serving_size | strip | jsonify }},
270
+ "calories": {{ page.nutrition.calories | append: " calories" | jsonify }}
271
+ }
272
+ {%- endif %}
273
+ }
274
+ </script>
data/_layouts/root.html CHANGED
@@ -46,12 +46,23 @@
46
46
  {%- else -%}
47
47
  {%- assign _bs_theme = 'dark' -%}
48
48
  {%- endif -%}
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 }}">
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-color-mode-lock="{{ site.color_mode_lock | default: false }}" data-theme-skin="{{ site.theme_skin | default: 'air' }}" data-zer0-bg="{{ site.theme_background.enabled | default: true }}">
50
50
  <head>
51
+ <!-- Progressive-enhancement hook: upgrade <html class="no-js"> to "js".
52
+ MUST be the first child of <head>, inline, and neither `defer` nor
53
+ `async` — it has to run before the first stylesheet is fetched or
54
+ `html.js` rules would flash. It cannot live in core/head.html:
55
+ Google Tag Manager is emitted above everything else there, so
56
+ "first in that include" is not "first in <head>".
57
+ classList.replace() is a no-op when `no-js` is absent, so this is
58
+ safe unconditionally. The served HTML keeps `no-js` on <html> so
59
+ the JS-disabled branch stays correct. -->
60
+ <script>document.documentElement.classList.replace("no-js", "js");</script>
61
+
51
62
  <!-- =============================================== -->
52
63
  <!-- HEAD SECTION: Meta tags, styles, and SEO setup -->
53
64
  <!-- =============================================== -->
54
-
65
+
55
66
  <!-- Core site metadata, stylesheets, and external dependencies -->
56
67
  {% include core/head.html %}
57
68
 
@@ -67,7 +78,11 @@
67
78
  <!-- ============================================== -->
68
79
  <!-- BODY SECTION: Site structure and content area -->
69
80
  <!-- ============================================== -->
70
-
81
+
82
+ <!-- Consumer hook: first thing inside <body> (e.g. GTM noscript).
83
+ Empty stub shipped by the theme; shadow _includes/custom/body-start.html to use. -->
84
+ {% include custom/body-start.html %}
85
+
71
86
  <!-- Inline SVG symbols for consistent iconography throughout the site -->
72
87
  <!-- Page-invariant: cached once per build via jekyll-include-cache. -->
73
88
  {% include_cached components/svg.html %}
@@ -103,8 +118,16 @@
103
118
  This is the ONE <main> landmark per page — child layouts (default/section/
104
119
  news) must use non-landmark wrappers (<div>) inside it (issue #299) — so
105
120
  assistive tech, search engines, and AI content extractors can isolate the
106
- primary content from the surrounding header/nav/footer chrome. -->
107
- <main id="main-content">
121
+ primary content from the surrounding header/nav/footer chrome.
122
+
123
+ tabindex="-1" is REQUIRED, not decorative: <main> is not natively
124
+ focusable, and Safari/WebKit (historically Firefox too) only scroll on a
125
+ fragment jump to a non-focusable container — keyboard focus stays put, so
126
+ the next Tab returns the user to the header nav they just skipped
127
+ (WCAG 2.4.1 Bypass Blocks). -1 keeps it out of the sequential Tab order
128
+ while allowing programmatic focus. The focus ring it would otherwise
129
+ paint is suppressed in _sass/utilities/_focus.scss. -->
130
+ <main id="main-content" tabindex="-1">
108
131
  <!-- Machine-translation disclosure — renders only on generated
109
132
  translations (page.machine_translated); no-op elsewhere. -->
110
133
  {%- include components/translation-notice.html -%}
@@ -125,6 +148,12 @@
125
148
  {%- include_cached core/footer.html lang=page.lang -%}
126
149
  {%- include core/footer-fabs.html -%}
127
150
 
151
+ <!-- Consumer hook: after the theme footer, before the consent banner.
152
+ Empty stub shipped by the theme; shadow _includes/custom/footer.html to use.
153
+ Deliberately OUTSIDE the cached footer include so page-dependent
154
+ consumer markup renders per page. -->
155
+ {%- include custom/footer.html -%}
156
+
128
157
  <!-- Privacy-compliant cookie consent banner — page-invariant. -->
129
158
  {%- include_cached components/cookie-consent.html -%}
130
159
 
@@ -148,5 +177,9 @@
148
177
 
149
178
  <!-- Analytics Integration -->
150
179
  {%- include analytics/posthog.html -%}
180
+
181
+ <!-- Consumer hook: last thing before </body> (site scripts, widgets).
182
+ Empty stub shipped by the theme; shadow _includes/custom/body-end.html to use. -->
183
+ {%- include custom/body-end.html -%}
151
184
  </body>
152
185
  </html>
@@ -46,6 +46,34 @@ layout: root
46
46
  {% comment %} SECTION DISCOVERY LOGIC {% endcomment %}
47
47
  {% comment %} ================================ {% endcomment %}
48
48
 
49
+ {% comment %}
50
+ Same `news:` config surface as _layouts/news.html:
51
+ - news.category_base (default "/news/") — base path for section pages
52
+ - news.index_url (default "/news/") — the news homepage this section
53
+ belongs to (breadcrumb + "Browse all news")
54
+ Section pills fall back to site.categories when _data/navigation/posts.yml
55
+ is absent, so the layout works on a data-less consumer site.
56
+ {% endcomment %}
57
+ {% assign news_cfg = site.news %}
58
+ {% assign news_base = news_cfg.category_base | default: "/news/" %}
59
+ {% assign news_index_url = news_cfg.index_url | default: "/news/" %}
60
+ {% capture _sec_names %}{% for item in site.data.navigation.posts %}{{ item.title }}{% unless forloop.last %}|{% endunless %}{% endfor %}{% endcapture %}
61
+ {% if _sec_names == "" %}{% capture _sec_names %}{% for cat in site.categories limit: 8 %}{{ cat[0] }}{% unless forloop.last %}|{% endunless %}{% endfor %}{% endcapture %}{% endif %}
62
+ {% assign news_sections = _sec_names | split: "|" %}
63
+
64
+ {% comment %}
65
+ Tags index link policy — identical to news.html: honor site.tags_page when
66
+ set, otherwise only link when a /tags/ page exists in the build.
67
+ {% endcomment %}
68
+ {% assign tags_base = site.tags_page | default: "/tags/" %}
69
+ {% assign _tags_linkable = false %}
70
+ {% if site.tags_page %}
71
+ {% assign _tags_linkable = true %}
72
+ {% else %}
73
+ {% assign _tags_index_page = site.html_pages | where: "url", tags_base | first %}
74
+ {% if _tags_index_page %}{% assign _tags_linkable = true %}{% endif %}
75
+ {% endif %}
76
+
49
77
  {% assign path_parts = page.path | split: "/" %}
50
78
  {% assign section_name = "" %}
51
79
  {% for part in path_parts %}
@@ -99,7 +127,7 @@ layout: root
99
127
  <nav aria-label="breadcrumb">
100
128
  <ol class="breadcrumb mb-0">
101
129
  <li class="breadcrumb-item">
102
- <a href="{{ site.baseurl }}/news/" class="text-white-50 text-decoration-none">
130
+ <a href="{{ news_index_url | relative_url }}" class="text-white-50 text-decoration-none">
103
131
  <i class="bi bi-newspaper me-1"></i>News
104
132
  </a>
105
133
  </li>
@@ -123,15 +151,21 @@ layout: root
123
151
  <div class="container-fluid bg-body-tertiary border-bottom py-2 mb-4">
124
152
  <div class="container-fluid px-4">
125
153
  <nav class="nav nav-pills flex-nowrap overflow-auto" style="scrollbar-width: none;">
126
- {% for item in site.data.navigation.posts %}
154
+ {% for sec_name in news_sections %}
127
155
  {% assign is_active = false %}
128
- {% if item.title == page.category or item.title == page.title %}
156
+ {% if sec_name == page.category or sec_name == page.title %}
129
157
  {% assign is_active = true %}
130
158
  {% endif %}
131
- <a class="nav-link text-nowrap {% if is_active %}active{% else %}text-body-secondary{% endif %}"
132
- href="{{ item.url | replace: '/posts/', '/news/' | relative_url }}">
133
- {% if item.icon %}<i class="bi {{ item.icon }} me-1"></i>{% endif %}
134
- {{ item.title }}
159
+ {% assign _sec_nav = site.data.navigation.posts | where: "title", sec_name | first %}
160
+ {% if _sec_nav %}
161
+ {% assign _sec_url = _sec_nav.url | replace: '/posts/', news_base %}
162
+ {% else %}
163
+ {% capture _sec_url %}{{ news_base }}{{ sec_name | slugify }}/{% endcapture %}
164
+ {% endif %}
165
+ <a class="nav-link text-nowrap {% if is_active %}active{% else %}text-body-secondary{% endif %}"
166
+ href="{{ _sec_url | relative_url }}">
167
+ {% if _sec_nav.icon %}<i class="bi {{ _sec_nav.icon }} me-1"></i>{% endif %}
168
+ {{ sec_name }}
135
169
  </a>
136
170
  {% endfor %}
137
171
  </nav>
@@ -148,8 +182,9 @@ layout: root
148
182
  {% include navigation/section-sidebar.html
149
183
  section_posts=section_posts
150
184
  sub_categories=sub_categories
151
- page_title=page.title
152
- sidebar_id="sectionSidebar"
185
+ page_title=page.title
186
+ sidebar_id="sectionSidebar"
187
+ section_style=section_style
153
188
  %}
154
189
  </div>
155
190
 
@@ -255,9 +290,11 @@ layout: root
255
290
  <h3 class="h4 mb-0">
256
291
  <i class="bi bi-tag text-primary me-2"></i>{{ subcat | capitalize }}
257
292
  </h3>
258
- <a href="{{ site.baseurl }}/tags/#{{ subcat | slugify }}" class="btn btn-outline-secondary btn-sm">
293
+ {% if _tags_linkable %}
294
+ <a href="{{ tags_base | relative_url }}#{{ subcat | slugify }}" class="btn btn-outline-secondary btn-sm">
259
295
  View All <i class="bi bi-arrow-right ms-1"></i>
260
296
  </a>
297
+ {% endif %}
261
298
  </div>
262
299
 
263
300
  <div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-4">
@@ -383,7 +420,17 @@ layout: root
383
420
  <section id="all-posts" class="grid-section mb-5 scroll-mt-5">
384
421
  <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
385
422
  {% for gpost in section_posts %}
386
- <div class="col" data-tags="{{ gpost.tags | join: ' ' | slugify }}">
423
+ {%- comment -%}
424
+ Slugify each tag separately and separate with a space. Joining
425
+ first and slugifying the result turned the separators into
426
+ hyphens, making a tag boundary indistinguishable from a hyphen
427
+ inside a slug ("edge-ai"), which broke filtering.
428
+ The trailing space is deliberate: guarding the separator with a
429
+ last-iteration conditional gives that conditional a
430
+ whitespace-only body, which Liquid treats as a blank block and
431
+ renders as nothing — silently gluing the tags together.
432
+ {%- endcomment -%}
433
+ <div class="col" data-tags="{% for t in gpost.tags %}{{ t | slugify }} {% endfor %}">
387
434
  <div class="card h-100 border-0 shadow-sm hover-lift">
388
435
  <div class="position-relative">
389
436
  {% if gpost.breaking %}
@@ -446,7 +493,8 @@ layout: root
446
493
 
447
494
  <section id="all-posts" class="list-section mb-5 scroll-mt-5">
448
495
  {% for lpost in section_posts %}
449
- <article class="card border-0 shadow-sm mb-3 hover-lift">
496
+ {%- comment -%} Per-tag slugify + space separator — see the grid branch above. {%- endcomment -%}
497
+ <article class="card border-0 shadow-sm mb-3 hover-lift" data-tags="{% for t in lpost.tags %}{{ t | slugify }} {% endfor %}">
450
498
  <div class="row g-0">
451
499
  <div class="col-md-3">
452
500
  <a href="{{ lpost.url | relative_url }}">
@@ -496,7 +544,7 @@ layout: root
496
544
  <div class="text-center py-5">
497
545
  <i class="bi bi-inbox fs-1 text-muted"></i>
498
546
  <p class="text-muted mt-3">No articles in this section yet.</p>
499
- <a href="{{ site.baseurl }}/news/" class="btn btn-outline-primary">
547
+ <a href="{{ news_index_url | relative_url }}" class="btn btn-outline-primary">
500
548
  Browse all news
501
549
  </a>
502
550
  </div>
@@ -510,15 +558,21 @@ layout: root
510
558
  <i class="bi bi-compass me-2"></i>Explore Other Sections
511
559
  </h3>
512
560
  <div class="row row-cols-2 row-cols-md-3 row-cols-lg-6 g-3">
513
- {% for nav_item in site.data.navigation.posts %}
514
- {% unless nav_item.title == page.category or nav_item.title == page.title %}
515
- {% assign other_section_posts = all_site_posts | where_exp: "post", "post.categories contains nav_item.title" %}
561
+ {% for sec_name in news_sections %}
562
+ {% unless sec_name == page.category or sec_name == page.title %}
563
+ {% assign other_section_posts = all_site_posts | where_exp: "post", "post.categories contains sec_name" %}
564
+ {% assign _sec_nav = site.data.navigation.posts | where: "title", sec_name | first %}
565
+ {% if _sec_nav %}
566
+ {% assign _sec_url = _sec_nav.url | replace: '/posts/', news_base %}
567
+ {% else %}
568
+ {% capture _sec_url %}{{ news_base }}{{ sec_name | slugify }}/{% endcapture %}
569
+ {% endif %}
516
570
  <div class="col">
517
- <a href="{{ nav_item.url | replace: '/posts/', '/news/' | relative_url }}"
571
+ <a href="{{ _sec_url | relative_url }}"
518
572
  class="card text-center h-100 text-decoration-none border-0 shadow-sm hover-lift">
519
573
  <div class="card-body py-3">
520
- <i class="bi {{ nav_item.icon | default: 'bi-folder' }} fs-4 text-primary mb-2 d-block"></i>
521
- <h6 class="card-title mb-1 small">{{ nav_item.title }}</h6>
574
+ <i class="bi {{ _sec_nav.icon | default: 'bi-folder' }} fs-4 text-primary mb-2 d-block"></i>
575
+ <h6 class="card-title mb-1 small">{{ sec_name }}</h6>
522
576
  <small class="text-muted">{{ other_section_posts.size }} articles</small>
523
577
  </div>
524
578
  </a>
@@ -555,17 +609,34 @@ layout: root
555
609
  btn.addEventListener('click', function() {
556
610
  const filter = this.dataset.filter;
557
611
 
558
- // Update active button
612
+ // Update active state across every filter control on the page — the
613
+ // topic pills here and the section sidebar's topic buttons both carry
614
+ // [data-filter], so they stay in sync. Only pills get the btn-* colour
615
+ // swap; the sidebar's controls are .nav-link and style themselves off
616
+ // .active alone.
559
617
  document.querySelectorAll('[data-filter]').forEach(b => {
560
- b.classList.remove('btn-primary', 'active');
561
- b.classList.add('btn-outline-secondary');
618
+ b.classList.remove('active');
619
+ if (b.classList.contains('btn')) {
620
+ b.classList.remove('btn-primary');
621
+ b.classList.add('btn-outline-secondary');
622
+ }
562
623
  });
563
- this.classList.remove('btn-outline-secondary');
564
- this.classList.add('btn-primary', 'active');
624
+ this.classList.add('active');
625
+ if (this.classList.contains('btn')) {
626
+ this.classList.remove('btn-outline-secondary');
627
+ this.classList.add('btn-primary');
628
+ }
565
629
 
566
630
  // Filter cards
631
+ // Match whole tags, not substrings. `data-tags` is a space-separated list
632
+ // of individually-slugified tags, so a slug never spans a separator and
633
+ // filtering by "ai" no longer also matches a post tagged "edge-ai".
634
+ // (This used to disagree with the sidebar's own count: the badge is built
635
+ // in Liquid with `post.tags contains subcat` — exact membership — so a
636
+ // topic showing 5 posts could reveal 6 cards when clicked.)
567
637
  document.querySelectorAll('[data-tags]').forEach(card => {
568
- if (filter === 'all' || card.dataset.tags.includes(filter)) {
638
+ const cardTags = (card.dataset.tags || '').split(' ').filter(Boolean);
639
+ if (filter === 'all' || cardTags.indexOf(filter) !== -1) {
569
640
  card.style.display = '';
570
641
  } else {
571
642
  card.style.display = 'none';