docs
    agency-site-kit: Images, labels and paths
    v0.11.1.md

    Images, labels and paths

    The small helpers every renderer prints through: turning an image field into something safe to put in src (with its real size when the file name says it), printing categories, service tags and dates the same way everywhere, and keeping raw page paths from a client's site out of your pages.

    Everything here is pure and imports from @sonordev/agency-site-kit/portfolio, except slugifyCategory, which lives in @sonordev/agency-site-kit/portfolio/server.

    Images: resolvePortfolioImage

    resolvePortfolioImage(input): { url: string; width?: number; height?: number } | null

    Pass it any image field on a case study: hero_image, a screenshot, a gallery image, a testimonial avatar. It takes a URL string, or the older { asset: { _ref } } image object some cached payloads still carry.

    • An absolute http(s) URL or a site-rooted path (/images/hero.png) comes back as { url }.
    • When the file name ends in -<width>x<height>.<ext>, the size is read from it: hero-1600x1000.png gives { url, width: 1600, height: 1000 }. Images Sonor stores are named that way, so you usually get real intrinsic sizes and no layout shift. A query string after the extension is fine.
    • Anything else is null: an empty value, a relative path like hero.png, or a bare asset ID that isn't a URL. Skip the image when you get null. The first bare asset ID also logs one console warning, since it means the payload is stale.
    import Image from 'next/image';
    import { resolvePortfolioImage } from '@sonordev/agency-site-kit/portfolio';
    
    const hero = resolvePortfolioImage(item.hero_screenshots?.desktop || item.hero_image);
    
    {hero ? (
      hero.width && hero.height ? (
        <Image src={hero.url} width={hero.width} height={hero.height} alt={item.hero_image_alt} priority />
      ) : (
        <img src={hero.url} alt={item.hero_image_alt} fetchPriority="high" />
      )
    ) : null}

    next/image needs the image's host in images.remotePatterns in your next.config. A plain <img> doesn't, which is why the starter renderer uses one.

    Several section types (gallery images, testimonial avatars, before and after shots) are typed as the old image object, SanityImageRef, but carry URL strings at runtime. Always pass them through resolvePortfolioImage rather than reading .asset yourself. See Types.

    Categories: formatCategoryLabel

    formatCategoryLabel(slug: string | undefined | null): string

    Categories are stored as slugs. This prints them for people: hyphens and underscores become spaces and every word is capitalized. A few words are genuinely hyphenated and keep their hyphen.

    InputOutput
    web-developmentWeb Development
    application_developmentApplication Development
    e-commerceE-Commerce
    null''

    Use it for the hero eyebrow, the card label and the category links, so they all agree.

    Category URLs: slugifyCategory

    import { slugifyCategory } from '@sonordev/agency-site-kit/portfolio/server';
    
    slugifyCategory('Web Development'); // 'web-development'

    The URL slug for a stored category name: lowercased, anything that isn't a letter or digit becomes a hyphen, and hyphens are trimmed from the ends. A category route carries this slug, but getPortfolioItems({ category }) filters on the stored name, so look the slug back up:

    // `category` is the route param, e.g. 'web-development'
    const categories = await getPortfolioCategories();
    const name = categories.find((c) => slugifyCategory(c) === category);
    const { items } = await getPortfolioItems({ category: name });

    Service tags: formatServiceTag

    formatServiceTag(tag: string | undefined | null): string

    Capitalizes the first letter of each word and leaves the rest alone, so stored tags with mixed casing print consistently without losing acronyms:

    InputOutput
    Next.js developmentNext.js Development
    technical SEO implementationTechnical SEO Implementation

    Dates: formatIsoDate

    formatIsoDate(value: string | null | undefined, options?: { day?: boolean }): string | null

    A calendar date for people, read exactly as written and formatted in UTC. A date formatted in the runtime's time zone prints the day before anywhere west of Greenwich, so a server render and a browser render would disagree.

    InputOutput
    '2026-09-22'September 22, 2026
    '2026-09-22T23:30:00Z'September 22, 2026
    '2026-09-22', { day: false }September 2026
    '2026-09'September 2026
    '2026-02-31'null
    'Sept 2026'null

    Only ISO dates count. Anything else, including a day that doesn't exist, returns null, and you decide what to show instead. Use it for launch dates, measurement dates and the "last refreshed" stamp.

    Site architecture paths

    A Site Architecture section lists the pages of the client's site. Those entries can arrive as raw paths from the client's domain (/services/storm-drainage). If a raw path lands in your page's HTML or its client payload, crawlers can pick it up and request it on your domain, where it's a 404.

    getPortfolioItem already strips them before returning, so a case study you fetched with the kit is safe. The two functions it uses are exported for data you get some other way:

    FunctionWhat it does
    humanizeArchPageEntry({ path?, title? })Returns { title } only. A real title is kept; a title that's a path, or a missing title, becomes a label from the last path segment (/services/storm-drainage.html gives Storm Drainage, / gives Home).
    sanitizeSiteArchitectureData(data)Runs every page in every group of a Site Architecture section's data through humanizeArchPageEntry. Data without a groups array comes back unchanged.
    import { sanitizeSiteArchitectureData } from '@sonordev/agency-site-kit/portfolio';
    
    sanitizeSiteArchitectureData({
      groups: [{ label: 'Services', pages: [{ path: '/services/storm-drainage', title: '/services/storm-drainage' }] }],
    });
    // { groups: [{ label: 'Services', pages: [{ title: 'Storm Drainage' }] }] }