JSON-LD
Build and Deploy / build-deploy (push) Successful in 1m5s

This commit is contained in:
2026-07-04 21:57:30 +01:00
parent fd32f1b1cf
commit 6366dd03e5
12 changed files with 305 additions and 26 deletions
+5
View File
@@ -25,6 +25,7 @@
"@types/react-dom": "^19",
"@types/three": "^0.183.0",
"babel-plugin-react-compiler": "1.0.0",
"schema-dts": "^2.0.0",
"tailwindcss": "^4",
"typescript": "^5",
},
@@ -723,6 +724,10 @@
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"schema-dts": ["schema-dts@2.0.0", "", { "dependencies": { "schema-dts-lib": "^1.0.0" } }, "sha512-t7NoCy3Rn5GHGx6p7s1qIYK/AeIb8ZxJNR9WUNFkwMv2CiiGZBmqqYWc2FlZVm5ZbiHMY4OvBWhj7QtyrFO2Jw=="],
"schema-dts-lib": ["schema-dts-lib@1.0.0", "", { "peerDependencies": { "typescript": ">=4.9.5" } }, "sha512-9MEO5vpQH9JdBioUupqluzxSYxPLjhmqRUudk15adUl/ypnRsM2/M1kN3AmVJQeG7nZqcL68H8JlGqQQT6vy9A=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
+1
View File
@@ -30,6 +30,7 @@
"@types/react-dom": "^19",
"@types/three": "^0.183.0",
"babel-plugin-react-compiler": "1.0.0",
"schema-dts": "^2.0.0",
"tailwindcss": "^4",
"typescript": "^5"
},
+3 -21
View File
@@ -3,6 +3,8 @@ import { Courier_Prime, Outfit, Space_Grotesk } from "next/font/google";
import type { ReactNode } from "react";
import "./globals.css";
import { MotionProvider } from "@/components/motion-provider";
import { graph } from "@/lib/schema";
import { SITE_DESCRIPTION, SITE_TITLE, SITE_URL } from "@/content/site";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
@@ -23,11 +25,6 @@ const courierPrime = Courier_Prime({
display: "swap",
});
const SITE_URL = "https://luisdralves.dev";
const SITE_TITLE = "luisdralves";
const SITE_DESCRIPTION =
"Personal site of Luís Alves. Building systems that connect, things to preserve, new ways to see familiar things. Easier to show than tell.";
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: SITE_TITLE,
@@ -57,21 +54,6 @@ export const metadata: Metadata = {
robots: { index: true, follow: true },
};
const personSchema = {
"@context": "https://schema.org",
"@type": "Person",
name: "Luís Alves",
alternateName: "luisdralves",
url: SITE_URL,
sameAs: [
"https://github.com/luisdralves",
"https://gitea.luisdralves.dev/luis",
"https://linkedin.com/in/luisdralves",
],
jobTitle: "Software Engineer",
description: SITE_DESCRIPTION,
};
export default function RootLayout({
children,
}: Readonly<{
@@ -121,7 +103,7 @@ export default function RootLayout({
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: required for JSON-LD schema markup
dangerouslySetInnerHTML={{ __html: JSON.stringify(personSchema) }}
dangerouslySetInnerHTML={{ __html: JSON.stringify(graph) }}
/>
<MotionProvider>{children}</MotionProvider>
</body>
+1 -2
View File
@@ -1,6 +1,5 @@
import type { MetadataRoute } from "next";
const SITE_URL = "https://luisdralves.dev";
import { SITE_URL } from "@/content/site";
export default function robots(): MetadataRoute.Robots {
return {
+3 -1
View File
@@ -6,6 +6,7 @@ import DocumentIcon from "@/components/icons/document.svg";
import GiteaIcon from "@/components/icons/gitea.svg";
import GitHubIcon from "@/components/icons/github.svg";
import LinkedInIcon from "@/components/icons/linkedin.svg";
import MailIcon from "@/components/icons/mail.svg";
import SourceIcon from "@/components/icons/source.svg";
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
@@ -19,6 +20,7 @@ type SocialLinksProps = {
};
const PLATFORM_ICONS: Record<string, FC<SVGProps<SVGSVGElement>>> = {
Email: MailIcon,
GitHub: GitHubIcon,
Gitea: GiteaIcon,
LinkedIn: LinkedInIcon,
@@ -41,7 +43,7 @@ const SocialLink = ({ link }: { link: Link }) => {
href={link.url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-2 rounded-full border border-foreground/15 bg-foreground/[0.02] px-5 py-2.5 font-body text-foreground/80 text-sm transition-colors hover:border-accent-cyan/40 hover:text-accent-cyan"
className="inline-flex items-center gap-2 rounded-full border border-foreground/15 bg-foreground/2 px-5 py-2.5 font-body text-foreground/80 text-sm transition-colors hover:border-accent-cyan/40 hover:text-accent-cyan"
>
<Icon className="size-4" />
<span>{link.platform}</span>
+1 -2
View File
@@ -1,6 +1,5 @@
import type { MetadataRoute } from "next";
const SITE_URL = "https://luisdralves.dev";
import { SITE_URL } from "@/content/site";
export default function sitemap(): MetadataRoute.Sitemap {
return [
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" aria-hidden="true" viewBox="0 0 24 24">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>

After

Width:  |  Height:  |  Size: 298 B

+6
View File
@@ -10,8 +10,14 @@ export type FooterContent = {
errorMessage: string;
};
import { person } from "@/content/person";
export const footer = {
socialLinks: [
{
platform: "Email",
url: `mailto:${person.email}`,
},
{
platform: "GitHub",
url: "https://github.com/luisdralves",
+138
View File
@@ -0,0 +1,138 @@
export type PersonContent = {
name: string;
alternateName: string;
givenName: string;
familyName: string;
jobTitle: string;
email: string;
image: string;
/** BCP-47 language tags, most to least proficient. */
knowsLanguage: readonly string[];
knowsAbout: readonly string[];
nationality: string;
homeLocation: {
locality: string;
/** ISO 3166-1 alpha-2 country code. */
countryCode: string;
};
worksFor: {
name: string;
url: string;
sameAs: readonly string[];
};
alumnusOf: {
name: string;
alternateName: string;
url: string;
sameAs: readonly string[];
parentOrganization: {
name: string;
url: string;
sameAs: readonly string[];
};
};
memberOf: {
name: string;
alternateName: string;
url: string;
role: string;
sameAs: readonly string[];
};
};
export const person = {
name: "Luís Alves",
alternateName: "luisdralves",
givenName: "Luís",
familyName: "Alves",
jobTitle: "Senior Frontend Engineer",
email: "hello@luisdralves.dev",
image: "https://avatars.githubusercontent.com/u/22676183?s=460",
knowsLanguage: ["pt", "en", "nl"],
knowsAbout: [
"Frontend engineering",
"TypeScript",
"JavaScript",
"Rust",
"Python",
"React",
"Next.js",
"React Native",
"Svelte",
"TanStack Query",
"tRPC",
"WebSockets",
"Server-Sent Events",
"Tailwind CSS",
"CSS Modules",
"styled-components",
"Vite",
"Bun",
"Monorepo tooling",
"WebGL",
"GLSL shaders",
"Three.js",
"Canvas API",
"Mapbox",
"Directus",
"Vitest",
"Cypress",
"Storybook",
"PostgreSQL",
"Redis",
"SQLite",
"Prisma",
"Drizzle",
"Docker",
"Caddy",
"Linux",
"GitHub Actions",
"Model Context Protocol",
"Embeddings and vector search",
"Web performance",
"Self-hosting",
],
nationality: "Portugal",
homeLocation: {
locality: "Viana do Castelo",
countryCode: "PT",
},
worksFor: {
name: "Untile",
url: "https://untile.pt",
sameAs: [
"https://www.linkedin.com/company/untiledigital/",
"https://github.com/untile",
"https://www.instagram.com/untile.digital/",
],
},
alumnusOf: {
name: "Faculty of Engineering of the University of Porto",
alternateName: "FEUP",
url: "https://fe.up.pt",
sameAs: [
"https://www.wikidata.org/wiki/Q5428834",
"https://www.linkedin.com/school/feup/",
"https://www.instagram.com/feup_porto/",
],
parentOrganization: {
name: "University of Porto",
url: "https://www.up.pt",
sameAs: [
"https://en.wikipedia.org/wiki/University_of_Porto",
"https://www.wikidata.org/wiki/Q209842",
],
},
},
memberOf: {
name: "Associação Juvenil de Deão",
alternateName: "AJD",
url: "https://ajdeao.pt",
role: "President",
sameAs: [
"https://www.linkedin.com/company/associacao-juvenil-de-deao",
"https://www.instagram.com/ajdeao",
"https://www.facebook.com/assocjuvenildeao",
],
},
} as const satisfies PersonContent;
+2
View File
@@ -25,6 +25,7 @@ export type Project = {
description: string;
techStack: string[];
liveUrl?: string;
os?: string;
sources?: ProjectSource[];
media: ProjectMedia[];
/** OKLCH "L C H" triplet (space-separated, no parentheses). Bound as --project-accent. */
@@ -162,6 +163,7 @@ export const projects = [
description:
"15+ services on hardware I own, behind a reverse proxy. Email, photos, code, media, dashboards, personal projects. A repurposed gaming desktop that ran out of room in two different ways: no free space left on its disks and no free bays left inside the case. Shown here mid-upgrade. Don't worry about the cables, they don't look like this now :)",
techStack: ["Linux", "Docker", "Caddy"],
os: "Linux",
media: [
{
type: "image",
+9
View File
@@ -0,0 +1,9 @@
export const SITE_URL = "https://luisdralves.dev";
export const SITE_TITLE = "luisdralves";
export const SITE_DESCRIPTION =
"Hi, Luís here. Welcome to my personal website. I'm a senior frontend engineer, six years in. I build tactile, slightly obsessive web experiments as well as practical solutions, and enjoy self-hosting anything that's critical to me, or just convenient.";
export const SITE_DESCRIPTION_OBJECTIVE =
"Luís Alves is a senior frontend engineer with six years of experience. He builds tactile web experiments as well as practical solutions, and self-hosts both his own projects and the critical services he depends on.";
export const SITE_CREATED = "2026-02-19T00:12:21Z";
+132
View File
@@ -0,0 +1,132 @@
import type {
Graph,
Person,
ProfilePage,
SoftwareApplication,
WebSite,
} from "schema-dts";
import { footer } from "@/content/footer";
import { person } from "@/content/person";
import { type Project, projects } from "@/content/projects";
import { SITE_CREATED, SITE_DESCRIPTION_OBJECTIVE, SITE_TITLE, SITE_URL } from "@/content/site";
const PERSON_ID = `${SITE_URL}/#person`;
const WEBSITE_ID = `${SITE_URL}/#website`;
const WEBPAGE_ID = `${SITE_URL}/#webpage`;
const sameAs = footer.socialLinks
.map((link) => link.url)
.filter((url) => url.startsWith("http"));
const websiteNode: WebSite = {
"@type": "WebSite",
"@id": WEBSITE_ID,
url: `${SITE_URL}/`,
name: SITE_TITLE,
alternateName: [person.name, new URL(SITE_URL).hostname],
description: SITE_DESCRIPTION_OBJECTIVE,
inLanguage: "en",
publisher: { "@id": PERSON_ID },
image: {
"@type": "ImageObject",
"@id": `${SITE_URL}/#website-image`,
url: `${SITE_URL}/og-image.png`,
caption: person.name,
},
};
const personNode: Person = {
"@type": "Person",
"@id": PERSON_ID,
url: `${SITE_URL}/`,
name: person.name,
alternateName: person.alternateName,
givenName: person.givenName,
familyName: person.familyName,
description: SITE_DESCRIPTION_OBJECTIVE,
jobTitle: person.jobTitle,
email: `mailto:${person.email}`,
image: {
"@type": "ImageObject",
"@id": `${SITE_URL}/#person-image`,
url: person.image,
caption: person.name,
},
knowsLanguage: person.knowsLanguage,
knowsAbout: person.knowsAbout,
nationality: { "@type": "Country", name: person.nationality },
homeLocation: {
"@type": "Place",
address: {
"@type": "PostalAddress",
addressLocality: person.homeLocation.locality,
addressCountry: person.homeLocation.countryCode,
},
},
worksFor: {
"@type": "Organization",
name: person.worksFor.name,
url: person.worksFor.url,
sameAs: person.worksFor.sameAs,
},
// I can't express how much it pains me to refer to myself (ourselves?) in the plural
alumniOf: {
"@type": "CollegeOrUniversity",
name: person.alumnusOf.name,
alternateName: person.alumnusOf.alternateName,
url: person.alumnusOf.url,
sameAs: person.alumnusOf.sameAs,
parentOrganization: {
"@type": "CollegeOrUniversity",
name: person.alumnusOf.parentOrganization.name,
url: person.alumnusOf.parentOrganization.url,
sameAs: person.alumnusOf.parentOrganization.sameAs,
},
},
memberOf: {
"@type": "OrganizationRole",
roleName: person.memberOf.role,
memberOf: {
"@type": "Organization",
name: person.memberOf.name,
alternateName: person.memberOf.alternateName,
url: person.memberOf.url,
sameAs: person.memberOf.sameAs,
},
},
sameAs,
};
const profilePageNode: ProfilePage = {
"@type": "ProfilePage",
"@id": WEBPAGE_ID,
url: `${SITE_URL}/`,
name: `${SITE_TITLE}${person.name}`,
isPartOf: { "@id": WEBSITE_ID },
inLanguage: "en",
dateCreated: SITE_CREATED,
dateModified: new Date().toISOString(),
about: { "@id": PERSON_ID },
mainEntity: { "@id": PERSON_ID },
};
const softwareApplications = projects.map(
(project: Project): SoftwareApplication => ({
"@type": "SoftwareApplication",
"@id": `${SITE_URL}/#project-${project.name}`,
name: project.name,
description: project.description,
applicationCategory: project.liveUrl ? "WebApplication" : "DeveloperApplication",
operatingSystem: project.os ?? (project.liveUrl && "Web browser"),
...(project.liveUrl && { url: project.liveUrl }),
creator: { "@id": PERSON_ID },
keywords: project.techStack.join(", "),
...(project.sources && { sameAs: project.sources.map((source) => source.url) }),
isPartOf: { "@id": WEBSITE_ID },
}),
);
export const graph: Graph = {
"@context": "https://schema.org",
"@graph": [websiteNode, personNode, profilePageNode, ...softwareApplications],
};