From eafe43941ad27e0b327b2633ef412308be89c997 Mon Sep 17 00:00:00 2001 From: Rodrigo Opalo Balog Date: Sun, 9 Aug 2026 17:15:54 -0400 Subject: [PATCH] feat(seo): connect Rodrigo Opalo with the devrodri brand --- public/brand/devrodri-wordmark-w1-black.svg | 5 ++ scripts/verify-http.mjs | 18 ++++ scripts/verify-prerender.mjs | 98 ++++++++++++++++++++- src/__tests__/webDeliveryPolicy.test.ts | 10 +-- src/seo/structuredData.ts | 50 ++++++++++- src/test/HeadHydration.test.tsx | 87 ++++++++++++++---- src/test/structuredData.test.ts | 58 +++++++++++- vercel.json | 4 +- 8 files changed, 301 insertions(+), 29 deletions(-) create mode 100644 public/brand/devrodri-wordmark-w1-black.svg diff --git a/public/brand/devrodri-wordmark-w1-black.svg b/public/brand/devrodri-wordmark-w1-black.svg new file mode 100644 index 0000000..2dae3d9 --- /dev/null +++ b/public/brand/devrodri-wordmark-w1-black.svg @@ -0,0 +1,5 @@ + +devrodri wordmark W1 negro +devrodri — wordmark W1. Contornos vectoriales, sin texto vivo y sin dependencia de fuentes. Inter SemiBold (d, e, v, o, i) + r propia W1. em=2048, linea base y=1561. + + \ No newline at end of file diff --git a/scripts/verify-http.mjs b/scripts/verify-http.mjs index 31e54e3..fcfb4a6 100644 --- a/scripts/verify-http.mjs +++ b/scripts/verify-http.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { once } from "node:events"; import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; @@ -340,6 +341,23 @@ try { assert.ok(!sitemap.includes("/gracias")); assert.ok(!sitemap.includes("/en/thank-you")); + const brandLogoResponse = await request( + "/brand/devrodri-wordmark-w1-black.svg", + ); + assert.equal(brandLogoResponse.status, 200, "brand logo"); + assert.equal( + brandLogoResponse.headers.get("content-type"), + "image/svg+xml", + "brand logo content type", + ); + const brandLogo = Buffer.from(await brandLogoResponse.arrayBuffer()); + assert.equal(brandLogo.byteLength, 3968, "brand logo byte length"); + assert.equal( + createHash("sha256").update(brandLogo).digest("hex"), + "3be5eaecfa9569652886dc812f4f34e6c9b1c5f4407ac6e476b16300fed9715f", + "brand logo SHA-256", + ); + const homeHtml = await readFile( path.join(projectRoot, "dist", "index.html"), "utf8", diff --git a/scripts/verify-prerender.mjs b/scripts/verify-prerender.mjs index 0a03879..8980cd2 100644 --- a/scripts/verify-prerender.mjs +++ b/scripts/verify-prerender.mjs @@ -279,22 +279,38 @@ const expectedStructuredData = new Map([ [ "/", { - types: ["WebSite", "Person", "EducationalOccupationalCredential"], + types: [ + "WebSite", + "Person", + "EducationalOccupationalCredential", + "Brand", + "ImageObject", + ], ids: [ "https://www.devrodri.com/#website", "https://www.devrodri.com/#person", "https://www.devrodri.com/#ibm-full-stack-credential", + "https://www.devrodri.com/#brand", + "https://www.devrodri.com/#brand-logo", ], }, ], [ "/en", { - types: ["WebSite", "Person", "EducationalOccupationalCredential"], + types: [ + "WebSite", + "Person", + "EducationalOccupationalCredential", + "Brand", + "ImageObject", + ], ids: [ "https://www.devrodri.com/#website", "https://www.devrodri.com/#person", "https://www.devrodri.com/#ibm-full-stack-credential", + "https://www.devrodri.com/#brand", + "https://www.devrodri.com/#brand-logo", ], }, ], @@ -877,6 +893,52 @@ for (const route of expectedRoutes) { personContent.jobTitle, `${route.pathname}: Person jobTitle`, ); + assert.deepEqual(person.sameAs, [ + "https://github.com/devrodri-com", + "https://www.linkedin.com/in/rodrigo-opalo-b56685390/", + ]); + assert.deepEqual(person.brand, { + "@id": "https://www.devrodri.com/#brand", + }); + + const website = parsed["@graph"].find( + (node) => node["@type"] === "WebSite", + ); + assert.deepEqual(website.creator, { + "@id": "https://www.devrodri.com/#person", + }); + assert.deepEqual(website.publisher, { + "@id": "https://www.devrodri.com/#person", + }); + assert.deepEqual(website.mainEntity, { + "@id": "https://www.devrodri.com/#person", + }); + assert.deepEqual(website.about, { + "@id": "https://www.devrodri.com/#brand", + }); + + const brand = parsed["@graph"].find( + (node) => node["@type"] === "Brand", + ); + assert.deepEqual(brand, { + "@type": "Brand", + "@id": "https://www.devrodri.com/#brand", + name: "devrodri", + url: "https://www.devrodri.com/", + owner: { "@id": "https://www.devrodri.com/#person" }, + logo: { "@id": "https://www.devrodri.com/#brand-logo" }, + }); + + const brandLogo = parsed["@graph"].find( + (node) => node["@type"] === "ImageObject", + ); + assert.deepEqual(brandLogo, { + "@type": "ImageObject", + "@id": "https://www.devrodri.com/#brand-logo", + contentUrl: + "https://www.devrodri.com/brand/devrodri-wordmark-w1-black.svg", + encodingFormat: "image/svg+xml", + }); } const hash = sha256Source(jsonLd); liveInlineScriptHashes.add(hash); @@ -896,6 +958,10 @@ for (const route of expectedRoutes) { '"review"', '"operatingSystem"', "SoftwareApplication", + '"@type":"Organization"', + '"@type":"ProfessionalService"', + '"@type":"LocalBusiness"', + '"@type":"ProfilePage"', ]) { assert.ok(!jsonLd.includes(forbidden), `${route.pathname}: ${forbidden}`); } @@ -914,6 +980,34 @@ for (const route of expectedRoutes) { } } +const brandLogoBuffer = await readFile( + path.join(distDirectory, "brand", "devrodri-wordmark-w1-black.svg"), +); +assert.equal(brandLogoBuffer.byteLength, 3968, "brand logo byte length"); +assert.equal( + createHash("sha256").update(brandLogoBuffer).digest("hex"), + "3be5eaecfa9569652886dc812f4f34e6c9b1c5f4407ac6e476b16300fed9715f", + "brand logo SHA-256", +); +const brandLogoSource = brandLogoBuffer.toString("utf8"); +assert.equal(count(brandLogoSource, " route.lang === "es").length, 6); diff --git a/src/__tests__/webDeliveryPolicy.test.ts b/src/__tests__/webDeliveryPolicy.test.ts index 8f432b3..cb5c79b 100644 --- a/src/__tests__/webDeliveryPolicy.test.ts +++ b/src/__tests__/webDeliveryPolicy.test.ts @@ -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-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"; + "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-XA+ZeYpYjPALeWHZDLTsu9Q27JN3EwR8PVvF5Par4g8=' 'sha256-vrx1r20Rqw1cq9I7n5yLC75yDekDRRhN4KlTEbAWDuo=' '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-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"; + "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-XA+ZeYpYjPALeWHZDLTsu9Q27JN3EwR8PVvF5Par4g8=' 'sha256-vrx1r20Rqw1cq9I7n5yLC75yDekDRRhN4KlTEbAWDuo=' '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}}"; @@ -88,7 +88,7 @@ const noJavaScriptBlock = ``; 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-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"; + "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-XA+ZeYpYjPALeWHZDLTsu9Q27JN3EwR8PVvF5Par4g8=' 'sha256-vrx1r20Rqw1cq9I7n5yLC75yDekDRRhN4KlTEbAWDuo=' '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"], @@ -372,8 +372,8 @@ describe("web delivery policy", () => { [ "'self'", "https://www.googletagmanager.com", - "'sha256-O8l3yTyim5wlcCf0NH8eooMDbrLdmZb7wspJwSqm1F8='", - "'sha256-14RAKb0fSoF/0NrAPsGFzhEcabDY08YgW52+HTiwOC8='", + "'sha256-XA+ZeYpYjPALeWHZDLTsu9Q27JN3EwR8PVvF5Par4g8='", + "'sha256-vrx1r20Rqw1cq9I7n5yLC75yDekDRRhN4KlTEbAWDuo='", "'sha256-5JAfEolKKBpyKuNkJyFVP4QM03AqudwLaL6W4YE9Dow='", "'sha256-MWbnLG8L270wPoFC3v581s2nVNl/XC/TRKETtAkKpjY='", ], diff --git a/src/seo/structuredData.ts b/src/seo/structuredData.ts index d640476..04eed8a 100644 --- a/src/seo/structuredData.ts +++ b/src/seo/structuredData.ts @@ -7,6 +7,10 @@ const SITE_URL = "https://www.devrodri.com/"; export const WEBSITE_ID = "https://www.devrodri.com/#website"; export const PERSON_ID = "https://www.devrodri.com/#person"; +export const BRAND_ID = "https://www.devrodri.com/#brand"; +export const BRAND_LOGO_ID = "https://www.devrodri.com/#brand-logo"; +export const BRAND_LOGO_URL = + "https://www.devrodri.com/brand/devrodri-wordmark-w1-black.svg"; export const IBM_CREDENTIAL_ID = "https://www.devrodri.com/#ibm-full-stack-credential"; export const LEM_BOX_WEB_APPLICATION_ID = @@ -29,6 +33,9 @@ type WebSiteNode = { name: "devrodri"; inLanguage: readonly ["es", "en"]; creator: Reference; + publisher: Reference; + mainEntity: Reference; + about: Reference; }; type PersonNode = { @@ -44,6 +51,7 @@ type PersonNode = { "https://www.linkedin.com/in/rodrigo-opalo-b56685390/", ]; hasCredential: Reference; + brand: Reference; }; type CredentialNode = { @@ -55,6 +63,22 @@ type CredentialNode = { description: string; }; +type BrandNode = { + "@type": "Brand"; + "@id": typeof BRAND_ID; + name: "devrodri"; + url: typeof SITE_URL; + owner: Reference; + logo: Reference; +}; + +type BrandLogoNode = { + "@type": "ImageObject"; + "@id": typeof BRAND_LOGO_ID; + contentUrl: typeof BRAND_LOGO_URL; + encodingFormat: "image/svg+xml"; +}; + type CreativeWorkNode = { "@type": "CreativeWork"; "@id": string; @@ -80,7 +104,13 @@ type WebApplicationNode = { export type HomeStructuredData = { "@context": typeof SCHEMA_CONTEXT; - "@graph": readonly [WebSiteNode, PersonNode, CredentialNode]; + "@graph": readonly [ + WebSiteNode, + PersonNode, + CredentialNode, + BrandNode, + BrandLogoNode, + ]; }; export type LemBoxStructuredData = { @@ -122,6 +152,9 @@ function createHomeStructuredData(locale: Language): HomeStructuredData { name: "devrodri", inLanguage: ["es", "en"], creator: reference(PERSON_ID), + publisher: reference(PERSON_ID), + mainEntity: reference(PERSON_ID), + about: reference(BRAND_ID), }, { "@type": "Person", @@ -136,6 +169,7 @@ function createHomeStructuredData(locale: Language): HomeStructuredData { "https://www.linkedin.com/in/rodrigo-opalo-b56685390/", ], hasCredential: reference(IBM_CREDENTIAL_ID), + brand: reference(BRAND_ID), }, { "@type": "EducationalOccupationalCredential", @@ -145,6 +179,20 @@ function createHomeStructuredData(locale: Language): HomeStructuredData { url: IBM_CREDLY_URL, description: credentialDescriptions[locale], }, + { + "@type": "Brand", + "@id": BRAND_ID, + name: "devrodri", + url: SITE_URL, + owner: reference(PERSON_ID), + logo: reference(BRAND_LOGO_ID), + }, + { + "@type": "ImageObject", + "@id": BRAND_LOGO_ID, + contentUrl: BRAND_LOGO_URL, + encodingFormat: "image/svg+xml", + }, ], }; } diff --git a/src/test/HeadHydration.test.tsx b/src/test/HeadHydration.test.tsx index 2aa8098..7bcbfa6 100644 --- a/src/test/HeadHydration.test.tsx +++ b/src/test/HeadHydration.test.tsx @@ -1,9 +1,12 @@ import type { FilledContext } from "react-helmet-async"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + BRAND_ID, + BRAND_LOGO_ID, PERSON_DESCRIPTIONS, PERSON_ID, PERSON_JOB_TITLES, + WEBSITE_ID, } from "../seo/structuredData"; type JsonLdNode = { "@type": string; "@id"?: string } & Record< @@ -13,27 +16,37 @@ type JsonLdNode = { "@type": string; "@id"?: string } & Record< type JsonLdGraph = { "@graph": JsonLdNode[] }; const routes = [ - { hasJsonLd: true, locale: "es" as const, pathname: "/" }, - { hasJsonLd: true, locale: "en" as const, pathname: "/en" }, - { hasJsonLd: false, locale: "es" as const, pathname: "/servicios" }, - { hasJsonLd: false, locale: "en" as const, pathname: "/en/services" }, + { graphKind: "home" as const, locale: "es" as const, pathname: "/" }, + { graphKind: "home" as const, locale: "en" as const, pathname: "/en" }, { - hasJsonLd: false, + graphKind: "lemBox" as const, + locale: "es" as const, + pathname: "/portfolio/lem-box", + }, + { + graphKind: "lemBox" as const, + locale: "en" as const, + pathname: "/en/portfolio/lem-box", + }, + { graphKind: null, locale: "es" as const, pathname: "/servicios" }, + { graphKind: null, locale: "en" as const, pathname: "/en/services" }, + { + graphKind: null, locale: "es" as const, pathname: "/servicios/sitios-web-para-empresas", }, { - hasJsonLd: false, + graphKind: null, locale: "en" as const, pathname: "/en/services/business-websites", }, { - hasJsonLd: false, + graphKind: null, locale: "es" as const, pathname: "/servicios/sistemas-a-medida", }, { - hasJsonLd: false, + graphKind: null, locale: "en" as const, pathname: "/en/services/custom-software", }, @@ -58,6 +71,39 @@ function personNode(script: Element | undefined): JsonLdNode { return person; } +function expectUniqueGraphNodes( + script: Element | undefined, + graphKind: "home" | "lemBox", +) { + const graph = JSON.parse(script?.textContent ?? "null") as JsonLdGraph; + const expectedTypes = graphKind === "home" + ? [ + "WebSite", + "Person", + "EducationalOccupationalCredential", + "Brand", + "ImageObject", + ] + : ["CreativeWork", "WebApplication"]; + + expect(graph["@graph"].map((node) => node["@type"])).toEqual(expectedTypes); + for (const type of expectedTypes) { + expect(graph["@graph"].filter((node) => node["@type"] === type)) + .toHaveLength(1); + } + + if (graphKind === "home") { + expect(graph["@graph"].find((node) => node["@type"] === "WebSite")?.["@id"]) + .toBe(WEBSITE_ID); + expect(graph["@graph"].find((node) => node["@type"] === "Person")?.["@id"]) + .toBe(PERSON_ID); + expect(graph["@graph"].find((node) => node["@type"] === "Brand")?.["@id"]) + .toBe(BRAND_ID); + expect(graph["@graph"].find((node) => node["@type"] === "ImageObject")?.["@id"]) + .toBe(BRAND_LOGO_ID); + } +} + // 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 @@ -106,7 +152,8 @@ afterEach(() => { describe("prerendered head hydration", () => { it.each(routes)( "hydrates $pathname without duplicating the head or the Person node", - async ({ hasJsonLd, locale, pathname }) => { + async ({ graphKind, locale, pathname }) => { + const hasJsonLd = graphKind !== null; const { appHtml, head } = await renderPrerenderedHead(pathname); document.head.innerHTML = head; @@ -116,10 +163,13 @@ describe("prerendered head hydration", () => { expect(prerenderedScripts).toHaveLength(hasJsonLd ? 1 : 0); if (hasJsonLd) { 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]); + expectUniqueGraphNodes(prerenderedScripts[0], graphKind); + if (graphKind === "home") { + 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"); @@ -183,10 +233,13 @@ describe("prerendered head hydration", () => { const hydratedScripts = jsonLdScripts(); expect(hydratedScripts).toHaveLength(hasJsonLd ? 1 : 0); if (hasJsonLd) { - 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]); + expectUniqueGraphNodes(hydratedScripts[0], graphKind); + if (graphKind === "home") { + 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(); diff --git a/src/test/structuredData.test.ts b/src/test/structuredData.test.ts index 2bc0445..774564c 100644 --- a/src/test/structuredData.test.ts +++ b/src/test/structuredData.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; import { + BRAND_ID, + BRAND_LOGO_ID, + BRAND_LOGO_URL, IBM_CREDENTIAL_ID, IBM_CREDLY_URL, LEM_BOX_CASE_STUDY_IDS, @@ -36,6 +39,13 @@ const forbiddenLemBoxProperties = [ "operatingSystem", ] as const; +const forbiddenSchemaTypes = [ + "Organization", + "ProfessionalService", + "LocalBusiness", + "ProfilePage", +] as const; + function graphTypes(value: { "@graph": readonly { "@type": string }[] }) { return value["@graph"].map((node) => node["@type"]); } @@ -70,7 +80,7 @@ describe("route structured data", () => { "publishes one connected, prudent Home graph in %s", (locale) => { const graph = STRUCTURED_DATA_BY_ROUTE[`home:${locale}`]; - const [website, person, credential] = graph["@graph"]; + const [website, person, credential, brand, brandLogo] = graph["@graph"]; expect(JSON.parse(JSON.stringify(graph))).toEqual(graph); expect(graph["@context"]).toBe("https://schema.org"); @@ -78,6 +88,8 @@ describe("route structured data", () => { "WebSite", "Person", "EducationalOccupationalCredential", + "Brand", + "ImageObject", ]); expect(website).toEqual({ "@type": "WebSite", @@ -86,6 +98,9 @@ describe("route structured data", () => { name: "devrodri", inLanguage: ["es", "en"], creator: { "@id": PERSON_ID }, + publisher: { "@id": PERSON_ID }, + mainEntity: { "@id": PERSON_ID }, + about: { "@id": BRAND_ID }, }); expect(person).toEqual({ "@type": "Person", @@ -100,6 +115,7 @@ describe("route structured data", () => { "https://www.linkedin.com/in/rodrigo-opalo-b56685390/", ], hasCredential: { "@id": IBM_CREDENTIAL_ID }, + brand: { "@id": BRAND_ID }, }); expect(person.jobTitle).toBe( locale === "es" @@ -111,18 +127,41 @@ describe("route structured data", () => { ? "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({ + expect(credential).toEqual({ "@type": "EducationalOccupationalCredential", "@id": IBM_CREDENTIAL_ID, name: "IBM Full Stack Software Developer Professional Certificate (V5)", credentialCategory: "Professional Certificate", url: IBM_CREDLY_URL, + description: + locale === "es" + ? "Certificación profesional verificada por IBM Skills Network y Credly." + : "Professional certification verified by IBM Skills Network and Credly.", }); expect(credential.description).toBe( locale === "es" ? "Certificación profesional verificada por IBM Skills Network y Credly." : "Professional certification verified by IBM Skills Network and Credly.", ); + expect(brand).toEqual({ + "@type": "Brand", + "@id": BRAND_ID, + name: "devrodri", + url: "https://www.devrodri.com/", + owner: { "@id": PERSON_ID }, + logo: { "@id": BRAND_LOGO_ID }, + }); + expect(brandLogo).toEqual({ + "@type": "ImageObject", + "@id": BRAND_LOGO_ID, + contentUrl: BRAND_LOGO_URL, + encodingFormat: "image/svg+xml", + }); + + for (const type of ["WebSite", "Person", "Brand", "ImageObject"]) { + expect(graph["@graph"].filter((node) => node["@type"] === type)) + .toHaveLength(1); + } const source = JSON.stringify(graph); expect(source).not.toContain("Meta React"); @@ -130,6 +169,9 @@ describe("route structured data", () => { for (const property of forbiddenHomeProperties) { expect(source).not.toContain(`"${property}"`); } + for (const type of forbiddenSchemaTypes) { + expect(source).not.toContain(`"@type":"${type}"`); + } }, ); @@ -175,6 +217,18 @@ describe("route structured data", () => { expect(source).not.toContain("SoftwareApplication"); expect(source).not.toContain("Android"); expect(source).not.toContain("iOS"); + for (const type of forbiddenSchemaTypes) { + expect(source).not.toContain(`"@type":"${type}"`); + } }, ); + + it("keeps the ES and EN LEM-BOX graphs serialized exactly", () => { + expect(JSON.stringify(STRUCTURED_DATA_BY_ROUTE["lem-box:es"])).toBe( + '{"@context":"https://schema.org","@graph":[{"@type":"CreativeWork","@id":"https://www.devrodri.com/portfolio/lem-box#case-study","name":"LEM-BOX: plataforma logística y producto propio","description":"LEM-BOX es un negocio logístico con más de 10 años de trayectoria. Su ecosistema digital actual forma parte de una evolución más reciente y conecta los sitios comerciales de Uruguay y Argentina con una plataforma central utilizada por clientes, partners y el equipo operativo.","url":"https://www.devrodri.com/portfolio/lem-box","image":"https://www.devrodri.com/img/lem-box-cover.png","inLanguage":"es","author":{"@id":"https://www.devrodri.com/#person"},"isPartOf":{"@id":"https://www.devrodri.com/#website"},"about":{"@id":"https://www.devrodri.com/#lem-box-web-application"}},{"@type":"WebApplication","@id":"https://www.devrodri.com/#lem-box-web-application","name":"LEM-BOX","url":"https://lem-box.com","description":"Diseñé y desarrollé una plataforma central conectada con las superficies comerciales de cada mercado. El resultado es un ecosistema donde la información acompaña el recorrido desde la captación hasta la operación y el seguimiento.","applicationCategory":"BusinessApplication","creator":{"@id":"https://www.devrodri.com/#person"}}]}', + ); + expect(JSON.stringify(STRUCTURED_DATA_BY_ROUTE["lem-box:en"])).toBe( + '{"@context":"https://schema.org","@graph":[{"@type":"CreativeWork","@id":"https://www.devrodri.com/en/portfolio/lem-box#case-study","name":"LEM-BOX: logistics platform and own product","description":"LEM-BOX is a logistics business with more than 10 years of experience. Its current digital ecosystem is part of a more recent evolution and connects the commercial websites for Uruguay and Argentina with a central platform used by customers, partners, and the operations team.","url":"https://www.devrodri.com/en/portfolio/lem-box","image":"https://www.devrodri.com/img/lem-box-cover.png","inLanguage":"en","author":{"@id":"https://www.devrodri.com/#person"},"isPartOf":{"@id":"https://www.devrodri.com/#website"},"about":{"@id":"https://www.devrodri.com/#lem-box-web-application"}},{"@type":"WebApplication","@id":"https://www.devrodri.com/#lem-box-web-application","name":"LEM-BOX","url":"https://lem-box.com","description":"I designed and developed a central platform connected to the commercial surfaces of each market. The result is an ecosystem where information follows the journey from acquisition through operations and tracking.","applicationCategory":"BusinessApplication","creator":{"@id":"https://www.devrodri.com/#person"}}]}', + ); + }); }); diff --git a/vercel.json b/vercel.json index ed659a1..5696a51 100644 --- a/vercel.json +++ b/vercel.json @@ -24,7 +24,7 @@ }, { "key": "Content-Security-Policy", - "value": "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" + "value": "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-XA+ZeYpYjPALeWHZDLTsu9Q27JN3EwR8PVvF5Par4g8=' 'sha256-vrx1r20Rqw1cq9I7n5yLC75yDekDRRhN4KlTEbAWDuo=' '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" } ] }, @@ -68,7 +68,7 @@ "Referrer-Policy": "strict-origin-when-cross-origin", "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()", "X-Frame-Options": "DENY", - "Content-Security-Policy": "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" + "Content-Security-Policy": "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-XA+ZeYpYjPALeWHZDLTsu9Q27JN3EwR8PVvF5Par4g8=' 'sha256-vrx1r20Rqw1cq9I7n5yLC75yDekDRRhN4KlTEbAWDuo=' '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" } } ]