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:
+16
@@ -31,3 +31,19 @@ body {
|
||||
overflow: hidden;
|
||||
background: var(--bg-space);
|
||||
}
|
||||
|
||||
/*
|
||||
* Visually-hidden but available to screen readers and crawlers. Used for the
|
||||
* text version of the portfolio (the interactive content is canvas-rendered).
|
||||
*/
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<title>Kevin Riehl | Interactive Skill Tree Portfolio</title>
|
||||
<title>Kevin Riehl — Software Engineer (React, Node.js, TypeScript) | Portfolio</title>
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,46 @@
|
||||
import { onMount } from 'svelte';
|
||||
import favicon from '$lib/assets/favicon.ico';
|
||||
import '../app.css';
|
||||
import { treeNodes, type OriginNode, type SkillNode, type EducationNode } from '$lib/data';
|
||||
|
||||
const { children } = $props();
|
||||
|
||||
// schema.org Person structured data, generated from data.ts (single source of
|
||||
// truth) so it stays in sync. Ties this domain to the LinkedIn/GitHub identity.
|
||||
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
||||
const knowsAbout = treeNodes
|
||||
.filter((n): n is SkillNode => n.category === 'skills' && !!n.proficiency)
|
||||
.map((n) => n.label);
|
||||
const edu = treeNodes.find(
|
||||
(n): n is EducationNode => n.category === 'education' && !!n.institution
|
||||
);
|
||||
|
||||
const personJsonLd = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: origin?.fullName,
|
||||
jobTitle: origin?.jobTitle,
|
||||
description: origin?.aboutMe,
|
||||
url: 'https://tehriehldeal.com',
|
||||
image: 'https://tehriehldeal.com/headshot.jpg',
|
||||
email: origin?.email,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Kent',
|
||||
addressRegion: 'WA',
|
||||
addressCountry: 'US'
|
||||
},
|
||||
alumniOf: edu ? { '@type': 'CollegeOrUniversity', name: edu.institution } : undefined,
|
||||
knowsAbout,
|
||||
sameAs: [origin?.linkedIn, origin?.github].filter(Boolean)
|
||||
// Escape '<' so the JSON can't break out of the <script> element.
|
||||
}).replace(/</g, '\\u003c');
|
||||
|
||||
// Wrap in the <script> tag here (close tag split across a concat) so the
|
||||
// literal closing tag never appears verbatim in source — that would either
|
||||
// terminate this block early or trip the Svelte ESLint parser.
|
||||
const personLdScript = '<script type="application/ld+json">' + personJsonLd + '</' + 'script>';
|
||||
|
||||
onMount(() => {
|
||||
// Register service worker for PWA support
|
||||
if ('serviceWorker' in navigator) {
|
||||
@@ -60,22 +97,26 @@
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icons/pwa-512x512.png" />
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<link rel="canonical" href="https://tehriehldeal.com/" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Interactive portfolio featuring a Path of Exile-inspired skill tree visualization showcasing Kevin Riehl's professional experience, projects, and technical skills."
|
||||
content="Kevin Riehl — Software Engineer with 5+ years building full-stack apps in React, Node.js, and TypeScript. Explore his experience, projects, and skills in an interactive skill-tree portfolio."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="portfolio, web developer, software engineer, interactive resume, skill tree"
|
||||
content="Kevin Riehl, software engineer, full-stack developer, React, Node.js, TypeScript, portfolio, interactive resume, skill tree"
|
||||
/>
|
||||
<meta name="author" content="Kevin Riehl" />
|
||||
|
||||
<!-- Open Graph Meta Tags for social sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Kevin Riehl - The Atlas of Skills" />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Kevin Riehl — Software Engineer (React, Node.js, TypeScript)"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Interactive portfolio featuring a Path of Exile-inspired skill tree visualization."
|
||||
content="Software Engineer with 5+ years building full-stack apps in React, Node.js, and TypeScript — shown as an interactive Path of Exile-inspired skill tree."
|
||||
/>
|
||||
<meta property="og:url" content="https://tehriehldeal.com" />
|
||||
<meta property="og:site_name" content="Atlas of Skills" />
|
||||
@@ -88,16 +129,24 @@
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Kevin Riehl - The Atlas of Skills" />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="Kevin Riehl — Software Engineer (React, Node.js, TypeScript)"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Interactive portfolio featuring a Path of Exile-inspired skill tree visualization."
|
||||
content="Software Engineer with 5+ years building full-stack apps in React, Node.js, and TypeScript — shown as an interactive Path of Exile-inspired skill tree."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://tehriehldeal.com/og-preview.jpg" />
|
||||
<meta
|
||||
name="twitter:image:alt"
|
||||
content="Kevin Riehl - The Atlas of Skills interactive portfolio"
|
||||
/>
|
||||
|
||||
<!-- Structured data (schema.org Person). Safe: built from our own data.ts
|
||||
with '<' escaped; not user input. -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html personLdScript}
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import SkillTree from '$lib/components/SkillTree.svelte';
|
||||
import SeoContent from '$lib/components/SeoContent.svelte';
|
||||
</script>
|
||||
|
||||
<!-- Crawlable / screen-reader text version (visually hidden, prerendered). -->
|
||||
<SeoContent />
|
||||
|
||||
<main>
|
||||
<SkillTree />
|
||||
</main>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
||||
Sitemap: https://tehriehldeal.com/sitemap.xml
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Service Worker for PWA offline support
|
||||
const CACHE_NAME = 'atlas-of-skills-v1';
|
||||
const RUNTIME_CACHE = 'atlas-runtime-v1';
|
||||
const CACHE_NAME = 'atlas-of-skills-v2';
|
||||
const RUNTIME_CACHE = 'atlas-runtime-v2';
|
||||
|
||||
// Assets to cache on install
|
||||
const PRECACHE_ASSETS = [
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://tehriehldeal.com/</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
Reference in New Issue
Block a user