core
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
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| Option | Type | Description |
|---|---|---|
centAmount | number | |
currency | string |
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[]| Option | Type | Description |
|---|---|---|
centAmount | number | |
currency | string |
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| Option | Type | Description |
|---|---|---|
centAmount | number | |
currency | string |
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| Option | Type | Description |
|---|---|---|
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[]| Option | Type | Description |
|---|---|---|
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| Option | Type | Description |
|---|---|---|
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>[]| Option | Type | Description |
|---|---|---|
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| Option | Type | Description |
|---|---|---|
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| Option | Type | Description |
|---|---|---|
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[]| Option | Type | Description |
|---|---|---|
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.DisplayNamesthrows 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 thatZZis 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): stringReturns 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| Option | Type | Description |
|---|---|---|
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>| Option | Type | Description |
|---|---|---|
name | string | |
url | string | |
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| Option | Type | Description |
|---|---|---|
name | string | |
origin | string | The site's absolute origin, such as |
variants | readonly 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>| Option | Type | Description |
|---|---|---|
name | string | |
origin | string | The site's absolute origin, such as |
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| Option | Type | Description |
|---|---|---|
routing | LocaleRouting | |
paths | readonly LocalizedPath[] | null | undefined | |
locale | string | 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. |
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| Option | Type | Description |
|---|---|---|
locales | readonly 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[]>| Option | Type | Description |
|---|---|---|
pageSize | number | |
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>| Option | Type | Description |
|---|---|---|
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>| Option | Type | Description |
|---|---|---|
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 | undefinedReturns SitemapEntry | undefined
sitemapIndexXmlA sitemapindex document naming each sitemap.
import { sitemapIndexXml } from "@evolve-storefront/core/sitemap";function sitemapIndexXml(locations: readonly string[]): stringReturns 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[]): stringReturns 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| Option | Type | Description |
|---|---|---|
merged | boolean | |
addedQuantity | number | |
undoable | boolean |
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): () => voidReturns () => 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 | nullReturns 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 | nullReturns CartMergeHandoff | null
articlesTagEvery article, overview or topic list query, and Product.articles.
import { articlesTag } from "@evolve-storefront/core/cache-tags";function articlesTag(locale: string): stringReturns 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>| Option | Type | Description |
|---|---|---|
secret | string | |
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): stringReturns 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): stringReturns string
pageTagThe tag of one page fetch, page:{locale}:{path}.
import { pageTag } from "@evolve-storefront/core/cache-tags";function pageTag(locale: string, path: string): stringReturns 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;};
