Files
skill-tree-portfolio/src/lib/components/SeoContent.svelte
T
TehRiehlDealandClaude Opus 4.8 df78dc694f 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>
2026-06-03 11:16:17 -07:00

123 lines
3.9 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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}