feat: make portfolio content crawlable + add SEO structured data

The entire portfolio rendered only on the PixiJS canvas, so crawlers saw a
near-empty body and the site ranked poorly for non-personalized searches. Fixes:

- NEW SeoContent.svelte: screen-reader/crawler-only (.sr-only) semantic HTML
  (h1 + bio + contact + Experience/Projects/Skills/Education), generated from
  data.ts (single source of truth, reuses aggregateSkills). Prerenders into
  build/index.html — verified it now contains the job/project/education text
  that was previously canvas-only. Also a big screen-reader accessibility win.
- JSON-LD schema.org Person in +layout (name, jobTitle, alumniOf, knowsAbout,
  sameAs LinkedIn/GitHub) — strong entity signal for name searches.
- Canonical URL; static/sitemap.xml + robots.txt Sitemap reference.
- Keyword-tuned <title>, description, og/twitter titles (name + role + stack).
- Demoted the transient welcome <h1> to a <div> so SeoContent's <h1> is the
  single canonical heading. Bumped SW cache to v2.

.sr-only utility added to app.css. check/lint/test/build clean; verified no
visual regression and no runtime exceptions (background + canvas unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
TehRiehlDealandClaude Opus 4.8 committed 2026-06-03 11:16:17 -07:00
1 parent 6f17cba9c5
commit df78dc694f
9 files changed
+217 -13

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
// Screen-reader / crawler-only rendition of the portfolio.
//
// The interactive tree lives entirely on a PixiJS canvas, which search
// engines and assistive tech can't read. This component renders the same
// content (sourced from data.ts — the single source of truth) as real,
// prerendered semantic HTML, visually hidden via the global `.sr-only` class.
import {
treeNodes,
type ExperienceNode,
type ProjectNode,
type EducationNode,
type OriginNode
} from '$lib/data';
import { aggregateSkills, BRANCH_IDS } from '$lib/utils/stats';
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
// Leaf nodes only — container/branch nodes lack these category fields.
const jobs = treeNodes.filter(
(n): n is ExperienceNode => n.category === 'experience' && !!n.company
);
const projects = treeNodes.filter(
(n): n is ProjectNode => n.category === 'projects' && !!n.techStack?.length
);
const education = treeNodes.filter(
(n): n is EducationNode => n.category === 'education' && !!n.institution
);
// Newest experience first (reverse-chronological, résumé convention).
const jobsNewestFirst = [...jobs].reverse();
const skillsByBranch = aggregateSkills();
</script>
{#if origin}
<section class="sr-only" aria-label="Portfolio (text version)">
<h1>{origin.fullName} — {origin.jobTitle}</h1>
{#if origin.aboutMe}<p>{origin.aboutMe}</p>{/if}
{#if origin.location}<p>Location: {origin.location}</p>{/if}
<nav aria-label="Contact and profiles">
<ul>
{#if origin.email}<li><a href="mailto:{origin.email}">Email: {origin.email}</a></li>{/if}
{#if origin.linkedIn}<li><a href={origin.linkedIn}>LinkedIn</a></li>{/if}
{#if origin.github}<li><a href={origin.github}>GitHub</a></li>{/if}
{#if origin.website}<li><a href={origin.website}>Website</a></li>{/if}
{#if origin.resumeUrl}<li><a href={origin.resumeUrl}>Résumé (PDF)</a></li>{/if}
</ul>
</nav>
<section aria-label="Work experience">
<h2>Work Experience</h2>
{#each jobsNewestFirst as job (job.id)}
<article>
<h3>{job.jobTitle} — {job.company}</h3>
{#if job.startDate}<p>{job.startDate} – {job.endDate || 'Present'}</p>{/if}
{#if job.highlights?.length}
<ul>
{#each job.highlights as highlight (highlight)}
<li>{highlight}</li>
{/each}
</ul>
{/if}
</article>
{/each}
</section>
<section aria-label="Projects">
<h2>Projects</h2>
{#each projects as project (project.id)}
<article>
<h3>{project.label}</h3>
<p>{project.description}</p>
{#if project.techStack?.length}
<p>Tech: {project.techStack.join(', ')}</p>
{/if}
<ul>
{#if project.projectUrl}<li><a href={project.projectUrl}>Live site</a></li>{/if}
{#if project.repoUrl}<li><a href={project.repoUrl}>Source code</a></li>{/if}
{#if project.pypiUrl}<li><a href={project.pypiUrl}>PyPI package</a></li>{/if}
{#if project.npmUrl}<li><a href={project.npmUrl}>npm package</a></li>{/if}
</ul>
</article>
{/each}
</section>
<section aria-label="Technical skills">
<h2>Technical Skills</h2>
{#each BRANCH_IDS as branchId (branchId)}
{@const branch = skillsByBranch[branchId]}
{#if branch && branch.skills.length}
<h3>{branch.label}</h3>
<ul>
{#each branch.skills as skill (skill.id)}
<li>
{skill.label}{#if skill.proficiency}
({skill.proficiency}{#if skill.yearsOfExperience}, {skill.yearsOfExperience} years{/if})
{/if}
</li>
{/each}
</ul>
{/if}
{/each}
</section>
<section aria-label="Education">
<h2>Education</h2>
{#each education as edu (edu.id)}
<article>
<h3>
{edu.degree}{#if edu.field}
in {edu.field}{/if}
</h3>
<p>
{edu.institution}{#if edu.graduationYear}, {edu.graduationYear}{/if}
</p>
</article>
{/each}
</section>
</section>
{/if}
+7 -4
View File
@@ -1,6 +1,8 @@
<!-- Welcome title card shown during the entrance animation. -->
<!-- Welcome title card shown during the entrance animation.
Decorative + transient (fades out), so it's a <div>, not the page <h1>
(the canonical <h1> lives in SeoContent for SEO). -->
<div class="welcome-title">
<h1>Kevin Riehl</h1>
<div class="welcome-name">Kevin Riehl</div>
<p>The Atlas of Skills</p>
</div>
@@ -16,8 +18,9 @@
animation: titleFadeInOut 2.5s ease-in-out forwards;
}
.welcome-title h1 {
.welcome-title .welcome-name {
font-size: 2.5rem;
font-weight: bold;
color: var(--color-gold);
margin: 0;
text-shadow:
@@ -59,7 +62,7 @@
animation: titleFadeInOutMobile 1.5s ease-in-out forwards;
}
.welcome-title h1 {
.welcome-title .welcome-name {
font-size: 1.8rem;
}