@jjlmoya/utils-pets 1.24.0 → 1.26.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 (32) hide show
  1. package/package.json +17 -5
  2. package/scripts/postinstall.mjs +2 -4
  3. package/src/components/ProductionBreadcrumb.astro +134 -0
  4. package/src/components/ProductionStructuredData.astro +46 -0
  5. package/src/components/ProductionWidget.astro +182 -0
  6. package/src/i18n/header-ui.ts +15 -0
  7. package/src/i18n/language-ui.ts +9 -0
  8. package/src/i18n/languages.ts +24 -0
  9. package/src/identity/brands.ts +5 -0
  10. package/src/layouts/ProductionCategoryPage.astro +184 -0
  11. package/src/layouts/ProductionPage.astro +218 -0
  12. package/src/layouts/ProductionUtilityPage.astro +283 -0
  13. package/src/mfe/assets.ts +39 -0
  14. package/src/mfe/category-ui.ts +34 -0
  15. package/src/mfe/manifest.ts +45 -0
  16. package/src/mfe/routes.ts +50 -0
  17. package/src/mfe/widget-height.ts +14 -0
  18. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug]/manifest.json.ts +49 -0
  19. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
  20. package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
  21. package/src/pages/index.astro +1 -1
  22. package/src/pages/utilidades/[slug]/manifest.json.ts +28 -0
  23. package/src/pages/utilidades/[slug].astro +90 -0
  24. package/src/pages/utilidades/categorias/[category].astro +38 -0
  25. package/src/tests/mfe_cache_contract.test.ts +11 -0
  26. package/src/tests/mfe_manifest.test.ts +28 -0
  27. package/src/tests/registry_contract.test.ts +67 -0
  28. package/src/tests/seo_length.test.ts +1 -0
  29. package/src/types.ts +4 -6
  30. package/src/worker.ts +25 -0
  31. package/src/pages/[locale]/[slug].astro +0 -172
  32. package/src/pages/[locale].astro +0 -270
@@ -0,0 +1,218 @@
1
+ ---
2
+ import Footer from "@jjlmoya/identity/footer";
3
+ import Header from "@jjlmoya/identity/header";
4
+ import LanguageSheet from "@jjlmoya/identity/language-sheet";
5
+ import LanguageTrigger from "@jjlmoya/identity/language-trigger";
6
+ import ThemeBoot from "@jjlmoya/identity/theme-boot";
7
+ import { getSiteNavigation } from "@jjlmoya/identity/navigation";
8
+ import "@jjlmoya/identity/styles.css";
9
+ import "@jjlmoya/utils-shared/theme.css";
10
+ import "@jjlmoya/utils-shared/styles/jjlmoya-shared.css";
11
+ import { HEADER_UI } from "../i18n/header-ui";
12
+ import { LANGUAGE_LOCALES, LANGUAGE_NAMES, type Language } from "../i18n/languages";
13
+ import { LANGUAGE_UI } from "../i18n/language-ui";
14
+ import { getBrand } from "../identity/brands";
15
+ import { getBrandAssetPath } from "../mfe/assets";
16
+
17
+ interface AlternateLink { language: Language; url: string; }
18
+ interface Props {
19
+ title: string;
20
+ description: string;
21
+ locale: Language;
22
+ image?: string | undefined;
23
+ toolCss?: string | undefined;
24
+ publicPath: string;
25
+ manifestPath?: string | undefined;
26
+ alternates: readonly AlternateLink[];
27
+ }
28
+
29
+ const { title, description, locale, image, toolCss, publicPath, manifestPath, alternates } = Astro.props;
30
+ const brand = getBrand(locale);
31
+ const brandFavicon = getBrandAssetPath(brand, "favicon");
32
+ const brandSizedFavicon = getBrandAssetPath(brand, "faviconSized");
33
+ const brandAppleTouchIcon = getBrandAssetPath(brand, "appleTouchIcon");
34
+ const origin = brand === "jjlmoya" ? "https://www.jjlmoya.es" : "https://www.gamebob.dev";
35
+ const canonical = new URL(publicPath, origin).href;
36
+ const socialImage = image ? new URL(image, canonical).href : undefined;
37
+ const languageUi = LANGUAGE_UI[locale];
38
+ const absoluteUrl = (language: Language, url: string): string => {
39
+ if (import.meta.env.DEV) return new URL(url, "http://localhost:4324").pathname;
40
+ return new URL(
41
+ url,
42
+ language === "es" ? "https://www.jjlmoya.es" : "https://www.gamebob.dev",
43
+ ).href;
44
+ };
45
+ const languageLinks = alternates.map(({ language, url }) => ({
46
+ code: language,
47
+ name: LANGUAGE_NAMES[language],
48
+ href: absoluteUrl(language, url),
49
+ current: language === locale,
50
+ }));
51
+ const homeUrl = brand === "jjlmoya" ? "https://www.jjlmoya.es/" : `https://www.gamebob.dev/${locale}/`;
52
+ const openGraphLocale = LANGUAGE_LOCALES[locale].replace("-", "_");
53
+ const xDefaultUrl = alternates.find(({ language }) => language === "en")?.url ?? canonical;
54
+ ---
55
+
56
+ <!doctype html>
57
+ <html lang={locale} data-brand={brand}>
58
+ <head>
59
+ <meta charset="UTF-8" />
60
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
61
+ <ThemeBoot defaultTheme={brand === "gamebob" ? "theme-dark" : "theme-system"} />
62
+ <title>{title}</title>
63
+ <meta name="title" content={title} />
64
+ <meta name="description" content={description} />
65
+ <meta name="author" content="jjlmoya" />
66
+ <meta name="robots" content="index, follow, max-image-preview:large" />
67
+ <meta name="referrer" content="strict-origin-when-cross-origin" />
68
+ <link rel="icon" type="image/x-icon" href={brandFavicon} />
69
+ <link rel="icon" type="image/webp" sizes={brand === "jjlmoya" ? "32x32" : "48x48"} href={brandSizedFavicon} />
70
+ <link rel="apple-touch-icon" href={brandAppleTouchIcon} />
71
+ {manifestPath && <link rel="manifest" href={manifestPath} />}
72
+ <link rel="canonical" href={canonical} />
73
+ {alternates.map(({ language, url }) => <link rel="alternate" hreflang={language} href={absoluteUrl(language, url)} />)}
74
+ <link rel="alternate" hreflang="x-default" href={absoluteUrl("en", xDefaultUrl)} />
75
+ {toolCss && <link rel="stylesheet" href={toolCss} />}
76
+ <meta property="og:type" content="website" />
77
+ <meta property="og:url" content={canonical} />
78
+ <meta property="og:title" content={title} />
79
+ <meta property="og:description" content={description} />
80
+ <meta property="og:site_name" content={brand === "jjlmoya" ? "jjlmoya" : "GameBob Studio"} />
81
+ <meta property="og:locale" content={openGraphLocale} />
82
+ {socialImage && <meta property="og:image" content={socialImage} />}
83
+ {socialImage && <meta property="og:image:secure_url" content={socialImage} />}
84
+ {socialImage && <meta property="og:image:type" content="image/webp" />}
85
+ {socialImage && <meta property="og:image:width" content="1200" />}
86
+ {socialImage && <meta property="og:image:height" content="630" />}
87
+ {socialImage && <meta property="og:image:alt" content={description} />}
88
+ <meta name="twitter:card" content={socialImage ? "summary_large_image" : "summary"} />
89
+ <meta name="twitter:url" content={canonical} />
90
+ <meta name="twitter:title" content={title} />
91
+ <meta name="twitter:description" content={description} />
92
+ {socialImage && <meta name="twitter:image" content={socialImage} />}
93
+ <script is:inline type="application/ld+json" set:html={JSON.stringify({
94
+ "@context": "https://schema.org",
95
+ "@type": "WebPage",
96
+ url: canonical,
97
+ name: title,
98
+ description,
99
+ inLanguage: LANGUAGE_LOCALES[locale],
100
+ })} />
101
+ <script>
102
+ import { installEcosystemTracking } from "@jjlmoya/tracking";
103
+ installEcosystemTracking();
104
+ </script>
105
+ <slot name="head" />
106
+ </head>
107
+ <body class="production-page">
108
+ <Header
109
+ class="utilities-site-header"
110
+ identity={brand}
111
+ {homeUrl}
112
+ navigation={getSiteNavigation({ language: locale, activeSection: "utilities" })}
113
+ labels={HEADER_UI[locale]}
114
+ >
115
+ <LanguageTrigger slot="desktop-actions" label={languageUi.change} />
116
+ <LanguageTrigger slot="mobile-actions" label={languageUi.change} />
117
+ </Header>
118
+ <LanguageSheet links={languageLinks} title={languageUi.select} closeLabel={languageUi.close} />
119
+ <main><slot /></main>
120
+ <Footer class="utilities-site-footer" identity={brand} language={locale} />
121
+ </body>
122
+ </html>
123
+
124
+ <style is:global>
125
+ :root {
126
+ --pets-accent: #10b981;
127
+ --pets-accent-strong: #0d9488;
128
+ --color-brand-blue: #1ddabc;
129
+ --color-brand-dark: #000;
130
+ --color-brand-white: #fff;
131
+ --color-primary: var(--color-brand-blue);
132
+ --color-primary-rgb: 29, 218, 188;
133
+ --space-2xs: 0.125rem;
134
+ --space-xs: 0.25rem;
135
+ --space-sm: 0.5rem;
136
+ --space-base: 0.75rem;
137
+ --space-md: 1rem;
138
+ --space-md-lg: 1.25rem;
139
+ --space-lg: 1.5rem;
140
+ --space-xl: 2rem;
141
+ --space-1-5xl: 2.5rem;
142
+ --space-2xl: 3rem;
143
+ --space-3xl: 4rem;
144
+ --space-4xl: 6rem;
145
+ --space-3-5xl: 5rem;
146
+ --radius-base: 0.5rem;
147
+ --radius-lg: 1rem;
148
+ --radius-2xl: 2rem;
149
+ --radius-full: 9999px;
150
+ --font-family: inter, system-ui, sans-serif;
151
+ --font-weight-bold: 800;
152
+ --color-white: #fff;
153
+ }
154
+
155
+ :root,
156
+ .theme-light {
157
+ --bg-main: #f0f4f8;
158
+ --bg-page: #f0f4f8;
159
+ --bg-surface: #fff;
160
+ --bg-muted: #f1f5f9;
161
+ --text-main: #000;
162
+ --text-base: #000;
163
+ --text-muted: #4a5568;
164
+ --text-dimmed: #64748b;
165
+ --border-base: #e2e8f0;
166
+ --border-color: #e2e8f0;
167
+ --glass-bg: rgba(0, 0, 0, 0.05);
168
+ --glass-border: rgba(0, 0, 0, 0.1);
169
+ --shadow-base: rgba(0, 0, 0, 0.05);
170
+ --shadow-hover: rgba(0, 0, 0, 0.1);
171
+ --primary: var(--color-primary);
172
+ --primary-10: rgba(29, 218, 188, 0.1);
173
+ }
174
+
175
+ .theme-dark {
176
+ --bg-main: #000;
177
+ --bg-page: #000;
178
+ --bg-surface: #0f172a;
179
+ --bg-muted: #1e293b;
180
+ --text-main: #fff;
181
+ --text-base: #fff;
182
+ --text-muted: #94a3b8;
183
+ --text-dimmed: #64748b;
184
+ --border-base: #1e293b;
185
+ --border-color: #1e293b;
186
+ --glass-bg: rgba(255, 255, 255, 0.03);
187
+ --glass-border: rgba(255, 255, 255, 0.08);
188
+ --shadow-base: rgba(0, 0, 0, 0.3);
189
+ --shadow-hover: rgba(0, 0, 0, 0.4);
190
+ --primary: var(--color-primary);
191
+ --primary-10: rgba(29, 218, 188, 0.1);
192
+ }
193
+
194
+ *, *::before, *::after {
195
+ box-sizing: border-box;
196
+ }
197
+
198
+ html {
199
+ background: var(--bg-main);
200
+ color: var(--text-main);
201
+ scroll-behavior: smooth;
202
+ }
203
+
204
+ body.production-page {
205
+ min-width: 320px;
206
+ min-height: 100vh;
207
+ margin: 0;
208
+ padding-top: 4.5rem;
209
+ background: var(--bg-main);
210
+ color: var(--text-main);
211
+ font-family: var(--font-family, Inter, system-ui, sans-serif);
212
+ }
213
+
214
+ body.production-page main {
215
+ width: 100%;
216
+ min-height: 100vh;
217
+ }
218
+ </style>
@@ -0,0 +1,283 @@
1
+ ---
2
+ import ProductionPage from "./ProductionPage.astro";
3
+ import { FAQSection, UtilityHeader } from "@jjlmoya/utils-shared";
4
+ import { Icon } from "astro-icon/components";
5
+ import ProductionBreadcrumb from "../components/ProductionBreadcrumb.astro";
6
+ import ProductionWidget from "../components/ProductionWidget.astro";
7
+ import ProductionStructuredData from "../components/ProductionStructuredData.astro";
8
+ import type { KnownLocale, ToolLocaleContent } from "../types";
9
+ import { getCategoryUi } from "../mfe/category-ui";
10
+ import { getCategoryPath, getUtilityPath } from "../mfe/routes";
11
+ import { getUtilityCssPath, TABLETOP_ASSET_VERSION } from "../mfe/assets";
12
+ import type { AstroComponentFactory } from "astro/runtime/server/index.js";
13
+
14
+ interface RelatedTool { title: string; description: string; href: string; icon: string; }
15
+ interface Props { locale: KnownLocale; content: ToolLocaleContent; englishSlug: string; categoryTitle: string; categorySlug: string; categoryHref: string; relatedTools: RelatedTool[]; Component: AstroComponentFactory; SEOComponent: AstroComponentFactory; BibliographyComponent: AstroComponentFactory; alternates: { language: KnownLocale; url: string }[]; image?: string | undefined; }
16
+ const { locale, content, englishSlug, categoryTitle, categorySlug, categoryHref, relatedTools, Component, SEOComponent, BibliographyComponent, alternates, image } = Astro.props;
17
+ const words = content.title.split(/\s+/).filter(Boolean);
18
+ const titleHighlight = words[0] ?? content.title;
19
+ const titleBase = words.slice(1).join(" ");
20
+ const publicPath = getUtilityPath(locale, categorySlug, content.slug);
21
+ const publicCategoryPath = getCategoryPath(locale, categorySlug);
22
+ const ui = getCategoryUi(locale);
23
+ ---
24
+
25
+ <ProductionPage title={content.title} description={content.description} {locale} {publicPath} manifestPath={`${publicPath}manifest.json?version=${TABLETOP_ASSET_VERSION}`} {alternates} {image} toolCss={getUtilityCssPath(englishSlug)}>
26
+ <div class="utility-production">
27
+ <div class="utility-production-inner">
28
+ <ProductionBreadcrumb locale={locale} categoryTitle={categoryTitle} categoryHref={categoryHref || publicCategoryPath} currentTitle={content.title} />
29
+ <UtilityHeader titleHighlight={titleHighlight} titleBase={titleBase} description={content.description} gradientFrom="#10b981" gradientTo="#0d9488" />
30
+ <section class="utility-tool-production"><Component ui={content.ui} locale={locale} /></section>
31
+ <div class="utility-zoom-production" aria-label={ui.zoomControls}>
32
+ <span class="utility-zoom-label">{ui.zoom}</span>
33
+ <div class="utility-zoom-controls"><button type="button" data-zoom="out" aria-label={ui.zoomOut}>−</button><button type="button" data-zoom="reset" aria-label={ui.resetZoom}>100%</button><button type="button" data-zoom="in" aria-label={ui.zoomIn}>+</button></div>
34
+ </div>
35
+ <ProductionWidget locale={locale} slug={content.slug} />
36
+ {content.faq.length > 0 && <section class="utility-section-production"><FAQSection items={content.faq} /></section>}
37
+ <Fragment slot="head">
38
+ <ProductionStructuredData locale={locale} {content} {publicPath} categoryTitle={categoryTitle} categoryHref={categoryHref || publicCategoryPath} />
39
+ </Fragment>
40
+ <section class="utility-section-production"><SEOComponent locale={locale} /></section>
41
+ {content.bibliography.length > 0 && <section class="utility-section-production"><BibliographyComponent locale={locale} /></section>}
42
+ {relatedTools.length > 0 && <section class="utility-related-production" aria-labelledby="related-tools-title"><div class="utility-related-heading"><span class="utility-related-eyebrow">{ui.relatedEyebrow}</span><h2 id="related-tools-title">{ui.moreToolsIn} {categoryTitle}</h2></div><div class="utility-related-grid">{relatedTools.map((tool, index) => <a href={tool.href} class="utility-related-card"><span class="utility-related-index">{String(index + 1).padStart(2, "0")}</span><span class="utility-related-icon"><Icon name={tool.icon} /></span><h3>{tool.title}</h3><p>{tool.description}</p><span class="utility-related-cta">{ui.openTool} <Icon name="mdi:arrow-top-right" /></span></a>)}</div></section>}
43
+ </div>
44
+ </div>
45
+ </ProductionPage>
46
+
47
+ <script>
48
+ import { observeWidgetHeight } from "../mfe/widget-height";
49
+ const isWidget = new URLSearchParams(window.location.search).get("widget") === "true";
50
+ const page = document.querySelector<HTMLElement>(".utility-production");
51
+ const container = document.querySelector<HTMLElement>(".utility-tool-production");
52
+ const zoomButton = document.querySelector<HTMLButtonElement>('[data-zoom="reset"]');
53
+ const storedZoom = isWidget ? 1 : Number(localStorage.getItem("gamebob-utility-zoom") ?? 1);
54
+ let zoom = Number.isFinite(storedZoom) ? Math.min(1.5, Math.max(0.75, storedZoom)) : 1;
55
+ const applyZoom = () => {
56
+ if (container) container.style.zoom = String(zoom);
57
+ if (zoomButton) zoomButton.textContent = `${Math.round(zoom * 100)}%`;
58
+ };
59
+ document.querySelector('[data-zoom="out"]')?.addEventListener("click", () => { zoom = Math.max(0.75, zoom - 0.1); applyZoom(); localStorage.setItem("gamebob-utility-zoom", String(zoom)); });
60
+ document.querySelector('[data-zoom="in"]')?.addEventListener("click", () => { zoom = Math.min(1.5, zoom + 0.1); applyZoom(); localStorage.setItem("gamebob-utility-zoom", String(zoom)); });
61
+ document.querySelector('[data-zoom="reset"]')?.addEventListener("click", () => { zoom = 1; applyZoom(); localStorage.removeItem("gamebob-utility-zoom"); });
62
+ if (isWidget) {
63
+ document.body.classList.add("utility-widget-body");
64
+ page?.classList.add("utility-widget-mode");
65
+ }
66
+ applyZoom();
67
+ if (isWidget && container) observeWidgetHeight(container);
68
+ </script>
69
+
70
+ <style>
71
+ .utility-production {
72
+ min-height: 100vh;
73
+ overflow-x: clip;
74
+ background: radial-gradient(ellipse at top, var(--primary-10), var(--bg-surface) 50%, var(--bg-main));
75
+ }
76
+
77
+ .utility-production.utility-widget-mode {
78
+ min-height: auto;
79
+ background: transparent;
80
+ }
81
+
82
+ .utility-production.utility-widget-mode .utility-production-inner {
83
+ max-width: none;
84
+ padding: 0;
85
+ }
86
+
87
+ .utility-production.utility-widget-mode .utility-tool-production {
88
+ margin-top: 0;
89
+ }
90
+
91
+ :global(.utility-production.utility-widget-mode .utility-production-inner > :not(.utility-tool-production)) {
92
+ display: none;
93
+ }
94
+
95
+ :global(body.utility-widget-body) {
96
+ margin: 0;
97
+ padding-top: 0;
98
+ background: transparent;
99
+ }
100
+
101
+ :global(body.utility-widget-body > :not(main)) {
102
+ display: none;
103
+ }
104
+
105
+ :global(body.utility-widget-body main), :global(body.utility-widget-body main .utility-production), :global(body.utility-widget-body main .utility-production-inner) {
106
+ min-height: auto;
107
+ }
108
+
109
+ .utility-production-inner {
110
+ max-width: 72rem;
111
+ margin: 0 auto;
112
+ padding: var(--space-xl) var(--space-lg) var(--space-4xl);
113
+ }
114
+
115
+ .utility-tool-production {
116
+ width: 100%;
117
+ margin-top: var(--space-2xl);
118
+ }
119
+
120
+ .utility-zoom-production {
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ gap: 0.75rem;
125
+ margin: var(--space-xl) auto 0;
126
+ color: var(--text-muted);
127
+ font-size: 0.8rem;
128
+ font-weight: 700;
129
+ letter-spacing: 0.08em;
130
+ text-transform: uppercase;
131
+ }
132
+
133
+ .utility-zoom-controls {
134
+ display: inline-flex;
135
+ overflow: hidden;
136
+ border: 1px solid var(--border-base);
137
+ border-radius: 0.75rem;
138
+ background: color-mix(in srgb, var(--bg-surface) 90%, transparent);
139
+ box-shadow: 0 0.5rem 1.5rem color-mix(in srgb, var(--shadow-base, #000) 35%, transparent);
140
+ }
141
+
142
+ .utility-zoom-production button {
143
+ min-width: 2.5rem;
144
+ border: 0;
145
+ border-right: 1px solid var(--border-base);
146
+ padding: 0.5rem 0.75rem;
147
+ background: transparent;
148
+ color: var(--text-main);
149
+ cursor: pointer;
150
+ font-size: 1rem;
151
+ }
152
+ .utility-zoom-production button:last-child { border-right: 0; }
153
+
154
+ .utility-zoom-production button:hover,
155
+ .utility-zoom-production button:focus-visible {
156
+ background: color-mix(in srgb, var(--pets-accent) 12%, transparent);
157
+ color: var(--pets-accent);
158
+ }
159
+
160
+ .utility-section-production, .utility-related-production {
161
+ max-width: 56rem;
162
+ margin: var(--space-4xl) auto 0;
163
+ }
164
+
165
+ .utility-section-production {
166
+ font-size: 1.125rem;
167
+ line-height: 1.75;
168
+ }
169
+
170
+ .utility-related-heading {
171
+ margin-bottom: var(--space-lg);
172
+ text-align: center;
173
+ }
174
+
175
+ .utility-related-eyebrow {
176
+ display: block;
177
+ margin-bottom: 0.45rem;
178
+ color: var(--pets-accent);
179
+ font-size: 0.75rem;
180
+ font-weight: 800;
181
+ letter-spacing: 0.14em;
182
+ text-transform: uppercase;
183
+ }
184
+
185
+ .utility-related-production h2 {
186
+ margin: 0;
187
+ color: var(--text-main);
188
+ font-size: clamp(1.75rem, 4vw, 2.5rem);
189
+ }
190
+
191
+ .utility-related-grid {
192
+ display: grid;
193
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
194
+ gap: 1rem;
195
+ }
196
+
197
+ .utility-related-card {
198
+ position: relative;
199
+ display: flex;
200
+ min-height: 238px;
201
+ flex-direction: column;
202
+ overflow: hidden;
203
+ padding: 1.25rem;
204
+ border: 1px solid color-mix(in srgb, var(--pets-accent) 18%, var(--border-base));
205
+ border-radius: 1rem;
206
+ background: linear-gradient(145deg, var(--bg-surface), color-mix(in srgb, var(--pets-accent) 4%, var(--bg-surface)));
207
+ color: inherit;
208
+ text-decoration: none;
209
+ box-shadow: 0 0.75rem 1.75rem color-mix(in srgb, var(--shadow-base, #000) 30%, transparent);
210
+ transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
211
+ }
212
+
213
+ .utility-related-card:hover,
214
+ .utility-related-card:focus-visible {
215
+ border-color: var(--pets-accent);
216
+ box-shadow: 0 1rem 2rem color-mix(in srgb, var(--pets-accent) 16%, transparent);
217
+ transform: translateY(-3px);
218
+ }
219
+
220
+ .utility-related-card:focus-visible {
221
+ outline: 3px solid color-mix(in srgb, var(--pets-accent) 55%, transparent);
222
+ outline-offset: 3px;
223
+ }
224
+
225
+ .utility-related-index {
226
+ position: absolute;
227
+ top: 1.25rem;
228
+ right: 1.25rem;
229
+ color: var(--text-dimmed, var(--text-muted));
230
+ font-family: ui-monospace, SFMono-Regular, monospace;
231
+ font-size: 0.7rem;
232
+ letter-spacing: 0.12em;
233
+ }
234
+
235
+ .utility-related-icon {
236
+ display: grid;
237
+ width: 2.75rem;
238
+ height: 2.75rem;
239
+ place-items: center;
240
+ margin-bottom: 1rem;
241
+ border: 1px solid color-mix(in srgb, var(--pets-accent) 28%, transparent);
242
+ border-radius: 0.8rem;
243
+ background: color-mix(in srgb, var(--pets-accent) 12%, var(--bg-surface));
244
+ color: var(--pets-accent);
245
+ font-size: 1.35rem;
246
+ }
247
+
248
+ .utility-related-card h3 {
249
+ margin: 0 0 0.5rem;
250
+ color: var(--text-main);
251
+ font-size: 1rem;
252
+ }
253
+
254
+ .utility-related-card p {
255
+ margin: 0 0 1rem;
256
+ color: var(--text-muted);
257
+ font-size: 0.9rem;
258
+ line-height: 1.5;
259
+ }
260
+
261
+ .utility-related-cta {
262
+ display: inline-flex;
263
+ align-items: center;
264
+ gap: 0.4rem;
265
+ margin-top: auto;
266
+ color: var(--pets-accent);
267
+ font-size: 0.75rem;
268
+ font-weight: 800;
269
+ letter-spacing: 0.08em;
270
+ text-transform: uppercase;
271
+ }
272
+
273
+ .utility-related-cta svg {
274
+ width: 1rem;
275
+ height: 1rem;
276
+ transition: transform 0.2s ease;
277
+ }
278
+ .utility-related-card:hover .utility-related-cta svg { transform: translate(2px, -2px); }
279
+
280
+ @media (width <= 640px) {
281
+ .utility-production-inner { padding: var(--space-lg) var(--space-sm) var(--space-3xl); }
282
+ }
283
+ </style>
@@ -0,0 +1,39 @@
1
+ import packageMetadata from "../../package.json";
2
+ import type { Brand } from "../identity/brands";
3
+
4
+ const UTILITY_ASSET_ROOT = "/_utilities/pets";
5
+
6
+ export const TABLETOP_ASSET_VERSION = encodeURIComponent(
7
+ import.meta.env.PUBLIC_APP_VERSION ?? packageMetadata.version,
8
+ );
9
+
10
+ const withAssetVersion = (path: string): string => `${path}?version=${TABLETOP_ASSET_VERSION}`;
11
+
12
+ export const getUtilityAssetPath = (assetName: string): string =>
13
+ withAssetVersion(`${UTILITY_ASSET_ROOT}/${assetName}`);
14
+
15
+ const BRAND_ASSETS = {
16
+ gamebob: {
17
+ favicon: "favicon.ico",
18
+ faviconSized: "favicon-48.webp",
19
+ appleTouchIcon: "apple-touch-icon-brand.webp",
20
+ },
21
+ jjlmoya: {
22
+ favicon: "favicon-jjlmoya.ico",
23
+ faviconSized: "favicon-jjlmoya-32.webp",
24
+ appleTouchIcon: "apple-touch-icon-jjlmoya.webp",
25
+ },
26
+ } as const satisfies Record<Brand, Record<"favicon" | "faviconSized" | "appleTouchIcon", string>>;
27
+
28
+ export const getBrandAssetPath = (
29
+ brand: Brand,
30
+ asset: keyof (typeof BRAND_ASSETS)[Brand],
31
+ ): string => getUtilityAssetPath(BRAND_ASSETS[brand][asset]);
32
+
33
+ export const getUtilityOgImage = (englishSlug: string): string =>
34
+ withAssetVersion(`/_utilities/pets/images/${englishSlug}.webp`);
35
+
36
+ export const getUtilityCssPath = (englishSlug: string): string =>
37
+ withAssetVersion(`/_utilities/pets/styles/${englishSlug}.css`);
38
+
39
+ export const CATEGORY_OG_IMAGE = getUtilityOgImage("pets");
@@ -0,0 +1,34 @@
1
+ import type { KnownLocale } from '../types';
2
+
3
+ export interface CategoryUi {
4
+ useTool: string;
5
+ openTool: string;
6
+ relatedEyebrow: string;
7
+ moreToolsIn: string;
8
+ zoom: string;
9
+ zoomControls: string;
10
+ zoomOut: string;
11
+ resetZoom: string;
12
+ zoomIn: string;
13
+ breadcrumb: string;
14
+ }
15
+
16
+ export const CATEGORY_UI: Record<KnownLocale, CategoryUi> = {
17
+ es: { useTool: 'Usar herramienta', openTool: 'Abrir herramienta', relatedEyebrow: 'Sigue explorando', moreToolsIn: 'Más herramientas en', zoom: 'Zoom', zoomControls: 'Controles de zoom', zoomOut: 'Reducir zoom', resetZoom: 'Restablecer zoom', zoomIn: 'Aumentar zoom', breadcrumb: 'Migas de pan' },
18
+ en: { useTool: 'Use tool', openTool: 'Open tool', relatedEyebrow: 'Keep exploring', moreToolsIn: 'More tools in', zoom: 'Zoom', zoomControls: 'Zoom controls', zoomOut: 'Zoom out', resetZoom: 'Reset zoom', zoomIn: 'Zoom in', breadcrumb: 'Breadcrumb' },
19
+ fr: { useTool: "Utiliser l'outil", openTool: "Ouvrir l'outil", relatedEyebrow: 'Continuez à explorer', moreToolsIn: "Plus d'outils dans", zoom: 'Zoom', zoomControls: 'Commandes de zoom', zoomOut: 'Réduire le zoom', resetZoom: 'Réinitialiser le zoom', zoomIn: 'Augmenter le zoom', breadcrumb: 'Fil d’Ariane' },
20
+ de: { useTool: 'Werkzeug verwenden', openTool: 'Werkzeug öffnen', relatedEyebrow: 'Mehr entdecken', moreToolsIn: 'Weitere Werkzeuge in', zoom: 'Zoom', zoomControls: 'Zoom-Steuerung', zoomOut: 'Verkleinern', resetZoom: 'Zoom zurücksetzen', zoomIn: 'Vergrößern', breadcrumb: 'Brotkrümelnavigation' },
21
+ it: { useTool: 'Usa lo strumento', openTool: 'Apri lo strumento', relatedEyebrow: 'Continua a esplorare', moreToolsIn: 'Altri strumenti in', zoom: 'Zoom', zoomControls: 'Controlli zoom', zoomOut: 'Riduci zoom', resetZoom: 'Reimposta zoom', zoomIn: 'Aumenta zoom', breadcrumb: 'Breadcrumb' },
22
+ pt: { useTool: 'Usar ferramenta', openTool: 'Abrir ferramenta', relatedEyebrow: 'Continue a explorar', moreToolsIn: 'Mais ferramentas em', zoom: 'Zoom', zoomControls: 'Controlos de zoom', zoomOut: 'Reduzir zoom', resetZoom: 'Repor zoom', zoomIn: 'Aumentar zoom', breadcrumb: 'Trilho de navegação' },
23
+ nl: { useTool: 'Tool gebruiken', openTool: 'Tool openen', relatedEyebrow: 'Blijf ontdekken', moreToolsIn: 'Meer tools in', zoom: 'Zoom', zoomControls: 'Zoomknoppen', zoomOut: 'Uitzoomen', resetZoom: 'Zoom herstellen', zoomIn: 'Inzoomen', breadcrumb: 'Broodkruimelnavigatie' },
24
+ sv: { useTool: 'Använd verktyget', openTool: 'Öppna verktyget', relatedEyebrow: 'Utforska mer', moreToolsIn: 'Fler verktyg i', zoom: 'Zoom', zoomControls: 'Zoomkontroller', zoomOut: 'Zooma ut', resetZoom: 'Återställ zoom', zoomIn: 'Zooma in', breadcrumb: 'Brödsmulenavigering' },
25
+ pl: { useTool: 'Użyj narzędzia', openTool: 'Otwórz narzędzie', relatedEyebrow: 'Odkrywaj dalej', moreToolsIn: 'Więcej narzędzi w', zoom: 'Powiększenie', zoomControls: 'Sterowanie powiększeniem', zoomOut: 'Pomniejsz', resetZoom: 'Resetuj powiększenie', zoomIn: 'Powiększ', breadcrumb: 'Okruszki nawigacji' },
26
+ id: { useTool: 'Gunakan alat', openTool: 'Buka alat', relatedEyebrow: 'Jelajahi lebih lanjut', moreToolsIn: 'Alat lainnya di', zoom: 'Zoom', zoomControls: 'Kontrol zoom', zoomOut: 'Perkecil', resetZoom: 'Atur ulang zoom', zoomIn: 'Perbesar', breadcrumb: 'Breadcrumb' },
27
+ tr: { useTool: 'Aracı kullan', openTool: 'Aracı aç', relatedEyebrow: 'Keşfetmeye devam edin', moreToolsIn: 'Diğer araçlar:', zoom: 'Yakınlaştırma', zoomControls: 'Yakınlaştırma denetimleri', zoomOut: 'Uzaklaştır', resetZoom: 'Yakınlaştırmayı sıfırla', zoomIn: 'Yakınlaştır', breadcrumb: 'İçerik haritası' },
28
+ ru: { useTool: 'Использовать инструмент', openTool: 'Открыть инструмент', relatedEyebrow: 'Продолжить изучение', moreToolsIn: 'Другие инструменты в', zoom: 'Масштаб', zoomControls: 'Управление масштабом', zoomOut: 'Уменьшить', resetZoom: 'Сбросить масштаб', zoomIn: 'Увеличить', breadcrumb: 'Навигационная цепочка' },
29
+ ja: { useTool: 'ツールを使う', openTool: 'ツールを開く', relatedEyebrow: 'さらに探索', moreToolsIn: 'その他のツール:', zoom: 'ズーム', zoomControls: 'ズーム操作', zoomOut: '縮小', resetZoom: 'ズームをリセット', zoomIn: '拡大', breadcrumb: 'パンくずリスト' },
30
+ ko: { useTool: '도구 사용', openTool: '도구 열기', relatedEyebrow: '더 탐색하기', moreToolsIn: '카테고리의 다른 도구:', zoom: '확대/축소', zoomControls: '확대/축소 컨트롤', zoomOut: '축소', resetZoom: '확대/축소 초기화', zoomIn: '확대', breadcrumb: '탐색 경로' },
31
+ zh: { useTool: '使用工具', openTool: '打开工具', relatedEyebrow: '继续探索', moreToolsIn: '更多工具:', zoom: '缩放', zoomControls: '缩放控件', zoomOut: '缩小', resetZoom: '重置缩放', zoomIn: '放大', breadcrumb: '面包屑导航' },
32
+ };
33
+
34
+ export const getCategoryUi = (locale: KnownLocale): CategoryUi => CATEGORY_UI[locale];
@@ -0,0 +1,45 @@
1
+ import { getUtilityOgImage } from "./assets";
2
+
3
+ export interface UtilityManifestInput {
4
+ title: string;
5
+ description: string;
6
+ startUrl: string;
7
+ englishSlug: string;
8
+ }
9
+
10
+ const buildUtilityManifest = ({
11
+ title,
12
+ description,
13
+ startUrl,
14
+ englishSlug,
15
+ }: UtilityManifestInput) => {
16
+ const shortName = title.length > 12
17
+ ? title.split(/\s+/).map((word) => word[0]).join("")
18
+ : title;
19
+ const manifest = {
20
+ name: title,
21
+ short_name: shortName,
22
+ description,
23
+ start_url: startUrl,
24
+ scope: startUrl,
25
+ icons: [{
26
+ src: getUtilityOgImage(englishSlug),
27
+ sizes: "512x512",
28
+ type: "image/webp",
29
+ purpose: "any",
30
+ }],
31
+ theme_color: "#0f172a",
32
+ background_color: "#0f172a",
33
+ display: "standalone",
34
+ orientation: "portrait",
35
+ };
36
+
37
+ return manifest;
38
+ };
39
+
40
+ export const createUtilityManifestResponse = (input: UtilityManifestInput): Response => new Response(JSON.stringify(buildUtilityManifest(input), null, 2), {
41
+ headers: {
42
+ "Content-Type": "application/manifest+json",
43
+ "Cache-Control": "public, max-age=31536000, immutable",
44
+ },
45
+ });
@@ -0,0 +1,50 @@
1
+ import {
2
+ getCategoryNamespace,
3
+ getCategoryPath as getSharedCategoryPath,
4
+ getUtilitiesPath as getSharedUtilitiesPath,
5
+ getUtilityNamespace,
6
+ getUtilityPath as getSharedUtilityPath,
7
+ type UtilityLocale,
8
+ } from "@jjlmoya/utils-shared/routing";
9
+
10
+ export type { UtilityLocale };
11
+ export { getCategoryNamespace, getUtilityNamespace };
12
+
13
+ export const INTERNAL_LOCALES = [
14
+ "en", "fr", "de", "it", "pt", "nl", "sv", "pl", "id", "tr", "ru", "ja", "ko", "zh",
15
+ ] as const satisfies readonly UtilityLocale[];
16
+
17
+ const getDevelopmentPath = (url: string): string => {
18
+ if (!import.meta.env.DEV) return url;
19
+ try {
20
+ return new URL(url, "http://localhost:4324").pathname;
21
+ } catch {
22
+ return url;
23
+ }
24
+ };
25
+
26
+ export const getUtilitiesPath = (locale: UtilityLocale): string =>
27
+ getDevelopmentPath(getSharedUtilitiesPath(locale));
28
+
29
+ export const getCategoryPath = (locale: UtilityLocale, categorySlug: string): string =>
30
+ getDevelopmentPath(getSharedCategoryPath(locale, categorySlug));
31
+
32
+ export const getUtilityPath = (
33
+ locale: UtilityLocale,
34
+ categorySlug: string,
35
+ toolSlug: string,
36
+ ): string => getDevelopmentPath(getSharedUtilityPath(locale, categorySlug, toolSlug));
37
+
38
+ export const getCategoryRoute = (locale: UtilityLocale, categorySlug: string): string =>
39
+ getCategoryPath(locale, categorySlug);
40
+
41
+ export const getUtilityRoute = (locale: UtilityLocale, categorySlug: string, toolSlug: string): string =>
42
+ getUtilityPath(locale, categorySlug, toolSlug);
43
+
44
+ export const getUtilityIndexRoute = (locale: UtilityLocale): string => getUtilitiesPath(locale);
45
+
46
+ export const getInternalCategoryRoute = (locale: Exclude<UtilityLocale, "es">, categorySlug: string): string =>
47
+ getCategoryPath(locale, categorySlug);
48
+
49
+ export const getInternalUtilityRoute = (locale: Exclude<UtilityLocale, "es">, categorySlug: string, toolSlug: string): string =>
50
+ getUtilityPath(locale, categorySlug, toolSlug);