Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 100 additions & 10 deletions scripts/generate-sitemap.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,49 @@
// Generates dist/sitemap.xml by scanning the prerendered HTML output.
// Decoupled from the SSG build so it works identically on every platform.
import { readdirSync, statSync, writeFileSync } from "node:fs";
// Includes hreflang alternates linking each Spanish route to its English
// counterpart (and vice versa) when both exist.
import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, relative, sep } from "node:path";

const SITE_URL = "https://modulaq.dev";
const distDir = join(dirname(fileURLToPath(import.meta.url)), "..", "dist");
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const distDir = join(repoRoot, "dist");

// Compatibility routes that canonicalize to /consultas, plus the noindex 404.
const excludedRoutes = new Set(["/contacto", "/solicitar-herramienta", "/404"]);
// Routes that canonicalize elsewhere or are noindex.
const excludedRoutes = new Set([
"/contacto",
"/solicitar-herramienta",
"/en/request-tool",
"/404",
]);

// Map ES → EN equivalents for the static shell routes. Tool detail pages are
// mapped programmatically via the tools list. Keys are paths without trailing
// slash; "/" maps to "/en".
const STATIC_ROUTE_MAP = new Map([
["/", "/en"],
["/herramientas", "/en/tools"],
["/consultas", "/en/contact"],
["/privacidad", "/en/privacy"],
]);

const REVERSE_ROUTE_MAP = new Map(Array.from(STATIC_ROUTE_MAP, ([es, en]) => [en, es]));

function collectToolRouteMaps() {
const source = readFileSync(join(repoRoot, "src", "features", "tools", "data", "tools.ts"), "utf-8");
const esToEn = new Map();

for (const match of source.matchAll(/slug:\s*"([^"]+)"[\s\S]*?slugEn:\s*"([^"]+)"/g)) {
esToEn.set(match[1], match[2]);
}

return {
esToEn,
enToEs: new Map(Array.from(esToEn, ([es, en]) => [en, es])),
};
}

const TOOL_ROUTE_MAPS = collectToolRouteMaps();

function collectHtmlRoutes(dir) {
const routes = [];
Expand Down Expand Up @@ -37,15 +72,70 @@ function collectHtmlRoutes(dir) {
return routes;
}

const routes = Array.from(new Set(collectHtmlRoutes(distDir)))
function findAlternate(route, allRoutes) {
if (STATIC_ROUTE_MAP.has(route)) {
const en = STATIC_ROUTE_MAP.get(route);
return allRoutes.has(en) ? en : null;
}
if (REVERSE_ROUTE_MAP.has(route)) {
const es = REVERSE_ROUTE_MAP.get(route);
return allRoutes.has(es) ? es : null;
}

const esTool = route.match(/^\/herramientas\/([^/]+)$/);
if (esTool) {
const enSlug = TOOL_ROUTE_MAPS.esToEn.get(esTool[1]);
const candidate = `/en/tools/${enSlug ?? esTool[1]}`;
if (allRoutes.has(candidate)) return candidate;
return null;
}
const enTool = route.match(/^\/en\/tools\/([^/]+)$/);
if (enTool) {
const esSlug = TOOL_ROUTE_MAPS.enToEs.get(enTool[1]);
const candidate = `/herramientas/${esSlug ?? enTool[1]}`;
if (allRoutes.has(candidate)) return candidate;
return null;
}
return null;
}

function isEnglishRoute(route) {
return route === "/en" || route.startsWith("/en/");
}

const allRoutes = Array.from(new Set(collectHtmlRoutes(distDir)))
.filter((route) => !excludedRoutes.has(route))
.sort();

const urls = routes
.map((route) => ` <url><loc>${SITE_URL}${route === "/" ? "/" : route}</loc></url>`)
const routeSet = new Set(allRoutes);

const urlsXml = allRoutes
.map((route) => {
const loc = `${SITE_URL}${route === "/" ? "/" : route}`;
const lang = isEnglishRoute(route) ? "en" : "es";
const alt = findAlternate(route, routeSet);
const altLang = lang === "es" ? "en" : "es";

const lines = [
" <url>",
` <loc>${loc}</loc>`,
` <xhtml:link rel="alternate" hreflang="${lang}" href="${loc}" />`,
];
if (alt) {
const altLoc = `${SITE_URL}${alt === "/" ? "/" : alt}`;
lines.push(` <xhtml:link rel="alternate" hreflang="${altLang}" href="${altLoc}" />`);
// x-default points to Spanish (the configured default).
const xDefault = lang === "es" ? loc : `${SITE_URL}${alt === "/" ? "/" : alt}`;
lines.push(` <xhtml:link rel="alternate" hreflang="x-default" href="${xDefault}" />`);
} else {
lines.push(` <xhtml:link rel="alternate" hreflang="x-default" href="${loc}" />`);
}
lines.push(" </url>");
return lines.join("\n");
})
.join("\n");

const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
const sitemap = `<?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${urlsXml}\n</urlset>\n`;
writeFileSync(join(distDir, "sitemap.xml"), sitemap, "utf-8");

console.log(`[sitemap] ${routes.length} URLs written to dist/sitemap.xml`);
console.log(`[sitemap] ${allRoutes.length} URLs written to dist/sitemap.xml`);
12 changes: 8 additions & 4 deletions src/app/layout/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { Link } from "react-router-dom";
import { CONTACT_EMAIL } from "../../config/contact";
import { routePaths } from "../routes/routePaths";
import { Container } from "../../shared/components/Container";
import { siteConfig } from "../../shared/constants/site";
import { useI18n } from "../../shared/i18n/I18nProvider";
import { localizedPath } from "../../shared/i18n/paths";

export function Footer() {
const { language, t } = useI18n();
const privacyPath = localizedPath("privacy", language);

return (
<footer className="border-t border-surface-200/80 bg-surface-100/70">
<Container className="grid gap-6 py-8 text-sm text-ink-500 lg:grid-cols-[1fr_auto] lg:items-center">
<div>
<p className="font-semibold text-ink-900">© 2026 {siteConfig.name}</p>
<p className="mt-1">Herramientas PDF, QR y texto para usar en el navegador.</p>
<p className="mt-1">{t("footer.tagline")}</p>
</div>
<div className="flex flex-wrap gap-4">
<Link to={routePaths.privacy} className="hover:text-ink-900">
Privacidad
<Link to={privacyPath} className="hover:text-ink-900">
{t("footer.privacy")}
</Link>
<a href={`mailto:${CONTACT_EMAIL}`} className="hover:text-ink-900">
{CONTACT_EMAIL}
Expand Down
87 changes: 49 additions & 38 deletions src/app/layout/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,65 +1,73 @@
import { Boxes, Menu } from "lucide-react";
import { useState } from "react";
import { NavLink } from "react-router-dom";
import { routePaths } from "../routes/routePaths";
import { navigationItems } from "../../config/navigation";
import { navigationItems, resolveNavPath } from "../../config/navigation";
import { useI18n } from "../../shared/i18n/I18nProvider";
import { localizedPath } from "../../shared/i18n/paths";
import { Container } from "../../shared/components/Container";
import { LanguageSwitcher } from "./LanguageSwitcher";

export function Header() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const { language, t } = useI18n();
const homePath = localizedPath("home", language);

return (
<header className="sticky top-0 z-20 border-b border-surface-200/80 bg-surface-50/88 backdrop-blur">
<Container className="flex h-16 items-center justify-between gap-6">
<NavLink to={routePaths.home} className="flex items-center gap-3">
<Container className="flex h-16 items-center justify-between gap-4">
<NavLink to={homePath} className="flex items-center gap-3">
<span className="grid h-9 w-9 place-items-center rounded-lg bg-ink-900 text-surface-50 shadow-soft">
<Boxes size={19} />
</span>
<span>
<span className="block text-base font-semibold leading-tight text-ink-900">Modulaq</span>
<span className="block text-xs text-ink-500">microherramientas modulares</span>
<span className="block text-xs text-ink-500">{t("nav.tagline")}</span>
</span>
</NavLink>

<nav className="hidden items-center gap-1 lg:flex">
{navigationItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
[
"rounded-md px-3 py-2 text-sm font-medium transition",
isActive
? "bg-surface-200 text-ink-900"
: "text-ink-700 hover:bg-surface-100 hover:text-ink-900",
].join(" ")
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="flex items-center gap-3">
<nav className="hidden items-center gap-1 lg:flex">
{navigationItems.map((item) => (
<NavLink
key={item.routeKey}
to={resolveNavPath(item, language)}
className={({ isActive }) =>
[
"rounded-md px-3 py-2 text-sm font-medium transition",
isActive
? "bg-surface-200 text-ink-900"
: "text-ink-700 hover:bg-surface-100 hover:text-ink-900",
].join(" ")
}
>
{t(item.labelKey)}
</NavLink>
))}
</nav>

<LanguageSwitcher className="hidden sm:inline-flex" />

<button
className="grid h-10 w-10 place-items-center rounded-md border border-surface-200 bg-surface-100 text-ink-700 lg:hidden"
type="button"
aria-controls="mobile-navigation"
aria-expanded={isMobileMenuOpen}
aria-label={isMobileMenuOpen ? "Cerrar navegación" : "Abrir navegación"}
title={isMobileMenuOpen ? "Cerrar navegación" : "Abrir navegación"}
onClick={() => setIsMobileMenuOpen((isOpen) => !isOpen)}
>
<Menu size={19} />
</button>
<button
className="grid h-10 w-10 place-items-center rounded-md border border-surface-200 bg-surface-100 text-ink-700 lg:hidden"
type="button"
aria-controls="mobile-navigation"
aria-expanded={isMobileMenuOpen}
aria-label={isMobileMenuOpen ? t("nav.closeMenu") : t("nav.openMenu")}
title={isMobileMenuOpen ? t("nav.closeMenu") : t("nav.openMenu")}
onClick={() => setIsMobileMenuOpen((isOpen) => !isOpen)}
>
<Menu size={19} />
</button>
</div>
</Container>

{isMobileMenuOpen ? (
<Container className="border-t border-surface-200 py-3 lg:hidden">
<nav id="mobile-navigation" className="grid gap-1" aria-label="Navegación principal">
<nav id="mobile-navigation" className="grid gap-1" aria-label={t("nav.openMenu")}>
{navigationItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
key={item.routeKey}
to={resolveNavPath(item, language)}
onClick={() => setIsMobileMenuOpen(false)}
className={({ isActive }) =>
[
Expand All @@ -70,9 +78,12 @@ export function Header() {
].join(" ")
}
>
{item.label}
{t(item.labelKey)}
</NavLink>
))}
<div className="mt-2 flex sm:hidden">
<LanguageSwitcher />
</div>
</nav>
</Container>
) : null}
Expand Down
51 changes: 51 additions & 0 deletions src/app/layout/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Globe } from "lucide-react";
import { useI18n } from "../../shared/i18n/I18nProvider";
import { SUPPORTED_LANGUAGES, type Language } from "../../shared/i18n/types";
import { cn } from "../../shared/utils/cn";

const LABELS: Record<Language, string> = {
es: "ES",
en: "EN",
};

const FULL_LABELS: Record<Language, string> = {
es: "Español",
en: "English",
};

export function LanguageSwitcher({ className }: { className?: string }) {
const { language, setLanguage, t } = useI18n();

return (
<div
role="group"
aria-label={t("nav.languageSwitch")}
className={cn(
"inline-flex items-center gap-0.5 rounded-md border border-surface-200/80 bg-surface-50/90 p-0.5 text-xs font-semibold shadow-sm",
className,
)}
>
<Globe size={14} className="mx-1 hidden text-ink-500 sm:block" aria-hidden="true" />
{SUPPORTED_LANGUAGES.map((lang) => {
const isActive = language === lang;
return (
<button
key={lang}
type="button"
aria-pressed={isActive}
aria-label={FULL_LABELS[lang]}
onClick={() => setLanguage(lang)}
className={cn(
"min-h-7 rounded-[5px] px-2 py-1 transition focus:outline-none focus:ring-2 focus:ring-accent-cyan/25",
isActive
? "bg-ink-900 text-surface-50"
: "text-ink-700 hover:bg-surface-100 hover:text-ink-900",
)}
>
{LABELS[lang]}
</button>
);
})}
</div>
);
}
23 changes: 13 additions & 10 deletions src/app/layout/RootLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { Outlet } from "react-router-dom";
import { ToolPrefsProvider } from "../../features/tools/context/ToolPrefsProvider";
import { I18nProvider } from "../../shared/i18n/I18nProvider";
import { ScrollToTop } from "../routes/ScrollToTop";
import { Footer } from "./Footer";
import { Header } from "./Header";

export function RootLayout() {
return (
<ToolPrefsProvider>
<div className="flex min-h-screen flex-col">
<ScrollToTop />
<Header />
<main className="flex-1">
<Outlet />
</main>
<Footer />
</div>
</ToolPrefsProvider>
<I18nProvider>
<ToolPrefsProvider>
<div className="flex min-h-screen flex-col">
<ScrollToTop />
<Header />
<main className="flex-1">
<Outlet />
</main>
<Footer />
</div>
</ToolPrefsProvider>
</I18nProvider>
);
}
13 changes: 11 additions & 2 deletions src/app/routes/routePaths.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import { buildToolPath as buildToolPathLocalized, localizedPath } from "../../shared/i18n/paths";
import type { Language } from "../../shared/i18n/types";

/**
* Mapa de paths ES (back-compat con componentes que aún no fueron migrados a
* useI18n). Para uso locale-aware preferí `localizedPath(key, language)`.
*/
export const routePaths = {
home: "/",
tools: "/herramientas",
Expand All @@ -8,6 +15,8 @@ export const routePaths = {
privacy: "/privacidad",
};

export function buildToolPath(slug: string) {
return `/herramientas/${slug}`;
export function buildToolPath(slug: string, language: Language = "es") {
return buildToolPathLocalized(slug, language);
}

export { localizedPath };
Loading
Loading