Skip to content
AstroCraft Docs
On this theme

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.

  • sameAs is derived — never edit it. It is socials.map(({ href }) => href), so the JSON-LD Organization.sameAs array cannot drift from the links in your footer. Edit socials; structured data follows.
  • author.twitter is the handle without the @. BaseHead renders @${siteData.author.twitter} into twitter:creator, and omits the tag entirely if the value is empty.
  • author.timeZone is an IANA zone name and it drives the live “my time” clock in the footer. A typo throws a RangeError at build time rather than shipping a wrong clock, which is the intended behaviour.
  • author.phone is 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 than satisfiessatisfies would infer the literal’s own shape, and the author.phone && … branch in the contact form would stop compiling the moment the field is absent.
  • defaultImage.src is a public/ path, not a bundled import, and it doubles as the JSON-LD Organization.logo. Replace public/og.jpg with a real 1200×630 image before launch.
  • timeZone, tagline and location all encode the same city and cannot be derived from one another. Only timeZone is 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:

  • useViewTransitions gates <ClientRouter />. Off means no client-side navigation and no page-transition curtain.
  • useAnimations is 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-motion is honoured by a global CSS guard whatever this flag says. See Motion.
  • defaultTheme is "dark" | "light" | "system", currently "dark". A visitor’s own ThemeToggle pick, stored under localStorage["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.

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:

  1. An invalid author.email — throws, naming the value.
  2. A social href that is not an absolute http(s) URL — throws, naming the label.
  3. An unknown IANA zone in author.timeZone — throws a RangeError from Intl.
  4. SITE_URL still on the placeholder during a Netlify production build — throws from astro.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.

NEXT STEPDeployment