jekyll-theme-zer0 1.28.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +430 -13
  3. data/_data/backlog.yml +356 -2
  4. data/_data/consumers.yml +109 -4
  5. data/_data/features.yml +157 -0
  6. data/_data/i18n/fr.yml +12 -7
  7. data/_data/i18n/manifest.yml +39 -12
  8. data/_data/ingredient_densities.yml +122 -0
  9. data/_data/navigation/main.yml +16 -0
  10. data/_data/recipe_courses.yml +64 -0
  11. data/_data/theme-manifest.yml +68 -40
  12. data/_data/ui-text.yml +8 -0
  13. data/_includes/README.md +15 -1
  14. data/_includes/components/background-customizer.html +2 -2
  15. data/_includes/components/cookie-consent.html +4 -4
  16. data/_includes/components/info-section.html +8 -3
  17. data/_includes/components/language-toggle.html +168 -21
  18. data/_includes/components/page-views-init.html +55 -0
  19. data/_includes/components/page-views.html +33 -0
  20. data/_includes/components/recipe-card.html +67 -0
  21. data/_includes/components/recipe-duration.html +50 -0
  22. data/_includes/components/recipe-grams.html +58 -0
  23. data/_includes/components/recipe-index.html +96 -0
  24. data/_includes/components/recipe-ingredients.html +90 -0
  25. data/_includes/components/recipe-meta.html +96 -0
  26. data/_includes/components/recipe-nutrition.html +57 -0
  27. data/_includes/components/recipe-qty.html +73 -0
  28. data/_includes/components/recipe-ratio.html +151 -0
  29. data/_includes/components/recipe-scaler.html +73 -0
  30. data/_includes/components/recipe-steps.html +86 -0
  31. data/_includes/components/recipe-temp.html +45 -0
  32. data/_includes/components/search-modal.html +2 -2
  33. data/_includes/components/shortcuts-modal.html +3 -0
  34. data/_includes/components/theme-info.html +8 -1
  35. data/_includes/content/jsonld-software.html +22 -3
  36. data/_includes/core/footer.html +18 -16
  37. data/_includes/core/head.html +12 -0
  38. data/_includes/core/header.html +7 -4
  39. data/_includes/navigation/section-sidebar.html +93 -11
  40. data/_includes/navigation/sidebar-left.html +2 -1
  41. data/_includes/navigation/sidebar-right.html +2 -1
  42. data/_includes/setup/wizard.html +173 -86
  43. data/_includes/stats/stats-metrics.html +2 -2
  44. data/_layouts/README.md +2 -0
  45. data/_layouts/admin.html +2 -2
  46. data/_layouts/article.html +5 -0
  47. data/_layouts/cookbook.html +88 -0
  48. data/_layouts/default.html +4 -4
  49. data/_layouts/recipe.html +274 -0
  50. data/_layouts/root.html +22 -3
  51. data/_layouts/section.html +39 -10
  52. data/_sass/components/_callout.scss +1 -1
  53. data/_sass/components/_footer.scss +37 -1
  54. data/_sass/components/_page-views.scss +36 -0
  55. data/_sass/components/_recipe.scss +506 -0
  56. data/_sass/components/_setup-wizard.scss +235 -0
  57. data/_sass/components/_ui-enhancements.scss +6 -6
  58. data/_sass/core/_navbar.scss +250 -15
  59. data/_sass/layouts/_landing.scss +2 -2
  60. data/_sass/tokens/_color.scss +6 -0
  61. data/_sass/tokens/_index.scss +2 -0
  62. data/_sass/tokens/_radius.scss +21 -0
  63. data/_sass/tokens/_typography.scss +4 -0
  64. data/_sass/utilities/_focus.scss +14 -0
  65. data/assets/css/main.scss +3 -0
  66. data/assets/js/modules/navigation/navbar.js +55 -0
  67. data/assets/js/page-views.js +372 -0
  68. data/assets/js/recipe-scaler.js +501 -0
  69. data/assets/js/search-modal.js +10 -0
  70. data/assets/js/setup-wizard.js +322 -20
  71. data/scripts/README.md +29 -0
  72. data/scripts/bin/audit-consumer +39 -7
  73. data/scripts/bin/giscus-discussions +213 -14
  74. data/scripts/bin/manifest +35 -12
  75. data/scripts/ci/agent_review_result.py +164 -0
  76. data/scripts/ci/test_agent_review_result.py +172 -0
  77. data/scripts/design-system-check.rb +170 -0
  78. data/scripts/lib/audit.sh +42 -2
  79. data/scripts/lint-liquid-raw.rb +137 -0
  80. data/scripts/test/lib/run_tests.sh +2 -1
  81. data/scripts/test/lib/test_agent_review_result.sh +27 -0
  82. data/scripts/translate.rb +71 -15
  83. metadata +31 -2
@@ -2,24 +2,61 @@
2
2
  {%- comment -%}
3
3
  Component: language-toggle
4
4
  Path: _includes/components/language-toggle.html
5
- Purpose: Navbar dropdown that switches between the English original and
6
- its machine-generated translations (scripts/translate.rb).
7
- Shows the source language plus every configured target
8
- language; targets without a generated translation for the
9
- current page render as disabled entries.
10
- Params: none — reads page.lang / page.url / page.translation_source_url
11
- and the generated _data/i18n/manifest.yml.
5
+ Purpose: Switches between the English original and its machine-generated
6
+ translations (scripts/translate.rb). Shows the source language
7
+ plus every configured target language. Every entry is a live
8
+ link: a target without a generated translation for the current
9
+ page falls back to the source page and is explained by one
10
+ footnote (it used to render as a disabled entry — see T-038).
11
+ Params: variant (string, optional, default "navbar")
12
+ - "navbar" — compact dropdown for the header utility cluster
13
+ - "panel" — self-contained "Language" section for the
14
+ Settings offcanvas (components/info-section.html),
15
+ rendered as an always-visible list of choices
16
+ with its own <h6> heading
17
+ Reads: page.lang / page.url / page.translation_source_url and the
18
+ generated _data/i18n/manifest.yml.
12
19
  Depends on: site.translation.* config, site.data.i18n.manifest,
13
20
  _data/i18n/languages.yml (display names), core/i18n.html,
14
- Bootstrap 5 dropdown, Bootstrap Icons (bi-translate)
21
+ Bootstrap 5 dropdown (navbar variant only), Bootstrap Icons
22
+ (bi-translate)
15
23
  Notes: Renders nothing when translation is disabled or no target
16
24
  languages are configured. The chosen language is remembered in
17
25
  localStorage("zer0-lang") for future UX (no auto-redirect).
18
26
  Must NOT be include_cached — the menu is page-dependent.
27
+ The theme ships the "panel" variant (the navbar is reserved for
28
+ main navigation); "navbar" stays for consumers that prefer the
29
+ header dropdown.
30
+
31
+ MENU CONTRACT (T-038 / #406) — shared by both variants:
32
+ * No DISABLED rows. A target language without a generated
33
+ translation is still a link; it goes to the source-language
34
+ page and still records the preference in zer0-lang, so the
35
+ menu never contains a dead end.
36
+ * The current language is marked with a check icon and a
37
+ subtle tint — NOT Bootstrap's `.active` primary fill, which
38
+ read as a selected nav item rather than "you are here".
39
+ * Machine-generated translations carry a small "auto" chip.
40
+ The source language never does (a human wrote it), and
41
+ neither do untranslated rows (nothing was generated).
42
+ * The per-row "(Not yet translated)" text is replaced by ONE
43
+ footnote under a divider, referenced from each untranslated
44
+ row via aria-describedby. Per-row text was the single
45
+ biggest contributor to menu width.
46
+ Trigger changes (icon-only, no caret) apply to the navbar
47
+ variant only — the panel has an <h6> heading, not a button.
48
+
49
+ New ui keys (lang_machine_translated, lang_machine_translated_title,
50
+ lang_untranslated_note) live in _data/ui-text.yml `en`. core/i18n.html
51
+ REPLACES the whole `ui` map on a translated page rather than merging
52
+ it, so until scripts/translate.rb regenerates _data/i18n/<lang>.yml
53
+ these keys are absent there — every use below therefore carries a
54
+ literal `| default:` fallback. Do not remove them.
19
55
  {%- endcomment -%}
20
56
  {%- assign _lt_langs = site.translation.languages -%}
21
57
  {%- if site.translation.enabled and _lt_langs and _lt_langs.size > 0 -%}
22
58
  {%- include core/i18n.html -%}
59
+ {%- assign _lt_variant = include.variant | default: 'navbar' -%}
23
60
  {%- assign _lt_source = site.translation.source_lang | default: 'en' -%}
24
61
  {%- assign _lt_current = page.lang | default: _lt_source -%}
25
62
  {%- if _lt_current == _lt_source -%}
@@ -30,25 +67,117 @@
30
67
  {%- assign _lt_entry = site.data.i18n.manifest.pages[_lt_src_url] -%}
31
68
  {%- assign _lt_names = site.data.i18n.languages -%}
32
69
  {%- assign _lt_source_name = _lt_names[_lt_source].native | default: _lt_source -%}
70
+ {%- comment -%}
71
+ Display name of the language being read, for the trigger's title/aria — the
72
+ icon-only button has no visible text, so the accessible name has to carry it.
73
+ {%- endcomment -%}
74
+ {%- if _lt_current == _lt_source -%}
75
+ {%- assign _lt_current_name = _lt_source_name -%}
76
+ {%- else -%}
77
+ {%- assign _lt_current_name = _lt_names[_lt_current].native | default: _lt_current -%}
78
+ {%- endif -%}
79
+ {%- comment -%}
80
+ Does ANY target language lack a translation of this page? Drives whether the
81
+ footnote + divider render at all — an all-translated menu shows neither.
82
+ A target we are currently reading is by definition translated.
83
+ {%- endcomment -%}
84
+ {%- assign _lt_untranslated = 0 -%}
85
+ {%- for _lt_l in _lt_langs -%}
86
+ {%- unless _lt_l == _lt_current or _lt_entry[_lt_l] -%}
87
+ {%- assign _lt_untranslated = _lt_untranslated | plus: 1 -%}
88
+ {%- endunless -%}
89
+ {%- endfor -%}
90
+ {%- assign _lt_note_id = 'zer0-lang-untranslated-note' -%}
91
+ {%- assign _lt_auto_label = ui.lang_machine_translated | default: 'auto' -%}
92
+ {%- assign _lt_auto_title = ui.lang_machine_translated_title | default: 'Machine-generated translation' -%}
93
+ {%- assign _lt_note_text = ui.lang_untranslated_note | default: 'Marked languages are not yet translated — those links open the original.' -%}
94
+ {%- assign _lt_fallback_url = _lt_src_url | default: '/' | relative_url -%}
95
+ {%- if _lt_variant == 'panel' -%}
96
+ <div class="mb-4 nav-lang-panel" id="zer0-lang-toggle">
97
+ <h6 class="text-body-secondary small text-uppercase fw-semibold mb-2">
98
+ <i class="bi bi-translate me-1" aria-hidden="true"></i>{{ ui.lang_toggle_label | default: "Language" }}
99
+ </h6>
100
+ <div class="list-group nav-lang-options" role="group" aria-label="{{ ui.lang_toggle_aria | default: 'Change language' }}">
101
+ {%- if _lt_current == _lt_source %}
102
+ <span class="list-group-item nav-lang-item is-current" aria-current="true">
103
+ <span class="nav-lang-code">{{ _lt_source | slice: 0, 2 | upcase }}</span>
104
+ <span class="nav-lang-name">{{ _lt_source_name }}</span>
105
+ <i class="bi bi-check2 nav-lang-check" aria-hidden="true"></i>
106
+ </span>
107
+ {%- else %}
108
+ <a class="list-group-item list-group-item-action nav-lang-item" data-lang="{{ _lt_source }}" hreflang="{{ _lt_source }}" href="{{ _lt_fallback_url }}">
109
+ <span class="nav-lang-code">{{ _lt_source | slice: 0, 2 | upcase }}</span>
110
+ <span class="nav-lang-name">{{ _lt_source_name }}</span>
111
+ </a>
112
+ {%- endif %}
113
+ {%- for _lt_lang in _lt_langs -%}
114
+ {%- assign _lt_name = _lt_names[_lt_lang].native | default: _lt_lang -%}
115
+ {%- assign _lt_t = _lt_entry[_lt_lang] %}
116
+ {%- if _lt_current == _lt_lang %}
117
+ <span class="list-group-item nav-lang-item is-current" aria-current="true">
118
+ <span class="nav-lang-code">{{ _lt_lang | slice: 0, 2 | upcase }}</span>
119
+ <span class="nav-lang-name">{{ _lt_name }}</span>
120
+ <span class="nav-lang-auto" title="{{ _lt_auto_title }}">{{ _lt_auto_label }}</span>
121
+ <i class="bi bi-check2 nav-lang-check" aria-hidden="true"></i>
122
+ </span>
123
+ {%- elsif _lt_t %}
124
+ <a class="list-group-item list-group-item-action nav-lang-item" data-lang="{{ _lt_lang }}" hreflang="{{ _lt_lang }}" href="{{ _lt_t.url | relative_url }}">
125
+ <span class="nav-lang-code">{{ _lt_lang | slice: 0, 2 | upcase }}</span>
126
+ <span class="nav-lang-name">{{ _lt_name }}</span>
127
+ <span class="nav-lang-auto" title="{{ _lt_auto_title }}">{{ _lt_auto_label }}</span>
128
+ </a>
129
+ {%- else %}
130
+ {%- comment -%}
131
+ No translation yet — still a LINK, not a disabled row. It goes to the
132
+ source page and records the preference, so the reader lands on real
133
+ content instead of a dead entry. hreflang describes the DESTINATION,
134
+ which is the source language, not the one that was clicked.
135
+ {%- endcomment -%}
136
+ <a class="list-group-item list-group-item-action nav-lang-item is-untranslated" data-lang="{{ _lt_lang }}" hreflang="{{ _lt_source }}" href="{{ _lt_fallback_url }}" aria-describedby="{{ _lt_note_id }}-panel">
137
+ <span class="nav-lang-code">{{ _lt_lang | slice: 0, 2 | upcase }}</span>
138
+ <span class="nav-lang-name">{{ _lt_name }}</span>
139
+ <span class="nav-lang-mark" aria-hidden="true">*</span>
140
+ </a>
141
+ {%- endif %}
142
+ {%- endfor %}
143
+ </div>
144
+ {%- if _lt_untranslated > 0 %}
145
+ <hr class="nav-lang-divider">
146
+ <p class="nav-lang-note" id="{{ _lt_note_id }}-panel">{{ _lt_note_text }}</p>
147
+ {%- endif %}
148
+ </div>
149
+ {%- else -%}
33
150
  <div class="dropdown nav-lang-toggle" id="zer0-lang-toggle">
151
+ {%- comment -%}
152
+ Icon-only, square, and caret-free. `dropdown-toggle` is what draws the
153
+ Bootstrap caret; it is NOT what wires the dropdown — Bootstrap binds on
154
+ [data-bs-toggle="dropdown"], so dropping the class costs no behaviour.
155
+ The visible EN/FR label is gone at every width (it was already hidden
156
+ below xl), so title + aria-label are the only accessible name and both
157
+ name the language being read.
158
+ {%- endcomment -%}
34
159
  <button
35
- class="btn nav-lang-button dropdown-toggle"
160
+ class="btn nav-lang-button"
36
161
  type="button"
37
162
  id="langToggleButton"
38
163
  data-bs-toggle="dropdown"
39
164
  aria-expanded="false"
40
165
  aria-label="{{ ui.lang_toggle_aria | default: 'Change language' }}"
41
- title="{{ ui.lang_toggle_label | default: 'Language' }}"
166
+ title="{{ ui.lang_toggle_label | default: 'Language' }}: {{ _lt_current_name }}"
42
167
  >
43
168
  <i class="{{ site.default_icon }} bi-translate" aria-hidden="true"></i>
44
- <span class="nav-link-text d-none d-xl-inline ms-1 text-uppercase">{{ _lt_current | slice: 0, 2 }}</span>
45
169
  </button>
46
- <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="langToggleButton">
170
+ <ul class="dropdown-menu dropdown-menu-end nav-lang-menu" aria-labelledby="langToggleButton">
47
171
  <li>
48
172
  {%- if _lt_current == _lt_source %}
49
- <span class="dropdown-item active" aria-current="true">{{ _lt_source_name }}</span>
173
+ <span class="dropdown-item nav-lang-item is-current" aria-current="true">
174
+ <span class="nav-lang-name">{{ _lt_source_name }}</span>
175
+ <i class="bi bi-check2 nav-lang-check" aria-hidden="true"></i>
176
+ </span>
50
177
  {%- else %}
51
- <a class="dropdown-item" data-lang="{{ _lt_source }}" hreflang="{{ _lt_source }}" href="{{ _lt_src_url | default: '/' | relative_url }}">{{ _lt_source_name }}</a>
178
+ <a class="dropdown-item nav-lang-item" data-lang="{{ _lt_source }}" hreflang="{{ _lt_source }}" href="{{ _lt_fallback_url }}">
179
+ <span class="nav-lang-name">{{ _lt_source_name }}</span>
180
+ </a>
52
181
  {%- endif %}
53
182
  </li>
54
183
  {%- for _lt_lang in _lt_langs -%}
@@ -56,23 +185,41 @@
56
185
  {%- assign _lt_t = _lt_entry[_lt_lang] %}
57
186
  <li>
58
187
  {%- if _lt_current == _lt_lang %}
59
- <span class="dropdown-item active" aria-current="true">{{ _lt_name }}</span>
188
+ <span class="dropdown-item nav-lang-item is-current" aria-current="true">
189
+ <span class="nav-lang-name">{{ _lt_name }}</span>
190
+ <span class="nav-lang-auto" title="{{ _lt_auto_title }}">{{ _lt_auto_label }}</span>
191
+ <i class="bi bi-check2 nav-lang-check" aria-hidden="true"></i>
192
+ </span>
60
193
  {%- elsif _lt_t %}
61
- <a class="dropdown-item" data-lang="{{ _lt_lang }}" hreflang="{{ _lt_lang }}" href="{{ _lt_t.url | relative_url }}">{{ _lt_name }}</a>
194
+ <a class="dropdown-item nav-lang-item" data-lang="{{ _lt_lang }}" hreflang="{{ _lt_lang }}" href="{{ _lt_t.url | relative_url }}">
195
+ <span class="nav-lang-name">{{ _lt_name }}</span>
196
+ <span class="nav-lang-auto" title="{{ _lt_auto_title }}">{{ _lt_auto_label }}</span>
197
+ </a>
62
198
  {%- else %}
63
- <span class="dropdown-item disabled" aria-disabled="true" title="{{ ui.lang_not_available | default: 'Not yet translated' }}">{{ _lt_name }} <small class="text-body-secondary">({{ ui.lang_not_available | default: 'Not yet translated' }})</small></span>
199
+ {%- comment -%} See the panel variant above — link, never a dead row. {%- endcomment -%}
200
+ <a class="dropdown-item nav-lang-item is-untranslated" data-lang="{{ _lt_lang }}" hreflang="{{ _lt_source }}" href="{{ _lt_fallback_url }}" aria-describedby="{{ _lt_note_id }}">
201
+ <span class="nav-lang-name">{{ _lt_name }}</span>
202
+ <span class="nav-lang-mark" aria-hidden="true">*</span>
203
+ </a>
64
204
  {%- endif %}
65
205
  </li>
66
206
  {%- endfor %}
207
+ {%- if _lt_untranslated > 0 %}
208
+ <li><hr class="dropdown-divider"></li>
209
+ <li><p class="nav-lang-note" id="{{ _lt_note_id }}">{{ _lt_note_text }}</p></li>
210
+ {%- endif %}
67
211
  </ul>
68
212
  </div>
213
+ {%- endif -%}
69
214
  <script>
70
215
  // Remember the visitor's language pick (no auto-redirect — just preference).
216
+ // Delegated at document level so it covers both variants and stays correct
217
+ // if a consumer renders the component more than once per page.
71
218
  (function () {
72
- var toggle = document.getElementById('zer0-lang-toggle');
73
- if (!toggle) return;
74
- toggle.addEventListener('click', function (event) {
75
- var item = event.target.closest('a.dropdown-item[data-lang]');
219
+ if (window.__zer0LangPrefWired) return;
220
+ window.__zer0LangPrefWired = true;
221
+ document.addEventListener('click', function (event) {
222
+ var item = event.target.closest('#zer0-lang-toggle a[data-lang]');
76
223
  if (!item) return;
77
224
  try { localStorage.setItem('zer0-lang', item.getAttribute('data-lang')); } catch (e) { /* private mode */ }
78
225
  });
@@ -0,0 +1,55 @@
1
+ {% comment %} Feature: ZER0-083 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: page-views-init
4
+ Path: _includes/components/page-views-init.html
5
+ Purpose: Bootstrap the page-view counter — inject its configuration and
6
+ load the behaviour. Renders no visible markup; the badge lives
7
+ in components/page-views.html.
8
+ Params: none — configured via site.page_views
9
+ Depends on: assets/js/page-views.js
10
+ Included by: _includes/core/head.html (once per page)
11
+ Notes: Split from the badge include on purpose: the badge may appear
12
+ many times on a page, this must appear exactly once.
13
+ {%- endcomment -%}
14
+
15
+ {%- unless site.page_views.enabled -%}{%- comment -%} counter disabled {%- endcomment -%}
16
+ {%- else -%}
17
+ {%- comment -%}
18
+ `respect_dnt` / `respect_gpc` / `require_consent` default to the PostHog
19
+ privacy block so a site configures its privacy posture once. Liquid's
20
+ `default` filter treats `false` as "missing", so each flag is resolved with
21
+ an if-tag instead — otherwise an explicit `false` would silently flip true.
22
+ {%- endcomment -%}
23
+ {%- assign pv_dnt = true -%}
24
+ {%- if site.page_views.respect_dnt == false -%}{%- assign pv_dnt = false -%}{%- endif -%}
25
+ {%- assign pv_gpc = true -%}
26
+ {%- if site.page_views.respect_gpc == false -%}{%- assign pv_gpc = false -%}{%- endif -%}
27
+ {%- assign pv_consent = false -%}
28
+ {%- if site.page_views.require_consent -%}{%- assign pv_consent = true -%}{%- endif -%}
29
+ {%- assign pv_track = true -%}
30
+ {%- if site.page_views.track == false -%}{%- assign pv_track = false -%}{%- endif -%}
31
+ {%- assign pv_fallback = false -%}
32
+ {%- if site.page_views.fallback_local -%}{%- assign pv_fallback = true -%}{%- endif -%}
33
+
34
+ <!-- Page-view counter configuration consumed by assets/js/page-views.js -->
35
+ <script id="pageViewsConfig" type="application/json">
36
+ {
37
+ "enabled": true,
38
+ "provider": {{ site.page_views.provider | default: "local" | jsonify }},
39
+ "endpoint": {{ site.page_views.endpoint | default: "" | jsonify }},
40
+ "method": {{ site.page_views.method | default: "POST" | jsonify }},
41
+ "countKey": {{ site.page_views.count_key | default: "views" | jsonify }},
42
+ "dedupe": {{ site.page_views.dedupe | default: "session" | jsonify }},
43
+ "maxEntries": {{ site.page_views.max_entries | default: 500 }},
44
+ "track": {{ pv_track | jsonify }},
45
+ "respectDnt": {{ pv_dnt | jsonify }},
46
+ "respectGpc": {{ pv_gpc | jsonify }},
47
+ "requireConsent": {{ pv_consent | jsonify }},
48
+ "fallbackLocal": {{ pv_fallback | jsonify }},
49
+ "baseurl": {{ site.baseurl | default: "" | jsonify }},
50
+ "labelOne": {{ site.page_views.label_one | default: "view" | jsonify }},
51
+ "labelOther": {{ site.page_views.label_other | default: "views" | jsonify }}
52
+ }
53
+ </script>
54
+ <script defer src="{{ '/assets/js/page-views.js' | relative_url }}"></script>
55
+ {%- endunless -%}
@@ -0,0 +1,33 @@
1
+ {% comment %} Feature: ZER0-083 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: page-views
4
+ Path: _includes/components/page-views.html
5
+ Purpose: Inline "N views" badge for a page. Ships hidden and is revealed
6
+ by assets/js/page-views.js once a count is known, so a page
7
+ with no data never flashes "0 views".
8
+ Params: url (string, optional, default: page.url) — page to show
9
+ class (string, optional, default: "") — extra classes
10
+ icon (string, optional, default: "bi-eye")
11
+ separator (bool, optional, default: false) — render a
12
+ leading "•" INSIDE the badge, so the separator hides
13
+ with it in a meta row
14
+ Depends on: components/page-views-init.html (config + behaviour, loaded once
15
+ from core/head.html), _sass/components/_page-views.scss
16
+ Notes: Safe to include more than once per page — each badge resolves
17
+ its own `data-page-views` path. Paths are site-relative and
18
+ baseurl-free (page.url); page-views.js normalizes the runtime
19
+ location the same way.
20
+ {%- endcomment -%}
21
+
22
+ {%- unless site.page_views.enabled -%}{%- comment -%} counter disabled {%- endcomment -%}
23
+ {%- else -%}
24
+ {%- assign pv_path = include.url | default: page.url -%}
25
+ {%- assign pv_class = include.class | default: "" -%}
26
+ {%- assign pv_icon = include.icon | default: "bi-eye" -%}
27
+ <span class="page-views{% if pv_class != "" %} {{ pv_class }}{% endif %}" data-page-views="{{ pv_path | escape }}" hidden>
28
+ {%- if include.separator -%}<span class="page-views__sep mx-2" aria-hidden="true">•</span>{%- endif -%}
29
+ <i class="bi {{ pv_icon }} me-1" aria-hidden="true"></i>
30
+ <span class="page-views__count" data-page-views-count>0</span>
31
+ <span class="page-views__label" data-page-views-label>{{ site.page_views.label_other | default: "views" }}</span>
32
+ </span>
33
+ {%- endunless -%}
@@ -0,0 +1,67 @@
1
+ {% comment %} Feature: ZER0-084 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: recipe-card
4
+ Path: _includes/components/recipe-card.html
5
+ Purpose: Index card for one recipe — photo, title, one-line description,
6
+ and the three facts a cook scans for: total time, what it makes,
7
+ and how hard it is.
8
+ Params: recipe (document, required) — a `recipes` collection doc
9
+ heading_level (number, optional, default: 3) — the title's
10
+ heading level, so a card never skips a level under whatever
11
+ heading precedes it
12
+ class (string, optional, default: "")
13
+ Depends on: components/preview-image.html, components/recipe-duration.html,
14
+ _sass/components/_recipe.scss (.recipe-card)
15
+ Notes: Total time comes from `times.total` or prep + cook + rest, so
16
+ the card never disagrees with the recipe page. The whole card is
17
+ one link for a large tap target; the facts row is plain text so
18
+ it stays readable when images fail to load.
19
+ {%- endcomment -%}
20
+ {%- if include.recipe == nil -%}
21
+ {%- comment -%} ERROR: recipe-card requires `recipe` {%- endcomment -%}
22
+ {%- else -%}
23
+ {%- assign _rc_r = include.recipe -%}
24
+ {%- assign _rc_img = _rc_r.image | default: _rc_r.preview | default: site.teaser -%}
25
+ {%- assign _rc_prep = _rc_r.times.prep | default: 0 -%}
26
+ {%- assign _rc_cook = _rc_r.times.cook | default: 0 -%}
27
+ {%- assign _rc_rest = _rc_r.times.rest | default: 0 -%}
28
+ {%- if _rc_r.times.total -%}
29
+ {%- assign _rc_total = _rc_r.times.total -%}
30
+ {%- else -%}
31
+ {%- assign _rc_total = _rc_prep | plus: _rc_cook | plus: _rc_rest -%}
32
+ {%- endif -%}
33
+ {%- capture _rc_total_h -%}{%- include components/recipe-duration.html minutes=_rc_total -%}{%- endcapture -%}
34
+ {%- assign _rc_total_h = _rc_total_h | strip -%}
35
+ <article class="recipe-card h-100 {{ include.class | default: '' }}">
36
+ <a href="{{ _rc_r.url | relative_url }}" class="recipe-card-link d-flex flex-column h-100 text-decoration-none text-body">
37
+ {%- if _rc_img -%}
38
+ <div class="recipe-card-media">
39
+ {%- include components/preview-image.html src=_rc_img alt=_rc_r.title class="" -%}
40
+ </div>
41
+ {%- endif -%}
42
+ <div class="recipe-card-body">
43
+ {%- assign _rc_hl = include.heading_level | default: 3 -%}
44
+ <h{{ _rc_hl }} class="h5 recipe-card-title">{{ _rc_r.title }}</h{{ _rc_hl }}>
45
+ {%- if _rc_r.description -%}
46
+ <p class="recipe-card-description small text-body-secondary">{{ _rc_r.description | strip_html | truncate: 120 }}</p>
47
+ {%- endif -%}
48
+ <p class="recipe-card-facts small text-body-secondary mb-0">
49
+ {%- assign _rc_sep = false -%}
50
+ {%- if _rc_total_h != "" -%}
51
+ <span class="recipe-card-fact"><i class="bi bi-clock-history me-1" aria-hidden="true"></i>{{ _rc_total_h }}</span>
52
+ {%- assign _rc_sep = true -%}
53
+ {%- endif -%}
54
+ {%- if _rc_r.yield.amount -%}
55
+ {%- if _rc_sep %}<span class="mx-1" aria-hidden="true">•</span>{% endif -%}
56
+ <span class="recipe-card-fact"><i class="bi bi-pie-chart me-1" aria-hidden="true"></i>{{ _rc_r.yield.amount }} {{ _rc_r.yield.unit | default: "servings" | escape }}</span>
57
+ {%- assign _rc_sep = true -%}
58
+ {%- endif -%}
59
+ {%- if _rc_r.difficulty -%}
60
+ {%- if _rc_sep %}<span class="mx-1" aria-hidden="true">•</span>{% endif -%}
61
+ <span class="recipe-difficulty recipe-difficulty--{{ _rc_r.difficulty | downcase | strip }}">{{ _rc_r.difficulty | capitalize }}</span>
62
+ {%- endif -%}
63
+ </p>
64
+ </div>
65
+ </a>
66
+ </article>
67
+ {%- endif -%}
@@ -0,0 +1,50 @@
1
+ {% comment %} Feature: ZER0-084 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: recipe-duration (value resolver — emits a bare string)
4
+ Path: _includes/components/recipe-duration.html
5
+ Purpose: Format a duration given in MINUTES, either for humans
6
+ ("1 hr 30 min") or as an ISO 8601 duration for schema.org
7
+ ("PT1H30M").
8
+ Params: minutes (number, required)
9
+ format (string, optional, default: "human") — "human" | "iso"
10
+ Depends on: nothing (pure Liquid arithmetic)
11
+ Notes: Emits nothing for a missing or zero duration, so callers can
12
+ test the captured value and skip the whole row. Days are not
13
+ special-cased — a 3-day cure reads as "72 hr", which is how
14
+ recipes actually write long ferments.
15
+
16
+ Liquid `assign` writes to the GLOBAL scope even inside an
17
+ include, so a helper called several times from one caller will
18
+ clobber the caller's variables if the names collide. Every
19
+ variable here is therefore prefixed `_rd_`, and the other recipe
20
+ components use their own prefixes. Keep that up when editing.
21
+
22
+ Capture it (this comment is brace-free so it cannot self-execute):
23
+ capture total -> include components/recipe-duration.html minutes=90
24
+ {%- endcomment -%}
25
+ {%- assign _rd_m = include.minutes -%}
26
+ {%- if _rd_m == nil or _rd_m == "" or _rd_m <= 0 -%}
27
+ {%- comment -%} recipe-duration: no duration — emitting nothing {%- endcomment -%}
28
+ {%- else -%}
29
+ {%- assign _rd_total = _rd_m | round -%}
30
+ {%- assign _rd_hours = _rd_total | divided_by: 60 -%}
31
+ {%- assign _rd_mins = _rd_total | modulo: 60 -%}
32
+ {%- if include.format == "iso" -%}
33
+ PT{% if _rd_hours > 0 %}{{ _rd_hours }}H{% endif %}{% if _rd_mins > 0 %}{{ _rd_mins }}M{% endif %}
34
+ {%- else -%}
35
+ {%- comment -%}
36
+ Built by appending rather than by emitting the separator from inside an
37
+ `if`: Liquid treats a block whose body is only whitespace as blank and
38
+ drops it, which silently swallowed the space in "12 hr 45 min".
39
+ {%- endcomment -%}
40
+ {%- assign _rd_out = "" -%}
41
+ {%- if _rd_hours > 0 -%}
42
+ {%- assign _rd_out = _rd_out | append: _rd_hours | append: " hr" -%}
43
+ {%- endif -%}
44
+ {%- if _rd_mins > 0 -%}
45
+ {%- if _rd_out != "" -%}{%- assign _rd_out = _rd_out | append: " " -%}{%- endif -%}
46
+ {%- assign _rd_out = _rd_out | append: _rd_mins | append: " min" -%}
47
+ {%- endif -%}
48
+ {{- _rd_out -}}
49
+ {%- endif -%}
50
+ {%- endif -%}
@@ -0,0 +1,58 @@
1
+ {% comment %} Feature: ZER0-084 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: recipe-grams (value resolver — emits a bare number)
4
+ Path: _includes/components/recipe-grams.html
5
+ Purpose: Convert one recipe quantity to grams so ratio tables and
6
+ baker's percentages can be computed at BUILD time (no JS needed).
7
+ Params: qty (number, required) — the amount as authored
8
+ unit (string, optional) — g, kg, oz, lb, ml, l, tsp,
9
+ tbsp, cup, fl oz, pint, …
10
+ grams_per_cup (number, optional) — density, required to convert
11
+ a VOLUME into a weight
12
+ Depends on: nothing (pure Liquid arithmetic)
13
+ Notes: Outputs a bare number rounded to 1 decimal, or nothing at all
14
+ when the unit is unknown/countable ("clove", "egg") or a volume
15
+ with no density. Callers MUST treat empty output as "unknown".
16
+ Mirrors the unit table in assets/js/recipe-scaler.js — change
17
+ both together.
18
+
19
+ Liquid `assign` writes to the GLOBAL scope even inside an
20
+ include, so every variable here is prefixed `_rg_` to keep
21
+ repeated calls from clobbering the caller's own variables.
22
+
23
+ Capture it (this comment is brace-free so it cannot self-execute):
24
+ capture grams -> include components/recipe-grams.html
25
+ qty=i.qty unit=i.unit grams_per_cup=density
26
+ {%- endcomment -%}
27
+ {%- if include.qty == nil or include.qty == "" -%}
28
+ {%- comment -%} ERROR: recipe-grams requires `qty` {%- endcomment -%}
29
+ {%- else -%}
30
+ {%- assign _rg_u = include.unit | default: "" | downcase | strip | replace: ".", "" | replace: "-", " " -%}
31
+ {%- assign _rg_mass = 0 -%}
32
+ {%- assign _rg_ml = 0 -%}
33
+ {%- case _rg_u -%}
34
+ {%- when "g" or "gram" or "grams" or "gr" -%}{%- assign _rg_mass = 1 -%}
35
+ {%- when "kg" or "kilogram" or "kilograms" -%}{%- assign _rg_mass = 1000 -%}
36
+ {%- when "mg" or "milligram" or "milligrams" -%}{%- assign _rg_mass = 0.001 -%}
37
+ {%- when "oz" or "ounce" or "ounces" -%}{%- assign _rg_mass = 28.3495 -%}
38
+ {%- when "lb" or "lbs" or "pound" or "pounds" -%}{%- assign _rg_mass = 453.592 -%}
39
+ {%- when "ml" or "milliliter" or "milliliters" or "millilitre" or "millilitres" or "cc" -%}{%- assign _rg_ml = 1 -%}
40
+ {%- when "l" or "liter" or "liters" or "litre" or "litres" -%}{%- assign _rg_ml = 1000 -%}
41
+ {%- when "dl" or "deciliter" or "deciliters" -%}{%- assign _rg_ml = 100 -%}
42
+ {%- when "tsp" or "teaspoon" or "teaspoons" -%}{%- assign _rg_ml = 4.92892 -%}
43
+ {%- when "tbsp" or "tbs" or "tablespoon" or "tablespoons" -%}{%- assign _rg_ml = 14.7868 -%}
44
+ {%- when "cup" or "cups" -%}{%- assign _rg_ml = 236.588 -%}
45
+ {%- when "fl oz" or "floz" or "fluid ounce" or "fluid ounces" -%}{%- assign _rg_ml = 29.5735 -%}
46
+ {%- when "pint" or "pints" or "pt" -%}{%- assign _rg_ml = 473.176 -%}
47
+ {%- when "quart" or "quarts" or "qt" -%}{%- assign _rg_ml = 946.353 -%}
48
+ {%- when "gallon" or "gallons" or "gal" -%}{%- assign _rg_ml = 3785.41 -%}
49
+ {%- endcase -%}
50
+ {%- if _rg_mass > 0 -%}
51
+ {%- assign _rg_g = include.qty | times: _rg_mass -%}
52
+ {{- _rg_g | round: 1 -}}
53
+ {%- elsif _rg_ml > 0 and include.grams_per_cup -%}
54
+ {%- assign _rg_vol = include.qty | times: _rg_ml -%}
55
+ {%- assign _rg_g = _rg_vol | times: include.grams_per_cup | divided_by: 236.588 -%}
56
+ {{- _rg_g | round: 1 -}}
57
+ {%- endif -%}
58
+ {%- endif -%}
@@ -0,0 +1,96 @@
1
+ {% comment %} Feature: ZER0-084 {% endcomment %}
2
+ {%- comment -%}
3
+ Component: recipe-index
4
+ Path: _includes/components/recipe-index.html
5
+ Purpose: Responsive grid of every recipe, grouped into course sections
6
+ ("Breads & Doughs", "Desserts") — drop it on a cookbook landing
7
+ page, a home page, or anywhere else recipes should be browsable.
8
+ Params: cookbook (string, optional) — a cookbook slug; only recipes
9
+ whose `cookbook:` matches are listed. Omit for every recipe.
10
+ heading (string, optional) — overall section heading (h2)
11
+ heading_level (string, optional, default: "h2")
12
+ grouped (bool, optional, default: true) — false renders one flat
13
+ grid with no course headings
14
+ class (string, optional, default: "")
15
+ Depends on: components/recipe-card.html, the `recipes` collection,
16
+ _data/recipe_courses.yml, _sass/components/_recipe.scss
17
+ Notes: Cookbook landing pages (`layout: cookbook`) are excluded — this
18
+ lists recipes, not covers. Courses render in the order they
19
+ appear in _data/recipe_courses.yml, then any course a recipe
20
+ used but the data file never declared. A recipe with no
21
+ `course:` falls under `uncategorized`. Recipes sort by title
22
+ within a course.
23
+ {%- endcomment -%}
24
+ {%- assign _rx_all = site.recipes | where_exp: "doc", "doc.layout != 'cookbook'" -%}
25
+ {%- if include.cookbook -%}
26
+ {%- assign _rx_all = _rx_all | where: "cookbook", include.cookbook -%}
27
+ {%- endif -%}
28
+ {%- if _rx_all == nil or _rx_all.size == 0 -%}
29
+ {%- comment -%} recipe-index: no recipes found{% if include.cookbook %} for cookbook "{{ include.cookbook }}"{% endif %} — skipping render {%- endcomment -%}
30
+ {%- else -%}
31
+ {%- assign _rx_hl = include.heading_level | default: "h2" -%}
32
+ {%- comment -%}
33
+ Heading levels must not skip. Course headings sit one level below the
34
+ section heading when there is one, and AT the section level when there is
35
+ not — otherwise a cookbook page goes h1 → h3. Card titles always sit one
36
+ level below whatever the course headings ended up being.
37
+ {%- endcomment -%}
38
+ {%- if include.heading -%}
39
+ {%- assign _rx_course_level = _rx_hl | remove: "h" | plus: 1 -%}
40
+ {%- else -%}
41
+ {%- assign _rx_course_level = _rx_hl | remove: "h" | plus: 0 -%}
42
+ {%- endif -%}
43
+ {%- assign _rx_card_level = _rx_course_level | plus: 1 -%}
44
+ <section class="recipe-index {{ include.class | default: '' }}">
45
+ {%- if include.heading -%}
46
+ <{{ _rx_hl }} class="mb-4">{{ include.heading }}</{{ _rx_hl }}>
47
+ {%- endif -%}
48
+
49
+ {%- if include.grouped == false -%}
50
+ <div class="row row-cols-1 row-cols-sm-2 row-cols-lg-3 g-4">
51
+ {%- assign _rx_sorted = _rx_all | sort: "title" -%}
52
+ {%- for _rx_r in _rx_sorted -%}
53
+ <div class="col">{%- include components/recipe-card.html recipe=_rx_r heading_level=_rx_course_level -%}</div>
54
+ {%- endfor -%}
55
+ </div>
56
+ {%- else -%}
57
+ {%- assign _rx_default_course = "uncategorized" -%}
58
+ {%- assign _rx_groups = _rx_all | group_by_exp: "doc", "doc.course | default: _rx_default_course" -%}
59
+
60
+ {%- comment -%} Shelf order: courses declared in _data/recipe_courses.yml first (file order), then any others. {%- endcomment -%}
61
+ {%- assign _rx_order = "," -%}
62
+ {%- for _rx_pair in site.data.recipe_courses -%}
63
+ {%- assign _rx_needle = _rx_pair[0] | prepend: "," | append: "," -%}
64
+ {%- unless _rx_order contains _rx_needle -%}
65
+ {%- for _rx_g in _rx_groups -%}{%- if _rx_g.name == _rx_pair[0] -%}{%- assign _rx_order = _rx_order | append: _rx_pair[0] | append: "," -%}{%- endif -%}{%- endfor -%}
66
+ {%- endunless -%}
67
+ {%- endfor -%}
68
+ {%- for _rx_g in _rx_groups -%}
69
+ {%- assign _rx_needle = _rx_g.name | prepend: "," | append: "," -%}
70
+ {%- unless _rx_order contains _rx_needle -%}{%- assign _rx_order = _rx_order | append: _rx_g.name | append: "," -%}{%- endunless -%}
71
+ {%- endfor -%}
72
+ {%- assign _rx_order_list = _rx_order | split: "," -%}
73
+
74
+ {%- for _rx_name in _rx_order_list -%}
75
+ {%- if _rx_name == "" -%}{%- continue -%}{%- endif -%}
76
+ {%- assign _rx_group = _rx_groups | where: "name", _rx_name | first -%}
77
+ {%- if _rx_group == nil -%}{%- continue -%}{%- endif -%}
78
+ {%- assign _rx_meta = site.data.recipe_courses[_rx_name] -%}
79
+ {%- assign _rx_recipes = _rx_group.items | sort: "title" -%}
80
+ <div class="recipe-course mb-5" id="course-{{ _rx_name }}">
81
+ <h{{ _rx_course_level }} class="h4 mb-1">
82
+ <i class="bi {{ _rx_meta.icon | default: 'bi-journal-bookmark' }} me-2" aria-hidden="true"></i>{{ _rx_meta.title | default: _rx_name | escape }}
83
+ </h{{ _rx_course_level }}>
84
+ {%- if _rx_meta.tagline -%}
85
+ <p class="text-body-secondary mb-3">{{ _rx_meta.tagline | escape }}</p>
86
+ {%- endif -%}
87
+ <div class="row row-cols-1 row-cols-sm-2 row-cols-lg-3 g-4">
88
+ {%- for _rx_r in _rx_recipes -%}
89
+ <div class="col">{%- include components/recipe-card.html recipe=_rx_r heading_level=_rx_card_level -%}</div>
90
+ {%- endfor -%}
91
+ </div>
92
+ </div>
93
+ {%- endfor -%}
94
+ {%- endif -%}
95
+ </section>
96
+ {%- endif -%}