# 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`

```ts
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.

```tsx
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](https://sonor.dev/agency-site-kit/types).

## Categories: `formatCategoryLabel`

```ts
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.

| 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`

```ts
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:

```ts
// `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`

```ts
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:

| Input                          | Output                         |
| ------------------------------ | ------------------------------ |
| `Next.js development`          | `Next.js Development`          |
| `technical SEO implementation` | `Technical SEO Implementation` |

## Dates: `formatIsoDate`

```ts
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.

| 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.                                                       |

```ts
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' }] }] }
```
