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
12 changes: 7 additions & 5 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,20 @@ Production: [www.devrodri.com](https://www.devrodri.com)
- Vitest 4
- Node.js 22 and npm 9.9.4

The site uses Vite + React and is prerendered at build time. It publishes six
static public routes:
The site uses Vite + React and is prerendered at build time. It publishes
twelve static public routes:

- Spanish: `/`, `/portfolio`, `/portfolio/lem-box`
- English: `/en`, `/en/portfolio`, `/en/portfolio/lem-box`
- Spanish: `/`, `/portfolio`, `/portfolio/lem-box`, `/servicios`,
`/servicios/sitios-web-para-empresas`, `/servicios/sistemas-a-medida`
- English: `/en`, `/en/portfolio`, `/en/portfolio/lem-box`, `/en/services`,
`/en/services/business-websites`, `/en/services/custom-software`

Each route ships server-visible HTML and content, including its localized
`title`, description, canonical URL, `hreflang`, Open Graph, and Twitter
metadata. Structured data is scoped per route where applicable, and React
hydrates the client after the initial document loads. Unknown paths return a
real HTTP 404 localized in Spanish or English. The build also publishes
`robots.txt` and `sitemap.xml`, which lists the six public URLs.
`robots.txt` and `sitemap.xml`, which lists the twelve public URLs.

## Development

Expand Down
12 changes: 7 additions & 5 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,21 @@ Producción: [www.devrodri.com](https://www.devrodri.com)
- Vitest 4
- Node.js 22 y npm 9.9.4

El sitio usa Vite + React y se prerenderiza durante el build. Publica seis rutas
estáticas:
El sitio usa Vite + React y se prerenderiza durante el build. Publica doce
rutas estáticas:

- Español: `/`, `/portfolio`, `/portfolio/lem-box`
- Inglés: `/en`, `/en/portfolio`, `/en/portfolio/lem-box`
- Español: `/`, `/portfolio`, `/portfolio/lem-box`, `/servicios`,
`/servicios/sitios-web-para-empresas`, `/servicios/sistemas-a-medida`
- Inglés: `/en`, `/en/portfolio`, `/en/portfolio/lem-box`, `/en/services`,
`/en/services/business-websites`, `/en/services/custom-software`

Cada ruta entrega HTML y contenido visibles desde el servidor, incluidos su
`title`, descripción, URL canonical, `hreflang`, Open Graph y metadata de
Twitter localizados. Los datos estructurados se definen por ruta cuando
corresponde, y React hidrata el cliente después de cargar el documento inicial.
Las rutas desconocidas devuelven un HTTP 404 real localizado en español o
inglés. El build también publica `robots.txt` y `sitemap.xml`, que enumera las
seis URLs públicas.
doce URLs públicas.

## Desarrollo

Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,20 @@ Production: [www.devrodri.com](https://www.devrodri.com)
- Vitest 4
- Node.js 22 and npm 9.9.4

The site uses Vite + React and is prerendered at build time. It publishes six
static public routes:
The site uses Vite + React and is prerendered at build time. It publishes
twelve static public routes:

- Spanish: `/`, `/portfolio`, `/portfolio/lem-box`
- English: `/en`, `/en/portfolio`, `/en/portfolio/lem-box`
- Spanish: `/`, `/portfolio`, `/portfolio/lem-box`, `/servicios`,
`/servicios/sitios-web-para-empresas`, `/servicios/sistemas-a-medida`
- English: `/en`, `/en/portfolio`, `/en/portfolio/lem-box`, `/en/services`,
`/en/services/business-websites`, `/en/services/custom-software`

Each route ships server-visible HTML and content, including its localized
`title`, description, canonical URL, `hreflang`, Open Graph, and Twitter
metadata. Structured data is scoped per route where applicable, and React
hydrates the client after the initial document loads. Unknown paths return a
real HTTP 404 localized in Spanish or English. The build also publishes
`robots.txt` and `sitemap.xml`, which lists the six public URLs.
`robots.txt` and `sitemap.xml`, which lists the twelve public URLs.

## Local development

Expand Down
47 changes: 45 additions & 2 deletions scripts/verify-http.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,15 @@ try {
"/",
"/portfolio",
"/portfolio/lem-box",
"/servicios",
"/servicios/sitios-web-para-empresas",
"/servicios/sistemas-a-medida",
"/en",
"/en/portfolio",
"/en/portfolio/lem-box",
"/en/services",
"/en/services/business-websites",
"/en/services/custom-software",
];
for (const pathname of indexablePaths) {
const response = await request(pathname);
Expand Down Expand Up @@ -206,6 +212,24 @@ try {
ctaPath: "/en",
},
],
[
"/servicios/no-existe",
{
lang: "es",
heading: "Página no encontrada",
cta: "Volver al inicio",
ctaPath: "/",
},
],
[
"/en/services/no-existe",
{
lang: "en",
heading: "Page not found",
cta: "Back to home",
ctaPath: "/en",
},
],
]);
const invalidBodies = [];
for (const [pathname, expected] of invalidPaths) {
Expand Down Expand Up @@ -242,12 +266,30 @@ try {
"/portfolio/lem-box/?source=vis01",
"/portfolio/lem-box?source=vis01",
],
["/servicios/?source=vis01", "/servicios?source=vis01"],
[
"/servicios/sitios-web-para-empresas/?source=vis01",
"/servicios/sitios-web-para-empresas?source=vis01",
],
[
"/servicios/sistemas-a-medida/?source=vis01",
"/servicios/sistemas-a-medida?source=vis01",
],
["/en/?source=vis01", "/en?source=vis01"],
["/en/portfolio/?source=vis01", "/en/portfolio?source=vis01"],
[
"/en/portfolio/lem-box/?source=vis01",
"/en/portfolio/lem-box?source=vis01",
],
["/en/services/?source=vis01", "/en/services?source=vis01"],
[
"/en/services/business-websites/?source=vis01",
"/en/services/business-websites?source=vis01",
],
[
"/en/services/custom-software/?source=vis01",
"/en/services/custom-software?source=vis01",
],
["/gracias/?source=vis01", "/gracias?source=vis01"],
[
"/en/thank-you/?source=vis01",
Expand Down Expand Up @@ -313,9 +355,10 @@ try {

const assetFiles = await readdir(path.join(projectRoot, "dist", "assets"));
const lazyChunks = assetFiles.filter((file) =>
/^(PortfolioPage|LemBoxCasePage)-.*\.js$/.test(file),
/^(PortfolioPage|LemBoxCasePage|ServicesHubPage|BusinessWebsitesPage|CustomSoftwarePage)-.*\.js$/
.test(file),
);
assert.equal(lazyChunks.length, 2);
assert.equal(lazyChunks.length, 5);
for (const lazyChunk of lazyChunks) {
const response = await request(`/assets/${lazyChunk}`);
assert.equal(response.status, 200, lazyChunk);
Expand Down
108 changes: 104 additions & 4 deletions scripts/verify-prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,79 @@ const expectedRoutes = [
"a82aa0233255e55dded2e20c18b752f88b22965f1692be24dfe75fa34319a27c",
noJavaScriptMarkers: 1,
},
{
pathname: "/servicios",
file: "servicios/index.html",
lang: "es",
title: "Servicios de desarrollo web y software a medida | Rodrigo Opalo",
description:
"Desarrollo sitios web profesionales, sistemas a medida y automatizaciones para empresas. Trabajo directo conmigo, en español e inglés, desde South Florida y en remoto para Estados Unidos y Latinoamérica.",
content: "Sitios web, sistemas y automatización para empresas.",
metadataHash:
"270c8a9a1de7606d8bf4e9d6c09cda7fbb8757b1c42174417cbcc56f425fa21d",
noJavaScriptMarkers: 1,
},
{
pathname: "/en/services",
file: "en/services/index.html",
lang: "en",
title: "Web development and custom software services | Rodrigo Opalo",
description:
"I build business websites, custom software, and automations for companies. You work directly with me, in English or Spanish, from South Florida and remotely across the United States and Latin America.",
content: "Websites, custom systems, and automation for businesses.",
metadataHash:
"a739a0d0ae391f63797c2a285e3fe3d43b44afe75b9fb8bda18413b8dffb07df",
noJavaScriptMarkers: 1,
},
{
pathname: "/servicios/sitios-web-para-empresas",
file: "servicios/sitios-web-para-empresas/index.html",
lang: "es",
title: "Sitios web profesionales para empresas | Rodrigo Opalo",
description:
"Diseño y desarrollo de sitios web profesionales para empresas: institucionales, landings y catálogos bilingües, rápidos, con base técnica SEO y formularios o WhatsApp para captar consultas.",
content: "Sitios web profesionales para empresas.",
metadataHash:
"c187c60f21e06be64ab215566532b98faab3d813c7517ac88ab1a1c0e8ce4627",
noJavaScriptMarkers: 1,
},
{
pathname: "/en/services/business-websites",
file: "en/services/business-websites/index.html",
lang: "en",
title: "Business website design and development | Rodrigo Opalo",
description:
"Custom website development for businesses: institutional sites, landing pages, and bilingual catalogs that load fast, with a technical SEO foundation and forms or WhatsApp to capture inquiries.",
content: "Professional websites for businesses.",
metadataHash:
"40cbbc7774f39ffa5be5a080634a209a1bb47eb154c711f90592d554a3b26176",
noJavaScriptMarkers: 1,
},
{
pathname: "/servicios/sistemas-a-medida",
file: "servicios/sistemas-a-medida/index.html",
lang: "es",
title: "Sistemas y aplicaciones a medida para empresas | Rodrigo Opalo",
description:
"Desarrollo de software a medida: aplicaciones web, portales de clientes y paneles internos con autenticación, roles, datos e integraciones. Implementación por etapas para empresas de Estados Unidos y Latinoamérica.",
content: "Sistemas y aplicaciones a medida para empresas.",
metadataHash:
"4f8aa5c638a7a52dca18a493c6a12d856e6cdf959751ad1f2427596ebf749f12",
noJavaScriptMarkers: 1,
},
{
pathname: "/en/services/custom-software",
file: "en/services/custom-software/index.html",
lang: "en",
title:
"Custom software and web applications for businesses | Rodrigo Opalo",
description:
"Custom software development: web applications, client portals, and internal panels with authentication, roles, data, and integrations. Phased delivery for businesses in the United States and Latin America.",
content: "Custom software and web applications for businesses.",
metadataHash:
"094a6a33e91d01670b3b638fc24aff51161f035ed0cd849ba695caaa0feabbba",
noJavaScriptMarkers: 1,
},
];
const expectedThankYouRoutes = [
{
Expand Down Expand Up @@ -112,12 +185,17 @@ const localePairs = [
["/", "/en"],
["/portfolio", "/en/portfolio"],
["/portfolio/lem-box", "/en/portfolio/lem-box"],
["/servicios", "/en/services"],
["/servicios/sitios-web-para-empresas", "/en/services/business-websites"],
["/servicios/sistemas-a-medida", "/en/services/custom-software"],
];
const suspenseFallbacks = [
"Cargando portfolio…",
"Loading portfolio…",
"Cargando caso LEM-BOX…",
"Loading LEM-BOX case study…",
">Cargando…<",
">Loading…<",
];
const expectedPersonContent = new Map([
[
Expand Down Expand Up @@ -234,12 +312,14 @@ function assertNoJavaScriptNavigation(
const navbar = html.slice(navbarStart, mobileFallbackEnd);
const mobileFallback = html.slice(mobileFallbackStart, mobileFallbackEnd);
const localizedHomePath = lang === "es" ? "/" : "/en";
const localizedServicesPath = lang === "es" ? "/servicios" : "/en/services";
const localizedPortfolioPath = lang === "es"
? "/portfolio"
: "/en/portfolio";
const expectedNavigationHrefs = [
`${localizedHomePath}#sobremi`,
`${localizedHomePath}#porqueelegirnos`,
localizedServicesPath,
localizedPortfolioPath,
`${localizedHomePath}#contacto`,
`${localizedHomePath}#faq`,
Expand Down Expand Up @@ -298,7 +378,7 @@ function assertNoJavaScriptNavigation(
`${lang}: fallback hrefs`,
);
assert.ok(fallbackAnchors.every((anchor) => /\shref="[^"]+"/.test(anchor)));
assert.equal(count(mobileFallback, "focus-visible:ring-2"), 7);
assert.equal(count(mobileFallback, "focus-visible:ring-2"), 8);
assert.ok(!mobileFallback.includes("<button"));
assert.ok(!mobileFallback.includes("aria-pressed"));
assert.ok(!mobileFallback.includes("tabindex="));
Expand Down Expand Up @@ -814,6 +894,19 @@ assert.equal(
assetFiles.filter((file) => /^LemBoxCasePage-.*\.js$/.test(file)).length,
1,
);
assert.equal(
assetFiles.filter((file) => /^ServicesHubPage-.*\.js$/.test(file)).length,
1,
);
assert.equal(
assetFiles.filter((file) => /^BusinessWebsitesPage-.*\.js$/.test(file))
.length,
1,
);
assert.equal(
assetFiles.filter((file) => /^CustomSoftwarePage-.*\.js$/.test(file)).length,
1,
);

const expectedNotFoundArtifacts = [
{
Expand Down Expand Up @@ -902,8 +995,8 @@ for (const artifact of expectedNotFoundArtifacts) {
prerenderedNoJavaScriptMarkerTotal += artifact.noJavaScriptMarkers;
}
assert.equal(new Set(notFoundDocuments).size, expectedNotFoundArtifacts.length);
assert.equal(noJavaScriptDocuments.length, 10);
assert.equal(prerenderedNoJavaScriptMarkerTotal, 58);
assert.equal(noJavaScriptDocuments.length, 16);
assert.equal(prerenderedNoJavaScriptMarkerTotal, 64);

const expectedHtmlArtifacts = [
...expectedRoutes.map((route) => route.file),
Expand Down Expand Up @@ -982,7 +1075,7 @@ assert.equal(vercelConfiguration.trailingSlash, false);
assert.deepEqual(vercelConfiguration.rewrites, []);
assert.ok(!JSON.stringify(vercelConfiguration).includes('"/index.html"'));
const localizedNotFoundRouteSource =
"/en/(?!(?:portfolio(?:/lem-box)?|thank-you)/?$).+$";
"/en/(?!(?:portfolio(?:/lem-box)?|services(?:/business-websites|/custom-software)?|thank-you)/?$).+$";
assert.deepEqual(vercelConfiguration.routes, [
{
src: localizedNotFoundRouteSource,
Expand Down Expand Up @@ -1021,6 +1114,12 @@ for (const publicEnglishPath of [
"/en/portfolio/",
"/en/portfolio/lem-box",
"/en/portfolio/lem-box/",
"/en/services",
"/en/services/",
"/en/services/business-websites",
"/en/services/business-websites/",
"/en/services/custom-software",
"/en/services/custom-software/",
"/en/thank-you",
"/en/thank-you/",
]) {
Expand All @@ -1029,6 +1128,7 @@ for (const publicEnglishPath of [
for (const invalidEnglishPath of [
"/en/no-existe",
"/en/portfolio/no-existe",
"/en/services/no-existe",
]) {
assert.equal(localizedNotFoundRoute.test(invalidEnglishPath), true);
}
Expand Down
29 changes: 29 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ import {

const PortfolioPage = lazy(() => import("./pages/PortfolioPage"));
const LemBoxCasePage = lazy(() => import("./pages/LemBoxCasePage"));
const ServicesHubPage = lazy(() => import("./pages/ServicesHubPage"));
const BusinessWebsitesPage = lazy(() => import("./pages/BusinessWebsitesPage"));
const CustomSoftwarePage = lazy(() => import("./pages/CustomSoftwarePage"));

const HomePage = () => (
<>
Expand Down Expand Up @@ -75,6 +78,11 @@ const HomePage = () => (

function PublicPage({ page }: { page: PageKey }) {
const { language } = useLanguage();
const lazyPageFallback = (
<div className="min-h-[45vh] flex items-center justify-center bg-black text-white/70 text-sm px-4 text-center">
{language === "es" ? "Cargando…" : "Loading…"}
</div>
);

if (page === "home") return <HomePage />;
if (page === "thank-you") return <ThankYouPage />;
Expand All @@ -91,6 +99,27 @@ function PublicPage({ page }: { page: PageKey }) {
</Suspense>
);
}
if (page === "services") {
return (
<Suspense fallback={lazyPageFallback}>
<ServicesHubPage />
</Suspense>
);
}
if (page === "business-websites") {
return (
<Suspense fallback={lazyPageFallback}>
<BusinessWebsitesPage />
</Suspense>
);
}
if (page === "custom-software") {
return (
<Suspense fallback={lazyPageFallback}>
<CustomSoftwarePage />
</Suspense>
);
}

return (
<Suspense
Expand Down
Loading
Loading