A shop plugin and a commerce theme for Mallok. Built for cross-border sellers who want to rank for what buyers actually search — not fight for the head term everyone else is bidding on.
Status: in development. Nundar is being rebuilt on Mallok. The catalogue, the cart, requests for a quote, checkout with Stripe, the seller's orders, customers and sales, and a second theme work locally; a purchase at Stripe has not been run end to end yet, and nothing has been deployed — see What works today. It is not ready to run a real shop.
If you manufacture something and want to sell it abroad, a product is usually treated as one page. That is the mistake. A buyer searching "titanium screws for semiconductor vacuum chambers" is far closer to purchase than one searching "titanium screw" — and almost nobody is competing for the first phrase.
A product is a content system, not a page.
| Content | Answers | Example search it can win |
|---|---|---|
| Product | What is it | M5 titanium socket head cap screw |
| Application page | Where is it used, and why | titanium screws for semiconductor vacuum chambers |
| Collection | Which products share this property | titanium countersunk screws |
An application page is its own landing page, with its own URL, title and structured data, and its own slug in each language. The sample catalogue writes one per industry:
/industries/semiconductor-vacuum
/de/industries/halbleiter-und-ultrahochvakuum
/fr/industries/semi-conducteurs-et-ultravide
/es/industries/semiconductores-y-ultra-alto-vacio
Each links to the product it discusses, the product lists the pages written about it, and every language version points at the others with correct hreflang. A case study does the same for a piece of work that was delivered.
Writing one is a deliberate act. A product's features, and the applications that do not deserve a page of their own, stay inside the product page. A page per feature would only multiply near-identical pages across products, which drags a whole domain down. Attribute searches are served by collections instead: one page that gathers the products sharing a property, so a buyer can compare them. For the same reason a product is one page with its sizes listed on it, not a page per size.
Nundar is not a second application beside Mallok. A shop is a Mallok site with a plugin and a theme added; the rest is a sample to start from:
| Part | Where | What it owns |
|---|---|---|
| Shop plugin | src/plugins/shop/ |
Variants, prices per currency, stock, minimum order quantities, the cart, the cart sent as an inquiry, exchange-rate repricing, shipping zones, checkout with Stripe, orders and their emails |
| Commerce theme | src/theme/ |
How the home page, the specification finder and the product, application, collection, case study, question, reference, cart, checkout and order pages look, in four languages |
| Editorial theme | src/theme-editorial/ |
The same pages drawn the opposite way — see Themes |
| Sample content | content/, seed/, site.json |
A titanium fastener catalogue in four languages: six products in five collections, five industry pages, four case studies, questions and answers, reference pages, and the site's own copy |
| Starter | src/starter/ |
The same sample, offered by Mallok's first-run wizard to a newly deployed site |
Mallok provides everything else: content and its editor, languages and hreflang, the sitemap, the admin and sign-in, media, the edge cache, email. That boundary is deliberate — commerce logic lives only in the plugin, and nothing about pages is reimplemented here.
Everything runs on Cloudflare Workers with D1 and R2. Local development needs no Cloudflare account.
Nundar builds on mallok@0.1.0-rc.11, the first release with the plugin API the rest of the shop needs. The right-hand column is what has not been built on it yet.
| Works today | Not built yet | |
|---|---|---|
| Pages | A home page with a specification finder; product pages with their sizes and SKUs, and for each size its price, minimum order, availability and lead time, read from the shop while the page is rendered and cached with it; the same offers in the page's Product structured data; a starting price under each product in the finder and the catalogue; collection, industry, case study, question, engineering reference and contact pages — all in English, German, French and Spanish, with hreflang, canonicals, sitemap and FAQ structured data; self-hosted fonts; prices in US dollars on English pages and in euros on the others, with a switch to any currency the shop prices in; no client JavaScript except three small scripts, each added to a page that is complete without it: filters for the finder, the currency switch, and calculators on the engineering reference page |
Prices beside the products a collection page lists (Mallok does not yet tell a plugin which products a content page shows) |
| Catalogue data | Variants, prices as integer minor units, stock, MOQ, lead time, a made-to-order policy — edited in the admin, in a form under each product's editor; every change of stock goes into a ledger; a deleted product takes its variants with it | |
| Pricing | USD base price; EUR and GBP derived from ECB rates with a buffer, rounding to a price point and a drift threshold; manual prices never overwritten; the pages of a product are purged from the cache when one of its prices moves | |
| Cart | A form beside each size on a product page, and a cart page in the site's own design and language: set a quantity, remove a line, choose a currency. No script anywhere in it. MOQ, stock and the most a line may hold (500) are enforced by the server, which says what it refused on the cart page | |
| Inquiries | The cart sent as one request for a quote, from a form on the cart page: who to answer, and a message. A size with no price can go in the cart to be asked about. The buyer lands on the inquiry's number; the seller is told by email, answerable straight to the buyer, and finds every inquiry in the admin with its lines — to mark, export as CSV, or delete | A challenge in front of the form (it waits for Mallok), which is why the email confirming to the buyer is off unless switched on |
| Taking your data with you | What the shop holds is in the site's export (mallok export), beside the content: variants, prices, the stock ledger, orders, payment events, inquiries and the shop's settings, a JSON file each under shop/, as stored |
An import of those files; an export of a table too large for one request (both wait for Mallok) |
| Shipping | Zones set in the admin: the countries each covers, a fixed rate in each currency, and an amount from which it ships free. A country in no zone is not offered at checkout | Rates by weight |
| Checkout and payment | A checkout page in the site's own design and language, with no script: the country the order goes to, shipping and the total for it, the buyer's company and address, then Stripe's hosted page to pay — by card, or Link, at the shop's own prices with Stripe's currency conversion off. Tax is not charged unless the shop switches Stripe Tax on. An order's own page, opened by the signed link Stripe returns the buyer to and every email carries. A payment takes stock exactly once however often Stripe reports it, is held to the order's total, and empties what was ordered from the cart; an unpaid order is cancelled an hour after its payment page lapses | A purchase made at Stripe in test mode has not been run yet (it needs a shop's own test keys); buyer accounts; invoices of the shop's own; partial refunds |
| Orders in the admin | Every order with its lines, its address and its amounts: mark shipped (tracking number and carrier), delivered, cancel one that was not paid, refund in full — at Stripe first, then the books, with the stock back unless the goods have shipped — or fulfil one that was paid for after its stock had gone, once restocked. Payments the shop could not keep are listed to refund. The buyer is written to in their language when an order is confirmed, shipped, refunded or cancelled; the seller is told of every new order and every refund owed | |
| Customers and sales | Every address that ordered or asked for a quote, with its orders and inquiries beneath it and what it has spent in each currency; sales by month — paid, refunded, net and the average order — and by product, per currency | |
| Finding an order again | No account to make: a buyer types the address they ordered with and is emailed a link, good for a day, to every order they paid for. The page says the same whether or not there were any, and asking is limited per visitor and per address | |
| Starting a site | A site deployed from this repository is offered the sample in Mallok's first-run wizard: the catalogue in four languages, its variants and prices, the shipping zones and the settings, with no command to run | The sample's pictures (a starter carries no files; npm run preview publishes them) |
The reasoning and the plan are in docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md.
- Money is always integer minor units. Never a float, anywhere.
- A page says whether a size can be had, never how many are left: a count would be wrong after the next sale, a state rarely is.
- The structured data offers exactly the prices the page prints. A test compares the two digit for digit.
- The cart stores variants and quantities only — never a price.
- MOQ, and the most a cart line may hold, are enforced by the form and by the server, because a form can be bypassed.
- An inquiry is a snapshot of the cart it was sent from, stored once however often the form arrives: tests send the same cart twice at the same moment.
- Nothing a buyer typed comes back through an address, becomes markup in an email, or runs as a formula in an exported spreadsheet.
- A public page is the same for every visitor and stays in the cache: nothing about a cart is in it, and the cart's cookie is sent only to the shop's own routes.
- Stock carries a database constraint, so a payment's decrement cannot go negative: a test proves the whole D1 batch rolls back.
- Stock comes off when a payment is confirmed, never before, and once: tests deliver the same payment twice at the same moment.
- A payment is checked against its order's currency and total; one that does not fit is kept for a refund, not taken for the order.
- Money moves at Stripe before the shop's books do: a refund is made at Stripe before the order says refunded, and a payment page is closed at Stripe before its order is cancelled. A cart has one payment page open at a time.
- An order's page opens only with the secret its link carries, never with its number alone.
- The form that emails a buyer their orders says the same whether or not there were any, in the same time, and writes only to an address a payment was made from.
- A payment is believed only with Stripe's signature on the exact bytes received, and only for five minutes.
- A manually set price is never overwritten by an exchange-rate refresh.
- Language is decided by the URL alone, never by the visitor's IP.
Requires Node.js 22 and npm. No Cloudflare account needed.
git clone https://github.com/fobstack/Nundar.git
cd Nundar
npm ci
npm run previewIn about a minute preview prints the address of a local shop with the sample catalogue in four languages, the address of its admin, and a login for it. Everything is set up for you: an administrator, the two plugins, the settings, the content with its images, the variants.
It is a throwaway. The database lives in a temporary directory and goes when you stop the command with Ctrl+C; the next run starts clean, with a new login. Use npm run preview -- --port 8800 if port 8799 is taken, and npm run preview -- --theme editorial to see the same shop in the other theme.
To see the same shop checked rather than shown — every kind of page, every navigation link in every language, the cart, the checkout page — run npm run smoke:shop.
The preview takes no payments: it has no Stripe keys, so its cart offers no checkout and its checkout page says payment is not available. Taking payments says how to give it some.
npm run dev starts an empty site whose database stays in .wrangler/ between runs. Filling it takes the steps preview does for you, and their order matters:
# First time only: local secrets. Never commit .dev.vars.
(umask 077; set -C; printf 'MALLOK_SECRET=%s\nMALLOK_SETUP_KEY=%s\n' \
"$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .dev.vars)
npm run dev- Open the URL Wrangler prints at
/_mallok/setup, not at/, and enter theMALLOK_SETUP_KEYfrom.dev.varsto create the administrator. If the page says the site has no setup key,.dev.varsis missing that line. - In the admin, at
/_mallok/app, switch the Shop and Inquiry plugins on, and create an API token with thecontent:write,settings:writeandmedia:writescopes.media:writeis for the images the sample bundles carry. - With that token in your environment, apply
site.json, publish the content and load the sample variants:
read -s "MALLOK_TOKEN?Paste the token: " && export MALLOK_TOKEN # zsh; in bash: read -s -p "Paste the token: " MALLOK_TOKEN && export MALLOK_TOKEN
npx mallok publish . --with-settings --url http://localhost:8787
npm run seed:localThe site is at / (English), /de/, /fr/ and /es/.
Only now open the site. Locally nothing clears the page cache: a page opened while the site was empty, or while a plugin was off, is served as it was for an hour, and restarting does not change that. If it happens, stop the server, delete .wrangler/state/v3/cache, and start it again; the database and the uploaded files are in the directories beside it and are not touched.
The sample is a supplier of titanium fasteners that does not exist. Its products, figures, certifications and case studies are illustrative, and its footer says so. It is there to show what the theme does with real-looking content, and to be replaced:
- Content is in
content/, one directory per page, with a Markdown file per language. Replace it with your own and publish. - The site's own copy — the name, the navigation, the home page's headline and sections, the footer — is in
site.json. Each language has its own values there; none of it is fixed in the theme. - Variants and prices are in
seed/shop-sample.sql, one variant for each SKU a product page lists, each with a US dollar base price and a euro and a sterling price entered by hand. Leave a currency out and the shop derives its price from the base price and the day's exchange rate. - Fonts are served from the site itself (
src/theme/assets/fonts/), under the SIL Open Font License; the licence texts and the source of each file are beside them. The theme loads nothing from a third party. - The starter (
src/starter/) is the same sample for Mallok's first-run wizard. Its list of documents is generated: after changingcontent/, runnpm run starter:documents, or the tests fail.
Two themes ship, and they are deliberately opposites. A theme is how a page looks and nothing else: what a page says, its hreflang and structured data, and every interface word are the same in both, so a theme can make a site look different but cannot change what a search engine reads or what a buyer is told.
Commerce (src/theme/, the default) |
Editorial (src/theme-editorial/) |
|
|---|---|---|
| Type | Space Grotesk and Inter, served by the site itself | DIN for headings and figures, Charter for text — from the visitor's own system (DIN Condensed and DIN Alternate on Apple's, Bahnschrift on Windows), with fallbacks |
| Look | Light and open, amber on white | An engineering bulletin: titanium grey, graphite and the site's accent, with attributes and results in a drawing's title block |
| Home page | A headline that points at the specification finder, then the collections and the finder | A headline beside a picture of a part, then the industries, the case studies with their results, a catalogue with pictures, and the product types |
| Scripts | Three small ones, each adding to a page that works without it | None |
| Files of its own | A stylesheet, fonts and images | None: its styles are in the page |
The second exists to keep the contract honest: a contract with one implementation is only a guess. Its tests hold its manifest to the commerce theme's and draw every page of the sample in it.
To deploy the editorial theme instead, set main in wrangler.jsonc to src/worker/editorial.ts. To build your own, copy src/theme-editorial/, give theme.json a new id, and add a Worker entry like src/worker/editorial.ts that names it. A theme with files of its own (fonts, pictures, a script) has to be the one at src/theme/: Mallok stages the files of that directory only.
The shop takes payments through Stripe's hosted checkout, on your own Stripe account. Until both of its Stripe secrets are set, it takes none, and says so.
- Shipping zones. In the admin, under the shop's Shipping zones, add a zone for each group of countries you ship to, with its rate in each currency you sell in. A country in no zone cannot be checked out to. The sample has four (
seed/shop-sample.sql). - The secret key. In Stripe's dashboard, under Developers → API keys, copy the secret key —
sk_test_…while you try it out — and enter it in the admin as the shop's Stripe secret key. The Test button beside it asks Stripe whether it accepts the key. - The webhook endpoint. In Stripe, add an endpoint at
https://<your site>/_mallok/p/shop/stripe/webhookfor the eventspayment_intent.succeededandcheckout.session.expired, on API version2026-10-28.endive— the version this code was written against. Stripe's dashboard creates an endpoint on the latest version; for another, create it through Stripe's API withapi_version. Enter the endpoint's signing secret (whsec_…) as the shop's Stripe webhook signing secret. Until it is set, the shop answers Stripe's deliveries with 503, and Stripe keeps them and delivers them again for up to three days. - The notification address. The shop's Notification email setting is where new orders, refunds owed and inquiries are announced. Email goes through the site's own email settings (Settings → Email).
- Optional. Tax set to
stripehas Stripe Tax add tax at payment, wherever your Stripe account is registered to collect it, for Stripe's fee; it isnoneby default, and prices are without tax. Terms page names a page a buyer ticks that they accept. Order number prefix isNDunless you set another.
To try it on a local site (npm run dev), Stripe's command-line tool can forward test events to it — stripe listen --forward-to http://localhost:8787/_mallok/p/shop/stripe/webhook --events payment_intent.succeeded,checkout.session.expired --latest — and prints the signing secret to enter. Without --latest, it sends events at your account's default API version. Pay on Stripe's page with one of Stripe's test cards.
| Command | What it does |
|---|---|
npm run preview |
A local shop with the sample catalogue and an admin login, on a throwaway database (-- --theme editorial for the other theme) |
npm run dev |
Local development server, with local D1 and R2 that keep their data; starts empty |
npm run build |
Stage assets, then a production build (a deploy dry run) |
npm test |
Project checks, then the plugin and theme tests inside the real Workers runtime |
npm run lint / npm run typecheck |
Lint and type checks |
npm run smoke |
A real request to a real local Worker |
npm run smoke:shop |
The whole shop on a real local Worker |
npm run seed:local |
Load the sample variants into the local database |
npm run starter:documents |
Regenerate the starter's list of documents after changing content/ |
Not yet. The shop can take payments now, and a purchase in Stripe's test mode has still to be run end to end on a real Stripe account before anyone relies on it. Nothing here has been measured on a real Cloudflare account either. When Nundar is ready, deployment is Mallok's own: npx mallok create . --slug <slug>, which creates the Worker, the D1 database and the R2 bucket on your own Cloudflare account, after which Mallok's first-run wizard offers the sample shop to start from. That path has not been run for this repository.
| Language | URL | Default currency |
|---|---|---|
English (default, carries x-default) |
/… |
USD |
| German | /de/… |
EUR |
| French | /fr/… |
EUR |
| Spanish | /es/… |
EUR |
Currencies: USD (the base, priced by hand), EUR and GBP. Adding a language means adding it to site.json and to the theme's locales/, then translating content — no schema change, because every language version is its own content item.
docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md— how Nundar is built on Mallok, and whydocs/superpowers/specs/2026-09-03-nundar-design.md— the original design; its commerce decisions still hold, its architecture is supersededdocs/superpowers/plans/— phase plans, and what implementation uncovered
Read the specs before changing anything structural. If a change contradicts a recorded decision, update the spec in the same pull request and explain the new reasoning.
The previous implementation, a standalone Next.js application, is kept at the tag nextjs-final.
See CONTRIBUTING.md. Contributions require a signed ICLA — one line, with the reasoning explained rather than assumed.
Please do not open a public issue for a security problem. See SECURITY.md.
Dual-licensed under MIT OR Apache-2.0, at your option. Mallok, which Nundar depends on, is Apache-2.0.
English is the authoritative version. Translations are provided for convenience and may lag behind it:
Corrections belong in the English version first.