@jjlmoya/utils-aquarium 1.60.0 → 1.62.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.
- package/package.json +18 -5
- package/scripts/postbuild.mjs +17 -0
- package/scripts/postinstall.mjs +2 -4
- package/src/category/index.ts +2 -1
- package/src/components/ProductionBreadcrumb.astro +134 -0
- package/src/components/ProductionStructuredData.astro +46 -0
- package/src/components/ProductionWidget.astro +182 -0
- package/src/entries.ts +4 -1
- package/src/i18n/header-ui.ts +15 -0
- package/src/i18n/language-ui.ts +9 -0
- package/src/i18n/languages.ts +24 -0
- package/src/identity/brands.ts +5 -0
- package/src/index.ts +2 -0
- package/src/layouts/ProductionCategoryPage.astro +184 -0
- package/src/layouts/ProductionPage.astro +218 -0
- package/src/layouts/ProductionUtilityPage.astro +283 -0
- package/src/mfe/assets.ts +39 -0
- package/src/mfe/category-ui.ts +34 -0
- package/src/mfe/manifest.ts +45 -0
- package/src/mfe/routes.ts +50 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug]/manifest.json.ts +49 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
- package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
- package/src/pages/index.astro +1 -1
- package/src/pages/mfe-sitemaps/[locale]/[vertical]/sitemap.xml.ts +75 -0
- package/src/pages/utilidades/[slug]/manifest.json.ts +28 -0
- package/src/pages/utilidades/[slug].astro +90 -0
- package/src/pages/utilidades/categorias/[category].astro +38 -0
- package/src/tests/i18n_coverage.test.ts +1 -1
- package/src/tests/mfe_cache_contract.test.ts +14 -0
- package/src/tests/mfe_manifest.test.ts +28 -0
- package/src/tests/registry_contract.test.ts +67 -0
- package/src/tests/tool_validation.test.ts +4 -3
- package/src/tool/aquariumEvaporationTopOffPlanner/aquarium-evaporation-top-off-planner.css +490 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/bibliography.astro +6 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/bibliography.ts +10 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/component.astro +61 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/controller.ts +232 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/dom-views.ts +39 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/entry.ts +27 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/evaluator.ts +9 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/de.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/en.ts +179 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/es.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/fr.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/id.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/it.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ja.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ko.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/nl.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/pl.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/pt.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/ru.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/shared.ts +79 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/sv.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/tr.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/translations.ts +509 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/i18n/zh.ts +3 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/index.ts +11 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/logic.test.ts +61 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/logic.ts +102 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/seo.astro +13 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/storage.ts +28 -0
- package/src/tool/aquariumEvaporationTopOffPlanner/ui.ts +49 -0
- package/src/tool/aquariumLightingPhotoperiodPlanner/i18n/zh.ts +2 -2
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/fr.ts +1 -1
- package/src/tool/aquariumTankVolumeWaterChangeCalculator/i18n/it.ts +1 -1
- package/src/tools.ts +6 -0
- package/src/types.ts +4 -6
- package/src/worker.ts +27 -0
- package/src/pages/[locale]/[slug].astro +0 -65
- package/src/pages/[locale].astro +0 -69
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { aquariumEvaporationTopOffPlanner } from './entry';
|
|
2
|
+
import type { ToolDefinition } from '../../types';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const AQUARIUM_EVAPORATION_TOP_OFF_PLANNER_TOOL: ToolDefinition = {
|
|
7
|
+
entry: aquariumEvaporationTopOffPlanner,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { calculateEvaporation } from './logic';
|
|
3
|
+
|
|
4
|
+
describe('calculateEvaporation', () => {
|
|
5
|
+
it('converts an observed loss into daily and scheduled top off volumes', () => {
|
|
6
|
+
const result = calculateEvaporation({
|
|
7
|
+
tankVolumeLitres: 120,
|
|
8
|
+
measuredLossMillilitres: 1200,
|
|
9
|
+
observationDays: 7,
|
|
10
|
+
refillIntervalDays: 3,
|
|
11
|
+
});
|
|
12
|
+
expect(result?.dailyLossLitres).toBeCloseTo(0.1714286, 6);
|
|
13
|
+
expect(result?.nextRefillLitres).toBeCloseTo(0.5142857, 6);
|
|
14
|
+
expect(result?.thirtyDayLossLitres).toBeCloseTo(5.142857, 5);
|
|
15
|
+
expect(result?.levelAfterIntervalPercent).toBeCloseTo(99.5714286, 6);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('shows the level before refill and the amount added on refill days', () => {
|
|
19
|
+
const result = calculateEvaporation({
|
|
20
|
+
tankVolumeLitres: 100,
|
|
21
|
+
measuredLossMillilitres: 700,
|
|
22
|
+
observationDays: 7,
|
|
23
|
+
refillIntervalDays: 2,
|
|
24
|
+
});
|
|
25
|
+
expect(result).not.toBeNull();
|
|
26
|
+
if (result === null) return;
|
|
27
|
+
expect(result.schedule[1]?.levelPercent).toBeCloseTo(99.9, 6);
|
|
28
|
+
expect(result.schedule[2]?.levelPercent).toBeCloseTo(99.8, 6);
|
|
29
|
+
expect(result.schedule[2]?.refillLitres).toBeCloseTo(0.2, 6);
|
|
30
|
+
expect(result.schedule[3]?.levelPercent).toBeCloseTo(99.9, 6);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('estimates reservoir autonomy, marine concentration and critical swings', () => {
|
|
34
|
+
const result = calculateEvaporation({
|
|
35
|
+
tankVolumeLitres: 100,
|
|
36
|
+
measuredLossMillilitres: 3000,
|
|
37
|
+
observationDays: 10,
|
|
38
|
+
refillIntervalDays: 10,
|
|
39
|
+
reservoirCapacityLitres: 12,
|
|
40
|
+
startingSpecificGravity: 1.025,
|
|
41
|
+
});
|
|
42
|
+
expect(result?.reservoirDays).toBeCloseTo(40, 6);
|
|
43
|
+
expect(result?.salinityAfterInterval).toBeCloseTo(1.025773, 6);
|
|
44
|
+
expect(result?.isCritical).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects impossible or unsupported values', () => {
|
|
48
|
+
expect(calculateEvaporation({
|
|
49
|
+
tankVolumeLitres: 0,
|
|
50
|
+
measuredLossMillilitres: 500,
|
|
51
|
+
observationDays: 7,
|
|
52
|
+
refillIntervalDays: 3,
|
|
53
|
+
})).toBeNull();
|
|
54
|
+
expect(calculateEvaporation({
|
|
55
|
+
tankVolumeLitres: 100,
|
|
56
|
+
measuredLossMillilitres: 500,
|
|
57
|
+
observationDays: 91,
|
|
58
|
+
refillIntervalDays: 3,
|
|
59
|
+
})).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export interface EvaporationInputs {
|
|
2
|
+
tankVolumeLitres: number;
|
|
3
|
+
measuredLossMillilitres: number;
|
|
4
|
+
observationDays: number;
|
|
5
|
+
refillIntervalDays: number;
|
|
6
|
+
reservoirCapacityLitres?: number;
|
|
7
|
+
startingSpecificGravity?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SchedulePoint {
|
|
11
|
+
day: number;
|
|
12
|
+
levelPercent: number;
|
|
13
|
+
lostLitres: number;
|
|
14
|
+
refillLitres: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface EvaporationResult {
|
|
18
|
+
tankVolumeLitres: number;
|
|
19
|
+
dailyLossLitres: number;
|
|
20
|
+
dailyRatePercent: number;
|
|
21
|
+
nextRefillLitres: number;
|
|
22
|
+
thirtyDayLossLitres: number;
|
|
23
|
+
levelAfterIntervalPercent: number;
|
|
24
|
+
twoPercentDays: number;
|
|
25
|
+
reservoirDays?: number;
|
|
26
|
+
salinityAfterInterval?: number;
|
|
27
|
+
isCritical: boolean;
|
|
28
|
+
schedule: SchedulePoint[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPositive(value: number): boolean {
|
|
32
|
+
return Number.isFinite(value) && value > 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validLimits(input: EvaporationInputs): boolean {
|
|
36
|
+
return input.observationDays <= 90 && input.refillIntervalDays <= 30;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validOptionalFields(input: EvaporationInputs): boolean {
|
|
40
|
+
const validReservoir = input.reservoirCapacityLitres === undefined || isPositive(input.reservoirCapacityLitres);
|
|
41
|
+
const validSalinity = input.startingSpecificGravity === undefined || (input.startingSpecificGravity >= 1 && input.startingSpecificGravity <= 1.04);
|
|
42
|
+
return validReservoir && validSalinity;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isValidInput(input: EvaporationInputs): boolean {
|
|
46
|
+
return isPositive(input.tankVolumeLitres)
|
|
47
|
+
&& isPositive(input.measuredLossMillilitres)
|
|
48
|
+
&& isPositive(input.observationDays)
|
|
49
|
+
&& isPositive(input.refillIntervalDays)
|
|
50
|
+
&& validLimits(input)
|
|
51
|
+
&& validOptionalFields(input);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function daysSinceRefill(day: number, intervalDays: number): number {
|
|
55
|
+
if (day === 0) return 0;
|
|
56
|
+
const remainder = day % intervalDays;
|
|
57
|
+
return remainder === 0 ? intervalDays : remainder;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createSchedule(
|
|
61
|
+
dailyLossLitres: number,
|
|
62
|
+
tankVolumeLitres: number,
|
|
63
|
+
intervalDays: number,
|
|
64
|
+
): SchedulePoint[] {
|
|
65
|
+
const schedule: SchedulePoint[] = [];
|
|
66
|
+
for (let day = 0; day <= 14; day += 1) {
|
|
67
|
+
const daySinceRefill = daysSinceRefill(day, intervalDays);
|
|
68
|
+
const refillDue = day > 0 && day % intervalDays === 0;
|
|
69
|
+
const lostLitres = daySinceRefill * dailyLossLitres;
|
|
70
|
+
schedule.push({
|
|
71
|
+
day,
|
|
72
|
+
levelPercent: Math.max(0, 100 - (lostLitres / tankVolumeLitres) * 100),
|
|
73
|
+
lostLitres,
|
|
74
|
+
refillLitres: refillDue ? intervalDays * dailyLossLitres : 0,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return schedule;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function calculateEvaporation(input: EvaporationInputs): EvaporationResult | null {
|
|
81
|
+
if (!isValidInput(input)) return null;
|
|
82
|
+
const dailyLossLitres = input.measuredLossMillilitres / 1000 / input.observationDays;
|
|
83
|
+
const dailyRatePercent = (dailyLossLitres / input.tankVolumeLitres) * 100;
|
|
84
|
+
const nextRefillLitres = dailyLossLitres * input.refillIntervalDays;
|
|
85
|
+
const remainingVolumeLitres = input.tankVolumeLitres - nextRefillLitres;
|
|
86
|
+
const result: EvaporationResult = {
|
|
87
|
+
tankVolumeLitres: input.tankVolumeLitres,
|
|
88
|
+
dailyLossLitres,
|
|
89
|
+
dailyRatePercent,
|
|
90
|
+
nextRefillLitres,
|
|
91
|
+
thirtyDayLossLitres: dailyLossLitres * 30,
|
|
92
|
+
levelAfterIntervalPercent: Math.max(0, 100 - dailyRatePercent * input.refillIntervalDays),
|
|
93
|
+
twoPercentDays: 2 / dailyRatePercent,
|
|
94
|
+
isCritical: dailyRatePercent * input.refillIntervalDays >= 2,
|
|
95
|
+
schedule: createSchedule(dailyLossLitres, input.tankVolumeLitres, input.refillIntervalDays),
|
|
96
|
+
};
|
|
97
|
+
if (input.reservoirCapacityLitres !== undefined) result.reservoirDays = input.reservoirCapacityLitres / dailyLossLitres;
|
|
98
|
+
if (input.startingSpecificGravity !== undefined && remainingVolumeLitres > 0) {
|
|
99
|
+
result.salinityAfterInterval = 1 + ((input.startingSpecificGravity - 1) * input.tankVolumeLitres) / remainingVolumeLitres;
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { aquariumEvaporationTopOffPlanner } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props { locale?: KnownLocale; }
|
|
7
|
+
|
|
8
|
+
const { locale = 'en' } = Astro.props;
|
|
9
|
+
const loader = aquariumEvaporationTopOffPlanner.i18n[locale] ?? aquariumEvaporationTopOffPlanner.i18n.en;
|
|
10
|
+
const content = loader === undefined ? undefined : await loader();
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
{content && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { EvaporationInputs } from './logic';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'aquarium-evaporation-top-off-planner';
|
|
4
|
+
|
|
5
|
+
export interface StoredEvaporationState {
|
|
6
|
+
units: 'metric' | 'imperial';
|
|
7
|
+
lossUnit?: 'millilitres' | 'litres' | 'fluidOunces';
|
|
8
|
+
waterType?: 'freshwater' | 'marine';
|
|
9
|
+
input: EvaporationInputs;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readEvaporationState(): StoredEvaporationState | null {
|
|
13
|
+
if (typeof window === 'undefined') return null;
|
|
14
|
+
try {
|
|
15
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
16
|
+
if (raw === null) return null;
|
|
17
|
+
return JSON.parse(raw) as StoredEvaporationState;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function writeEvaporationState(state: StoredEvaporationState): void {
|
|
24
|
+
if (typeof window === 'undefined') return;
|
|
25
|
+
try {
|
|
26
|
+
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
27
|
+
} catch {}
|
|
28
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface AquariumEvaporationTopOffPlannerUI {
|
|
2
|
+
[key: string]: string;
|
|
3
|
+
unitsLabel: string;
|
|
4
|
+
metricLabel: string;
|
|
5
|
+
imperialLabel: string;
|
|
6
|
+
tankVolumeLabel: string;
|
|
7
|
+
measuredLossLabel: string;
|
|
8
|
+
lossUnitLabel: string;
|
|
9
|
+
observationDaysLabel: string;
|
|
10
|
+
refillIntervalLabel: string;
|
|
11
|
+
reservoirCapacityLabel: string;
|
|
12
|
+
advancedLabel: string;
|
|
13
|
+
waterTypeLabel: string;
|
|
14
|
+
freshwaterLabel: string;
|
|
15
|
+
marineLabel: string;
|
|
16
|
+
startingSalinityLabel: string;
|
|
17
|
+
estimatedSalinityLabel: string;
|
|
18
|
+
reservoirDaysLabel: string;
|
|
19
|
+
criticalAlert: string;
|
|
20
|
+
volumeHint: string;
|
|
21
|
+
lossHint: string;
|
|
22
|
+
intervalHint: string;
|
|
23
|
+
resultTitle: string;
|
|
24
|
+
dailyLossLabel: string;
|
|
25
|
+
dailyRateLabel: string;
|
|
26
|
+
nextRefillLabel: string;
|
|
27
|
+
thirtyDayLabel: string;
|
|
28
|
+
levelAfterIntervalLabel: string;
|
|
29
|
+
twoPercentDaysLabel: string;
|
|
30
|
+
scheduleTitle: string;
|
|
31
|
+
dayLabel: string;
|
|
32
|
+
topOffLabel: string;
|
|
33
|
+
everyLabel: string;
|
|
34
|
+
levelLabel: string;
|
|
35
|
+
stableStatus: string;
|
|
36
|
+
watchStatus: string;
|
|
37
|
+
frequentStatus: string;
|
|
38
|
+
emptyState: string;
|
|
39
|
+
invalidState: string;
|
|
40
|
+
measurementNote: string;
|
|
41
|
+
saltwaterNote: string;
|
|
42
|
+
resetLabel: string;
|
|
43
|
+
litreUnit: string;
|
|
44
|
+
gallonUnit: string;
|
|
45
|
+
millilitreUnit: string;
|
|
46
|
+
fluidOunceUnit: string;
|
|
47
|
+
dayUnit: string;
|
|
48
|
+
percentUnit: string;
|
|
49
|
+
}
|
|
@@ -67,7 +67,7 @@ const softwareApplicationSchema: WithContext<SoftwareApplication> = {
|
|
|
67
67
|
name: '水族箱灯光光照周期规划器',
|
|
68
68
|
applicationCategory: 'UtilitiesApplication',
|
|
69
69
|
operatingSystem: 'Web',
|
|
70
|
-
description: '
|
|
70
|
+
description: '根据水族箱类型、水草需求及调光功能,计算并规划水族灯自动定时时间表,方便日常维护和安排。',
|
|
71
71
|
offers: { '@type': 'Offer', price: '0', priceCurrency: 'CNY' },
|
|
72
72
|
};
|
|
73
73
|
|
|
@@ -87,7 +87,7 @@ const howToSchema: WithContext<HowTo> = {
|
|
|
87
87
|
export const content: AquariumLightingPhotoperiodPlannerLocaleContent = {
|
|
88
88
|
slug: 'aquarium-lighting-photoperiod-schedule-planner',
|
|
89
89
|
title: '水族箱灯光与光照周期(Photoperiod)规划器',
|
|
90
|
-
description: '
|
|
90
|
+
description: '结合水族箱类型、水草环境、开灯时间及调光渐变,制定稳定高效的水族箱定时开灯时间表,帮助安排日常养护。',
|
|
91
91
|
ui,
|
|
92
92
|
seo: [
|
|
93
93
|
{ type: 'title', level: 2, text: '为什么规律的光照周期至关重要' },
|
|
@@ -4,7 +4,7 @@ export const content = createAquariumContent({
|
|
|
4
4
|
locale: 'fr',
|
|
5
5
|
slug: 'calculateur-volume-aquarium-changement-eau',
|
|
6
6
|
title: "Calculateur du volume d'aquarium et du changement d'eau",
|
|
7
|
-
description: "Calculez le volume d'eau utile, le déplacement du substrat et la quantité d'eau à remplacer à partir des dimensions intérieures de votre aquarium.",
|
|
7
|
+
description: "Calculez le volume d'eau utile, le déplacement du substrat et la quantité d'eau à remplacer à partir des dimensions intérieures de votre aquarium pour planifier l'entretien.",
|
|
8
8
|
faq: [
|
|
9
9
|
{ question: "Le calculateur utilise-t-il la capacité indiquée sur l'aquarium ?", answer: "Non. Il retire la hauteur du substrat du volume géométrique et fournit une estimation de l'eau réellement disponible." },
|
|
10
10
|
{ question: "Comment fonctionne l'option façade bombée ?", answer: "Elle ajoute un segment circulaire approximatif à une base rectangulaire. Le résultat reste indicatif, car les profils de verre varient selon les aquariums." },
|
|
@@ -4,7 +4,7 @@ export const content = createAquariumContent({
|
|
|
4
4
|
locale: 'it',
|
|
5
5
|
slug: 'calcolatore-volume-acquario-cambio-acqua',
|
|
6
6
|
title: "Calcolatore del volume dell'acquario e del cambio d'acqua",
|
|
7
|
-
description: "Calcola il volume d'acqua utile, lo spazio occupato dal substrato e la quantità da sostituire usando le misure interne del tuo acquario.",
|
|
7
|
+
description: "Calcola il volume d'acqua utile, lo spazio occupato dal substrato e la quantità da sostituire usando le misure interne del tuo acquario per pianificare la manutenzione.",
|
|
8
8
|
faq: [
|
|
9
9
|
{ question: "Il calcolatore usa la capacità indicata sulla confezione?", answer: "No. Sottrae l'altezza del substrato dal volume geometrico e fornisce una stima dell'acqua effettivamente disponibile." },
|
|
10
10
|
{ question: "Come funziona l'opzione con fronte curvo?", answer: "Aggiunge un segmento circolare approssimato alla base rettangolare. Il risultato è indicativo perché i profili del vetro curvo cambiano da una vasca all'altra." },
|
package/src/tools.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { ALL_ENTRIES } from './entries';
|
|
|
2
2
|
import { aquariumTankVolumeWaterChangeCalculator } from './tool/aquariumTankVolumeWaterChangeCalculator/entry';
|
|
3
3
|
import { aquariumFilterTurnoverStockingPlanner } from './tool/aquariumFilterTurnoverStockingPlanner/entry';
|
|
4
4
|
import { aquariumLightingPhotoperiodPlanner } from './tool/aquariumLightingPhotoperiodPlanner/entry';
|
|
5
|
+
import { aquariumEvaporationTopOffPlanner } from './tool/aquariumEvaporationTopOffPlanner/entry';
|
|
5
6
|
|
|
6
7
|
export const ALL_TOOLS = [{
|
|
7
8
|
entry: aquariumTankVolumeWaterChangeCalculator,
|
|
@@ -18,4 +19,9 @@ export const ALL_TOOLS = [{
|
|
|
18
19
|
Component: () => import('./tool/aquariumLightingPhotoperiodPlanner/component.astro'),
|
|
19
20
|
SEOComponent: () => import('./tool/aquariumLightingPhotoperiodPlanner/seo.astro'),
|
|
20
21
|
BibliographyComponent: () => import('./tool/aquariumLightingPhotoperiodPlanner/bibliography.astro'),
|
|
22
|
+
}, {
|
|
23
|
+
entry: aquariumEvaporationTopOffPlanner,
|
|
24
|
+
Component: () => import('./tool/aquariumEvaporationTopOffPlanner/component.astro'),
|
|
25
|
+
SEOComponent: () => import('./tool/aquariumEvaporationTopOffPlanner/seo.astro'),
|
|
26
|
+
BibliographyComponent: () => import('./tool/aquariumEvaporationTopOffPlanner/bibliography.astro'),
|
|
21
27
|
}];
|
package/src/types.ts
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
import type { SEOSection } from '@jjlmoya/utils-shared';
|
|
2
|
+
import type { UtilityLocale } from '@jjlmoya/utils-shared/routing';
|
|
2
3
|
import type { WithContext, Thing } from 'schema-dts';
|
|
3
4
|
|
|
4
5
|
export type { SEOSection };
|
|
5
6
|
|
|
6
|
-
export type KnownLocale =
|
|
7
|
-
| 'ar' | 'da' | 'de' | 'en' | 'es' | 'fi'
|
|
8
|
-
| 'fr' | 'id' | 'it' | 'ja' | 'ko' | 'nb' | 'nl'
|
|
9
|
-
| 'pl' | 'pt' | 'ru' | 'sv' | 'tr' | 'zh';
|
|
7
|
+
export type KnownLocale = UtilityLocale;
|
|
10
8
|
|
|
11
9
|
export interface FAQItem { question: string; answer: string; }
|
|
12
10
|
export interface BibliographyEntry { name: string; url: string; }
|
|
13
11
|
export interface HowToStep { name: string; text: string; }
|
|
14
12
|
|
|
15
|
-
export interface ToolLocaleContent<TUI extends
|
|
13
|
+
export interface ToolLocaleContent<TUI extends object = Record<string, string>> {
|
|
16
14
|
slug: string;
|
|
17
15
|
title: string;
|
|
18
16
|
description: string;
|
|
@@ -34,7 +32,7 @@ export interface CategoryLocaleContent {
|
|
|
34
32
|
export type LocaleLoader<T> = () => Promise<T>;
|
|
35
33
|
export type LocaleMap<T> = Partial<Record<KnownLocale, LocaleLoader<T>>>;
|
|
36
34
|
|
|
37
|
-
export interface AquariumToolEntry<TUI extends
|
|
35
|
+
export interface AquariumToolEntry<TUI extends object = Record<string, string>> {
|
|
38
36
|
id: string;
|
|
39
37
|
icons: { bg: string; fg: string; };
|
|
40
38
|
i18n: LocaleMap<ToolLocaleContent<TUI>>;
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface UtilityMfeEnvironment {
|
|
2
|
+
ASSETS: { fetch(request: Request): Promise<Response> };
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export const LONG_LIVED_ASSET_CACHE = "public, max-age=31536000, immutable";
|
|
6
|
+
export const SITEMAP_CACHE = "public, max-age=3600, s-maxage=3600, must-revalidate";
|
|
7
|
+
|
|
8
|
+
export const getCacheControl = (pathname: string): string | undefined => {
|
|
9
|
+
if (pathname.endsWith("/sitemap.xml")) return SITEMAP_CACHE;
|
|
10
|
+
if (pathname.endsWith("/manifest.json")) return LONG_LIVED_ASSET_CACHE;
|
|
11
|
+
if (pathname.startsWith("/_utilities/")) {
|
|
12
|
+
return LONG_LIVED_ASSET_CACHE;
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
async fetch(request: Request, environment: UtilityMfeEnvironment): Promise<Response> {
|
|
19
|
+
const response = await environment.ASSETS.fetch(request);
|
|
20
|
+
const cacheControl = getCacheControl(new URL(request.url).pathname);
|
|
21
|
+
if (!cacheControl) return response;
|
|
22
|
+
|
|
23
|
+
const headers = new Headers(response.headers);
|
|
24
|
+
headers.set("Cache-Control", cacheControl);
|
|
25
|
+
return new Response(response.body, { status: response.status, headers });
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import PreviewLayout from '../../layouts/PreviewLayout.astro';
|
|
3
|
-
import PreviewNavSidebar from '../../components/PreviewNavSidebar.astro';
|
|
4
|
-
import { ALL_TOOLS } from '../../index';
|
|
5
|
-
import { UtilityHeader, FAQSection, Bibliography, SEORenderer } from '@jjlmoya/utils-shared';
|
|
6
|
-
import type { KnownLocale, ToolLocaleContent } from '../../types';
|
|
7
|
-
import type { UtilitySEOContent } from '@jjlmoya/utils-shared';
|
|
8
|
-
|
|
9
|
-
export async function getStaticPaths() {
|
|
10
|
-
const paths = [];
|
|
11
|
-
for (const { entry, Component: lazyComponent } of ALL_TOOLS) {
|
|
12
|
-
const { default: Component } = await lazyComponent();
|
|
13
|
-
const localeContents = await Promise.all(Object.entries(entry.i18n).map(async ([locale, loader]) => ({
|
|
14
|
-
locale: locale as KnownLocale,
|
|
15
|
-
content: await loader(),
|
|
16
|
-
})));
|
|
17
|
-
const localeUrls = Object.fromEntries(localeContents.map(({ locale, content }) => [locale, '/' + locale + '/' + content.slug]));
|
|
18
|
-
for (const { locale, content } of localeContents) {
|
|
19
|
-
const allToolsNav = (await Promise.all(ALL_TOOLS.map(async ({ entry: navEntry }) => {
|
|
20
|
-
const loader = navEntry.i18n[locale] ?? navEntry.i18n.en;
|
|
21
|
-
if (loader === undefined) return null;
|
|
22
|
-
const navContent = await loader();
|
|
23
|
-
return { id: navEntry.id, title: navContent.title, href: '/' + locale + '/' + navContent.slug, isActive: navEntry.id === entry.id };
|
|
24
|
-
}))).filter((item): item is { id: string; title: string; href: string; isActive: boolean } => item !== null);
|
|
25
|
-
const englishLoader = entry.i18n.en ?? Object.values(entry.i18n)[0];
|
|
26
|
-
const englishSlug = englishLoader === undefined ? entry.id : (await englishLoader()).slug;
|
|
27
|
-
paths.push({ params: { locale, slug: content.slug }, props: { Component, locale, content, localeUrls, allToolsNav, englishSlug } });
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return paths;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props as {
|
|
34
|
-
Component: (_props: Record<string, unknown>) => unknown;
|
|
35
|
-
locale: KnownLocale;
|
|
36
|
-
content: ToolLocaleContent;
|
|
37
|
-
localeUrls: Partial<Record<KnownLocale, string>>;
|
|
38
|
-
allToolsNav: { id: string; title: string; href: string; isActive?: boolean }[];
|
|
39
|
-
englishSlug: string;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const cssFiles = import.meta.glob('../../tool/**' + '/' + '*.css', { query: '?raw', import: 'default' });
|
|
43
|
-
const cssKey = Object.keys(cssFiles).find((key) => key.endsWith('/' + englishSlug + '.css'));
|
|
44
|
-
const cssLoader = cssKey === undefined ? undefined : cssFiles[cssKey];
|
|
45
|
-
const toolCss = cssLoader === undefined ? '' : await cssLoader() as string;
|
|
46
|
-
const seoContent: UtilitySEOContent = { locale, sections: content.seo };
|
|
47
|
-
const words = content.title.split(' ');
|
|
48
|
-
const titleHighlight = words[0] ?? '';
|
|
49
|
-
const titleBase = words.slice(1).join(' ');
|
|
50
|
-
---
|
|
51
|
-
|
|
52
|
-
<PreviewLayout title={content.title} currentLocale={locale} localeUrls={localeUrls} hasSidebar={true}>
|
|
53
|
-
<PreviewNavSidebar slot="sidebar" categoryTitle="Aquarium tools" tools={allToolsNav} />
|
|
54
|
-
<Fragment slot="head">
|
|
55
|
-
{toolCss !== '' && <Fragment set:html={'<style is:inline>' + toolCss + '</style>'} />}
|
|
56
|
-
{content.schemas.map((schema: unknown) => <script is:inline type="application/ld+json" set:html={JSON.stringify(schema)} />)}
|
|
57
|
-
</Fragment>
|
|
58
|
-
<div class="tool-page">
|
|
59
|
-
<UtilityHeader titleHighlight={titleHighlight} titleBase={titleBase} description={content.description} />
|
|
60
|
-
<section class="section-tool"><Component ui={content.ui} /></section>
|
|
61
|
-
<section class="section-seo"><SEORenderer content={seoContent} /></section>
|
|
62
|
-
<section class="section-faq"><FAQSection items={content.faq} /></section>
|
|
63
|
-
<section class="section-bibliography"><Bibliography links={content.bibliography} /></section>
|
|
64
|
-
</div>
|
|
65
|
-
</PreviewLayout>
|
package/src/pages/[locale].astro
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import PreviewLayout from '../layouts/PreviewLayout.astro';
|
|
3
|
-
import PreviewNavSidebar from '../components/PreviewNavSidebar.astro';
|
|
4
|
-
import { aquariumCategory, ALL_TOOLS } from '../index';
|
|
5
|
-
import { Icon } from 'astro-icon/components';
|
|
6
|
-
import type { KnownLocale, ToolLocaleContent } from '../types';
|
|
7
|
-
|
|
8
|
-
export async function getStaticPaths() {
|
|
9
|
-
const locales = ['en'] as KnownLocale[];
|
|
10
|
-
return locales.map((locale) => ({ params: { locale } }));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const currentLocale = (Astro.params.locale ?? 'en') as KnownLocale;
|
|
14
|
-
const categoryLoader = aquariumCategory.i18n[currentLocale] ?? aquariumCategory.i18n.en;
|
|
15
|
-
const categoryContent = categoryLoader === undefined ? {
|
|
16
|
-
slug: 'aquarium',
|
|
17
|
-
title: 'Aquarium Tools',
|
|
18
|
-
description: 'Aquarium volume tools',
|
|
19
|
-
seo: [],
|
|
20
|
-
} : await categoryLoader();
|
|
21
|
-
|
|
22
|
-
const toolsWithContent = await Promise.all(
|
|
23
|
-
ALL_TOOLS.map(async ({ entry, Component }) => {
|
|
24
|
-
const localeEntries = await Promise.all(
|
|
25
|
-
Object.entries(entry.i18n).map(async ([locale, loader]) => [locale, await loader()] as const),
|
|
26
|
-
);
|
|
27
|
-
const localeContents = Object.fromEntries(localeEntries) as Record<string, ToolLocaleContent>;
|
|
28
|
-
const locale = localeContents[currentLocale] ?? localeContents.en;
|
|
29
|
-
const availableLocales: Record<string, string> = {};
|
|
30
|
-
for (const [localeCode, content] of Object.entries(localeContents)) {
|
|
31
|
-
availableLocales[localeCode] = '/' + localeCode + '/' + content.slug;
|
|
32
|
-
}
|
|
33
|
-
return { entry, Component, locale, availableLocales };
|
|
34
|
-
}),
|
|
35
|
-
);
|
|
36
|
-
---
|
|
37
|
-
|
|
38
|
-
<PreviewLayout title={categoryContent.title} currentLocale={currentLocale} hasSidebar={true}>
|
|
39
|
-
<PreviewNavSidebar
|
|
40
|
-
slot="sidebar"
|
|
41
|
-
categoryTitle={categoryContent.title}
|
|
42
|
-
tools={toolsWithContent.map(({ entry, locale, availableLocales }) => ({
|
|
43
|
-
id: entry.id,
|
|
44
|
-
title: locale?.title ?? entry.id,
|
|
45
|
-
href: availableLocales[currentLocale] ?? '#',
|
|
46
|
-
}))}
|
|
47
|
-
/>
|
|
48
|
-
<div class="dashboard">
|
|
49
|
-
<header class="preview-header">
|
|
50
|
-
<span class="badge">preview · @jjlmoya/utils-aquarium</span>
|
|
51
|
-
<h1>{categoryContent.title}</h1>
|
|
52
|
-
<p>{categoryContent.description}</p>
|
|
53
|
-
</header>
|
|
54
|
-
<div class="tool-list">
|
|
55
|
-
{toolsWithContent.map(({ entry, locale, availableLocales }) => (
|
|
56
|
-
<article class="tool-card">
|
|
57
|
-
<a href={availableLocales[currentLocale] ?? '#'} class="tool-card-link">
|
|
58
|
-
<div class="tool-icons"><div class="icon-wrapper bg"><Icon name={entry.icons.bg} /></div><div class="icon-wrapper fg"><Icon name={entry.icons.fg} /></div></div>
|
|
59
|
-
<div class="tool-card-content"><h2 class="tool-title">{locale?.title}</h2><p class="tool-description">{locale?.description}</p></div>
|
|
60
|
-
<div class="tool-card-meta"><span class="tool-id">{entry.id}</span></div>
|
|
61
|
-
</a>
|
|
62
|
-
<div class="tool-locales">
|
|
63
|
-
{Object.entries(availableLocales).map(([localeCode, url]) => <a href={url} class:list={{ active: localeCode === currentLocale }} class="locale-badge">{localeCode.toUpperCase()}</a>)}
|
|
64
|
-
</div>
|
|
65
|
-
</article>
|
|
66
|
-
))}
|
|
67
|
-
</div>
|
|
68
|
-
</div>
|
|
69
|
-
</PreviewLayout>
|