fix: bind section labels by canonical id
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
import ContactDestination from './ContactDestination.vue';
|
import ContactDestination from './ContactDestination.vue';
|
||||||
import ExperienceTimeline from './ExperienceTimeline.vue';
|
import ExperienceTimeline from './ExperienceTimeline.vue';
|
||||||
import InfrastructureDiagram from './InfrastructureDiagram.vue';
|
import InfrastructureDiagram from './InfrastructureDiagram.vue';
|
||||||
@@ -7,9 +8,25 @@ import ProjectCard from './ProjectCard.vue';
|
|||||||
import ProjectCaseStudy from './ProjectCaseStudy.vue';
|
import ProjectCaseStudy from './ProjectCaseStudy.vue';
|
||||||
import SiteNavigation from './SiteNavigation.vue';
|
import SiteNavigation from './SiteNavigation.vue';
|
||||||
import ToolkitGrid from './ToolkitGrid.vue';
|
import ToolkitGrid from './ToolkitGrid.vue';
|
||||||
import type { PortfolioContent } from '@/content/types';
|
import type { PortfolioContent, SectionId } from '@/content/types';
|
||||||
|
|
||||||
defineProps<{ content: Readonly<PortfolioContent> }>();
|
const props = defineProps<{ content: Readonly<PortfolioContent> }>();
|
||||||
|
|
||||||
|
const canonicalSectionIds = [
|
||||||
|
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||||
|
] as const satisfies readonly SectionId[];
|
||||||
|
|
||||||
|
const navigationById = computed(() => new Map(props.content.navigation.map((item) => [item.id, item])));
|
||||||
|
|
||||||
|
const sectionLabel = (id: SectionId): string => {
|
||||||
|
const item = navigationById.value.get(id);
|
||||||
|
if (item === undefined) {
|
||||||
|
throw new Error(`Missing canonical navigation label: ${id}`);
|
||||||
|
}
|
||||||
|
return item.label;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canonicalNavigation = computed(() => canonicalSectionIds.map((id) => ({ id, label: sectionLabel(id) })));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -17,15 +34,15 @@ defineProps<{ content: Readonly<PortfolioContent> }>();
|
|||||||
<header>
|
<header>
|
||||||
<h1>{{ content.identity.name }}</h1>
|
<h1>{{ content.identity.name }}</h1>
|
||||||
<p>{{ content.identity.role }}</p>
|
<p>{{ content.identity.role }}</p>
|
||||||
<SiteNavigation :items="content.navigation" />
|
<SiteNavigation :items="canonicalNavigation" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="portfolio-content">
|
<main id="portfolio-content">
|
||||||
<PortfolioSection id="start" :index="1" :eyebrow="content.navigation[0]!.label.toUpperCase()" :heading="content.navigation[0]!.label.toUpperCase()">
|
<PortfolioSection id="start" :index="1" :eyebrow="sectionLabel('start').toUpperCase()" :heading="sectionLabel('start').toUpperCase()">
|
||||||
<p>{{ content.identity.valueProposition }}</p>
|
<p>{{ content.identity.valueProposition }}</p>
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="profile" :index="2" :eyebrow="content.navigation[1]!.label.toUpperCase()" :heading="content.navigation[1]!.label.toUpperCase()">
|
<PortfolioSection id="profile" :index="2" :eyebrow="sectionLabel('profile').toUpperCase()" :heading="sectionLabel('profile').toUpperCase()">
|
||||||
<p>{{ content.profile.introduction }}</p>
|
<p>{{ content.profile.introduction }}</p>
|
||||||
<dl>
|
<dl>
|
||||||
<template v-for="fact in content.profile.screeningFacts" :key="fact.label">
|
<template v-for="fact in content.profile.screeningFacts" :key="fact.label">
|
||||||
@@ -35,27 +52,27 @@ defineProps<{ content: Readonly<PortfolioContent> }>();
|
|||||||
</dl>
|
</dl>
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="experience" :index="3" :eyebrow="content.navigation[2]!.label.toUpperCase()" :heading="content.navigation[2]!.label.toUpperCase()">
|
<PortfolioSection id="experience" :index="3" :eyebrow="sectionLabel('experience').toUpperCase()" :heading="sectionLabel('experience').toUpperCase()">
|
||||||
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
|
<ExperienceTimeline :experience="content.experience" :systems-operations="content.systemsOperations" />
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="infrastructure" :index="4" :eyebrow="content.navigation[3]!.label.toUpperCase()" :heading="content.navigation[3]!.label.toUpperCase()">
|
<PortfolioSection id="infrastructure" :index="4" :eyebrow="sectionLabel('infrastructure').toUpperCase()" :heading="sectionLabel('infrastructure').toUpperCase()">
|
||||||
<InfrastructureDiagram :infrastructure="content.infrastructure" />
|
<InfrastructureDiagram :infrastructure="content.infrastructure" />
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="selected-builds" :index="5" :eyebrow="content.navigation[4]!.label.toUpperCase()" :heading="content.navigation[4]!.label.toUpperCase()">
|
<PortfolioSection id="selected-builds" :index="5" :eyebrow="sectionLabel('selected-builds').toUpperCase()" :heading="sectionLabel('selected-builds').toUpperCase()">
|
||||||
<ProjectCaseStudy v-for="project in content.projects.selected" :key="project.id" :project="project" />
|
<ProjectCaseStudy v-for="project in content.projects.selected" :key="project.id" :project="project" />
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="additional-work" :index="6" :eyebrow="content.navigation[5]!.label.toUpperCase()" :heading="content.navigation[5]!.label.toUpperCase()">
|
<PortfolioSection id="additional-work" :index="6" :eyebrow="sectionLabel('additional-work').toUpperCase()" :heading="sectionLabel('additional-work').toUpperCase()">
|
||||||
<ProjectCard v-for="project in content.projects.additional" :key="project.id" :project="project" />
|
<ProjectCard v-for="project in content.projects.additional" :key="project.id" :project="project" />
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="toolkit" :index="7" :eyebrow="content.navigation[6]!.label.toUpperCase()" :heading="content.navigation[6]!.label.toUpperCase()">
|
<PortfolioSection id="toolkit" :index="7" :eyebrow="sectionLabel('toolkit').toUpperCase()" :heading="sectionLabel('toolkit').toUpperCase()">
|
||||||
<ToolkitGrid :groups="content.toolkit" />
|
<ToolkitGrid :groups="content.toolkit" />
|
||||||
</PortfolioSection>
|
</PortfolioSection>
|
||||||
|
|
||||||
<PortfolioSection id="contact" :index="8" :eyebrow="content.navigation[7]!.label.toUpperCase()" :heading="content.navigation[7]!.label.toUpperCase()">
|
<PortfolioSection id="contact" :index="8" :eyebrow="sectionLabel('contact').toUpperCase()" :heading="sectionLabel('contact').toUpperCase()">
|
||||||
<footer>
|
<footer>
|
||||||
<ContactDestination :contact="content.contact" :identity="content.identity" />
|
<ContactDestination :contact="content.contact" :identity="content.identity" />
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { resolve } from 'node:path';
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { validateProductionInputs } from '@/content/schemas';
|
import { validateProductionInputs } from '@/content/schemas';
|
||||||
import { render } from '@/entry-server';
|
import { render } from '@/entry-server';
|
||||||
|
import type { SectionId } from '@/content/types';
|
||||||
|
|
||||||
const fixture = (name: string): unknown =>
|
const fixture = (name: string): unknown =>
|
||||||
JSON.parse(readFileSync(resolve(process.cwd(), 'tests/fixtures', name), 'utf8'));
|
JSON.parse(readFileSync(resolve(process.cwd(), 'tests/fixtures', name), 'utf8'));
|
||||||
@@ -15,8 +16,12 @@ const validInputs = validateProductionInputs(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderedDocument = async (): Promise<Document> => {
|
const renderedDocument = async (): Promise<Document> => {
|
||||||
|
return renderedDocumentFor(validInputs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderedDocumentFor = async (inputs: typeof validInputs): Promise<Document> => {
|
||||||
const document = globalThis.document.implementation.createHTMLDocument('Portfolio');
|
const document = globalThis.document.implementation.createHTMLDocument('Portfolio');
|
||||||
document.body.innerHTML = await render('/', validInputs);
|
document.body.innerHTML = await render('/', inputs);
|
||||||
return document;
|
return document;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,6 +57,33 @@ describe('semantic portfolio document', () => {
|
|||||||
expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.');
|
expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('binds navigation and section labels to their canonical IDs when content navigation is reordered', async () => {
|
||||||
|
const content = fixture('portfolio-content.valid.json') as {
|
||||||
|
navigation: Array<{ id: SectionId; label: string }>;
|
||||||
|
};
|
||||||
|
const navigationById = new Map(content.navigation.map((item) => [item.id, item]));
|
||||||
|
const reorderedIds: readonly SectionId[] = [
|
||||||
|
'contact', 'toolkit', 'additional-work', 'selected-builds', 'infrastructure', 'experience', 'profile', 'start',
|
||||||
|
];
|
||||||
|
content.navigation = reorderedIds.map((id) => navigationById.get(id)!);
|
||||||
|
const reorderedInputs = validateProductionInputs(
|
||||||
|
content,
|
||||||
|
fixture('asset-manifest.valid.json'),
|
||||||
|
fixture('site-config.preview.json'),
|
||||||
|
new URL('file:///prepared-assets/'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const document = await renderedDocumentFor(reorderedInputs);
|
||||||
|
|
||||||
|
expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([
|
||||||
|
'START', 'PROFILE', 'EXPERIENCE', 'INFRASTRUCTURE', 'SELECTED BUILDS', 'ADDITIONAL WORK', 'TOOLKIT', 'CONTACT',
|
||||||
|
]);
|
||||||
|
expect([...document.querySelectorAll('nav a')].map((link) => [link.getAttribute('href'), link.textContent])).toEqual([
|
||||||
|
['#start', 'Start'], ['#profile', 'Profile'], ['#experience', 'Experience'], ['#infrastructure', 'Infrastructure'],
|
||||||
|
['#selected-builds', 'Selected builds'], ['#additional-work', 'Additional work'], ['#toolkit', 'Toolkit'], ['#contact', 'Contact'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('includes systems and software evidence in server-rendered content', async () => {
|
it('includes systems and software evidence in server-rendered content', async () => {
|
||||||
const document = await renderedDocument();
|
const document = await renderedDocument();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user