Skip to content

core

@evolve-storefront/core · v1.0.0

Small, domain-free helpers that the other Evolve storefront packages share: money and address formatting, country names, and pagination and query-result helpers. Each is its own import path (@evolve-storefront/core/money), so you load only what you use.

Get started

  1. Install the package. It comes from the Evolve registry, which needs a one-time sign-in.

    pnpm add @evolve-storefront/core

Then render it on a page:

import { formatAddressLines } from "@evolve-storefront/core/address";
import { formatMoney } from "@evolve-storefront/core/money";
import { countryName } from "@evolve-storefront/core/region";
formatMoney({ centAmount: 1999, currency: "EUR" }, "nl-NL"); // "€ 19,99"
countryName("NL", "en"); // "Netherlands"
formatAddressLines(
{ streetName: "Keizersgracht", streetNumber: "281", postalCode: "1016 ED", city: "Amsterdam", country: "NL" },
{ locale: "en" },
); // ["Keizersgracht 281", "1016 ED Amsterdam", "Netherlands"]

Functions

formatMoneyFormat a Money (minor units + ISO currency) for display.
import { formatMoney } from "@evolve-storefront/core/money";
function formatMoney(money: Money, locale?: string): string
OptionTypeDescription
centAmountnumber
currencystring

Returns string

formatMoneyPartsformatMoney split into the locale's parts — currency, integer, group, decimal, fraction, literal, minusSign — in the order the locale writes them, so a rendering can style one part without assuming its separator or place.
import { formatMoneyParts } from "@evolve-storefront/core/money";
function formatMoneyParts(money: Money, locale?: string): NumberFormatPart[]
OptionTypeDescription
centAmountnumber
currencystring

Returns Intl.NumberFormatPart[]

isZeroTrue when the amount is zero — used to omit empty lines.
import { isZero } from "@evolve-storefront/core/money";
function isZero(money: Money | null | undefined): boolean
OptionTypeDescription
centAmountnumber
currencystring

Returns boolean

addressLocationNameWhere an address is, as a business names its sites: the city, then the street and number, such as "Rotterdam · Trade Plaza 1".
import { addressLocationName } from "@evolve-storefront/core/address";
function addressLocationName(address: AddressFields): string
OptionTypeDescription
company?string | null
givenName?string | null
familyName?string | null
streetName?string | null
streetNumber?string | null
streetNumberSuffix?string | null
additionalAddressInfo?string | null
postalCode?string | null
city?string | null
region?string | null
country?string | null

Returns string

formatAddressLinesAn address as display lines, in postal order, localizing the country.
import { formatAddressLines } from "@evolve-storefront/core/address";

Only non-empty lines are returned, which is the point: optional in the type is not optional in the render, and returning a fixed-length array would push "what does an absent postal code look like?" out to every caller, where it becomes a blank line or a stray separator. A missing field collapses its line; a missing half of a line collapses just that half.

Phone is not included — it is contact information with its own treatment (muted, below the address), not a postal line. The caller renders it.

Pass includeName: false where the recipient name is the card's heading instead of one of its lines, which is what the account address book does. Without it the name renders twice, and that duplication is invisible in code review because both halves look correct on their own.

function formatAddressLines(
address: AddressFields,
options?: { locale?: string | undefined; includeName?: boolean | undefined; },
): string[]
OptionTypeDescription
company?string | null
givenName?string | null
familyName?string | null
streetName?string | null
streetNumber?string | null
streetNumberSuffix?string | null
additionalAddressInfo?string | null
postalCode?string | null
city?string | null
region?string | null
country?string | null
locale?string
includeName?boolean

Returns string[]

fullNameA person's name in the order their locale writes it — given-first for every shipped locale today, so this is latent until a family-name-first one ships.
import { fullName } from "@evolve-storefront/core/address";

Trimmed, and "" when there is no name: a template returning " " is truthy, and company || fullName(a) || fallback then titles a nameless card with a space. Excludes any salutation, so a caller wanting one prepends it.

function fullName(
address: Pick<AddressFields, "givenName" | "familyName">,
locale?: string,
): string
OptionTypeDescription
givenName?string | null
familyName?string | null

Returns string

groupAddressesByInitialAddresses grouped by the first letter of what names them, the way a contact list groups.
import { groupAddressesByInitial } from "@evolve-storefront/core/address";
function groupAddressesByInitial<Address>(
addresses: readonly Address[],
locale: string,
options?: { by?: "name" | "location" | undefined; },
): AddressGroup<Address>[]
OptionTypeDescription
by?"name" | "location"

Returns AddressGroup<Address>[]

isDeliverableAddressisDeliveryAddressComplete as a type guard, so filtering a saved list yields addresses that satisfy a delivery input's required fields without a cast: book.items.filter(isDeliverableAddress) drops what cannot be delivered to.
import { isDeliverableAddress } from "@evolve-storefront/core/address";
function isDeliverableAddress<Address>(
address: Address | null | undefined,
): address is DeliverableAddress<Address>

Returns address is DeliverableAddress<Address>

isDeliveryAddressCompleteWhether an address carries every field a delivery needs.
import { isDeliveryAddressComplete } from "@evolve-storefront/core/address";
function isDeliveryAddressComplete(
address: AddressFields | null | undefined,
): boolean
OptionTypeDescription
company?string | null
givenName?string | null
familyName?string | null
streetName?string | null
streetNumber?string | null
streetNumberSuffix?string | null
additionalAddressInfo?string | null
postalCode?string | null
city?string | null
region?string | null
country?string | null

Returns boolean

isSameAddressWhether two addresses would put the same label on a parcel.
import { isSameAddress } from "@evolve-storefront/core/address";

Compares the label fields, not id: a cart's address and the book entry it was picked from are separate records with separate ids. Contact details are ignored. Case, surrounding and repeated whitespace, and spaces in a postal code do not count as a difference. false when either side is absent.

function isSameAddress(
a: AddressFields | null | undefined,
b: AddressFields | null | undefined,
): boolean
OptionTypeDescription
company?string | null
givenName?string | null
familyName?string | null
streetName?string | null
streetNumber?string | null
streetNumberSuffix?string | null
additionalAddressInfo?string | null
postalCode?string | null
city?string | null
region?string | null
country?string | null
company?string | null
givenName?string | null
familyName?string | null
streetName?string | null
streetNumber?string | null
streetNumberSuffix?string | null
additionalAddressInfo?string | null
postalCode?string | null
city?string | null
region?string | null
country?string | null

Returns boolean

missingDeliveryAddressFieldsWhich delivery fields an address is missing, in the order above.
import { missingDeliveryAddressFields } from "@evolve-storefront/core/address";

Returns the list rather than a boolean so a caller can name what is missing instead of saying "incomplete" — an empty array means deliverable. Whitespace counts as missing, because a trimmed-to-nothing field is what a half-filled form actually produces.

recipient is a person name OR a company, not both and not givenName + familyName literally. A delivery needs someone to hand the parcel to, and in B2B that is the company — every company delivery address in this repo's own fixtures carries company and no person name, so requiring givenName/familyName outright would refuse them all. A person name means both halves: a lone given name is not a recipient.

function missingDeliveryAddressFields(
address: AddressFields | null | undefined,
): DeliveryAddressField[]
OptionTypeDescription
company?string | null
givenName?string | null
familyName?string | null
streetName?string | null
streetNumber?string | null
streetNumberSuffix?string | null
additionalAddressInfo?string | null
postalCode?string | null
city?string | null
region?string | null
country?string | null

Returns DeliveryAddressField[]

countryNameLocalized name for an ISO-3166-1 alpha-2 region code — "NL" → "Netherlands" / "Nederland" / "Pays-Bas".
import { countryName } from "@evolve-storefront/core/region";

Lives in core rather than beside the country list in feature-account because addresses are rendered by several features, and a feature must never import another feature.

Degrades to the code itself rather than to a blank line in the middle of an address. Two distinct ways that happens, both deliberate:

  • Intl.DisplayNames throws on a structurally invalid code rather than returning undefined, so a malformed record would otherwise take the page down with it.
  • Its default fallback: "code" already returns the code for a well-formed but unassigned region, which is the behaviour we want. Note that ZZ is not one of those — CLDR names it "Unknown Region".

The lookup is case-insensitive because it only matches uppercase, while the address form uppercases on save; a record written before that would otherwise silently render its raw code forever.

function countryName(code: string | null | undefined, locale?: string): string

Returns string

requireDataAssert a GraphQL fetcher response returned data, throwing (with transport / top-level GraphQL errors surfaced) otherwise.
import { requireData } from "@evolve-storefront/core/data";
function requireData<T>(
response: { data?: T | null | undefined; errors?: readonly { message?: string | undefined; }[] | null | undefined; },
message?: string,
): T
OptionTypeDescription
data?T | null
errors?readonly { message?: string | undefined; }[] | null

Returns T

buildOrganizationJsonLdThe schema.org Organization behind a storefront.
import { buildOrganizationJsonLd } from "@evolve-storefront/core/seo";
function buildOrganizationJsonLd(
options: OrganizationJsonLdInput,
): WithContext<Organization>
OptionTypeDescription
namestring
urlstring
description?string | null
logo?string | null

An absolute URL to the logo image.

sameAs?readonly (string | null | undefined)[]

Profile URLs elsewhere, such as the footer's social links. Blanks are dropped.

Returns WithContext<Organization>

buildProductJsonLdThe schema.org Product for a product page, or a ProductGroup with one Product per variant when there are several; null without variants.
import { buildProductJsonLd } from "@evolve-storefront/core/seo";
function buildProductJsonLd(input: ProductJsonLdInput): any
OptionTypeDescription
namestring
originstring

The site's absolute origin, such as https://shop.example.

variantsreadonly ProductJsonLdVariant[]
brand?string | null
category?string | null
dimensions?readonly { name: string; options: readonly { label: string; sku?: string | null | undefined; }[]; }[]

The dimensions the variants differ in, each option naming its variant's SKU.

Returns WithContext<Product> | WithContext<ProductGroup> | null

buildWebSiteJsonLdThe schema.org WebSite for a storefront, with a SearchAction pointing at its search page so a search engine can offer a search box.
import { buildWebSiteJsonLd } from "@evolve-storefront/core/seo";
function buildWebSiteJsonLd(
options: WebSiteJsonLdInput,
): WithContext<WebSite>
OptionTypeDescription
namestring
originstring

The site's absolute origin, such as https://shop.example.

locale?string
searchPath?string

The localized search page's path. Omit and no search box is described.

queryParam?string

The search page's query parameter.

Returns WithContext<WebSite>

createAlternatesA page's hreflang set and canonical.
import { createAlternates } from "@evolve-storefront/core/seo";
function createAlternates(
options: { routing: LocaleRouting; paths: readonly LocalizedPath[] | null | undefined; locale: string; homeSlug?: string | undefined; },
): Alternates
OptionTypeDescription
routingLocaleRouting
pathsreadonly LocalizedPath[] | null | undefined
localestring

The locale asking; its own entry is the canonical.

homeSlug?string

The CMS slug of the page served at each locale's root, e.g. homepage.

Returns Alternates

localePrefixThe prefix a locale's URLs carry under routing, or "" for none.
import { localePrefix } from "@evolve-storefront/core/seo";
function localePrefix(routing: LocaleRouting, locale: string): string
OptionTypeDescription
localesreadonly string[]
defaultLocale?string
localePrefix?"always" | "as-needed" | "never" | { mode?: "always" | "as-needed" | "never" | undefined; prefixes?: Partial<Record<string, string>> | undefined; }

Returns string

collectPagesEvery item of a paged listing: asks page after page until one comes back short or missing.
import { collectPages } from "@evolve-storefront/core/sitemap";
function collectPages<Item>(
fetchPage: (page: number) => Promise<readonly Item[] | null | undefined>,
options: { pageSize: number; firstPage?: number | undefined; },
): Promise<Item[]>
OptionTypeDescription
pageSizenumber
firstPage?number

Returns Promise<Item[]>

createSitemapIndexRouteA GET handler for the sitemap index, cached for maxAge seconds — a day by default.
import { createSitemapIndexRoute } from "@evolve-storefront/core/sitemap";
function createSitemapIndexRoute(
options: { sitemaps: (context: { request: Request; }) => readonly string[] | Promise<readonly string[]>; maxAge?: number | undefined; },
): (request: Request) => Promise<Response>
OptionTypeDescription
sitemaps(context: { request: Request; }) => readonly string[] | Promise<readonly string[]>
maxAge?number

Returns (request: Request) => Promise<Response>

createSitemapRouteA GET handler for app/sitemap/[locale]/<name>.xml/route.ts.
import { createSitemapRoute } from "@evolve-storefront/core/sitemap";
function createSitemapRoute(
options: { entries: (context: { locale: string; request: Request; }) => Promise<readonly SitemapEntry[]>; maxAge?: number | undefined; },
): (
request: Request,
context: { params: Promise<{ locale: string }> },
) => Promise<Response>
OptionTypeDescription
entries(context: { locale: string; request: Request; }) => Promise<readonly SitemapEntry[]>
maxAge?number

Returns ( request: Request, context: { params: Promise<{ locale: string }> }, ) => Promise<Response>

pageSitemapEntryA page's entry in locale's sitemap, its hreflang set made absolute on origin.
import { pageSitemapEntry } from "@evolve-storefront/core/sitemap";
function pageSitemapEntry(
options: { routing: LocaleRouting; locale: string; origin: string; paths: readonly LocalizedPath[]; homeSlug?: string | undefined; } & Omit<SitemapEntry, "url" | "languages">,
): SitemapEntry | undefined

Returns SitemapEntry | undefined

sitemapIndexXmlA sitemapindex document naming each sitemap.
import { sitemapIndexXml } from "@evolve-storefront/core/sitemap";
function sitemapIndexXml(locations: readonly string[]): string

Returns string

sitemapXmlA urlset document, with xhtml:link alternates for each entry's languages.
import { sitemapXml } from "@evolve-storefront/core/sitemap";
function sitemapXml(entries: readonly SitemapEntry[]): string

Returns string

handOffCartMergeKeep what a sign-in merged until a page that shows the cart takes it, across the reload a sign-in usually triggers.
import { handOffCartMerge } from "@evolve-storefront/core/cart-merge";
function handOffCartMerge(merge: CartMergeHandoff | null | undefined): void
OptionTypeDescription
mergedboolean
addedQuantitynumber
undoableboolean

Returns void

onCartMergeHandOffCall listener on each hand-off in this tab, for a page already showing the cart.
import { onCartMergeHandOff } from "@evolve-storefront/core/cart-merge";
function onCartMergeHandOff(listener: () => void): () => void

Returns () => void

peekCartMergeThe merge handOffCartMerge kept, left in place: for a page that shows it now but may reload straight after, as a sign-in usually does, so the next page still has it.
import { peekCartMerge } from "@evolve-storefront/core/cart-merge";
function peekCartMerge(): CartMergeHandoff | null

Returns CartMergeHandoff | null

takeCartMergeThe merge handOffCartMerge kept, removed as it is read, so it shows once.
import { takeCartMerge } from "@evolve-storefront/core/cart-merge";
function takeCartMerge(): CartMergeHandoff | null

Returns CartMergeHandoff | null

articlesTagEvery article, overview or topic list query, and Product.articles.
import { articlesTag } from "@evolve-storefront/core/cache-tags";
function articlesTag(locale: string): string

Returns string

createRevalidateRouteA POST handler for the framework's signed purge: { tags } in the body, the x-evolve-signature header as t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">.
import { createRevalidateRoute } from "@evolve-storefront/core/cache-tags";
function createRevalidateRoute(
options: { secret: string; revalidate: (tag: string) => void | Promise<void>; now?: (() => number) | undefined; },
): (request: Request) => Promise<Response>
OptionTypeDescription
secretstring
revalidate(tag: string) => void | Promise<void>
now?(() => number)

Unix seconds; for tests.

Returns (request: Request) => Promise<Response>

layoutTagNavigation and site settings.
import { layoutTag } from "@evolve-storefront/core/cache-tags";
function layoutTag(locale: string): string

Returns string

messagesTagThe store's fixed system copy, such as the empty cart and page-not-found text.
import { messagesTag } from "@evolve-storefront/core/cache-tags";
function messagesTag(locale: string): string

Returns string

pageTagThe tag of one page fetch, page:{locale}:{path}.
import { pageTag } from "@evolve-storefront/core/cache-tags";
function pageTag(locale: string, path: string): string

Returns string

pageTagsEvery page fetch carries pages:{locale}, which the framework purges when embedded content changes.
import { pageTags } from "@evolve-storefront/core/cache-tags";
function pageTags(locale: string, path: string): string[]

Returns string[]

Constants

DELIVERY_ADDRESS_FIELDSThe fields an address must carry to be delivered to — not merely rendered.
import { DELIVERY_ADDRESS_FIELDS } from "@evolve-storefront/core/address";

Every field in AddressFields is optional because the render path tolerates anything: a missing line simply collapses. Deliverability is the opposite question, and conflating the two is a live bug class — a cart carrying { postalCode: "3011CD" } is a non-null shippingAddress and an undeliverable order, so anything gating on presence alone lets it through.

Deliberately not country-specific. Real postal rules differ (Ireland has no postcode outside Dublin, Hong Kong has no postcode at all), and the backend is the authority on whether an address is acceptable. This is the storefront's floor: enough to stop a request that cannot succeed.

const DELIVERY_ADDRESS_FIELDS: readonly ["recipient", "streetName", "streetNumber", "postalCode", "city", "country"]
PRODUCT_AVAILABILITYschema.org's availability per ProductAvailabilityType, the framework's enum.
import { PRODUCT_AVAILABILITY } from "@evolve-storefront/core/seo";
const PRODUCT_AVAILABILITY: { readonly IN_STOCK: "https://schema.org/InStock"; readonly OUT_OF_STOCK: "https://schema.org/OutOfStock"; readonly BACKORDER: "https://schema.org/BackOrder"; r…

Types

MoneyMoney as minor units + ISO currency.
import { type Money } from "@evolve-storefront/core/money";
export type Money = { centAmount: number; currency: string };
AddressFieldsThe fields an address rendering reads — the contract between anything that stores an address and anything that displays one.
import { type AddressFields } from "@evolve-storefront/core/address";

Deliberately the loosest shape of every address type in the workspace, so a saved Address (with its required postalCode/country) and an order's OrderAddress (where both are optional and there is no id) are each assignable without a cast. It cannot be derived from those types, because core must not import a feature; instead each feature asserts it still satisfies this contract, so a drift fails their build rather than this one.

id and salutation are absent because no rendering reads them. Leaving salutation out also sidesteps the one genuine type conflict between the feature shapes — a Salutation union on one side, a bare string on the other.

export type AddressFields = {
company?: string | null;
givenName?: string | null;
familyName?: string | null;
streetName?: string | null;
streetNumber?: string | null;
streetNumberSuffix?: string | null;
additionalAddressInfo?: string | null;
postalCode?: string | null;
city?: string | null;
region?: string | null;
country?: string | null;
};
AddressGroupOne letter's addresses, under the heading a contact list would give them.
import { type AddressGroup } from "@evolve-storefront/core/address";
export type AddressGroup<Address extends AddressFields = AddressFields> = {
initial: string;
addresses: Address[];
};
DeliverableAddressAn address whose postal fields are known to be filled.
import { type DeliverableAddress } from "@evolve-storefront/core/address";
export type DeliverableAddress<Address extends AddressFields = AddressFields> =
Address & Record<(typeof POSTAL_FIELDS)[number], string>;
DeliveryAddressField
import { type DeliveryAddressField } from "@evolve-storefront/core/address";
export type DeliveryAddressField = (typeof DELIVERY_ADDRESS_FIELDS)[number];
PaginationThe schema's shared Pagination, carried by every <Thing>Result.
import { type Pagination } from "@evolve-storefront/core/data";
export type Pagination = {
page: number;
pageSize: number;
totalCount?: number | null;
pageCount?: number | null;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
AlternatesNext's Metadata["alternates"], as far as these helpers fill it.
import { type Alternates } from "@evolve-storefront/core/seo";
export type Alternates = {
languages: Record<string, string>;
canonical?: string;
};
LocaleRoutingThe slice of a next-intl routing config the SEO helpers read: which locales exist, and how each one's URLs are prefixed.
import { type LocaleRouting } from "@evolve-storefront/core/seo";
export type LocaleRouting = {
locales: readonly string[];
defaultLocale?: string;
localePrefix?:
| "always"
| "as-needed"
| "never"
| {
mode?: "always" | "as-needed" | "never";
prefixes?: Partial<Record<string, string>>;
};
};
LocalizedPathA page's route in one locale, as the schema spells it — a path or a slug.
import { type LocalizedPath } from "@evolve-storefront/core/seo";
export type LocalizedPath =
| { locale: string; path: string }
| { locale: string; slug: string };
OrganizationJsonLdInput
import { type OrganizationJsonLdInput } from "@evolve-storefront/core/seo";
export type OrganizationJsonLdInput = {
name: string;
url: string;
description?: string | null;
logo?: string | null;
sameAs?: readonly (string | null | undefined)[];
};
ProductJsonLdInput
import { type ProductJsonLdInput } from "@evolve-storefront/core/seo";
export type ProductJsonLdInput = {
name: string;
origin: string;
brand?: string | null;
category?: string | null;
variants: readonly ProductJsonLdVariant[];
dimensions?: readonly {
name: string;
options: readonly { label: string; sku?: string | null }[];
}[];
};
ProductJsonLdVariant
import { type ProductJsonLdVariant } from "@evolve-storefront/core/seo";
export type ProductJsonLdVariant = {
sku: string;
availability?: string | null;
description?: string | null;
images?: readonly string[];
price?: Money | null;
regularPrice?: Money | null;
path: string;
};
WebSiteJsonLdInput
import { type WebSiteJsonLdInput } from "@evolve-storefront/core/seo";
export type WebSiteJsonLdInput = {
name: string;
origin: string;
locale?: string;
searchPath?: string;
queryParam?: string;
};
SitemapEntryOne <url> of a sitemap, with its hreflang set where the page has translations.
import { type SitemapEntry } from "@evolve-storefront/core/sitemap";
export type SitemapEntry = {
url: string;
languages?: Record<string, string>;
lastModified?: string;
changeFrequency?:
| "always"
| "hourly"
| "daily"
| "weekly"
| "monthly"
| "yearly"
| "never";
priority?: number;
};
CartMergeHandoffWhat a sign-in did with the session's guest cart (schema CartMerge), handed from the package that signs in to the one that shows the cart, which import nothing from each other.
import { type CartMergeHandoff } from "@evolve-storefront/core/cart-merge";
export type CartMergeHandoff = {
merged: boolean;
addedQuantity: number;
undoable: boolean;
};