Developers
Conventions
The rules the storefront codebase follows — strict TypeScript, pnpm, Server Components first, logical CSS for RTL, message catalogs, SEO through PageSeo, centralised brand identity, money at the display edge, and ADR discipline.
These are the conventions the storefront repository holds itself to. Most are enforced by the type checker, ESLint, a CI guard or a smoke test, so breaking one usually fails a build rather than a review. Each section says what enforces it.
TypeScript and tooling
Section titled “TypeScript and tooling”| Rule | Enforced by |
|---|---|
TypeScript is fully strict everywhere: strict, noUncheckedIndexedAccess, verbatimModuleSyntax, isolatedModules |
tsconfig.base.json, which every package extends; pnpm typecheck in CI |
| pnpm only, at the pinned version. No npm or Yarn lockfiles | packageManager in the root package.json; CI installs with --frozen-lockfile |
| Node 22 or newer | engines in the root package.json; CI uses Node 22 |
Packages ship raw TypeScript through exports; no per-package build |
transpilePackages in apps/storefront/next.config.ts |
Import packages through their public exports, never @welabs/*/src/... |
ESLint no-restricted-imports |
| Only the Composition Root names an adapter package | ESLint no-restricted-imports, with a carve-out for lib/commerce/adapters/index.ts, tests and e2e/ |
With verbatimModuleSyntax, type-only imports must say so: import type { Product } from "@/lib/commerce".
Server Components first
Section titled “Server Components first”Pages and layouts are Server Components by default. Keep "use client" boundaries small: a client island is the interactive piece
(the gallery, add to cart, a form), not the page around it. Data is read on the server through the Port, usually with
getLocalizedAdapter() or one of the thin wrappers in lib/server/, and passed down as canonical, serialisable values. Mutations go
through Server Actions in features/*/actions.ts, or Route Handlers under app/api/ where a client needs an endpoint.
Logical CSS only, for right-to-left
Section titled “Logical CSS only, for right-to-left”Arabic ships with the storefront, so every layout must mirror correctly. Components express direction in logical terms (start and end), never physical ones (left and right). ADR 0005 calls this RTL-first styling.
| Don’t | Do |
|---|---|
ml-4, mr-4 |
ms-4, me-4 |
pl-2, pr-2 |
ps-2, pe-2 |
left-0, right-0 |
start-0, end-0 |
text-left, text-right |
text-start, text-end |
rounded-l-md, rounded-r-md |
rounded-s-md, rounded-e-md |
border-l, border-r |
border-s, border-e |
Two guards enforce it. The ESLint rule noPhysicalDirectionUtilities (from eslint.rtl.mjs) flags those utilities in string
literals and template strings. A grep step in CI’s checks job repeats the check across the app, storefront-ui and templates.
A genuinely physical case (a popover positioned by computed side, say) carries an eslint-disable comment with its reason.
Message catalogs
Section titled “Message catalogs”All UI copy comes from message catalogs (next-intl), never from string literals in components.
- English is the Source Catalog. Other Locales merge over it, so a missing key renders English instead of failing.
- Each layer owns its keys, beside the components that render them: the app (
apps/storefront/messages/en.json,ar.json),storefront-ui(src/messages) and each Template Family intemplates(src/messages). The app deep-merges them per request, packages first and the app last. - Preset data uses Translatable Markers. A Shell Preset label is written as a message-key marker and resolved at the chrome boundary. Backend-sent text is never a marker: translating backend content is the backend’s job.
- Render the exact UI copy the SRS spec quotes. The §4 UI tables of each spec are the contract.
- Adding a Locale is catalog-only: add the files and list the Locale in
STOREFRONT_LOCALES. Direction is derived from the Locale (ar,fa,he,urare right-to-left).
SEO: the storefront prints, the backend supplies words
Section titled “SEO: the storefront prints, the backend supplies words”ADR 0023 (SEO) makes the storefront the only author of tags and structured data. The backend supplies words only: SEO Fields per item and SEO Patterns per item kind and Locale, from Yoast SEO, Rank Math or the Companion’s own box.
- A public page describes itself once, as a
PageSeo(lib/seo/metadata.ts), and hands it to bothpageMetadata()(title, description, canonical address fromSITE_URL, hreflang alternates, Open Graph and Twitter tags, robots) and<PageJsonLd>(the structured data). Building it once means the two cannot disagree. - Never assemble metadata or JSON-LD by hand. Any other JSON-LD block goes through
<JsonLd>, which escapes it. - A private page (account, cart, checkout, sign-in, search, compare, order confirmation, email links) carries
robots: NOINDEXfromlib/seo/robots.ts: through its route-group layout, or spread into its owngenerateMetadata, because Next.js replacesrobotsrather than merging it. - Add every new route to
e2e/smoke/seo.spec.ts, in the public or the private list.
This excerpt is abbreviated from the FAQ page:
async function faqSeo(title: string): Promise<PageSeo> { const [menu, meta] = await Promise.all([getTranslations("chrome.menu"), getTranslations("meta")]); return underHome({ kind: "faq", path: "/faq", title, description: meta("faq.description") }, menu("home"));}
export async function generateMetadata(): Promise<Metadata> { const { title } = await resolveFaq(); return pageMetadata(await faqSeo(title));}
export default async function FAQPage() { // … return ( <article className="pb-16"> <PageJsonLd page={await faqSeo(title)} /> {jsonLd && <JsonLd data={jsonLd} />} {/* … */} </article> );}And a private route group, from app/[locale]/(auth)/layout.tsx:
// Sign-in, registration and password pages are never indexed.export const metadata: Metadata = { robots: NOINDEX };Brand and store identity are centralised
Section titled “Brand and store identity are centralised”Never hardcode the store name, tagline or currency. Brand identity lives in lib/config/site.ts (siteConfig, BRAND,
siteTitle), store identity in lib/config/store.ts. Static informational content (About, store coordinates) is typed config in
lib/config/pages/, read through the one swap seam getPageContent() and rendered with components/storefront/prose.tsx. Policy
Pages are the exception: they come from the backend through the Port (ADR 0016). See Store identity.
Money at the display edge
Section titled “Money at the display edge”Money is integer minor units from the adapter to the moment it is rendered. Format only there: formatMinor() on the server with
the store Currency, useFormatMinor() in client components. Never divide by 100 and never format in an adapter or a Server Action.
Details in Canonical model.
Buttons
Section titled “Buttons”Before touching a button, pill call to action, icon disc or text link styled as a button, read the repository’s
storefront-buttons skill. The pill geometry, the hover sweep and the cascade-layer rules they depend on are not guessable from
the class lists.
Vocabulary and decisions
Section titled “Vocabulary and decisions”- Use the terms in
CONTEXT.mdin code, comments, tickets and docs: Shopper, not “user”; Processor, not “gateway”, above the Port; Capability, not “module”. Each entry lists the words to avoid. - When vocabulary changes, update
CONTEXT.mdin the same change. - Write an ADR in
docs/adr/for any decision that is hard to reverse, numbered after the last one. When a new decision withdraws part of an earlier one, say so in both status lines, as ADR 0025 and ADR 0022 do. - New backend-visible behaviour needs a contract-suite section that both adapters pass.