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 } | nullPass 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.pnggives{ 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 likehero.png, or a bare asset ID that isn't a URL. Skip the image when you getnull. 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): stringCategories 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.
| Input | Output |
|---|---|
web-development | Web Development |
application_development | Application Development |
e-commerce | E-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): stringCapitalizes the first letter of each word and leaves the rest alone, so stored tags with mixed casing print consistently without losing acronyms:
| Input | Output |
|---|---|
Next.js development | Next.js Development |
technical SEO implementation | Technical SEO Implementation |
Dates: formatIsoDate
formatIsoDate(value: string | null | undefined, options?: { day?: boolean }): string | nullA 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.
| Input | Output |
|---|---|
'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:
| Function | What 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' }] }] }