Configuration
Grafio has no CMS and no integration options object. Its configuration is six TypeScript modules in src/config/, imported directly by the pages and sections that need them. They are typed, so a missing field is a build error rather than an empty string in production, and three of them run real value checks at import time.
They are named *.json.ts but imported without the extension:
import siteData from "@config/siteData.json";
The @config/* alias resolves to ./src/config/*, and TypeScript appends .ts. They are modules, not JSON — which is exactly why they can carry comments, derive one field from another, and throw on bad input.
siteData.json.ts — who the site is
The identity layer: brand name, page title, description, the author block, the default social image, and the social links. Fifteen files read from it, including BaseHead, the footer, the home hero and the contact form.
const socials = [
{ label: "LinkedIn", href: "https://www.linkedin.com/in/simongraham" },
{ label: "X", href: "https://x.com/simongraham" },
{ label: "Github", href: "https://github.com/simongraham" },
{ label: "Behance", href: "https://www.behance.net/simongraham" },
] as const;
const siteData: SiteDataProps = {
name: "Grafio",
title: "Grafio — Creative Developer Portfolio",
description: "Grafio is an editorial, monochrome portfolio theme…",
author: {
name: "Simon Graham",
email: "[email protected]",
twitter: "simongraham",
tagline: "Creative developer, based in New York",
timeZone: "America/New_York",
location: "Brooklyn, New York",
},
defaultImage: { src: "/og.jpg", alt: "Grafio — creative developer portfolio theme" },
socials,
sameAs: socials.map(({ href }) => href),
};
Six things about this file will save you time later.
sameAsis derived — never edit it. It issocials.map(({ href }) => href), so the JSON-LDOrganization.sameAsarray cannot drift from the links in your footer. Editsocials; structured data follows.author.twitteris the handle without the@.BaseHeadrenders@${siteData.author.twitter}intotwitter:creator, and omits the tag entirely if the value is empty.author.timeZoneis an IANA zone name and it drives the live “my time” clock in the footer. A typo throws aRangeErrorat build time rather than shipping a wrong clock, which is the intended behaviour.author.phoneis optional and ships commented out. Uncomment it and a phone line appears under the email on/contact/. This is also why the file uses a type annotation rather thansatisfies—satisfieswould infer the literal’s own shape, and theauthor.phone && …branch in the contact form would stop compiling the moment the field is absent.defaultImage.srcis apublic/path, not a bundled import, and it doubles as the JSON-LDOrganization.logo. Replacepublic/og.jpgwith a real 1200×630 image before launch.timeZone,taglineandlocationall encode the same city and cannot be derived from one another. OnlytimeZoneis machine-checked; the other two will happily say “New York” while the clock says Tokyo.
The build-time guards
Two checks run when the module is evaluated — at pnpm build, and on the first dev request that loads it:
if (!z.email().safeParse(siteData.author.email).success) {
throw new Error(`siteData.author.email is not a valid email address: "${siteData.author.email}"`);
}
for (const { label, href } of socials) {
const url = URL.parse(href);
if (!url || (url.protocol !== "https:" && url.protocol !== "http:")) {
throw new Error(`siteData.socials: "${label}" needs an absolute http(s) URL, got "${href}"`);
}
}
So x.com/me without a scheme fails, and so does a mailto: in the socials list. Both messages name the offending field and echo the value.
These are the only value-level checks in the config layer, and that is deliberate. Config is not a trust boundary — it is code you wrote. Zod is reserved for data arriving from outside the type system: MDX frontmatter and contact-form input.
siteSettings.json.ts — how the site behaves
Three named exports, no default export:
export const siteLang = "en" as const;
export const siteLocale = "en-US" as const;
export const siteSettings = {
useViewTransitions: true,
useAnimations: true,
defaultTheme: "dark",
} satisfies SiteSettingsProps;
siteLang becomes <html lang>. siteLocale is the BCP-47 tag used for Intl date formatting, og:locale, the JSON-LD inLanguage and the RSS <language>.
The three switches:
useViewTransitionsgates<ClientRouter />. Off means no client-side navigation and no page-transition curtain.useAnimationsis the master switch for the decorative motion layer — scroll reveals, ambient loops, the WebGL image frames. It does not touch intentional micro-interactions like a rotating dropdown chevron, and it has nothing to do with accessibility:prefers-reduced-motionis honoured by a global CSS guard whatever this flag says. See Motion.defaultThemeis"dark" | "light" | "system", currently"dark". A visitor’s ownThemeTogglepick, stored underlocalStorage["colorTheme"], always wins over it. Only"system"keeps listening to live OS changes.
All three fields are required by the interface, on purpose. Optional fields would mean every consumer carrying its own ?? fallback — six of them once did, and one had drifted to a different default than the config’s, so “what theme does a first-time visitor get” had two answers.
Note that PageTransition needs both switches. Setting useViewTransitions: false silently disables the curtain even with useAnimations: true, because with no ClientRouter there is no swap to cover.
navData.json.ts — the links
const navData = {
main: [
{ href: "/about/", label: "About" },
{ href: "/work/", label: "Work" },
{ href: "/blog/", label: "Blog" },
],
cta: { href: "/contact/", label: "Contact" },
footer: [
{ href: "/terms/", label: "Terms" },
{ href: "/privacy/", label: "Privacy" },
{ href: "/rss.xml", label: "RSS" },
],
} satisfies NavData;
main fills the header nav and the mobile drawer, cta fills the header pill, footer fills the bottom utility row. A nav link is routing and label only — nothing presentational.
The trailing slashes are load-bearing. The site is configured trailingSlash: "always", and the header decides which link is current by comparing this href against the pathname. /rss.xml is a file and correctly takes none.
One asymmetry to know: the closing CTA section on every page uses navData.cta.href for its destination but pageCopy.cta.label for its text. Renaming the button in navData changes the header pill, not the CTA band.
pageCopy.json.ts — the words
Per-page framing copy: the eyebrow, headline and standfirst each page opens with, plus the shared CTA and testimonial. Keys are home, about, work, blog, contact, cta and testimonial.
Two rules govern this file, and both have teeth.
A headline that is an array is two lines, not one string with a break in it. TextReveal takes plain text and rebuilds the element from textContent, so a <br> inside would be silently discarded:
work: {
headline: ["The shape of", "our recent works."],
standfirst: "Selected projects from 2024 to 2026. …",
},
The Headline type is string | readonly string[], but the work and contact heroes index it positionally — HEADLINE[0] and HEADLINE[1]. Replacing either with a single string compiles cleanly and renders the first two characters. The blog headline is a plain string by contrast, because that hero draws one line.
Only framing copy lives here. Repeating records stay with the section that draws them, because they carry more than words: the testimonials sit in Sections/Home/TestimonialSlider.astro beside their avatar imports, the process steps in Home/Process.astro beside their photos, the client roster in Home/Clients.astro beside its SVG imports, the career rows in About/Experience.astro beside their brand colours. The FAQ is the one record set with its own config file, because it is pure text with no companion asset. That is the test — if an entry carries anything but words, it lives with its section.
So when you go looking for “where do I edit the client logos”, the answer is the section, not src/config/.
faqData.json.ts — the FAQ rows
A flat array of { question, answer }, six entries out of the box, rendered by Sections/Home/Faq.astro as a native exclusive accordion:
const faqData = [
{ question: "Do you work with clients outside New York?", answer: "…" },
{ question: "How long does a project take?", answer: "…" },
] satisfies FaqData;
Array order is both render order and stagger order. Adding a seventh row needs no other edit — the entrance delays come from an index-derived ramp that clamps at the sixth value.
The section’s own <h2> and its FAQ eyebrow are hard-coded in the component, not in config. Only the question-and-answer rows are configurable.
legalData.json.ts — terms and privacy
One record per document, both required by the type:
{
title: string;
description: string; // the page's meta description
lastUpdated: string; // ISO date, YYYY-MM-DD
intro: string;
sections: readonly { heading: string; body: readonly string[] }[];
}
Each body entry becomes one <p>. Both documents ship with nine placeholder sections and an explicit warning at the top of the file: this is template text, replace it with your own, reviewed by a qualified legal professional.
lastUpdated is formatted in UTC so a YYYY-MM-DD string never shifts a day across time zones.
The renderer is deliberately simple: heading plus paragraphs, no nested lists, no tables, no inline links. If your legal documents need richer structure, the documented upgrade path is to promote legalData to a markdown content collection and render <Content /> in Sections/Legal/LegalArticle.astro.
What is not in the config layer
Colours, type and spacing are not here. They are CSS custom properties in src/styles/tailwind-theme.css and src/styles/global.css — see Colors and Typography. Putting design tokens behind a JavaScript config would mean a build step between you and a colour change, for no gain.
Long-form content is not here either. Blog posts, case studies and author profiles are content-collection entries under src/data/ — see Content Collections.
The failure modes, collected
Four things in this layer can fail your build, and all four are intentional:
- An invalid
author.email— throws, naming the value. - A social
hrefthat is not an absolutehttp(s)URL — throws, naming the label. - An unknown IANA zone in
author.timeZone— throws aRangeErrorfromIntl. SITE_URLstill on the placeholder during a Netlify production build — throws fromastro.config.mjs. See Deployment.
Everything else is caught by the type checker. Run pnpm check for a fast pass and pnpm build for the real one.