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
52 changes: 52 additions & 0 deletions scripts/verify-prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,24 @@ const suspenseFallbacks = [
"Cargando caso LEM-BOX…",
"Loading LEM-BOX case study…",
];
const expectedPersonContent = new Map([
[
"/",
{
description:
"Creo sitios, aplicaciones y sistemas a medida combinando estrategia, experiencia de usuario y tecnología. También implemento automatizaciones, integraciones y asistentes con IA para conectar herramientas, optimizar procesos y reducir trabajo manual.",
jobTitle: "Integrador de tecnología con mentalidad de producto",
},
],
[
"/en",
{
description:
"I create custom websites, applications, and systems by combining strategy, user experience, and technology. I also implement automations, integrations, and AI assistants to connect tools, optimize processes, and reduce manual work.",
jobTitle: "Technology integrator with a product mindset",
},
],
]);
const expectedStructuredData = new Map([
[
"/",
Expand Down Expand Up @@ -616,6 +634,13 @@ for (const route of expectedRoutes) {
const expectedGraph = expectedStructuredData.get(route.pathname);
const expectedJsonLdCount = expectedGraph === undefined ? 0 : 1;
assert.equal(scripts.length, expectedJsonLdCount, route.pathname);
if (expectedJsonLdCount === 1) {
assert.match(
html,
/<script data-rh="true" type="application\/ld\+json">/,
`${route.pathname}: JSON-LD data-rh marker`,
);
}
for (const [, jsonLd] of scripts) {
const parsed = JSON.parse(jsonLd);
assert.equal(parsed["@context"], "https://schema.org", route.pathname);
Expand All @@ -629,6 +654,33 @@ for (const route of expectedRoutes) {
expectedGraph.ids,
`${route.pathname}: graph IDs`,
);
const personContent = expectedPersonContent.get(route.pathname);
if (personContent !== undefined) {
const personNodes = parsed["@graph"].filter(
(node) => node["@type"] === "Person",
);
assert.equal(
personNodes.length,
1,
`${route.pathname}: single canonical Person node`,
);
const [person] = personNodes;
assert.equal(
person["@id"],
"https://www.devrodri.com/#person",
`${route.pathname}: canonical Person @id`,
);
assert.equal(
person.description,
personContent.description,
`${route.pathname}: Person description`,
);
assert.equal(
person.jobTitle,
personContent.jobTitle,
`${route.pathname}: Person jobTitle`,
);
}
const hash = sha256Source(jsonLd);
liveInlineScriptHashes.add(hash);
assert.ok(
Expand Down
16 changes: 11 additions & 5 deletions src/__tests__/webDeliveryPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ const vercelConfigurationPath = path.join(projectRoot, "vercel.json");
const indexHtmlPath = path.join(projectRoot, "index.html");

const contentSecurityPolicyBeforeCleanup =
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self' https://formsubmit.co; script-src 'self' https://www.googletagmanager.com 'sha256-PFtIWoSjDUGI9FDBejrY6GsmT+jNY2fnEk/Wu0PMTxo=' 'sha256-NJsY0F2k7FEFgpKyakOXbgKzmylETn07jGhl+846ouI=' 'sha256-5JAfEolKKBpyKuNkJyFVP4QM03AqudwLaL6W4YE9Dow=' 'sha256-MWbnLG8L270wPoFC3v581s2nVNl/XC/TRKETtAkKpjY='; script-src-attr 'none'; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' https://*.google-analytics.com https://www.googletagmanager.com; media-src 'self'; connect-src 'self' https://formsubmit.co https://www.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com; frame-src 'none'; manifest-src 'self'; worker-src 'none'; upgrade-insecure-requests";
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self' https://formsubmit.co; script-src 'self' https://www.googletagmanager.com 'sha256-O8l3yTyim5wlcCf0NH8eooMDbrLdmZb7wspJwSqm1F8=' 'sha256-14RAKb0fSoF/0NrAPsGFzhEcabDY08YgW52+HTiwOC8=' 'sha256-5JAfEolKKBpyKuNkJyFVP4QM03AqudwLaL6W4YE9Dow=' 'sha256-MWbnLG8L270wPoFC3v581s2nVNl/XC/TRKETtAkKpjY='; script-src-attr 'none'; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' https://*.google-analytics.com https://www.googletagmanager.com; media-src 'self'; connect-src 'self' https://formsubmit.co https://www.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com; frame-src 'none'; manifest-src 'self'; worker-src 'none'; upgrade-insecure-requests";

const contentSecurityPolicyBeforeNoJavaScript =
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self' https://formsubmit.co; script-src 'self' https://www.googletagmanager.com 'sha256-PFtIWoSjDUGI9FDBejrY6GsmT+jNY2fnEk/Wu0PMTxo=' 'sha256-NJsY0F2k7FEFgpKyakOXbgKzmylETn07jGhl+846ouI=' 'sha256-5JAfEolKKBpyKuNkJyFVP4QM03AqudwLaL6W4YE9Dow=' 'sha256-MWbnLG8L270wPoFC3v581s2nVNl/XC/TRKETtAkKpjY='; script-src-attr 'none'; style-src 'self'; style-src-attr 'unsafe-inline'; font-src 'self'; img-src 'self' https://*.google-analytics.com https://www.googletagmanager.com; media-src 'self'; connect-src 'self' https://formsubmit.co https://www.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com; frame-src 'none'; manifest-src 'self'; worker-src 'none'; upgrade-insecure-requests";
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self' https://formsubmit.co; script-src 'self' https://www.googletagmanager.com 'sha256-O8l3yTyim5wlcCf0NH8eooMDbrLdmZb7wspJwSqm1F8=' 'sha256-14RAKb0fSoF/0NrAPsGFzhEcabDY08YgW52+HTiwOC8=' 'sha256-5JAfEolKKBpyKuNkJyFVP4QM03AqudwLaL6W4YE9Dow=' 'sha256-MWbnLG8L270wPoFC3v581s2nVNl/XC/TRKETtAkKpjY='; script-src-attr 'none'; style-src 'self'; style-src-attr 'unsafe-inline'; font-src 'self'; img-src 'self' https://*.google-analytics.com https://www.googletagmanager.com; media-src 'self'; connect-src 'self' https://formsubmit.co https://www.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com; frame-src 'none'; manifest-src 'self'; worker-src 'none'; upgrade-insecure-requests";

const noJavaScriptStyle =
"[data-nojs-visible]{opacity:1!important;transform:none!important}[data-nojs-hide]{display:none!important}[data-nojs-language]{display:flex!important}@media(max-width:639px){[data-nojs-navbar]{position:static!important}[data-nojs-mobile-nav]{display:flex!important}}";
Expand All @@ -88,7 +88,7 @@ const noJavaScriptBlock =
`<noscript><style>${noJavaScriptStyle}</style></noscript>`;

const expectedContentSecurityPolicy =
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self' https://formsubmit.co; script-src 'self' https://www.googletagmanager.com 'sha256-PFtIWoSjDUGI9FDBejrY6GsmT+jNY2fnEk/Wu0PMTxo=' 'sha256-NJsY0F2k7FEFgpKyakOXbgKzmylETn07jGhl+846ouI=' 'sha256-5JAfEolKKBpyKuNkJyFVP4QM03AqudwLaL6W4YE9Dow=' 'sha256-MWbnLG8L270wPoFC3v581s2nVNl/XC/TRKETtAkKpjY='; script-src-attr 'none'; style-src 'self' 'sha256-F3e8uFPgP10pK68RX7B5e1ZHd++LBK67gz3K7SNPrgA='; style-src-attr 'unsafe-inline'; font-src 'self'; img-src 'self' https://*.google-analytics.com https://www.googletagmanager.com; media-src 'self'; connect-src 'self' https://formsubmit.co https://www.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com; frame-src 'none'; manifest-src 'self'; worker-src 'none'; upgrade-insecure-requests";
"default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self' https://formsubmit.co; script-src 'self' https://www.googletagmanager.com 'sha256-O8l3yTyim5wlcCf0NH8eooMDbrLdmZb7wspJwSqm1F8=' 'sha256-14RAKb0fSoF/0NrAPsGFzhEcabDY08YgW52+HTiwOC8=' 'sha256-5JAfEolKKBpyKuNkJyFVP4QM03AqudwLaL6W4YE9Dow=' 'sha256-MWbnLG8L270wPoFC3v581s2nVNl/XC/TRKETtAkKpjY='; script-src-attr 'none'; style-src 'self' 'sha256-F3e8uFPgP10pK68RX7B5e1ZHd++LBK67gz3K7SNPrgA='; style-src-attr 'unsafe-inline'; font-src 'self'; img-src 'self' https://*.google-analytics.com https://www.googletagmanager.com; media-src 'self'; connect-src 'self' https://formsubmit.co https://www.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com; frame-src 'none'; manifest-src 'self'; worker-src 'none'; upgrade-insecure-requests";

const publishedStructuredData = [
STRUCTURED_DATA_BY_ROUTE["home:es"],
Expand Down Expand Up @@ -372,8 +372,8 @@ describe("web delivery policy", () => {
[
"'self'",
"https://www.googletagmanager.com",
"'sha256-PFtIWoSjDUGI9FDBejrY6GsmT+jNY2fnEk/Wu0PMTxo='",
"'sha256-NJsY0F2k7FEFgpKyakOXbgKzmylETn07jGhl+846ouI='",
"'sha256-O8l3yTyim5wlcCf0NH8eooMDbrLdmZb7wspJwSqm1F8='",
"'sha256-14RAKb0fSoF/0NrAPsGFzhEcabDY08YgW52+HTiwOC8='",
"'sha256-5JAfEolKKBpyKuNkJyFVP4QM03AqudwLaL6W4YE9Dow='",
"'sha256-MWbnLG8L270wPoFC3v581s2nVNl/XC/TRKETtAkKpjY='",
],
Expand Down Expand Up @@ -430,6 +430,12 @@ describe("web delivery policy", () => {
expect(scriptSources).not.toContain(
"'sha256-NGTPVuZtJv6KrQNvN78pj9Fz+4CuArpGSOhRfc3CjvI='",
);
expect(scriptSources).not.toContain(
"'sha256-PFtIWoSjDUGI9FDBejrY6GsmT+jNY2fnEk/Wu0PMTxo='",
);
expect(scriptSources).not.toContain(
"'sha256-NJsY0F2k7FEFgpKyakOXbgKzmylETn07jGhl+846ouI='",
);

expect(directives.get("form-action")).toContain("https://formsubmit.co");
expect(directives.get("connect-src")).toContain("https://formsubmit.co");
Expand Down
16 changes: 16 additions & 0 deletions src/seo/structuredData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type PersonNode = {
"@type": "Person";
"@id": typeof PERSON_ID;
name: "Rodrigo Opalo";
jobTitle: string;
description: string;
url: typeof SITE_URL;
image: "https://www.devrodri.com/img/sobremi.jpg";
sameAs: readonly [
Expand Down Expand Up @@ -93,6 +95,18 @@ const credentialDescriptions = {
en: "Professional certification verified by IBM Skills Network and Credly.",
} as const satisfies Record<Language, string>;

// Mirrors the visible About-section copy in SobreMiSection.tsx verbatim.
export const PERSON_JOB_TITLES = {
es: "Integrador de tecnología con mentalidad de producto",
en: "Technology integrator with a product mindset",
} as const satisfies Record<Language, string>;

// Mirrors the visible About-section copy in SobreMiSection.tsx verbatim.
export const PERSON_DESCRIPTIONS = {
es: "Creo sitios, aplicaciones y sistemas a medida combinando estrategia, experiencia de usuario y tecnología. También implemento automatizaciones, integraciones y asistentes con IA para conectar herramientas, optimizar procesos y reducir trabajo manual.",
en: "I create custom websites, applications, and systems by combining strategy, user experience, and technology. I also implement automations, integrations, and AI assistants to connect tools, optimize processes, and reduce manual work.",
} as const satisfies Record<Language, string>;

function reference(id: string): Reference {
return { "@id": id };
}
Expand All @@ -113,6 +127,8 @@ function createHomeStructuredData(locale: Language): HomeStructuredData {
"@type": "Person",
"@id": PERSON_ID,
name: "Rodrigo Opalo",
jobTitle: PERSON_JOB_TITLES[locale],
description: PERSON_DESCRIPTIONS[locale],
url: SITE_URL,
image: "https://www.devrodri.com/img/sobremi.jpg",
sameAs: [
Expand Down
173 changes: 173 additions & 0 deletions src/test/HeadHydration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { FilledContext } from "react-helmet-async";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
PERSON_DESCRIPTIONS,
PERSON_ID,
PERSON_JOB_TITLES,
} from "../seo/structuredData";

type JsonLdNode = { "@type": string; "@id"?: string } & Record<
string,
unknown
>;
type JsonLdGraph = { "@graph": JsonLdNode[] };

const routes = [
{ locale: "es" as const, pathname: "/" },
{ locale: "en" as const, pathname: "/en" },
];

function jsonLdScripts() {
return document.head.querySelectorAll(
'script[type="application/ld+json"]',
);
}

function personNode(script: Element | undefined): JsonLdNode {
const graph = JSON.parse(script?.textContent ?? "null") as JsonLdGraph;
const personNodes = graph["@graph"].filter(
(node) => node["@type"] === "Person",
);
expect(personNodes).toHaveLength(1);
const [person] = personNodes;
if (person === undefined) {
throw new Error("Missing Person node");
}
return person;
}

// react-helmet-async decides once, at module load, whether it can use the
// DOM directly. jsdom always provides `document`, so a normal import would
// make it behave like a client instead of populating helmetContext. Hiding
// the DOM globals and forcing a fresh module load reproduces the real
// server-render code path, matching scripts/prerender.mjs in production.
async function renderPrerenderedHead(pathname: string) {
vi.stubGlobal("document", undefined);
vi.stubGlobal("window", undefined);
vi.resetModules();

try {
const [{ createServerApplication }, { renderToString }] =
await Promise.all([
import("../entry-server"),
import("react-dom/server"),
]);
const helmetContext: Partial<FilledContext> = {};
const appHtml = renderToString(
createServerApplication(pathname, helmetContext),
);
const { helmet } = helmetContext;
if (helmet === undefined) {
throw new Error("Helmet did not produce server metadata");
}

return {
appHtml,
head: [
helmet.title.toString(),
helmet.meta.toString(),
helmet.link.toString(),
helmet.script.toString(),
].join("\n"),
};
} finally {
vi.unstubAllGlobals();
vi.resetModules();
}
}

afterEach(() => {
document.head.innerHTML = "";
document.body.innerHTML = "";
});

describe("prerendered head hydration", () => {
it.each(routes)(
"hydrates $pathname without duplicating the head or the Person node",
async ({ locale, pathname }) => {
const { appHtml, head } = await renderPrerenderedHead(pathname);

document.head.innerHTML = head;
document.body.innerHTML = `<div id="root">${appHtml}</div>`;

const prerenderedScripts = jsonLdScripts();
expect(prerenderedScripts).toHaveLength(1);
expect(prerenderedScripts[0]).toHaveAttribute("data-rh", "true");
const prerenderedPerson = personNode(prerenderedScripts[0]);
expect(prerenderedPerson["@id"]).toBe(PERSON_ID);
expect(prerenderedPerson.description).toBe(PERSON_DESCRIPTIONS[locale]);
expect(prerenderedPerson.jobTitle).toBe(PERSON_JOB_TITLES[locale]);

const container = document.getElementById("root");
if (container === null) {
throw new Error("Missing prerendered #root");
}

// Fresh imports here (post-restore) pair a live 'react'/'react-dom'
// instance with react-helmet-async in its real client (canUseDOM)
// mode, matching what main.tsx does in production.
const [
{ act, waitFor },
{ hydrateRoot },
{ MemoryRouter },
{ RoutedLanguageProvider },
{ default: App },
] = await Promise.all([
import("@testing-library/react"),
import("react-dom/client"),
import("react-router-dom"),
import("../i18n/LanguageProvider"),
import("../App"),
]);

const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
let root: import("react-dom/client").Root | undefined;

try {
await act(async () => {
root = hydrateRoot(
container,
<MemoryRouter initialEntries={[pathname]}>
<RoutedLanguageProvider>
<App />
</RoutedLanguageProvider>
</MemoryRouter>,
);
});

await waitFor(() => {
expect(jsonLdScripts()).toHaveLength(1);
});

expect(consoleError.mock.calls.flat().join(" ")).not.toMatch(
/hydration|did not match|server html/i,
);

expect(document.head.querySelectorAll("title")).toHaveLength(1);
expect(
document.head.querySelectorAll('meta[name="description"]'),
).toHaveLength(1);
expect(
document.head.querySelectorAll('link[rel="canonical"]'),
).toHaveLength(1);
expect(
document.head.querySelectorAll('link[rel="alternate"]'),
).toHaveLength(3);

const hydratedScripts = jsonLdScripts();
expect(hydratedScripts).toHaveLength(1);
const hydratedPerson = personNode(hydratedScripts[0]);
expect(hydratedPerson["@id"]).toBe(PERSON_ID);
expect(hydratedPerson.description).toBe(PERSON_DESCRIPTIONS[locale]);
expect(hydratedPerson.jobTitle).toBe(PERSON_JOB_TITLES[locale]);
} finally {
consoleError.mockRestore();
await act(async () => {
root?.unmount();
});
}
},
);
});
14 changes: 14 additions & 0 deletions src/test/structuredData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
IBM_CREDLY_URL,
LEM_BOX_CASE_STUDY_IDS,
LEM_BOX_WEB_APPLICATION_ID,
PERSON_DESCRIPTIONS,
PERSON_ID,
PERSON_JOB_TITLES,
STRUCTURED_DATA_BY_ROUTE,
WEBSITE_ID,
getStructuredData,
Expand Down Expand Up @@ -77,6 +79,8 @@ describe("route structured data", () => {
"@type": "Person",
"@id": PERSON_ID,
name: "Rodrigo Opalo",
jobTitle: PERSON_JOB_TITLES[locale],
description: PERSON_DESCRIPTIONS[locale],
url: "https://www.devrodri.com/",
image: "https://www.devrodri.com/img/sobremi.jpg",
sameAs: [
Expand All @@ -85,6 +89,16 @@ describe("route structured data", () => {
],
hasCredential: { "@id": IBM_CREDENTIAL_ID },
});
expect(person.jobTitle).toBe(
locale === "es"
? "Integrador de tecnología con mentalidad de producto"
: "Technology integrator with a product mindset",
);
expect(person.description).toBe(
locale === "es"
? "Creo sitios, aplicaciones y sistemas a medida combinando estrategia, experiencia de usuario y tecnología. También implemento automatizaciones, integraciones y asistentes con IA para conectar herramientas, optimizar procesos y reducir trabajo manual."
: "I create custom websites, applications, and systems by combining strategy, user experience, and technology. I also implement automations, integrations, and AI assistants to connect tools, optimize processes, and reduce manual work.",
);
expect(credential).toMatchObject({
"@type": "EducationalOccupationalCredential",
"@id": IBM_CREDENTIAL_ID,
Expand Down
Loading
Loading