@jjlmoya/utils-nature 1.1.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 (61) hide show
  1. package/package.json +60 -0
  2. package/src/category/i18n/en.ts +110 -0
  3. package/src/category/i18n/es.ts +127 -0
  4. package/src/category/i18n/fr.ts +110 -0
  5. package/src/category/index.ts +14 -0
  6. package/src/category/seo.astro +15 -0
  7. package/src/components/PreviewNavSidebar.astro +116 -0
  8. package/src/components/PreviewToolbar.astro +143 -0
  9. package/src/data.ts +11 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +30 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +146 -0
  14. package/src/pages/[locale].astro +251 -0
  15. package/src/pages/index.astro +4 -0
  16. package/src/tests/faq_count.test.ts +19 -0
  17. package/src/tests/locale_completeness.test.ts +42 -0
  18. package/src/tests/mocks/astro_mock.js +2 -0
  19. package/src/tests/no_h1_in_components.test.ts +48 -0
  20. package/src/tests/schemas_fulfillment.test.ts +23 -0
  21. package/src/tests/seo_length.test.ts +22 -0
  22. package/src/tests/title_quality.test.ts +55 -0
  23. package/src/tests/tool_validation.test.ts +17 -0
  24. package/src/tool/cricketThermometer/bibliography.astro +14 -0
  25. package/src/tool/cricketThermometer/component.astro +549 -0
  26. package/src/tool/cricketThermometer/i18n/en.ts +181 -0
  27. package/src/tool/cricketThermometer/i18n/es.ts +181 -0
  28. package/src/tool/cricketThermometer/i18n/fr.ts +181 -0
  29. package/src/tool/cricketThermometer/index.ts +34 -0
  30. package/src/tool/cricketThermometer/logic.ts +6 -0
  31. package/src/tool/cricketThermometer/seo.astro +15 -0
  32. package/src/tool/cricketThermometer/ui.ts +11 -0
  33. package/src/tool/digitalCarbon/bibliography.astro +9 -0
  34. package/src/tool/digitalCarbon/component.astro +582 -0
  35. package/src/tool/digitalCarbon/i18n/en.ts +235 -0
  36. package/src/tool/digitalCarbon/i18n/es.ts +235 -0
  37. package/src/tool/digitalCarbon/i18n/fr.ts +235 -0
  38. package/src/tool/digitalCarbon/index.ts +33 -0
  39. package/src/tool/digitalCarbon/logic.ts +107 -0
  40. package/src/tool/digitalCarbon/seo.astro +14 -0
  41. package/src/tool/digitalCarbon/ui.ts +38 -0
  42. package/src/tool/rainHarvester/bibliography.astro +9 -0
  43. package/src/tool/rainHarvester/component.astro +559 -0
  44. package/src/tool/rainHarvester/i18n/en.ts +185 -0
  45. package/src/tool/rainHarvester/i18n/es.ts +185 -0
  46. package/src/tool/rainHarvester/i18n/fr.ts +185 -0
  47. package/src/tool/rainHarvester/index.ts +33 -0
  48. package/src/tool/rainHarvester/logic.ts +12 -0
  49. package/src/tool/rainHarvester/seo.astro +14 -0
  50. package/src/tool/rainHarvester/ui.ts +23 -0
  51. package/src/tool/seedCalculator/bibliography.astro +8 -0
  52. package/src/tool/seedCalculator/component.astro +812 -0
  53. package/src/tool/seedCalculator/i18n/en.ts +213 -0
  54. package/src/tool/seedCalculator/i18n/es.ts +213 -0
  55. package/src/tool/seedCalculator/i18n/fr.ts +213 -0
  56. package/src/tool/seedCalculator/index.ts +34 -0
  57. package/src/tool/seedCalculator/logic.ts +19 -0
  58. package/src/tool/seedCalculator/seo.astro +9 -0
  59. package/src/tool/seedCalculator/ui.ts +39 -0
  60. package/src/tools.ts +12 -0
  61. package/src/types.ts +72 -0
@@ -0,0 +1,582 @@
1
+ ---
2
+ import { Icon } from 'astro-icon/components';
3
+ import { digitalCarbon } from './index';
4
+
5
+ const { ui: propUi, locale = 'es' } = Astro.props;
6
+ let ui = propUi;
7
+
8
+ if (!ui) {
9
+ const content = await digitalCarbon.i18n[locale as keyof typeof digitalCarbon.i18n]?.();
10
+ ui = content?.ui;
11
+ }
12
+
13
+ if (!ui) return null;
14
+ ---
15
+
16
+ <digital-carbon class="dc-wrap" data-ui={JSON.stringify(ui)}>
17
+ <div class="dc-container">
18
+ <div class="dc-inputs">
19
+ <section class="dc-section">
20
+ <h2 class="dc-section-head">
21
+ <Icon name="mdi:magnify" class="dc-head-icon" />
22
+ {ui.headInputs}
23
+ </h2>
24
+
25
+ <div class="dc-field">
26
+ <label for="urlInput" class="dc-label">{ui.labelUrl}</label>
27
+ <div class="dc-search-box">
28
+ <input type="url" id="urlInput" class="dc-input" placeholder={ui.placeholderUrl} required />
29
+ <button id="btnAnalyze" class="dc-btn-primary">
30
+ <span class="btn-text">{ui.btnAnalyze}</span>
31
+ <span class="btn-loader dc-hidden"><Icon name="mdi:loading" class="dc-spin" /></span>
32
+ </button>
33
+ </div>
34
+ <p id="errorMsg" class="dc-error dc-hidden"></p>
35
+ </div>
36
+ </section>
37
+ </div>
38
+
39
+ <div id="resultsWrapper" class="dc-results-wrapper dc-hidden">
40
+ <div id="resultsGrid" class="dc-results-grid">
41
+ <div class="dc-result-card main">
42
+ <div class="dc-rating-badge" id="ratingBadge">A+</div>
43
+ <div class="dc-rating-info">
44
+ <h3 class="dc-rating-title">{ui.resultTitle}</h3>
45
+ <p id="ratingDesc" class="dc-rating-desc"></p>
46
+ </div>
47
+ </div>
48
+
49
+ <div class="dc-stats-grid">
50
+ <div class="dc-stat-item">
51
+ <span class="dc-stat-label">{ui.co2PerVisit}</span>
52
+ <div class="dc-stat-value"><span id="resCo2">0</span><small>g</small></div>
53
+ </div>
54
+ <div class="dc-stat-item">
55
+ <span class="dc-stat-label">{ui.energyPerVisit}</span>
56
+ <div class="dc-stat-value"><span id="resEnergy">0</span><small>Wh</small></div>
57
+ </div>
58
+ <div class="dc-stat-item full">
59
+ <span class="dc-stat-label">{ui.co2Annual}</span>
60
+ <div class="dc-stat-value"><span id="resCo2Year">0</span><small>kg</small></div>
61
+ </div>
62
+ </div>
63
+ </div>
64
+
65
+ <div class="dc-sidebar">
66
+ <div id="impactBox" class="dc-impact-box">
67
+ <h3 class="dc-impact-title">{ui.impactTitle}</h3>
68
+ <div class="dc-impact-items">
69
+ <div class="dc-impact-card">
70
+ <Icon name="mdi:tree-outline" class="dc-impact-icon tree" />
71
+ <div class="dc-impact-content">
72
+ <span id="resTrees" class="dc-impact-val">0</span>
73
+ <span class="dc-impact-label">{ui.treesLabel}</span>
74
+ </div>
75
+ </div>
76
+ <div class="dc-impact-card">
77
+ <Icon name="mdi:car-side" class="dc-impact-icon car" />
78
+ <div class="dc-impact-content">
79
+ <span id="resKm" class="dc-impact-val">0</span>
80
+ <span class="dc-impact-label">{ui.kmLabel}</span>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <div id="tipsBox" class="dc-tips-box">
87
+ <h3 class="dc-tips-title">{ui.headTips}</h3>
88
+ <ul id="tipsList" class="dc-tips-list"></ul>
89
+ </div>
90
+
91
+ <div class="dc-decoration">
92
+ <Icon name="mdi:leaf" />
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </digital-carbon>
98
+
99
+ <style>
100
+ .dc-wrap {
101
+ --dc-bg: #fff;
102
+ --dc-bg-alt: #f8fafc;
103
+ --dc-border: #e2e8f0;
104
+ --dc-text: #0f172a;
105
+ --dc-text-muted: #64748b;
106
+ --dc-accent: #10b981;
107
+ --dc-accent-hover: #059669;
108
+ --dc-error: #ef4444;
109
+
110
+ display: block;
111
+ max-width: 72rem;
112
+ margin: 0 auto;
113
+ padding: 1rem;
114
+ }
115
+
116
+ :global(.theme-dark) .dc-wrap {
117
+ --dc-bg: #0f172a;
118
+ --dc-bg-alt: #1e293b;
119
+ --dc-border: #334155;
120
+ --dc-text: #f1f5f9;
121
+ --dc-text-muted: #94a3b8;
122
+ }
123
+
124
+ .dc-container {
125
+ background: var(--dc-bg);
126
+ border-radius: 2rem;
127
+ border: 1px solid var(--dc-border);
128
+ overflow: hidden;
129
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
130
+ }
131
+
132
+ :global(.dc-results-wrapper) {
133
+ display: grid;
134
+ grid-template-columns: 1fr;
135
+ border-top: 1px solid var(--dc-border);
136
+ animation: dc-slide-up 0.6s cubic-bezier(0.16, 1, 0.3, 1);
137
+ }
138
+
139
+ @media (min-width: 1024px) {
140
+ :global(.dc-results-wrapper) {
141
+ grid-template-columns: 1.5fr 1fr;
142
+ }
143
+ }
144
+
145
+ @keyframes dc-slide-up {
146
+ from {
147
+ opacity: 0;
148
+ transform: translateY(40px);
149
+ }
150
+
151
+ to {
152
+ opacity: 1;
153
+ transform: translateY(0);
154
+ }
155
+ }
156
+
157
+ .dc-inputs {
158
+ padding: 3rem;
159
+ }
160
+
161
+ .dc-section-head {
162
+ display: flex;
163
+ align-items: center;
164
+ gap: 0.75rem;
165
+ font-size: 1.5rem;
166
+ font-weight: 800;
167
+ margin-bottom: 2rem;
168
+ color: var(--dc-text);
169
+ }
170
+
171
+ .dc-head-icon {
172
+ font-size: 2rem;
173
+ color: var(--dc-accent);
174
+ }
175
+
176
+ .dc-label {
177
+ display: block;
178
+ font-size: 0.875rem;
179
+ font-weight: 700;
180
+ text-transform: uppercase;
181
+ letter-spacing: 0.05em;
182
+ margin-bottom: 0.75rem;
183
+ color: var(--dc-text-muted);
184
+ }
185
+
186
+ .dc-search-box {
187
+ display: flex;
188
+ gap: 1rem;
189
+ background: var(--dc-bg-alt);
190
+ padding: 0.5rem;
191
+ border-radius: 1rem;
192
+ border: 1px solid var(--dc-border);
193
+ }
194
+
195
+ .dc-input {
196
+ flex: 1;
197
+ background: transparent;
198
+ border: none;
199
+ padding: 0.75rem 1rem;
200
+ font-size: 1.125rem;
201
+ color: var(--dc-text);
202
+ outline: none;
203
+ }
204
+
205
+ .dc-btn-primary {
206
+ background: var(--dc-accent);
207
+ color: #fff;
208
+ border: none;
209
+ padding: 0 2rem;
210
+ border-radius: 0.75rem;
211
+ font-weight: 700;
212
+ cursor: pointer;
213
+ transition: all 0.2s;
214
+ display: flex;
215
+ align-items: center;
216
+ justify-content: center;
217
+ min-width: 160px;
218
+ }
219
+
220
+ .dc-btn-primary:hover {
221
+ background: var(--dc-accent-hover);
222
+ transform: translateY(-2px);
223
+ }
224
+
225
+ .dc-btn-primary:disabled {
226
+ opacity: 0.7;
227
+ cursor: not-allowed;
228
+ transform: none;
229
+ }
230
+
231
+ .dc-spin {
232
+ animation: dc-spin 1s linear infinite;
233
+ font-size: 1.5rem;
234
+ }
235
+
236
+ @keyframes dc-spin {
237
+ from { transform: rotate(0deg); }
238
+ to { transform: rotate(360deg); }
239
+ }
240
+
241
+ .dc-error {
242
+ color: var(--dc-error);
243
+ font-size: 0.875rem;
244
+ font-weight: 600;
245
+ margin-top: 0.5rem;
246
+ }
247
+
248
+ :global(.btn-text.dc-hidden),
249
+ :global(.btn-loader.dc-hidden) {
250
+ display: none;
251
+ }
252
+
253
+ :global(.dc-hidden) {
254
+ display: none;
255
+ }
256
+
257
+ .dc-results-grid {
258
+ padding: 3rem;
259
+ display: flex;
260
+ flex-direction: column;
261
+ gap: 2rem;
262
+ }
263
+
264
+ .dc-result-card.main {
265
+ display: flex;
266
+ align-items: center;
267
+ gap: 2rem;
268
+ background: var(--dc-bg-alt);
269
+ padding: 2rem;
270
+ border-radius: 1.5rem;
271
+ border: 1px solid var(--dc-border);
272
+ }
273
+
274
+ .dc-rating-badge {
275
+ width: 5rem;
276
+ height: 5rem;
277
+ display: flex;
278
+ align-items: center;
279
+ justify-content: center;
280
+ border-radius: 1.25rem;
281
+ font-size: 2.25rem;
282
+ font-weight: 900;
283
+ color: #fff;
284
+ flex-shrink: 0;
285
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
286
+ }
287
+
288
+ .dc-rating-title {
289
+ font-size: 1.25rem;
290
+ font-weight: 800;
291
+ margin-bottom: 0.25rem;
292
+ color: var(--dc-text);
293
+ }
294
+
295
+ .dc-rating-desc {
296
+ color: var(--dc-text-muted);
297
+ line-height: 1.5;
298
+ }
299
+
300
+ .dc-stats-grid {
301
+ display: grid;
302
+ grid-template-columns: repeat(2, 1fr);
303
+ gap: 1.5rem;
304
+ }
305
+
306
+ .dc-stat-item {
307
+ background: var(--dc-bg-alt);
308
+ padding: 1.5rem;
309
+ border-radius: 1.25rem;
310
+ border: 1px solid var(--dc-border);
311
+ }
312
+
313
+ .dc-stat-item.full {
314
+ grid-column: span 2;
315
+ }
316
+
317
+ .dc-stat-label {
318
+ display: block;
319
+ font-size: 0.75rem;
320
+ font-weight: 700;
321
+ text-transform: uppercase;
322
+ color: var(--dc-text-muted);
323
+ margin-bottom: 0.5rem;
324
+ }
325
+
326
+ .dc-stat-value {
327
+ font-size: 2rem;
328
+ font-weight: 800;
329
+ color: var(--dc-text);
330
+ }
331
+
332
+ .dc-stat-value small {
333
+ font-size: 1rem;
334
+ margin-left: 0.25rem;
335
+ color: var(--dc-text-muted);
336
+ }
337
+
338
+ .dc-sidebar {
339
+ background: var(--dc-bg-alt);
340
+ padding: 3rem;
341
+ border-left: 1px solid var(--dc-border);
342
+ display: flex;
343
+ flex-direction: column;
344
+ gap: 3rem;
345
+ position: relative;
346
+ }
347
+
348
+ @media (max-width: 1023px) {
349
+ .dc-sidebar {
350
+ border-left: none;
351
+ border-top: 1px solid var(--dc-border);
352
+ }
353
+ }
354
+
355
+ .dc-impact-title {
356
+ font-size: 1.125rem;
357
+ font-weight: 800;
358
+ margin-bottom: 1.5rem;
359
+ text-align: center;
360
+ color: var(--dc-text);
361
+ }
362
+
363
+ .dc-impact-items {
364
+ display: grid;
365
+ grid-template-columns: 1fr 1fr;
366
+ gap: 1rem;
367
+ }
368
+
369
+ .dc-impact-card {
370
+ background: var(--dc-bg);
371
+ padding: 1.5rem;
372
+ border-radius: 1.25rem;
373
+ border: 1px solid var(--dc-border);
374
+ display: flex;
375
+ flex-direction: column;
376
+ align-items: center;
377
+ gap: 1rem;
378
+ text-align: center;
379
+ }
380
+
381
+ .dc-impact-icon {
382
+ font-size: 2.5rem;
383
+ }
384
+
385
+ .dc-impact-icon.tree { color: #059669; }
386
+ .dc-impact-icon.car { color: #4b5563; }
387
+
388
+ .dc-impact-val {
389
+ display: block;
390
+ font-size: 1.5rem;
391
+ font-weight: 800;
392
+ color: var(--dc-text);
393
+ }
394
+
395
+ :global(.dc-impact-label) {
396
+ font-size: 0.75rem;
397
+ color: var(--dc-text-muted);
398
+ font-weight: 600;
399
+ }
400
+
401
+ :global(.dc-tips-title) {
402
+ font-size: 1.125rem;
403
+ font-weight: 800;
404
+ margin-bottom: 1.5rem;
405
+ color: var(--dc-text);
406
+ }
407
+
408
+ :global(.dc-tips-list) {
409
+ list-style: none;
410
+ padding: 0;
411
+ margin: 0;
412
+ display: flex;
413
+ flex-direction: column;
414
+ gap: 1rem;
415
+ }
416
+
417
+ :global(.dc-tips-list li) {
418
+ padding-left: 2rem;
419
+ position: relative;
420
+ font-size: 0.9375rem;
421
+ color: var(--dc-text-muted);
422
+ line-height: 1.4;
423
+ }
424
+
425
+ :global(.dc-tips-list li::before) {
426
+ content: "→";
427
+ position: absolute;
428
+ left: 0;
429
+ color: var(--dc-accent);
430
+ font-weight: 900;
431
+ }
432
+
433
+ .dc-decoration {
434
+ position: absolute;
435
+ bottom: 1rem;
436
+ right: 1rem;
437
+ font-size: 3rem;
438
+ opacity: 0.05;
439
+ color: var(--dc-accent);
440
+ pointer-events: none;
441
+ }
442
+ </style>
443
+
444
+ <script>
445
+ import { estimatePageSize, buildResult } from './logic';
446
+ import type { DCFResult } from './logic';
447
+
448
+ class DigitalCarbon extends HTMLElement {
449
+ ui: Record<string, string> = {};
450
+ elements: { [key: string]: HTMLElement | null } = {};
451
+
452
+ connectedCallback() {
453
+ this.ui = JSON.parse(this.dataset.ui || '{}');
454
+ this.setupElements();
455
+ this.setupListeners();
456
+ }
457
+
458
+ setupElements() {
459
+ this.elements = {
460
+ input: this.querySelector('#urlInput'),
461
+ btn: this.querySelector('#btnAnalyze'),
462
+ btnText: this.querySelector('.btn-text'),
463
+ btnLoader: this.querySelector('.btn-loader'),
464
+ error: this.querySelector('#errorMsg'),
465
+ badge: this.querySelector('#ratingBadge'),
466
+ ratingDesc: this.querySelector('#ratingDesc'),
467
+ resCo2: this.querySelector('#resCo2'),
468
+ resEnergy: this.querySelector('#resEnergy'),
469
+ resCo2Year: this.querySelector('#resCo2Year'),
470
+ resTrees: this.querySelector('#resTrees'),
471
+ resKm: this.querySelector('#resKm'),
472
+ results: this.querySelector('#resultsWrapper'),
473
+ };
474
+ }
475
+
476
+ setupListeners() {
477
+ const btn = this.elements.btn;
478
+ btn?.addEventListener('click', () => this.analyze());
479
+
480
+ const input = this.elements.input;
481
+ input?.addEventListener('keypress', (e: KeyboardEvent) => {
482
+ if (e.key === 'Enter') this.analyze();
483
+ });
484
+ }
485
+
486
+ async analyze() {
487
+ const input = this.elements.input as HTMLInputElement;
488
+ const url = input.value.trim();
489
+
490
+ if (!url || !url.startsWith('http')) {
491
+ this.showError(this.ui.errorInvalidUrl);
492
+ return;
493
+ }
494
+
495
+ this.setLoading(true);
496
+ this.hideError();
497
+
498
+ try {
499
+ const bytes = await estimatePageSize(url);
500
+ const result = buildResult(url, bytes);
501
+ this.showResult(result);
502
+ } catch {
503
+ this.showError(this.ui.errorFetchFailed);
504
+ } finally {
505
+ this.setLoading(false);
506
+ }
507
+ }
508
+
509
+ setLoading(loading: boolean) {
510
+ const btn = this.elements.btn as HTMLButtonElement;
511
+ const btnText = this.elements.btnText as HTMLElement;
512
+ const btnLoader = this.elements.btnLoader as HTMLElement;
513
+
514
+ if (loading) {
515
+ btn.disabled = true;
516
+ btnText.classList.add('dc-hidden');
517
+ btnLoader.classList.remove('dc-hidden');
518
+ } else {
519
+ btn.disabled = false;
520
+ btnText.classList.remove('dc-hidden');
521
+ btnLoader.classList.add('dc-hidden');
522
+ }
523
+ }
524
+
525
+ showError(msg: string) {
526
+ const error = this.elements.error as HTMLElement;
527
+ error.textContent = msg;
528
+ error.classList.remove('dc-hidden');
529
+ }
530
+
531
+ hideError() {
532
+ const error = this.elements.error as HTMLElement;
533
+ error.classList.add('dc-hidden');
534
+ }
535
+
536
+ showResult(res: DCFResult) {
537
+ const results = this.elements.results as HTMLElement;
538
+ results.classList.remove('dc-hidden');
539
+
540
+ const badge = this.elements.badge as HTMLElement;
541
+ const ratingDesc = this.elements.ratingDesc as HTMLElement;
542
+ const resCo2 = this.elements.resCo2 as HTMLElement;
543
+ const resEnergy = this.elements.resEnergy as HTMLElement;
544
+ const resCo2Year = this.elements.resCo2Year as HTMLElement;
545
+ const resTrees = this.elements.resTrees as HTMLElement;
546
+ const resKm = this.elements.resKm as HTMLElement;
547
+ const tipsList = this.querySelector('#tipsList') as HTMLElement;
548
+
549
+ badge.textContent = res.rating.label;
550
+ badge.style.backgroundColor = res.rating.color;
551
+ ratingDesc.textContent = this.ui[res.rating.descKey];
552
+
553
+ this.animateValue(resCo2, res.co2g, 2);
554
+ this.animateValue(resEnergy, res.energyWh, 1);
555
+ this.animateValue(resCo2Year, res.co2Year, 1);
556
+ this.animateValue(resTrees, res.trees, 1);
557
+ this.animateValue(resKm, res.km, 0);
558
+
559
+ tipsList.innerHTML = res.tipKeys
560
+ .map((key: string) => `<li>${this.ui[key]}</li>`)
561
+ .join('');
562
+ }
563
+
564
+ animateValue(el: HTMLElement, end: number | string, decimals = 0) {
565
+ const val = typeof end === 'string' ? parseFloat(end) : end;
566
+ const start = 0;
567
+ const duration = 1000;
568
+ let startTime: number | null = null;
569
+
570
+ const step = (timestamp: number) => {
571
+ if (!startTime) startTime = timestamp;
572
+ const progress = Math.min((timestamp - startTime) / duration, 1);
573
+ const current = start + (val - start) * progress;
574
+ el.textContent = current.toFixed(decimals);
575
+ if (progress < 1) requestAnimationFrame(step);
576
+ };
577
+ requestAnimationFrame(step);
578
+ }
579
+ }
580
+
581
+ customElements.define('digital-carbon', DigitalCarbon);
582
+ </script>