@jjlmoya/utils-civic 1.9.0 → 1.11.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.
@@ -82,11 +82,10 @@ jobs:
82
82
  {"baseUrl":"https://www.gamebob.dev","pathPrefixes":["/en/utilities/","/fr/utilitaires/","/de/werkzeuge/","/it/utilita/","/pt/utilidades/","/nl/hulpmiddelen/","/sv/verktyg/","/pl/narzedzia/","/id/utilitas/","/tr/araclar/","/ru/instrumenty/","/ja/utilities/","/ko/utilities/","/zh/utilities/"]}
83
83
  ]
84
84
  - name: Deploy both domains to Cloudflare
85
- uses: cloudflare/wrangler-action@v3
86
- with:
87
- apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
88
- accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
89
- command: deploy
85
+ env:
86
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
87
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
88
+ run: ./node_modules/.bin/wrangler deploy
90
89
  - name: Save deployed page manifest
91
90
  continue-on-error: true
92
91
  uses: Game-Bob/ai-knows/.github/actions/indexnow-save@main
package/astro.config.mjs CHANGED
@@ -2,7 +2,13 @@ import { defineConfig } from "astro/config";
2
2
  import icon from "astro-icon";
3
3
 
4
4
  export default defineConfig({
5
+ output: "static",
6
+ site: "https://www.gamebob.dev",
5
7
  integrations: [icon()],
8
+ trailingSlash: "always",
9
+ build: {
10
+ assets: "_utilities/civic",
11
+ },
6
12
  server: {
7
13
  port: 3037,
8
14
  host: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-civic",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -32,6 +32,7 @@
32
32
  "dev": "astro dev --host",
33
33
  "start": "astro dev",
34
34
  "build": "astro build",
35
+ "postbuild": "node scripts/postbuild.mjs",
35
36
  "preview": "astro preview",
36
37
  "astro": "astro",
37
38
  "lint": "eslint src/ --max-warnings 0 && stylelint \"src/**/*.{css,astro}\"",
@@ -0,0 +1,17 @@
1
+ import { copyFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+
4
+ const sourceRoot = resolve('dist/mfe-sitemaps');
5
+ const destinationRoot = resolve('dist/_utilities');
6
+
7
+ if (existsSync(sourceRoot)) {
8
+ for (const locale of readdirSync(sourceRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory())) {
9
+ for (const vertical of readdirSync(join(sourceRoot, locale.name), { withFileTypes: true }).filter((entry) => entry.isDirectory())) {
10
+ const source = join(sourceRoot, locale.name, vertical.name, 'sitemap.xml');
11
+ const destinationDirectory = join(destinationRoot, locale.name, vertical.name);
12
+ mkdirSync(destinationDirectory, { recursive: true });
13
+ copyFileSync(source, join(destinationDirectory, 'sitemap.xml'));
14
+ }
15
+ }
16
+ rmSync(sourceRoot, { recursive: true, force: true });
17
+ }
@@ -5,6 +5,7 @@ import { Icon } from "astro-icon/components";
5
5
  import ProductionBreadcrumb from "../components/ProductionBreadcrumb.astro";
6
6
  import ProductionWidget from "../components/ProductionWidget.astro";
7
7
  import type { KnownLocale, ToolLocaleContent } from "../types";
8
+ import { getCategoryUi } from "../mfe/category-ui";
8
9
  import { getCategoryPath, getUtilityPath } from "../mfe/routes";
9
10
  import { getUtilityCssPath } from "../mfe/assets";
10
11
  import type { AstroComponentFactory } from "astro/runtime/server/index.js";
@@ -17,6 +18,7 @@ const titleHighlight = words[0] ?? content.title;
17
18
  const titleBase = words.slice(1).join(" ");
18
19
  const publicPath = getUtilityPath(locale, categorySlug, content.slug);
19
20
  const publicCategoryPath = getCategoryPath(locale, categorySlug);
21
+ const ui = getCategoryUi(locale);
20
22
  ---
21
23
 
22
24
  <ProductionPage title={content.title} description={content.description} {locale} {publicPath} {alternates} {image} toolCss={getUtilityCssPath(englishSlug)}>
@@ -36,18 +38,31 @@ const publicCategoryPath = getCategoryPath(locale, categorySlug);
36
38
  </Fragment>
37
39
  <section class="utility-section-production"><SEOComponent locale={locale} /></section>
38
40
  {content.bibliography.length > 0 && <section class="utility-section-production"><BibliographyComponent locale={locale} /></section>}
39
- {relatedTools.length > 0 && <section class="utility-related-production" aria-labelledby="related-tools-title"><div class="utility-related-heading"><span class="utility-related-eyebrow">Keep exploring</span><h2 id="related-tools-title">More tools in {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">Open tool <Icon name="mdi:arrow-top-right" /></span></a>)}</div></section>}
41
+ {relatedTools.length > 0 && <section class="utility-related-production" aria-labelledby="related-tools-title"><div class="utility-related-heading"><span class="utility-related-eyebrow">Keep exploring</span><h2 id="related-tools-title">More tools in {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>}
40
42
  </div>
41
43
  </div>
42
44
  </ProductionPage>
43
45
 
44
46
  <script>
47
+ const isWidget = new URLSearchParams(window.location.search).get("widget") === "true";
48
+ const page = document.querySelector<HTMLElement>(".utility-production");
45
49
  const container = document.querySelector<HTMLElement>(".utility-tool-production");
46
- let zoom = Number(localStorage.getItem("gamebob-utility-zoom") ?? 1);
47
- const applyZoom = () => { if (container) container.style.zoom = String(zoom); };
50
+ const zoomButton = document.querySelector<HTMLButtonElement>('[data-zoom="reset"]');
51
+ const storedZoom = isWidget ? 1 : Number(localStorage.getItem("gamebob-utility-zoom") ?? 1);
52
+ let zoom = Number.isFinite(storedZoom) ? Math.min(1.5, Math.max(0.75, storedZoom)) : 1;
53
+ const applyZoom = () => {
54
+ if (container) container.style.zoom = String(zoom);
55
+ if (zoomButton) zoomButton.textContent = `${Math.round(zoom * 100)}%`;
56
+ };
48
57
  document.querySelector('[data-zoom="out"]')?.addEventListener("click", () => { zoom = Math.max(0.75, zoom - 0.1); applyZoom(); localStorage.setItem("gamebob-utility-zoom", String(zoom)); });
49
58
  document.querySelector('[data-zoom="in"]')?.addEventListener("click", () => { zoom = Math.min(1.5, zoom + 0.1); applyZoom(); localStorage.setItem("gamebob-utility-zoom", String(zoom)); });
50
59
  document.querySelector('[data-zoom="reset"]')?.addEventListener("click", () => { zoom = 1; applyZoom(); localStorage.removeItem("gamebob-utility-zoom"); });
60
+ if (isWidget) {
61
+ document.body.classList.add("utility-widget-body");
62
+ if (page) {
63
+ page.classList.add("utility-widget-mode");
64
+ }
65
+ }
51
66
  applyZoom();
52
67
  </script>
53
68
 
@@ -58,6 +73,38 @@ const publicCategoryPath = getCategoryPath(locale, categorySlug);
58
73
  background: radial-gradient(ellipse at top, var(--primary-10), var(--bg-surface) 50%, var(--bg-main));
59
74
  }
60
75
 
76
+ .utility-production.utility-widget-mode {
77
+ min-height: auto;
78
+ background: transparent;
79
+ }
80
+
81
+ .utility-production.utility-widget-mode .utility-production-inner {
82
+ max-width: none;
83
+ padding: 0;
84
+ }
85
+
86
+ .utility-production.utility-widget-mode .utility-tool-production {
87
+ margin-top: 0;
88
+ }
89
+
90
+ .utility-production.utility-widget-mode .utility-production-inner > :not(.utility-tool-production) {
91
+ display: none;
92
+ }
93
+
94
+ :global(body.utility-widget-body) {
95
+ margin: 0;
96
+ padding-top: 0;
97
+ background: transparent;
98
+ }
99
+
100
+ :global(body.utility-widget-body > :not(main)) {
101
+ display: none;
102
+ }
103
+
104
+ :global(body.utility-widget-body main), :global(body.utility-widget-body main .utility-production), :global(body.utility-widget-body main .utility-production-inner) {
105
+ min-height: auto;
106
+ }
107
+
61
108
  .utility-production-inner {
62
109
  max-width: 72rem;
63
110
  margin: 0 auto;
@@ -2,24 +2,25 @@ import type { KnownLocale } from '../types';
2
2
 
3
3
  export interface CategoryUi {
4
4
  useTool: string;
5
+ openTool: string;
5
6
  }
6
7
 
7
8
  export const CATEGORY_UI: Record<KnownLocale, CategoryUi> = {
8
- es: { useTool: 'Usar herramienta' },
9
- en: { useTool: 'Use tool' },
10
- fr: { useTool: "Utiliser l'outil" },
11
- de: { useTool: 'Werkzeug verwenden' },
12
- it: { useTool: 'Usa lo strumento' },
13
- pt: { useTool: 'Usar ferramenta' },
14
- nl: { useTool: 'Tool gebruiken' },
15
- sv: { useTool: 'Använd verktyget' },
16
- pl: { useTool: 'Użyj narzędzia' },
17
- id: { useTool: 'Gunakan alat' },
18
- tr: { useTool: 'Aracı kullan' },
19
- ru: { useTool: 'Использовать инструмент' },
20
- ja: { useTool: 'ツールを使う' },
21
- ko: { useTool: '도구 사용' },
22
- zh: { useTool: '使用工具' },
9
+ es: { useTool: 'Usar herramienta', openTool: 'Abrir herramienta' },
10
+ en: { useTool: 'Use tool', openTool: 'Open tool' },
11
+ fr: { useTool: "Utiliser l'outil", openTool: "Ouvrir l'outil" },
12
+ de: { useTool: 'Werkzeug verwenden', openTool: 'Werkzeug öffnen' },
13
+ it: { useTool: 'Usa lo strumento', openTool: 'Apri lo strumento' },
14
+ pt: { useTool: 'Usar ferramenta', openTool: 'Abrir ferramenta' },
15
+ nl: { useTool: 'Tool gebruiken', openTool: 'Tool openen' },
16
+ sv: { useTool: 'Använd verktyget', openTool: 'Öppna verktyget' },
17
+ pl: { useTool: 'Użyj narzędzia', openTool: 'Otwórz narzędzie' },
18
+ id: { useTool: 'Gunakan alat', openTool: 'Buka alat' },
19
+ tr: { useTool: 'Aracı kullan', openTool: 'Aracı aç' },
20
+ ru: { useTool: 'Использовать инструмент', openTool: 'Открыть инструмент' },
21
+ ja: { useTool: 'ツールを使う', openTool: 'ツールを開く' },
22
+ ko: { useTool: '도구 사용', openTool: '도구 열기' },
23
+ zh: { useTool: '使用工具', openTool: '打开工具' },
23
24
  };
24
25
 
25
26
  export const getCategoryUi = (locale: KnownLocale): CategoryUi => CATEGORY_UI[locale];
@@ -0,0 +1,75 @@
1
+ import type { APIRoute } from "astro";
2
+ import { civicCategory } from "../../../../category";
3
+ import { ALL_ENTRIES } from "../../../../entries";
4
+ import { LANGUAGE_CODES, type Language } from "../../../../i18n/languages";
5
+ import { getCategoryPath, getUtilityPath } from "../../../../mfe/routes";
6
+
7
+ const GAMEBOB_URL = "https://www.gamebob.dev";
8
+ const JJLMOYA_URL = "https://www.jjlmoya.es";
9
+
10
+ type LocalizedEntry = {
11
+ i18n: Partial<Record<Language, () => Promise<{ slug: string }>>>;
12
+ };
13
+
14
+ const absoluteUrl = (locale: Language, path: string): string =>
15
+ path.startsWith("http") ? path : `${locale === "es" ? JJLMOYA_URL : GAMEBOB_URL}${path}`;
16
+
17
+ const escapeXml = (value: string): string => value
18
+ .replaceAll("&", "&amp;")
19
+ .replaceAll("\"", "&quot;")
20
+ .replaceAll("'", "&apos;")
21
+ .replaceAll("<", "&lt;")
22
+ .replaceAll(">", "&gt;");
23
+
24
+ const getContent = async (entry: LocalizedEntry, locale: Language): Promise<{ slug: string }> => {
25
+ const loader = entry.i18n[locale] ?? entry.i18n.en;
26
+ if (!loader) throw new Error(`Missing ${locale} and English fallback`);
27
+ return loader();
28
+ };
29
+
30
+ export const prerender = true;
31
+
32
+ export function getStaticPaths() {
33
+ return LANGUAGE_CODES.map((locale) => ({
34
+ params: { locale, vertical: "civic" },
35
+ props: { locale },
36
+ }));
37
+ }
38
+
39
+ const buildUrlEntry = async (locale: Language, path: string, index: number): Promise<string> => {
40
+ const links = await Promise.all(LANGUAGE_CODES.map(async (alternateLocale) => {
41
+ const alternateCategory = await getContent(civicCategory, alternateLocale);
42
+ const alternateContent = index === 0 ? null : await getContent(ALL_ENTRIES[index - 1]!, alternateLocale);
43
+ const alternatePath = alternateContent
44
+ ? getUtilityPath(alternateLocale, alternateCategory.slug, alternateContent.slug)
45
+ : getCategoryPath(alternateLocale, alternateCategory.slug);
46
+ return ` <xhtml:link rel="alternate" hreflang="${alternateLocale}" href="${escapeXml(absoluteUrl(alternateLocale, alternatePath))}"/>`;
47
+ }));
48
+ return [
49
+ " <url>",
50
+ ` <loc>${escapeXml(absoluteUrl(locale, path))}</loc>`,
51
+ ...links,
52
+ ` <changefreq>${index === 0 ? "weekly" : "monthly"}</changefreq>`,
53
+ ` <priority>${index === 0 ? "0.7" : "0.6"}</priority>`,
54
+ " </url>",
55
+ ].join("\n");
56
+ };
57
+
58
+ export const GET: APIRoute = async ({ props }) => {
59
+ const locale = props.locale as Language;
60
+ const category = await getContent(civicCategory, locale);
61
+ const contents = await Promise.all(ALL_ENTRIES.map((entry) => getContent(entry, locale)));
62
+ const paths = [
63
+ getCategoryPath(locale, category.slug),
64
+ ...contents.map((content) => getUtilityPath(locale, category.slug, content.slug)),
65
+ ];
66
+ const urls = await Promise.all(paths.map((path, index) => buildUrlEntry(locale, path, index)));
67
+
68
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${urls.join("\n")}\n</urlset>`;
69
+ return new Response(xml, {
70
+ headers: {
71
+ "Content-Type": "application/xml; charset=utf-8",
72
+ "Cache-Control": "public, max-age=3600, s-maxage=3600, must-revalidate",
73
+ },
74
+ });
75
+ };
package/src/types.ts CHANGED
@@ -20,7 +20,7 @@ export interface HowToStep {
20
20
  text: string;
21
21
  }
22
22
 
23
- export interface ToolLocaleContent<TUI extends Record<string, string> = Record<string, string>> {
23
+ export interface ToolLocaleContent<TUI extends object = Record<string, string>> {
24
24
  slug: string;
25
25
  title: string;
26
26
  description: string;
@@ -43,7 +43,7 @@ export type LocaleLoader<T> = () => Promise<T>;
43
43
 
44
44
  export type LocaleMap<T> = Partial<Record<KnownLocale, LocaleLoader<T>>>;
45
45
 
46
- export interface CivicToolEntry<TUI extends Record<string, string> = Record<string, string>> {
46
+ export interface CivicToolEntry<TUI extends object = Record<string, string>> {
47
47
  id: string;
48
48
  phase?: 'english-first' | 'localized';
49
49
  icons: {
package/src/worker.ts CHANGED
@@ -2,8 +2,25 @@ interface UtilityMfeEnvironment {
2
2
  ASSETS: { fetch(request: Request): Promise<Response> };
3
3
  }
4
4
 
5
+ const LONG_LIVED_ASSET_CACHE = "public, max-age=31536000, immutable";
6
+ const SITEMAP_CACHE = "public, max-age=3600, s-maxage=3600, must-revalidate";
7
+
8
+ const getCacheControl = (pathname: string): string | undefined => {
9
+ if (pathname.endsWith("/sitemap.xml")) return SITEMAP_CACHE;
10
+ if (pathname.startsWith("/_utilities/")) {
11
+ return LONG_LIVED_ASSET_CACHE;
12
+ }
13
+ return undefined;
14
+ };
15
+
5
16
  export default {
6
- fetch(request: Request, environment: UtilityMfeEnvironment): Promise<Response> {
7
- return environment.ASSETS.fetch(request);
17
+ async fetch(request: Request, environment: UtilityMfeEnvironment): Promise<Response> {
18
+ const response = await environment.ASSETS.fetch(request);
19
+ const cacheControl = getCacheControl(new URL(request.url).pathname);
20
+ if (!cacheControl) return response;
21
+
22
+ const headers = new Headers(response.headers);
23
+ headers.set("Cache-Control", cacheControl);
24
+ return new Response(response.body, { status: response.status, headers });
8
25
  },
9
26
  };