jekyll-theme-zer0 1.28.0 → 1.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1143 -13
- data/_data/README.md +2 -0
- data/_data/ai.yml +5 -3
- data/_data/ai_pricing.yml +36 -0
- data/_data/backlog.yml +507 -2
- data/_data/consumers.yml +157 -9
- data/_data/features.yml +303 -20
- data/_data/feedback_types.yml +17 -12
- data/_data/i18n/fr.yml +12 -7
- data/_data/i18n/manifest.yml +39 -12
- data/_data/ingredient_densities.yml +122 -0
- data/_data/landing.yml +5 -2
- data/_data/navigation/main.yml +16 -0
- data/_data/navigation/quickstart.yml +4 -0
- data/_data/recipe_courses.yml +64 -0
- data/_data/site_builder.yml +874 -0
- data/_data/theme-manifest.yml +160 -124
- data/_data/ui-text.yml +26 -0
- data/_includes/README.md +26 -2
- data/_includes/analytics/posthog.html +2 -2
- data/_includes/components/admin-links.html +2 -2
- data/_includes/components/admin-tabs.html +2 -2
- data/_includes/components/ai-chat.html +14 -11
- data/_includes/components/analytics-dashboard.html +8 -8
- data/_includes/components/author-bio.html +1 -1
- data/_includes/components/author-card.html +10 -2
- data/_includes/components/author-eeat.html +4 -4
- data/_includes/components/background-customizer.html +10 -10
- data/_includes/components/background-image.html +114 -0
- data/_includes/components/background-settings.html +28 -15
- data/_includes/components/collection-manager.html +5 -5
- data/_includes/components/component-showcase.html +13 -13
- data/_includes/components/config-editor.html +12 -12
- data/_includes/components/config-viewer.html +8 -8
- data/_includes/components/cookie-consent.html +15 -15
- data/_includes/components/cta-button.html +7 -2
- data/_includes/components/dev-shortcuts.html +7 -7
- data/_includes/components/env-dashboard.html +8 -8
- data/_includes/components/env-switcher.html +9 -9
- data/_includes/components/feature-card.html +2 -2
- data/_includes/components/halfmoon.html +2 -2
- data/_includes/components/info-section.html +42 -37
- data/_includes/components/js-cdn.html +15 -15
- data/_includes/components/language-toggle.html +168 -21
- data/_includes/components/mermaid.html +72 -435
- data/_includes/components/nanobar.html +5 -5
- data/_includes/components/nav-editor.html +2 -2
- data/_includes/components/nav-export.html +2 -2
- data/_includes/components/nav-overview.html +2 -2
- data/_includes/components/page-feedback.html +45 -30
- data/_includes/components/page-views-init.html +55 -0
- data/_includes/components/page-views.html +33 -0
- data/_includes/components/post-card.html +22 -22
- data/_includes/components/post-type-badge.html +2 -2
- data/_includes/components/powered-by.html +2 -2
- data/_includes/components/preview-image.html +6 -0
- data/_includes/components/quick-index.html +2 -2
- data/_includes/components/recipe-card.html +67 -0
- data/_includes/components/recipe-duration.html +50 -0
- data/_includes/components/recipe-grams.html +58 -0
- data/_includes/components/recipe-index.html +96 -0
- data/_includes/components/recipe-ingredients.html +90 -0
- data/_includes/components/recipe-meta.html +96 -0
- data/_includes/components/recipe-nutrition.html +57 -0
- data/_includes/components/recipe-qty.html +73 -0
- data/_includes/components/recipe-ratio.html +151 -0
- data/_includes/components/recipe-scaler.html +73 -0
- data/_includes/components/recipe-steps.html +86 -0
- data/_includes/components/recipe-temp.html +45 -0
- data/_includes/components/search-modal.html +29 -4
- data/_includes/components/searchbar.html +2 -2
- data/_includes/components/shortcuts-modal.html +3 -0
- data/_includes/components/svg-background.html +2 -2
- data/_includes/components/theme-customizer.html +2 -2
- data/_includes/components/theme-info.html +14 -7
- data/_includes/components/theme-preview-gallery.html +22 -22
- data/_includes/content/giscus.html +2 -2
- data/_includes/content/intro.html +8 -8
- data/_includes/content/jsonld-faq.html +2 -2
- data/_includes/content/jsonld-software.html +24 -5
- data/_includes/content/seo.html +4 -4
- data/_includes/content/sitemap.html +27 -27
- data/_includes/content/toc.html +183 -183
- data/_includes/core/branding.html +6 -6
- data/_includes/core/console-capture.html +32 -74
- data/_includes/core/favicon.html +49 -7
- data/_includes/core/footer-fabs.html +17 -3
- data/_includes/core/footer.html +49 -34
- data/_includes/core/head.html +110 -86
- data/_includes/core/header.html +76 -54
- data/_includes/docs/bootstrap-docs.html +8 -8
- data/_includes/landing/landing-install-cards.html +2 -2
- data/_includes/landing/landing-quick-links.html +1 -1
- data/_includes/navigation/admin-nav.html +2 -2
- data/_includes/navigation/nav-tree.html +8 -8
- data/_includes/navigation/navbar.html +12 -12
- data/_includes/navigation/section-sidebar.html +109 -27
- data/_includes/navigation/sidebar-config.html +36 -2
- data/_includes/navigation/sidebar-left.html +17 -16
- data/_includes/navigation/sidebar-right.html +8 -7
- data/_includes/obsidian/full-graph.html +2 -2
- data/_includes/setup/claude-session.html +72 -0
- data/_includes/setup/prereq-checklist.html +90 -0
- data/_includes/setup/wizard.html +924 -222
- data/_includes/stats/stats-categories.html +8 -8
- data/_includes/stats/stats-header.html +14 -14
- data/_includes/stats/stats-metrics.html +14 -14
- data/_includes/stats/stats-no-data.html +12 -12
- data/_includes/stats/stats-overview.html +6 -6
- data/_includes/stats/stats-tags.html +8 -8
- data/_layouts/404.html +38 -24
- data/_layouts/README.md +2 -0
- data/_layouts/admin.html +24 -24
- data/_layouts/article.html +43 -33
- data/_layouts/author.html +20 -20
- data/_layouts/authors.html +2 -2
- data/_layouts/book-abc.html +12 -12
- data/_layouts/book-story.html +15 -15
- data/_layouts/book.html +12 -12
- data/_layouts/collection.html +33 -33
- data/_layouts/cookbook.html +88 -0
- data/_layouts/default.html +31 -28
- data/_layouts/home.html +23 -23
- data/_layouts/index.html +10 -10
- data/_layouts/landing.html +17 -17
- data/_layouts/news.html +44 -44
- data/_layouts/note.html +38 -38
- data/_layouts/notebook.html +34 -34
- data/_layouts/recipe.html +274 -0
- data/_layouts/root.html +92 -55
- data/_layouts/section.html +62 -33
- data/_layouts/setup.html +3 -3
- data/_layouts/sitemap-collection.html +49 -49
- data/_layouts/stats.html +40 -40
- data/_layouts/tag.html +12 -12
- data/_layouts/welcome.html +21 -21
- data/_sass/components/_callout.scss +1 -1
- data/_sass/components/_footer.scss +37 -1
- data/_sass/components/_mermaid.scss +375 -0
- data/_sass/components/_page-views.scss +36 -0
- data/_sass/components/_recipe.scss +506 -0
- data/_sass/components/_setup-wizard.scss +764 -0
- data/_sass/components/_ui-enhancements.scss +6 -6
- data/_sass/core/_navbar.scss +261 -46
- data/_sass/layouts/_landing.scss +2 -2
- data/_sass/layouts/_navbar-extras.scss +14 -4
- data/_sass/tokens/_color.scss +6 -0
- data/_sass/tokens/_index.scss +2 -0
- data/_sass/tokens/_radius.scss +21 -0
- data/_sass/tokens/_typography.scss +4 -0
- data/_sass/utilities/_focus.scss +14 -0
- data/assets/css/main.scss +4 -0
- data/assets/js/ai-chat.js +47 -5
- data/assets/js/fleet-feedback-capture.js +124 -0
- data/assets/js/fleet-feedback.js +853 -0
- data/assets/js/mermaid-diagrams.js +1267 -0
- data/assets/js/modules/navigation/config.js +9 -6
- data/assets/js/modules/navigation/navbar.js +55 -0
- data/assets/js/modules/navigation/scroll-spy.js +315 -80
- data/assets/js/modules/theme/appearance.js +8 -2
- data/assets/js/obsidian-wiki-links.js +8 -3
- data/assets/js/page-feedback.js +125 -192
- data/assets/js/page-views.js +372 -0
- data/assets/js/recipe-scaler.js +501 -0
- data/assets/js/search-modal.js +36 -0
- data/assets/js/setup-wizard.js +2279 -226
- data/assets/js/site-builder.js +1834 -0
- data/assets/js/ui-enhancements.js +11 -3
- data/scripts/README.md +44 -0
- data/scripts/ai/README.md +38 -0
- data/scripts/ai/api_call.rb +124 -0
- data/scripts/ai/usage.rb +314 -0
- data/scripts/ai/usage_report.rb +225 -0
- data/scripts/bin/audit-consumer +39 -7
- data/scripts/bin/giscus-discussions +213 -14
- data/scripts/bin/manifest +35 -12
- data/scripts/ci/agent_review_result.py +164 -0
- data/scripts/ci/test_agent_review_result.py +172 -0
- data/scripts/ci/test_visual_evidence_autogen.py +341 -0
- data/scripts/ci/visual_evidence_autogen.py +1060 -0
- data/scripts/content-review.rb +20 -1
- data/scripts/design-system-check.rb +170 -0
- data/scripts/lib/audit.sh +42 -2
- data/scripts/lint-liquid-raw.rb +137 -0
- data/scripts/test/integration/mermaid +22 -8
- data/scripts/test/lib/run_tests.sh +3 -1
- data/scripts/test/lib/test_agent_review_result.sh +27 -0
- data/scripts/test/lib/test_visual_evidence_autogen.sh +24 -0
- data/scripts/translate.rb +94 -16
- metadata +48 -2
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// Feature: ZER0-084
|
|
2
|
+
/**
|
|
3
|
+
* Recipe scaling and unit conversion.
|
|
4
|
+
*
|
|
5
|
+
* Rewrites every [data-recipe-qty] and [data-recipe-temp] inside a
|
|
6
|
+
* [data-recipe] root when the reader changes the yield or the unit system.
|
|
7
|
+
* The server-rendered markup is already the correct recipe as written, so this
|
|
8
|
+
* file is pure progressive enhancement — it reveals the controls
|
|
9
|
+
* (.recipe-scaler is `hidden` until then) and never runs without them.
|
|
10
|
+
*
|
|
11
|
+
* Contract with the Liquid components (_includes/components/recipe-*.html):
|
|
12
|
+
* [data-recipe] root; data-recipe-id keys the saved scale
|
|
13
|
+
* [data-recipe-scaler] control bar; data-base-yield, data-yield-unit
|
|
14
|
+
* [data-recipe-qty] data-qty, data-qty-max, data-unit,
|
|
15
|
+
* data-grams-per-cup, data-weigh, data-scale
|
|
16
|
+
* [data-recipe-temp] data-temp-f, data-temp-c
|
|
17
|
+
* [data-recipe-name] data-singular, data-plural (countables only)
|
|
18
|
+
*
|
|
19
|
+
* The unit table below mirrors _includes/components/recipe-grams.html — change
|
|
20
|
+
* the two together.
|
|
21
|
+
*/
|
|
22
|
+
(function () {
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
var UNITS_KEY = 'zer0-recipe-units';
|
|
26
|
+
var SCALE_KEY_PREFIX = 'zer0-recipe-scale:';
|
|
27
|
+
var ML_PER_CUP = 236.588;
|
|
28
|
+
|
|
29
|
+
// Canonical unit table. `dim` is the dimension; `base` is the size of one
|
|
30
|
+
// unit in grams (mass) or millilitres (volume).
|
|
31
|
+
var UNITS = {
|
|
32
|
+
g: { dim: 'mass', base: 1, label: 'g' },
|
|
33
|
+
kg: { dim: 'mass', base: 1000, label: 'kg' },
|
|
34
|
+
mg: { dim: 'mass', base: 0.001, label: 'mg' },
|
|
35
|
+
oz: { dim: 'mass', base: 28.3495, label: 'oz' },
|
|
36
|
+
lb: { dim: 'mass', base: 453.592, label: 'lb' },
|
|
37
|
+
ml: { dim: 'volume', base: 1, label: 'ml' },
|
|
38
|
+
dl: { dim: 'volume', base: 100, label: 'dl' },
|
|
39
|
+
l: { dim: 'volume', base: 1000, label: 'l' },
|
|
40
|
+
tsp: { dim: 'volume', base: 4.92892, label: 'tsp' },
|
|
41
|
+
tbsp: { dim: 'volume', base: 14.7868, label: 'tbsp' },
|
|
42
|
+
cup: { dim: 'volume', base: ML_PER_CUP, label: 'cups' },
|
|
43
|
+
floz: { dim: 'volume', base: 29.5735, label: 'fl oz' },
|
|
44
|
+
pint: { dim: 'volume', base: 473.176, label: 'pints' },
|
|
45
|
+
quart: { dim: 'volume', base: 946.353, label: 'quarts' },
|
|
46
|
+
gallon: { dim: 'volume', base: 3785.41, label: 'gallons' }
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Everything an author might type, mapped to a canonical key above.
|
|
50
|
+
var ALIASES = {
|
|
51
|
+
g: 'g', gr: 'g', gram: 'g', grams: 'g', gramme: 'g', grammes: 'g',
|
|
52
|
+
kg: 'kg', kilo: 'kg', kilos: 'kg', kilogram: 'kg', kilograms: 'kg',
|
|
53
|
+
mg: 'mg', milligram: 'mg', milligrams: 'mg',
|
|
54
|
+
oz: 'oz', ounce: 'oz', ounces: 'oz',
|
|
55
|
+
lb: 'lb', lbs: 'lb', pound: 'lb', pounds: 'lb',
|
|
56
|
+
ml: 'ml', cc: 'ml', milliliter: 'ml', milliliters: 'ml',
|
|
57
|
+
millilitre: 'ml', millilitres: 'ml',
|
|
58
|
+
dl: 'dl', deciliter: 'dl', deciliters: 'dl',
|
|
59
|
+
l: 'l', liter: 'l', liters: 'l', litre: 'l', litres: 'l',
|
|
60
|
+
tsp: 'tsp', teaspoon: 'tsp', teaspoons: 'tsp',
|
|
61
|
+
tbsp: 'tbsp', tbs: 'tbsp', tablespoon: 'tbsp', tablespoons: 'tbsp',
|
|
62
|
+
cup: 'cup', cups: 'cup',
|
|
63
|
+
floz: 'floz', 'fl oz': 'floz', 'fluid ounce': 'floz', 'fluid ounces': 'floz',
|
|
64
|
+
pt: 'pint', pint: 'pint', pints: 'pint',
|
|
65
|
+
qt: 'quart', quart: 'quart', quarts: 'quart',
|
|
66
|
+
gal: 'gallon', gallon: 'gallon', gallons: 'gallon'
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Ladders used to pick a readable unit after converting. Ordered small→large.
|
|
70
|
+
var LADDERS = {
|
|
71
|
+
'metric:mass': ['g', 'kg'],
|
|
72
|
+
'metric:volume': ['ml', 'l'],
|
|
73
|
+
'us:mass': ['oz', 'lb'],
|
|
74
|
+
'us:volume': ['tsp', 'tbsp', 'cup', 'quart', 'gallon']
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
var VULGAR = {
|
|
78
|
+
0.125: '⅛', 0.25: '¼', 0.333: '⅓', 0.375: '⅜',
|
|
79
|
+
0.5: '½', 0.625: '⅝', 0.667: '⅔', 0.75: '¾',
|
|
80
|
+
0.875: '⅞'
|
|
81
|
+
};
|
|
82
|
+
// Denominators US amounts are rounded to, largest quantity first. A whole
|
|
83
|
+
// cup is measured in quarters; anything under one, in eighths. Finer than
|
|
84
|
+
// that is precision no measuring spoon can deliver.
|
|
85
|
+
var US_STEPS = [
|
|
86
|
+
{ min: 1, step: 0.25 },
|
|
87
|
+
{ min: 0, step: 0.125 }
|
|
88
|
+
];
|
|
89
|
+
// Units that read as fractions on a measuring cup or spoon rather than as
|
|
90
|
+
// decimals on a scale.
|
|
91
|
+
var FRACTIONAL_UNITS = ['tsp', 'tbsp', 'cup', 'floz', 'pint', 'quart', 'gallon', 'oz', 'lb'];
|
|
92
|
+
|
|
93
|
+
/* ------------------------------------------------------------------ *
|
|
94
|
+
* Unit helpers
|
|
95
|
+
* ------------------------------------------------------------------ */
|
|
96
|
+
|
|
97
|
+
function canonicalUnit(raw) {
|
|
98
|
+
if (!raw) return null;
|
|
99
|
+
var key = String(raw).toLowerCase().trim()
|
|
100
|
+
.replace(/\./g, '')
|
|
101
|
+
.replace(/-/g, ' ')
|
|
102
|
+
.replace(/\s+/g, ' ');
|
|
103
|
+
if (ALIASES[key]) return ALIASES[key];
|
|
104
|
+
if (ALIASES[key.replace(/\s/g, '')]) return ALIASES[key.replace(/\s/g, '')];
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Pick the friendliest unit on a ladder for a magnitude in base units. */
|
|
109
|
+
function pickUnit(ladder, baseAmount) {
|
|
110
|
+
var chosen = ladder[0];
|
|
111
|
+
for (var i = 0; i < ladder.length; i++) {
|
|
112
|
+
var unit = UNITS[ladder[i]];
|
|
113
|
+
var value = baseAmount / unit.base;
|
|
114
|
+
// Step up while the amount would still read as at least one whole unit,
|
|
115
|
+
// so 1500 g becomes 1.5 kg but 900 g stays 900 g.
|
|
116
|
+
if (value >= 1) chosen = ladder[i];
|
|
117
|
+
}
|
|
118
|
+
return chosen;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ------------------------------------------------------------------ *
|
|
122
|
+
* Number formatting
|
|
123
|
+
* ------------------------------------------------------------------ */
|
|
124
|
+
|
|
125
|
+
function roundTo(value, step) {
|
|
126
|
+
return Math.round(value / step) * step;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** "1½", "¾", "2" — US amounts read as fractions, never as 0.75. */
|
|
130
|
+
function formatFraction(value) {
|
|
131
|
+
var step = 0.0625;
|
|
132
|
+
for (var i = 0; i < US_STEPS.length; i++) {
|
|
133
|
+
if (value >= US_STEPS[i].min) { step = US_STEPS[i].step; break; }
|
|
134
|
+
}
|
|
135
|
+
var rounded = roundTo(value, step);
|
|
136
|
+
// Too small to round to even an eighth — show the decimal rather than "0".
|
|
137
|
+
if (rounded === 0) return trimNumber(value, 2);
|
|
138
|
+
var whole = Math.floor(rounded + 1e-9);
|
|
139
|
+
var frac = rounded - whole;
|
|
140
|
+
var key = Math.round(frac * 1000) / 1000;
|
|
141
|
+
if (key < 1e-6) return String(whole);
|
|
142
|
+
var glyph = VULGAR[key];
|
|
143
|
+
if (glyph) return (whole > 0 ? whole : '') + glyph;
|
|
144
|
+
return trimNumber(rounded, 2);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Metric amounts: precise when small, round when large. */
|
|
148
|
+
function formatMetric(value) {
|
|
149
|
+
if (value >= 100) return String(Math.round(value));
|
|
150
|
+
if (value >= 10) return trimNumber(Math.round(value * 2) / 2, 1);
|
|
151
|
+
if (value >= 1) return trimNumber(Math.round(value * 10) / 10, 1);
|
|
152
|
+
return trimNumber(Math.round(value * 100) / 100, 2);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Countable things ("3 eggs"): whole numbers, halves when small. */
|
|
156
|
+
function formatCount(value) {
|
|
157
|
+
if (value >= 10) return String(Math.round(value));
|
|
158
|
+
return formatFraction(value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function trimNumber(value, decimals) {
|
|
162
|
+
var text = value.toFixed(decimals);
|
|
163
|
+
if (text.indexOf('.') !== -1) {
|
|
164
|
+
text = text.replace(/0+$/, '').replace(/\.$/, '');
|
|
165
|
+
}
|
|
166
|
+
return text;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
var COUNTED_UNITS = { cup: 'cup', pint: 'pint', quart: 'quart', gallon: 'gallon' };
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Recipe usage, not strict grammar: anything up to and including one takes
|
|
173
|
+
* the singular — "¾ cup", "1 cup", "1½ cups".
|
|
174
|
+
*/
|
|
175
|
+
function pluralize(unitKey, value) {
|
|
176
|
+
var singular = COUNTED_UNITS[unitKey];
|
|
177
|
+
if (!singular) return UNITS[unitKey].label;
|
|
178
|
+
return value <= 1 + 1e-9 ? singular : singular + 's';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* ------------------------------------------------------------------ *
|
|
182
|
+
* Quantity conversion
|
|
183
|
+
* ------------------------------------------------------------------ */
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Convert one amount into the requested system.
|
|
187
|
+
* Returns { value, unit } where `unit` is display text (may be the
|
|
188
|
+
* author's original string when nothing convertible was found).
|
|
189
|
+
*/
|
|
190
|
+
function convert(amount, rawUnit, system, density, allowWeigh) {
|
|
191
|
+
var key = canonicalUnit(rawUnit);
|
|
192
|
+
|
|
193
|
+
// "As written" still scales — it keeps the author's own unit words, but
|
|
194
|
+
// a scaled amount gets the matching singular/plural so halving "2 cups"
|
|
195
|
+
// reads "1 cup" rather than "1 cups".
|
|
196
|
+
if (!key || system === 'original') {
|
|
197
|
+
var label = rawUnit || '';
|
|
198
|
+
if (key && COUNTED_UNITS[key]) label = pluralize(key, amount);
|
|
199
|
+
return { value: amount, unit: label, key: null, sourceKey: key, keepLabel: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
var unit = UNITS[key];
|
|
203
|
+
var target = system === 'metric' ? 'metric' : 'us';
|
|
204
|
+
|
|
205
|
+
// Volume ↔ weight, but only when we know how heavy a cup of it is.
|
|
206
|
+
var dim = unit.dim;
|
|
207
|
+
var baseAmount = amount * unit.base; // grams or millilitres
|
|
208
|
+
|
|
209
|
+
if (density && allowWeigh) {
|
|
210
|
+
if (target === 'metric' && dim === 'volume') {
|
|
211
|
+
baseAmount = (baseAmount / ML_PER_CUP) * density; // ml → g
|
|
212
|
+
dim = 'mass';
|
|
213
|
+
} else if (target === 'us' && dim === 'mass') {
|
|
214
|
+
baseAmount = (baseAmount / density) * ML_PER_CUP; // g → ml
|
|
215
|
+
dim = 'volume';
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
var ladder = LADDERS[target + ':' + dim];
|
|
220
|
+
if (!ladder) {
|
|
221
|
+
return { value: amount, unit: rawUnit || '', key: null, sourceKey: key, keepLabel: true };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
var chosen = pickUnit(ladder, baseAmount);
|
|
226
|
+
var value = baseAmount / UNITS[chosen].base;
|
|
227
|
+
return { value: value, unit: pluralize(chosen, value), key: chosen };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Format a converted amount, choosing the notation its unit is measured in. */
|
|
231
|
+
function renderAmount(result) {
|
|
232
|
+
var unitKey = result.key || result.sourceKey;
|
|
233
|
+
var text;
|
|
234
|
+
if (!unitKey) {
|
|
235
|
+
text = formatCount(result.value); // "3 eggs", "1½ lemons"
|
|
236
|
+
} else if (FRACTIONAL_UNITS.indexOf(unitKey) !== -1) {
|
|
237
|
+
text = formatFraction(result.value); // "1¾ cups"
|
|
238
|
+
} else {
|
|
239
|
+
text = formatMetric(result.value); // "375 g", "1.5 l"
|
|
240
|
+
}
|
|
241
|
+
return result.unit ? text + ' ' + result.unit : text;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* ------------------------------------------------------------------ *
|
|
245
|
+
* DOM updates
|
|
246
|
+
* ------------------------------------------------------------------ */
|
|
247
|
+
|
|
248
|
+
function updateQuantity(el, factor, system) {
|
|
249
|
+
var base = parseFloat(el.getAttribute('data-qty'));
|
|
250
|
+
if (isNaN(base)) return;
|
|
251
|
+
|
|
252
|
+
var scales = el.getAttribute('data-scale') !== 'false';
|
|
253
|
+
var allowWeigh = el.getAttribute('data-weigh') !== 'false';
|
|
254
|
+
var rawUnit = el.getAttribute('data-unit') || '';
|
|
255
|
+
var density = parseFloat(el.getAttribute('data-grams-per-cup'));
|
|
256
|
+
if (isNaN(density)) density = null;
|
|
257
|
+
|
|
258
|
+
var multiplier = scales ? factor : 1;
|
|
259
|
+
var parts = [renderAmount(convert(base * multiplier, rawUnit, system, density, allowWeigh))];
|
|
260
|
+
|
|
261
|
+
var max = parseFloat(el.getAttribute('data-qty-max'));
|
|
262
|
+
if (!isNaN(max) && max > base) {
|
|
263
|
+
var hi = renderAmount(convert(max * multiplier, rawUnit, system, density, allowWeigh));
|
|
264
|
+
// "2–3 cups", not "2 cups–3 cups".
|
|
265
|
+
var lo = parts[0];
|
|
266
|
+
var loParts = lo.split(' ');
|
|
267
|
+
var hiParts = hi.split(' ');
|
|
268
|
+
if (loParts.length > 1 && loParts.slice(1).join(' ') === hiParts.slice(1).join(' ')) {
|
|
269
|
+
parts = [loParts[0] + '–' + hi];
|
|
270
|
+
} else {
|
|
271
|
+
parts = [lo + '–' + hi];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
var text = parts[0];
|
|
276
|
+
if (el.textContent !== text) el.textContent = text;
|
|
277
|
+
|
|
278
|
+
// Keep what the recipe actually said one hover away.
|
|
279
|
+
var original = el.getAttribute('data-original-text');
|
|
280
|
+
if (original && original !== text) {
|
|
281
|
+
el.title = 'As written: ' + original;
|
|
282
|
+
} else {
|
|
283
|
+
el.removeAttribute('title');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* "2 large eggs" halved is "1 large egg", not "1 large eggs". Only applies to
|
|
289
|
+
* countable ingredients — where there is a unit, the unit carries the number
|
|
290
|
+
* and the ingredient name never changes.
|
|
291
|
+
*/
|
|
292
|
+
function updateName(qtyEl, factor) {
|
|
293
|
+
var item = qtyEl.closest('.recipe-ingredient');
|
|
294
|
+
if (!item) return;
|
|
295
|
+
var name = item.querySelector('[data-recipe-name]');
|
|
296
|
+
if (!name) return;
|
|
297
|
+
if (qtyEl.getAttribute('data-unit')) return;
|
|
298
|
+
|
|
299
|
+
var base = parseFloat(qtyEl.getAttribute('data-qty'));
|
|
300
|
+
if (isNaN(base)) return;
|
|
301
|
+
var scales = qtyEl.getAttribute('data-scale') !== 'false';
|
|
302
|
+
var amount = base * (scales ? factor : 1);
|
|
303
|
+
// Same rule as the units: one or less takes the singular, so half an egg
|
|
304
|
+
// is "½ egg yolk" rather than "½ egg yolks".
|
|
305
|
+
var singular = amount <= 1 + 1e-9;
|
|
306
|
+
var text = singular ? name.getAttribute('data-singular') : name.getAttribute('data-plural');
|
|
307
|
+
if (text && name.textContent !== text) name.textContent = text;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function updateTemperature(el, system) {
|
|
311
|
+
var f = parseFloat(el.getAttribute('data-temp-f'));
|
|
312
|
+
var c = parseFloat(el.getAttribute('data-temp-c'));
|
|
313
|
+
if (isNaN(f) && isNaN(c)) return;
|
|
314
|
+
var label = el.getAttribute('data-temp-label') || '';
|
|
315
|
+
var text;
|
|
316
|
+
if (system === 'metric') {
|
|
317
|
+
text = c + ' °C';
|
|
318
|
+
} else if (system === 'us') {
|
|
319
|
+
text = f + ' °F';
|
|
320
|
+
} else {
|
|
321
|
+
text = f + ' °F (' + c + ' °C)';
|
|
322
|
+
}
|
|
323
|
+
el.textContent = label ? label + ' ' + text : text;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* ------------------------------------------------------------------ *
|
|
327
|
+
* Storage (never fatal — private windows throw on access)
|
|
328
|
+
* ------------------------------------------------------------------ */
|
|
329
|
+
|
|
330
|
+
function readStore(key) {
|
|
331
|
+
try { return window.localStorage.getItem(key); } catch (e) { return null; }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function writeStore(key, value) {
|
|
335
|
+
try { window.localStorage.setItem(key, value); } catch (e) { /* ignore */ }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* ------------------------------------------------------------------ *
|
|
339
|
+
* Controller
|
|
340
|
+
* ------------------------------------------------------------------ */
|
|
341
|
+
|
|
342
|
+
function setupRecipe(scaler) {
|
|
343
|
+
var scope = scaler.closest('[data-recipe]') || document;
|
|
344
|
+
var baseYield = parseFloat(scaler.getAttribute('data-base-yield'));
|
|
345
|
+
if (isNaN(baseYield) || baseYield <= 0) baseYield = 1;
|
|
346
|
+
|
|
347
|
+
var yieldUnit = scaler.getAttribute('data-yield-unit') || 'servings';
|
|
348
|
+
var recipeId = scaler.getAttribute('data-recipe-id') || 'recipe';
|
|
349
|
+
var scaleKey = SCALE_KEY_PREFIX + recipeId;
|
|
350
|
+
|
|
351
|
+
var input = scaler.querySelector('[data-recipe-yield-input]');
|
|
352
|
+
var status = scaler.querySelector('[data-recipe-status]');
|
|
353
|
+
var quantities = scope.querySelectorAll('[data-recipe-qty]');
|
|
354
|
+
var temperatures = scope.querySelectorAll('[data-recipe-temp]');
|
|
355
|
+
|
|
356
|
+
// Remember what the page shipped with so "As written" is exactly that.
|
|
357
|
+
var i;
|
|
358
|
+
for (i = 0; i < quantities.length; i++) {
|
|
359
|
+
quantities[i].setAttribute('data-original-text', quantities[i].textContent.trim());
|
|
360
|
+
}
|
|
361
|
+
for (i = 0; i < temperatures.length; i++) {
|
|
362
|
+
temperatures[i].setAttribute('data-original-text', temperatures[i].textContent.trim());
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
var state = {
|
|
366
|
+
target: baseYield,
|
|
367
|
+
system: readStore(UNITS_KEY) || 'original'
|
|
368
|
+
};
|
|
369
|
+
if (['original', 'metric', 'us'].indexOf(state.system) === -1) state.system = 'original';
|
|
370
|
+
|
|
371
|
+
var saved = parseFloat(readStore(scaleKey));
|
|
372
|
+
if (!isNaN(saved) && saved > 0) state.target = saved;
|
|
373
|
+
|
|
374
|
+
// ?scale=2 or ?servings=24 wins over the remembered value — that is what a
|
|
375
|
+
// shared link is for.
|
|
376
|
+
var params = new URLSearchParams(window.location.search);
|
|
377
|
+
var fromUrl = parseFloat(params.get('servings'));
|
|
378
|
+
var scaleParam = parseFloat(params.get('scale'));
|
|
379
|
+
if (!isNaN(fromUrl) && fromUrl > 0) state.target = fromUrl;
|
|
380
|
+
else if (!isNaN(scaleParam) && scaleParam > 0) state.target = baseYield * scaleParam;
|
|
381
|
+
|
|
382
|
+
function factor() {
|
|
383
|
+
return state.target / baseYield;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function render(announce) {
|
|
387
|
+
var f = factor();
|
|
388
|
+
var j;
|
|
389
|
+
for (j = 0; j < quantities.length; j++) {
|
|
390
|
+
// Nothing to recompute when the reader is looking at the authored
|
|
391
|
+
// units AND this amount is not moving — either because the scale is
|
|
392
|
+
// 1x, or because the amount is pinned (data-scale="false", e.g. the
|
|
393
|
+
// ratio table's per-unit weight). Re-formatting it anyway would nudge
|
|
394
|
+
// the authored 81.9 g to 82 g for no reason.
|
|
395
|
+
var pinned = quantities[j].getAttribute('data-scale') === 'false';
|
|
396
|
+
var still = Math.abs(f - 1) < 1e-9 || pinned;
|
|
397
|
+
if (state.system === 'original' && still) {
|
|
398
|
+
quantities[j].textContent = quantities[j].getAttribute('data-original-text');
|
|
399
|
+
quantities[j].removeAttribute('title');
|
|
400
|
+
} else {
|
|
401
|
+
updateQuantity(quantities[j], f, state.system);
|
|
402
|
+
}
|
|
403
|
+
updateName(quantities[j], f);
|
|
404
|
+
}
|
|
405
|
+
for (j = 0; j < temperatures.length; j++) {
|
|
406
|
+
updateTemperature(temperatures[j], state.system);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (input) input.value = trimNumber(state.target, 2);
|
|
410
|
+
|
|
411
|
+
var multiplierButtons = scaler.querySelectorAll('[data-recipe-multiplier]');
|
|
412
|
+
for (j = 0; j < multiplierButtons.length; j++) {
|
|
413
|
+
var m = parseFloat(multiplierButtons[j].getAttribute('data-recipe-multiplier'));
|
|
414
|
+
var active = Math.abs(f - m) < 1e-6;
|
|
415
|
+
multiplierButtons[j].classList.toggle('active', active);
|
|
416
|
+
multiplierButtons[j].setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
var unitButtons = scaler.querySelectorAll('[data-recipe-units]');
|
|
420
|
+
for (j = 0; j < unitButtons.length; j++) {
|
|
421
|
+
var on = unitButtons[j].getAttribute('data-recipe-units') === state.system;
|
|
422
|
+
unitButtons[j].classList.toggle('active', on);
|
|
423
|
+
unitButtons[j].setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
scaler.setAttribute('data-current-system', state.system);
|
|
427
|
+
if (scope !== document) scope.setAttribute('data-scaled', Math.abs(f - 1) < 1e-9 ? 'false' : 'true');
|
|
428
|
+
|
|
429
|
+
if (announce && status) {
|
|
430
|
+
status.textContent = 'Scaled to ' + trimNumber(state.target, 2) + ' ' + yieldUnit +
|
|
431
|
+
', shown in ' + (state.system === 'original' ? 'the original units' :
|
|
432
|
+
state.system === 'metric' ? 'metric units' : 'US units') + '.';
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function setYield(value, announce) {
|
|
437
|
+
if (isNaN(value) || value <= 0) return;
|
|
438
|
+
state.target = Math.round(value * 100) / 100;
|
|
439
|
+
writeStore(scaleKey, String(state.target));
|
|
440
|
+
render(announce);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
scaler.addEventListener('click', function (event) {
|
|
444
|
+
var button = event.target.closest('button');
|
|
445
|
+
if (!button || !scaler.contains(button)) return;
|
|
446
|
+
|
|
447
|
+
if (button.hasAttribute('data-recipe-step')) {
|
|
448
|
+
var direction = parseFloat(button.getAttribute('data-recipe-step'));
|
|
449
|
+
var stepSize = state.target <= 2 ? 0.5 : 1;
|
|
450
|
+
setYield(state.target + direction * stepSize, true);
|
|
451
|
+
} else if (button.hasAttribute('data-recipe-multiplier')) {
|
|
452
|
+
setYield(baseYield * parseFloat(button.getAttribute('data-recipe-multiplier')), true);
|
|
453
|
+
} else if (button.hasAttribute('data-recipe-units')) {
|
|
454
|
+
state.system = button.getAttribute('data-recipe-units');
|
|
455
|
+
writeStore(UNITS_KEY, state.system);
|
|
456
|
+
render(true);
|
|
457
|
+
} else if (button.hasAttribute('data-recipe-reset')) {
|
|
458
|
+
state.system = 'original';
|
|
459
|
+
writeStore(UNITS_KEY, state.system);
|
|
460
|
+
setYield(baseYield, true);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
if (input) {
|
|
465
|
+
input.addEventListener('change', function () {
|
|
466
|
+
setYield(parseFloat(input.value), true);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
scaler.hidden = false;
|
|
471
|
+
scaler.classList.add('recipe-scaler--ready');
|
|
472
|
+
render(false);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Cross off an ingredient as you use it. */
|
|
476
|
+
function setupChecklist(root) {
|
|
477
|
+
root.addEventListener('change', function (event) {
|
|
478
|
+
var box = event.target;
|
|
479
|
+
if (!box.hasAttribute || !box.hasAttribute('data-recipe-check')) return;
|
|
480
|
+
var item = box.closest('.recipe-ingredient');
|
|
481
|
+
if (item) item.classList.toggle('recipe-ingredient--done', box.checked);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function init() {
|
|
486
|
+
var scalers = document.querySelectorAll('[data-recipe-scaler]');
|
|
487
|
+
for (var i = 0; i < scalers.length; i++) {
|
|
488
|
+
setupRecipe(scalers[i]);
|
|
489
|
+
}
|
|
490
|
+
var roots = document.querySelectorAll('[data-recipe]');
|
|
491
|
+
for (var j = 0; j < roots.length; j++) {
|
|
492
|
+
setupChecklist(roots[j]);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (document.readyState === 'loading') {
|
|
497
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
498
|
+
} else {
|
|
499
|
+
init();
|
|
500
|
+
}
|
|
501
|
+
})();
|
data/assets/js/search-modal.js
CHANGED
|
@@ -59,6 +59,9 @@
|
|
|
59
59
|
const searchForm = modalEl.querySelector('[data-search-form]');
|
|
60
60
|
const resultsContainer = modalEl.querySelector('[data-search-results]');
|
|
61
61
|
const emptyState = modalEl.querySelector('[data-search-empty]');
|
|
62
|
+
// Visually-hidden aria-live region. Announces a COUNT, not the results
|
|
63
|
+
// themselves — see the rationale in components/search-modal.html.
|
|
64
|
+
const statusRegion = modalEl.querySelector('[data-search-status]');
|
|
62
65
|
const searchIndexUrl = new URL('/search.json', window.location.origin);
|
|
63
66
|
let searchIndex = null;
|
|
64
67
|
let searchIndexPromise = null;
|
|
@@ -109,6 +112,16 @@
|
|
|
109
112
|
});
|
|
110
113
|
});
|
|
111
114
|
|
|
115
|
+
// Hover hints ship as "Ctrl+K" in the markup; swap to the macOS glyph
|
|
116
|
+
// so the affordance matches the key the visitor actually has.
|
|
117
|
+
if (/Mac|iPhone|iPad/i.test(navigator.platform || navigator.userAgent)) {
|
|
118
|
+
document.querySelectorAll('[data-search-toggle]').forEach((button) => {
|
|
119
|
+
if (button.title) {
|
|
120
|
+
button.title = button.title.replace(/Ctrl\+K/i, '⌘K');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
// Fallback keyboard shortcut ("/" or Cmd/Ctrl+K) in case other modules are unavailable
|
|
113
126
|
document.addEventListener('keydown', (event) => {
|
|
114
127
|
if (event.target.matches('input, textarea, select, [contenteditable="true"]')) {
|
|
@@ -172,6 +185,18 @@
|
|
|
172
185
|
});
|
|
173
186
|
}
|
|
174
187
|
|
|
188
|
+
/** Announce to screen readers. Guarded: the region is markup the theme
|
|
189
|
+
* ships, but a consumer shadowing search-modal.html may not have it. */
|
|
190
|
+
function announce(message) {
|
|
191
|
+
if (!statusRegion) return;
|
|
192
|
+
// Re-assigning the identical string is a no-op for most screen
|
|
193
|
+
// readers, so clear first when the message has not changed —
|
|
194
|
+
// otherwise "No results found." stays silent as the user keeps
|
|
195
|
+
// typing characters that all miss.
|
|
196
|
+
if (statusRegion.textContent === message) statusRegion.textContent = '';
|
|
197
|
+
statusRegion.textContent = message;
|
|
198
|
+
}
|
|
199
|
+
|
|
175
200
|
function clearResults() {
|
|
176
201
|
if (!resultsContainer) return;
|
|
177
202
|
resultsContainer.innerHTML = '';
|
|
@@ -188,6 +213,9 @@
|
|
|
188
213
|
|
|
189
214
|
if (!query) {
|
|
190
215
|
clearResults();
|
|
216
|
+
// Silence on an emptied box: the user cleared it deliberately
|
|
217
|
+
// and does not need telling.
|
|
218
|
+
if (statusRegion) statusRegion.textContent = '';
|
|
191
219
|
return;
|
|
192
220
|
}
|
|
193
221
|
|
|
@@ -200,12 +228,20 @@
|
|
|
200
228
|
? 'No results found.'
|
|
201
229
|
: 'Search is unavailable on this site.';
|
|
202
230
|
resultsContainer.appendChild(empty);
|
|
231
|
+
announce(empty.textContent);
|
|
203
232
|
return;
|
|
204
233
|
}
|
|
205
234
|
|
|
206
235
|
const list = document.createElement('div');
|
|
207
236
|
list.className = 'list-group';
|
|
208
237
|
|
|
238
|
+
// Announce the count, not the results. `shown` is what the user can
|
|
239
|
+
// actually reach in the list; when the index matched more than that
|
|
240
|
+
// say so, or "8 results" for a 40-match query would be a lie.
|
|
241
|
+
const shown = Math.min(items.length, 8);
|
|
242
|
+
const suffix = items.length > shown ? ` of ${items.length}` : '';
|
|
243
|
+
announce(`${shown}${suffix} result${shown === 1 ? '' : 's'} for ${query}`);
|
|
244
|
+
|
|
209
245
|
items.slice(0, 8).forEach((item) => {
|
|
210
246
|
const link = document.createElement('a');
|
|
211
247
|
link.className = 'list-group-item list-group-item-action';
|