diff --git a/site/src/components/case-study/ArchDiagram.astro b/site/src/components/case-study/ArchDiagram.astro new file mode 100644 index 0000000..7f87726 --- /dev/null +++ b/site/src/components/case-study/ArchDiagram.astro @@ -0,0 +1,121 @@ +--- +/* + Data-driven architecture diagram (COLOUR_SYSTEM §3.5): surface nodes, accent flow, + amber for external systems, mono labels. Fully legible with zero interaction; the + /<desc> give it an accessible text alternative (A5). +*/ +interface Node { + id: string; + x: number; + y: number; + w: number; + h: number; + kind: 'internal' | 'primary' | 'external'; + label: string; + sub?: string; +} +interface Edge { + d: string; + kind: 'flow' | 'external'; + label?: string; + labelX?: number; + labelY?: number; +} +interface Props { + diagram: { + viewBox: string; + title: string; + desc: string; + nodes: Node[]; + edges: Edge[]; + }; + class?: string; +} +const { diagram, class: cls } = Astro.props; + +const nodeStroke = { + internal: 'var(--border-strong)', + primary: 'var(--accent)', + external: 'var(--amber)', +}; +--- + +<figure class:list={['overflow-hidden rounded-md border border-line bg-surface-1 p-4', cls]}> + <svg + viewBox={diagram.viewBox} + role="img" + aria-labelledby="diagram-title diagram-desc" + class="w-full" + style="min-width: 640px;" + > + <title id="diagram-title">{diagram.title} + {diagram.desc} + + {/* Edges first (under nodes) */} + { + diagram.edges.map((e) => ( + + + {e.label && e.labelX !== undefined && e.labelY !== undefined && ( + + {e.label} + + )} + + )) + } + + {/* Nodes */} + { + diagram.nodes.map((n) => ( + + + + {n.label} + + {n.sub && ( + + {n.sub} + + )} + + )) + } + + diff --git a/site/src/components/case-study/CaseStudyPage.astro b/site/src/components/case-study/CaseStudyPage.astro new file mode 100644 index 0000000..8485282 --- /dev/null +++ b/site/src/components/case-study/CaseStudyPage.astro @@ -0,0 +1,64 @@ +--- +import type { Locale } from '@i18n/locales'; +import type { ProjectPageId } from '@i18n/slugMap'; +import Base from '@layouts/Base.astro'; +import { getProject, getMeta } from '@lib/content'; +import CsHeader from '@components/case-study/CsHeader.astro'; +import TldrBox from '@components/case-study/TldrBox.astro'; +import MiniToc from '@components/case-study/MiniToc.astro'; +import SectionRenderer from '@components/case-study/SectionRenderer.astro'; +import NextPrev from '@components/case-study/NextPrev.astro'; +import FramedImage from '@components/ui/FramedImage.astro'; + +interface Props { + id: ProjectPageId; + locale: Locale; +} +const { id, locale } = Astro.props; +const project = getProject(id, locale); +const m = getMeta(id, locale); +const hero = project.media[0]; +const isCapability = project.template === 'capability'; +const projectType = id === 'inboxintel' ? 'SoftwareSourceCode' : 'SoftwareApplication'; +--- + + +
+ + + {hero &&
} + +
+
+ {!isCapability && } +
+ { + project.sections.map((s) => ( + + )) + } +
+
+ + {!isCapability && } +
+ + +
+ + + diff --git a/site/src/components/case-study/CsHeader.astro b/site/src/components/case-study/CsHeader.astro new file mode 100644 index 0000000..12e673b --- /dev/null +++ b/site/src/components/case-study/CsHeader.astro @@ -0,0 +1,52 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import type { ProjectVM } from '@lib/content'; +import SectionLabel from '@components/ui/SectionLabel.astro'; +import Chip from '@components/ui/Chip.astro'; + +interface Props { + locale: Locale; + project: ProjectVM; +} +const { locale, project } = Astro.props; +const d = useDict(locale); +const eyebrow = + project.template === 'case-study' ? d.project.caseStudyEyebrow : d.project.capabilityEyebrow; +const linkLabel: Record = { + repo: d.project.repository, + live: d.project.liveDemo, + docs: 'Docs', +}; +--- + +
+ +
+

{project.name}

+ +
+

{project.valueProp}

+ +
+ {project.stack.map((s) => )} +
+ + { + project.links.length > 0 && ( +
+ {project.links + .filter((l) => l.public) + .map((l) => ( + + {linkLabel[l.type] ?? l.type} ↗ + + ))} +
+ ) + } +
diff --git a/site/src/components/case-study/DecisionList.astro b/site/src/components/case-study/DecisionList.astro new file mode 100644 index 0000000..028bd34 --- /dev/null +++ b/site/src/components/case-study/DecisionList.astro @@ -0,0 +1,31 @@ +--- +interface Decision { + n: number; + choice: string; + alternative: string; + rationale: string; +} +interface Props { + decisions: Decision[]; +} +const { decisions } = Astro.props; +--- + +
    + { + decisions.map((dec) => ( +
  1. +

    + + {String(dec.n).padStart(2, '0')} + + {dec.choice} +

    +

    + alt: {dec.alternative} +

    +

    {dec.rationale}

    +
  2. + )) + } +
diff --git a/site/src/components/case-study/Gallery.astro b/site/src/components/case-study/Gallery.astro new file mode 100644 index 0000000..fa16917 --- /dev/null +++ b/site/src/components/case-study/Gallery.astro @@ -0,0 +1,28 @@ +--- +import FramedImage from '@components/ui/FramedImage.astro'; + +interface Media { + src: string; + width: number; + height: number; + alt: string; + caption: string; +} +interface Props { + media: Media[]; + /** Index offset so lightbox indices stay unique across the page. */ + startIndex?: number; +} +const { media, startIndex = 0 } = Astro.props; +--- + +
+ { + media.map((m, i) => ( +
+ +
{m.caption}
+
+ )) + } +
diff --git a/site/src/components/case-study/MiniToc.astro b/site/src/components/case-study/MiniToc.astro new file mode 100644 index 0000000..1eb1796 --- /dev/null +++ b/site/src/components/case-study/MiniToc.astro @@ -0,0 +1,37 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; + +interface Props { + locale: Locale; + sections: { heading: string; anchorId: string }[]; +} +const { locale, sections } = Astro.props; +const d = useDict(locale); +--- + + + + diff --git a/site/src/components/case-study/NextPrev.astro b/site/src/components/case-study/NextPrev.astro new file mode 100644 index 0000000..780bb25 --- /dev/null +++ b/site/src/components/case-study/NextPrev.astro @@ -0,0 +1,54 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import { pathFor } from '@i18n/slugMap'; +import { getProjectCards } from '@lib/content'; + +interface Props { + locale: Locale; + currentId: string; +} +const { locale, currentId } = Astro.props; +const d = useDict(locale); +const cards = getProjectCards(locale); +const idx = cards.findIndex((c) => c.id === currentId); +const prev = idx > 0 ? cards[idx - 1] : undefined; +const next = idx < cards.length - 1 ? cards[idx + 1] : undefined; +--- + + diff --git a/site/src/components/case-study/SectionRenderer.astro b/site/src/components/case-study/SectionRenderer.astro new file mode 100644 index 0000000..3126246 --- /dev/null +++ b/site/src/components/case-study/SectionRenderer.astro @@ -0,0 +1,79 @@ +--- +import type { Locale } from '@i18n/locales'; +import ArchDiagram from '@components/case-study/ArchDiagram.astro'; +import DecisionList from '@components/case-study/DecisionList.astro'; +import Gallery from '@components/case-study/Gallery.astro'; + +interface Block { + type: 'p' | 'ul'; + text?: string; + items?: string[]; +} +interface Section { + kind: string; + heading: string; + anchorId: string; + blocks?: Block[]; + decisions?: { n: number; choice: string; alternative: string; rationale: string }[]; +} +interface Media { + src: string; + width: number; + height: number; + alt: string; + caption: string; +} +interface Diagram { + viewBox: string; + title: string; + desc: string; + nodes: any[]; + edges: any[]; +} +interface Props { + locale: Locale; + section: Section; + diagram?: Diagram; + media?: Media[]; +} +const { section, diagram, media = [] } = Astro.props; +const galleryMedia = media.slice(1); // media[0] is the hero shown above the article +--- + +
+

{section.heading}

+ + { + section.blocks && ( +
+ {section.blocks.map((b) => + b.type === 'p' ? ( +

{b.text}

+ ) : ( +
    + {b.items?.map((it) => ( +
  • +
  • + ))} +
+ ), + )} +
+ ) + } + + {section.kind === 'architecture' && diagram && } + + {section.kind === 'decisions' && section.decisions && ( +
+ )} + + { + section.kind === 'screenshots' && + (galleryMedia.length > 0 ? ( +
+ ) : null) + } +
diff --git a/site/src/components/case-study/TldrBox.astro b/site/src/components/case-study/TldrBox.astro new file mode 100644 index 0000000..5d8190e --- /dev/null +++ b/site/src/components/case-study/TldrBox.astro @@ -0,0 +1,32 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; + +interface Props { + locale: Locale; + tldr: { what: string; why: string; stack: string; role: string }; +} +const { locale, tldr } = Astro.props; +const d = useDict(locale); +const rows: [string, string][] = [ + [d.project.tldr.what, tldr.what], + [d.project.tldr.why, tldr.why], + [d.project.tldr.stack, tldr.stack], + [d.project.tldr.role, tldr.role], +]; +--- + +
+ +

TL;DR

+
+ { + rows.map(([term, val]) => ( +
+
{term}
+
{val}
+
+ )) + } +
+
diff --git a/site/src/components/home/AboutTeaser.astro b/site/src/components/home/AboutTeaser.astro index 20e42f8..95c57c2 100644 --- a/site/src/components/home/AboutTeaser.astro +++ b/site/src/components/home/AboutTeaser.astro @@ -28,7 +28,7 @@ const d = useDict(locale);

- Languages + {d.about.languages}

diff --git a/site/src/components/home/ProjectCards.astro b/site/src/components/home/ProjectCards.astro index 6ea3901..42982ce 100644 --- a/site/src/components/home/ProjectCards.astro +++ b/site/src/components/home/ProjectCards.astro @@ -9,17 +9,30 @@ import FramedImage from '@components/ui/FramedImage.astro'; interface Props { locale: Locale; + number?: string; + heading?: string; + header?: boolean; // false on the standalone projects index (it has its own H1) } -const { locale } = Astro.props; +const { locale, number = '02', heading, header = true } = Astro.props; const d = useDict(locale); const projects = getProjectCards(locale); const caseStudies = projects.filter((p) => p.template === 'case-study'); const capabilities = projects.filter((p) => p.template === 'capability'); --- -
- -

{d.home.projectsHeading}

+
+ { + header && ( + <> + +

{heading ?? d.home.projectsHeading}

+ + ) + }
{ diff --git a/site/src/components/pages/AboutPage.astro b/site/src/components/pages/AboutPage.astro new file mode 100644 index 0000000..038e214 --- /dev/null +++ b/site/src/components/pages/AboutPage.astro @@ -0,0 +1,48 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import Base from '@layouts/Base.astro'; +import { getProfile, getMeta } from '@lib/content'; +import SectionLabel from '@components/ui/SectionLabel.astro'; +import Portrait from '@components/ui/Portrait.astro'; + +interface Props { + locale: Locale; +} +const { locale } = Astro.props; +const d = useDict(locale); +const profile = getProfile(locale); +const m = getMeta('about', locale); +--- + + +
+
+ +

{profile.aboutHeading}

+
+ {profile.aboutParagraphs.map((para) =>

{para}

)} +
+

+ {d.about.interests} + {profile.interestsLine} +

+
+ + +
+ diff --git a/site/src/components/pages/ColophonPage.astro b/site/src/components/pages/ColophonPage.astro new file mode 100644 index 0000000..b337954 --- /dev/null +++ b/site/src/components/pages/ColophonPage.astro @@ -0,0 +1,60 @@ +--- +/* + Colophon — "how this site works" (CONTENT_STRATEGY §4). A deliberate P2 hook: it + states the production decisions behind the site honestly. Copy authored per locale. +*/ +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import Base from '@layouts/Base.astro'; +import { getMeta } from '@lib/content'; +import SectionLabel from '@components/ui/SectionLabel.astro'; + +interface Props { + locale: Locale; +} +const { locale } = Astro.props; +const d = useDict(locale); +const m = getMeta('colophon', locale); + +const copy = { + en: { + lead: 'This site is a small work sample in its own right. A few of the decisions behind it:', + points: [ + ['Static by default', 'Built with Astro and served as static HTML — no client framework runtime, so the largest content paint is just text and CSS. The three interactive parts (menu, lightbox, contact form) are tiny progressive-enhancement modules.'], + ['Bilingual from the ground up', 'English and Norwegian are equal citizens: a typed slug map is the single source of truth for routes, the language switch and hreflang, so they can never drift apart.'], + ['Motion that confirms, never performs', 'One signature animation, everything else is restraint. Every effect has a reduced-motion variant, and content is never gated behind an animation.'], + ['Self-hosted', 'Built and deployed from my own Gitea instance to Docker behind nginx. The contact form is a small stateless .NET service — even that is part of my own stack.'], + ['No tracking', 'No analytics beacons, no third-party fonts, no cookies — which is also why there is no cookie banner.'], + ], + }, + no: { + lead: 'Denne siden er også et lite arbeidsprøve i seg selv. Noen av valgene bak den:', + points: [ + ['Statisk som standard', 'Bygget med Astro og servert som statisk HTML — ingen klient-rammeverk i drift, så det første innholdet som vises er bare tekst og CSS. De tre interaktive delene (meny, lightbox, kontaktskjema) er små moduler for progressiv forbedring.'], + ['Tospråklig fra bunnen', 'Engelsk og norsk er likestilt: et typet slug-kart er én kilde til sannhet for ruter, språkbytte og hreflang, så de kan aldri komme i utakt.'], + ['Bevegelse som bekrefter, aldri opptrer', 'Én signaturanimasjon, ellers tilbakeholdenhet. Hver effekt har en variant for redusert bevegelse, og innhold er aldri sperret bak en animasjon.'], + ['Egendriftet', 'Bygget og rullet ut fra min egen Gitea-instans til Docker bak nginx. Kontaktskjemaet er en liten tilstandsløs .NET-tjeneste — også den er en del av min egen stack.'], + ['Ingen sporing', 'Ingen analyse-beacons, ingen tredjeparts-fonter, ingen informasjonskapsler — som også er grunnen til at det ikke finnes noe cookie-banner.'], + ], + }, +}[locale]; +--- + + +
+ +

{d.footer.colophon}

+

{copy.lead}

+ +
+ { + copy.points.map(([term, desc]) => ( +
+
{term}
+
{desc}
+
+ )) + } +
+
+ diff --git a/site/src/components/pages/ContactPage.astro b/site/src/components/pages/ContactPage.astro new file mode 100644 index 0000000..3e0275c --- /dev/null +++ b/site/src/components/pages/ContactPage.astro @@ -0,0 +1,110 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import Base from '@layouts/Base.astro'; +import { getProfile, getMeta } from '@lib/content'; +import SectionLabel from '@components/ui/SectionLabel.astro'; + +interface Props { + locale: Locale; +} +const { locale } = Astro.props; +const d = useDict(locale); +const profile = getProfile(locale); +const m = getMeta('contact', locale); + +const fieldClass = + 'w-full rounded-sm border border-line-strong bg-surface-1 px-3 py-2.5 text-body text-ink transition-colors focus:border-accent focus:outline-none aria-[invalid=true]:border-danger'; +--- + + +
+
+ +

{profile.contactHeading}

+

{profile.contactBody}

+ +
+
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + {/* Honeypot — hidden from users and assistive tech */} + + + +
+ + + + +
+ + +
+ + + diff --git a/site/src/components/pages/CvPage.astro b/site/src/components/pages/CvPage.astro new file mode 100644 index 0000000..88bdd64 --- /dev/null +++ b/site/src/components/pages/CvPage.astro @@ -0,0 +1,55 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict, interpolate } from '@i18n/t'; +import Base from '@layouts/Base.astro'; +import { getProfile, getMeta } from '@lib/content'; +import SectionLabel from '@components/ui/SectionLabel.astro'; + +interface Props { + locale: Locale; +} +const { locale } = Astro.props; +const d = useDict(locale); +const profile = getProfile(locale); +const m = getMeta('cv', locale); + +const cards = [ + { code: 'EN', label: d.cv.english, cv: profile.cv.en }, + { code: 'NO', label: d.cv.norsk, cv: profile.cv.no }, +]; +--- + + +
+ +

{d.cvPage.heading}

+

{d.cvPage.intro}

+ +
+ { + cards.map((c) => ( +
+
+ + {c.code} + + {c.label} +
+

+ {interpolate(d.cv.fileMeta, { size: c.cv.sizeKb })} · {d.cvPage.updated} {c.cv.updated} +

+ + {d.cv.download} + +
+ )) + } +
+ +

{d.cvPage.atsNote}

+
+ diff --git a/site/src/components/pages/ExperiencePage.astro b/site/src/components/pages/ExperiencePage.astro new file mode 100644 index 0000000..77e4423 --- /dev/null +++ b/site/src/components/pages/ExperiencePage.astro @@ -0,0 +1,26 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import Base from '@layouts/Base.astro'; +import { getMeta } from '@lib/content'; +import SectionLabel from '@components/ui/SectionLabel.astro'; +import ExperienceTimeline from '@components/home/ExperienceTimeline.astro'; + +interface Props { + locale: Locale; +} +const { locale } = Astro.props; +const d = useDict(locale); +const m = getMeta('experience', locale); +--- + + +
+ +

{d.home.experienceHeading}

+

{m.description}

+
+
+ +
+ diff --git a/site/src/components/pages/ProjectsIndexPage.astro b/site/src/components/pages/ProjectsIndexPage.astro new file mode 100644 index 0000000..d2b58a0 --- /dev/null +++ b/site/src/components/pages/ProjectsIndexPage.astro @@ -0,0 +1,26 @@ +--- +import type { Locale } from '@i18n/locales'; +import { useDict } from '@i18n/t'; +import Base from '@layouts/Base.astro'; +import { getMeta } from '@lib/content'; +import SectionLabel from '@components/ui/SectionLabel.astro'; +import ProjectCards from '@components/home/ProjectCards.astro'; + +interface Props { + locale: Locale; +} +const { locale } = Astro.props; +const d = useDict(locale); +const m = getMeta('projects', locale); +--- + + +
+ +

{d.home.projectsHeading}

+

{m.description}

+
+
+ +
+ diff --git a/site/src/components/ui/Portrait.astro b/site/src/components/ui/Portrait.astro new file mode 100644 index 0000000..07ec47f --- /dev/null +++ b/site/src/components/ui/Portrait.astro @@ -0,0 +1,26 @@ +--- +/* Framed portrait, placeholder-aware. Real headshot drops into the same content ref. */ +interface Props { + src: string; + alt: string; + caption: string; + class?: string; +} +const { src, alt, caption, class: cls } = Astro.props; +const isPlaceholder = src.startsWith('placeholder:'); +--- + +
+
+ { + isPlaceholder ? ( +
+ [ portrait ] +
+ ) : ( + {alt} + ) + } +
+

{caption}

+
diff --git a/site/src/i18n/dictionary.en.ts b/site/src/i18n/dictionary.en.ts index 0ba1158..d7b454b 100644 --- a/site/src/i18n/dictionary.en.ts +++ b/site/src/i18n/dictionary.en.ts @@ -23,6 +23,18 @@ export const en: Dictionary = { about: 'About', contact: 'Contact', }, + about: { + languages: 'Languages', + interests: 'Interests', + }, + cvPage: { + heading: 'Download my CV', + intro: + 'For a full overview of my experience, skills and work history, download my CV in either language.', + atsNote: + 'These are plain, ATS-friendly PDFs — single column, real text, standard headings.', + updated: 'Updated', + }, lang: { switchTo: 'Switch to Norwegian', en: 'EN', diff --git a/site/src/i18n/dictionary.no.ts b/site/src/i18n/dictionary.no.ts index 05f15b3..4af0100 100644 --- a/site/src/i18n/dictionary.no.ts +++ b/site/src/i18n/dictionary.no.ts @@ -34,6 +34,18 @@ export const no: Dictionary = { about: 'Om meg', contact: 'Kontakt', }, + about: { + languages: 'Språk', + interests: 'Interesser', + }, + cvPage: { + heading: 'Last ned CV-en min', + intro: + 'For en full oversikt over erfaring, kompetanse og arbeidshistorikk kan du laste ned CV-en min på ønsket språk.', + atsNote: + 'Dette er enkle, ATS-vennlige PDF-er — én kolonne, ekte tekst og standard overskrifter.', + updated: 'Oppdatert', + }, lang: { switchTo: 'Switch to English', en: 'EN', diff --git a/site/src/i18n/dictionary.ts b/site/src/i18n/dictionary.ts index aac24d2..dd063c9 100644 --- a/site/src/i18n/dictionary.ts +++ b/site/src/i18n/dictionary.ts @@ -27,6 +27,16 @@ export interface Dictionary { about: string; contact: string; }; + about: { + languages: string; + interests: string; + }; + cvPage: { + heading: string; + intro: string; + atsNote: string; + updated: string; + }; lang: { /** aria-label for the switch group, phrased in the target language. */ switchTo: string; diff --git a/site/src/pages/404.astro b/site/src/pages/404.astro new file mode 100644 index 0000000..0c5db84 --- /dev/null +++ b/site/src/pages/404.astro @@ -0,0 +1,57 @@ +--- +import '@fontsource-variable/space-grotesk'; +import '@fontsource-variable/inter'; +import '@fontsource-variable/jetbrains-mono'; +import '@styles/global.css'; +import ThemeScript from '@components/core/ThemeScript.astro'; +import { en } from '@i18n/dictionary.en'; +import { no } from '@i18n/dictionary.no'; +--- + + + + + + + 404 — Connor Babbington + + + + + +
+ {/* Trace motif frays at the break (A10) */} + + +

+ {en.notFound.code} · {no.notFound.code} +

+

{en.notFound.title}

+

{en.notFound.body}

+ +

{no.notFound.title}

+

{no.notFound.body}

+ + +
+ + diff --git a/site/src/pages/about/index.astro b/site/src/pages/about/index.astro new file mode 100644 index 0000000..c12bd67 --- /dev/null +++ b/site/src/pages/about/index.astro @@ -0,0 +1,5 @@ +--- +import AboutPage from '@components/pages/AboutPage.astro'; +--- + + diff --git a/site/src/pages/colophon/index.astro b/site/src/pages/colophon/index.astro new file mode 100644 index 0000000..b6d0b03 --- /dev/null +++ b/site/src/pages/colophon/index.astro @@ -0,0 +1,5 @@ +--- +import ColophonPage from '@components/pages/ColophonPage.astro'; +--- + + diff --git a/site/src/pages/contact/index.astro b/site/src/pages/contact/index.astro new file mode 100644 index 0000000..2da4723 --- /dev/null +++ b/site/src/pages/contact/index.astro @@ -0,0 +1,5 @@ +--- +import ContactPage from '@components/pages/ContactPage.astro'; +--- + + diff --git a/site/src/pages/cv/index.astro b/site/src/pages/cv/index.astro new file mode 100644 index 0000000..e95fb20 --- /dev/null +++ b/site/src/pages/cv/index.astro @@ -0,0 +1,5 @@ +--- +import CvPage from '@components/pages/CvPage.astro'; +--- + + diff --git a/site/src/pages/experience/index.astro b/site/src/pages/experience/index.astro new file mode 100644 index 0000000..7fcbe8f --- /dev/null +++ b/site/src/pages/experience/index.astro @@ -0,0 +1,5 @@ +--- +import ExperiencePage from '@components/pages/ExperiencePage.astro'; +--- + + diff --git a/site/src/pages/no/cv/index.astro b/site/src/pages/no/cv/index.astro new file mode 100644 index 0000000..bce3d39 --- /dev/null +++ b/site/src/pages/no/cv/index.astro @@ -0,0 +1,5 @@ +--- +import CvPage from '@components/pages/CvPage.astro'; +--- + + diff --git a/site/src/pages/no/erfaring/index.astro b/site/src/pages/no/erfaring/index.astro new file mode 100644 index 0000000..3174c4d --- /dev/null +++ b/site/src/pages/no/erfaring/index.astro @@ -0,0 +1,5 @@ +--- +import ExperiencePage from '@components/pages/ExperiencePage.astro'; +--- + + diff --git a/site/src/pages/no/kolofon/index.astro b/site/src/pages/no/kolofon/index.astro new file mode 100644 index 0000000..40cdc30 --- /dev/null +++ b/site/src/pages/no/kolofon/index.astro @@ -0,0 +1,5 @@ +--- +import ColophonPage from '@components/pages/ColophonPage.astro'; +--- + + diff --git a/site/src/pages/no/kontakt/index.astro b/site/src/pages/no/kontakt/index.astro new file mode 100644 index 0000000..d6eeb9f --- /dev/null +++ b/site/src/pages/no/kontakt/index.astro @@ -0,0 +1,5 @@ +--- +import ContactPage from '@components/pages/ContactPage.astro'; +--- + + diff --git a/site/src/pages/no/om-meg/index.astro b/site/src/pages/no/om-meg/index.astro new file mode 100644 index 0000000..0b063c5 --- /dev/null +++ b/site/src/pages/no/om-meg/index.astro @@ -0,0 +1,5 @@ +--- +import AboutPage from '@components/pages/AboutPage.astro'; +--- + + diff --git a/site/src/pages/no/prosjekter/hjemmelab.astro b/site/src/pages/no/prosjekter/hjemmelab.astro new file mode 100644 index 0000000..468990f --- /dev/null +++ b/site/src/pages/no/prosjekter/hjemmelab.astro @@ -0,0 +1,5 @@ +--- +import CaseStudyPage from '@components/case-study/CaseStudyPage.astro'; +--- + + diff --git a/site/src/pages/no/prosjekter/inboxintel.astro b/site/src/pages/no/prosjekter/inboxintel.astro new file mode 100644 index 0000000..00f7769 --- /dev/null +++ b/site/src/pages/no/prosjekter/inboxintel.astro @@ -0,0 +1,5 @@ +--- +import CaseStudyPage from '@components/case-study/CaseStudyPage.astro'; +--- + + diff --git a/site/src/pages/no/prosjekter/index.astro b/site/src/pages/no/prosjekter/index.astro new file mode 100644 index 0000000..016fffb --- /dev/null +++ b/site/src/pages/no/prosjekter/index.astro @@ -0,0 +1,5 @@ +--- +import ProjectsIndexPage from '@components/pages/ProjectsIndexPage.astro'; +--- + + diff --git a/site/src/pages/no/prosjekter/jobtrack.astro b/site/src/pages/no/prosjekter/jobtrack.astro new file mode 100644 index 0000000..77ef801 --- /dev/null +++ b/site/src/pages/no/prosjekter/jobtrack.astro @@ -0,0 +1,5 @@ +--- +import CaseStudyPage from '@components/case-study/CaseStudyPage.astro'; +--- + + diff --git a/site/src/pages/projects/homelab.astro b/site/src/pages/projects/homelab.astro new file mode 100644 index 0000000..d0a52f7 --- /dev/null +++ b/site/src/pages/projects/homelab.astro @@ -0,0 +1,5 @@ +--- +import CaseStudyPage from '@components/case-study/CaseStudyPage.astro'; +--- + + diff --git a/site/src/pages/projects/inboxintel.astro b/site/src/pages/projects/inboxintel.astro new file mode 100644 index 0000000..8b99e79 --- /dev/null +++ b/site/src/pages/projects/inboxintel.astro @@ -0,0 +1,5 @@ +--- +import CaseStudyPage from '@components/case-study/CaseStudyPage.astro'; +--- + + diff --git a/site/src/pages/projects/index.astro b/site/src/pages/projects/index.astro new file mode 100644 index 0000000..013156e --- /dev/null +++ b/site/src/pages/projects/index.astro @@ -0,0 +1,5 @@ +--- +import ProjectsIndexPage from '@components/pages/ProjectsIndexPage.astro'; +--- + + diff --git a/site/src/pages/projects/jobtrack.astro b/site/src/pages/projects/jobtrack.astro new file mode 100644 index 0000000..9e997fb --- /dev/null +++ b/site/src/pages/projects/jobtrack.astro @@ -0,0 +1,5 @@ +--- +import CaseStudyPage from '@components/case-study/CaseStudyPage.astro'; +--- + + diff --git a/site/src/pages/robots.txt.ts b/site/src/pages/robots.txt.ts new file mode 100644 index 0000000..92e67db --- /dev/null +++ b/site/src/pages/robots.txt.ts @@ -0,0 +1,10 @@ +import type { APIRoute } from 'astro'; + +/* robots.txt (SEO_SPEC §2): allow all, point to the sitemap. */ +export const GET: APIRoute = ({ site }) => { + const origin = site?.href ?? 'https://cesnimda.co.uk/'; + const body = ['User-agent: *', 'Allow: /', '', `Sitemap: ${new URL('sitemap.xml', origin).href}`, ''].join( + '\n', + ); + return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); +}; diff --git a/site/src/pages/sitemap.xml.ts b/site/src/pages/sitemap.xml.ts new file mode 100644 index 0000000..641ccb4 --- /dev/null +++ b/site/src/pages/sitemap.xml.ts @@ -0,0 +1,33 @@ +import type { APIRoute } from 'astro'; +import { PAGE_IDS, pathFor } from '@i18n/slugMap'; +import { LOCALES } from '@i18n/locales'; +import { absUrl, alternates } from '@lib/seo'; + +/* + Sitemap built from the slug map (ARCHITECTURE A5, SEO_SPEC §5). Every URL carries + xhtml:link alternates for both locales + x-default, mirroring the hreflang tags. +*/ +export const GET: APIRoute = ({ site }) => { + const origin = site?.href ?? 'https://cesnimda.co.uk/'; + + const urls: string[] = []; + for (const pageId of PAGE_IDS) { + const alts = alternates(pageId, origin); + const altXml = alts + .map((a) => ` `) + .join('\n'); + for (const locale of LOCALES) { + urls.push( + ` \n ${absUrl(pathFor(pageId, locale), origin)}\n${altXml}\n `, + ); + } + } + + const xml = ` + +${urls.join('\n')} + +`; + + return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } }); +};