From ff7157489aa7efd3cd149b8363c54b70308891fd Mon Sep 17 00:00:00 2001 From: JasonYv Date: Thu, 8 Oct 2026 17:15:46 +0800 Subject: [PATCH 1/5] chore: move to mallok 0.1.0-rc.11, the release with plugin API 2 Pinned exactly: rc.11 is under npm's next tag only, and latest is still rc.10. The upgrade changed no code. Its notes ask every site for a second rate-limit binding, RATE_LIMITER_RELAXED, which mallok upgrade does not add; it takes the strict tier's namespace plus one. --- CLAUDE.md | 24 +++++++++++++++--------- README.md | 4 ++-- README.zh-CN.md | 4 ++-- package-lock.json | 8 ++++---- package.json | 2 +- test/project.test.ts | 16 ++++++++++++---- wrangler.jsonc | 14 +++++++++++--- 7 files changed, 47 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a85d57..3aa3624 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this repository is -Nundar is a **shop plugin and a commerce theme for Mallok**, packaged as a Mallok site. It is not a standalone application: Mallok (the `mallok` npm package, pinned to an exact version, currently `0.1.0-rc.9`) provides routing, rendering, content, languages, hreflang, the sitemap, the admin, sign-in, media, email and the edge cache. Nundar adds only commerce. +Nundar is a **shop plugin and a commerce theme for Mallok**, packaged as a Mallok site. It is not a standalone application: Mallok (the `mallok` npm package, pinned to an exact version, currently `0.1.0-rc.11`) provides routing, rendering, content, languages, hreflang, the sitemap, the admin, sign-in, media, email and the edge cache. Nundar adds only commerce. `docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md` is the source of truth for the architecture, with the owner's decisions in its §11. The commerce rules in `docs/superpowers/specs/2026-09-03-nundar-design.md` (§4–§7) still hold; its stack and architecture are superseded. The previous standalone Next.js implementation is at the tag `nextjs-final` — read it for reference, never restore it. -Nundar is in development. Several features wait for extension points Mallok does not have yet (design §7). **Do not work around a missing Mallok capability** — no writing into Mallok's core tables, no `onRequest` hook to intercept pages, no client-side fetching of prices. The Mallok-side work is done by the owner in the Mallok repository; do not edit that repository from a session here. +Nundar is in development. `mallok@0.1.0-rc.11` carries plugin API 2 — the extension points of design §7 — and several features are not built on them yet (the list is below). **Do not work around a missing Mallok capability** — no writing into Mallok's core tables, no `onRequest` hook to intercept pages, no client-side fetching of prices. A defect or a gap met in Mallok is written up for the owner, who does the Mallok-side work in the Mallok repository; do not edit that repository from a session here. ## Commands @@ -72,15 +72,21 @@ Mallok decides the contracts on both sides. Its documentation is the reference: - Each language of a bundle is its own content item with its own `slug` (`index.md`, `index..md`); Mallok puts them in one `translation_group`. A bundle carries a `mallok.json` only when something outside the content must name it: each product's fixes the `translation_group` that `seed/shop-sample.sql` attaches its variants to. `test/content.test.ts` checks the reference rule, keeps every identity file in step with its bundle, and holds the seed's variants to the SKUs the product pages list. - The default language (English) is unprefixed; others are `//…`. English pages default to USD, the rest to EUR (`lib/currency.ts`), never by IP. -### Known limits of mallok 0.1.0-rc.9 that shape the code +### What is not built on plugin API 2 yet -- No render-time hook with database access, so prices and variants are not on pages yet. -- Plugin routes cannot render through the theme, so there is no cart page yet. -- Plugin admin panels are read-only tables; variants are seeded from `seed/shop-sample.sql`. -- Plugin routes receive a parsed body, so a Stripe signature cannot be checked in one: there is no webhook route, and so no checkout. -- `reference[]` fields are not resolved, so a product names one collection. +`mallok@0.1.0-rc.11` allows each of these; the code here does not use it yet. -Each is a task in Mallok's plan for plugin API 2. When Mallok ships one, upgrade, remove the corresponding limitation here, and prove the new behaviour with a test or the smoke run. +- No `renderData` hook, so prices and variants are not on pages. +- No page route and no `pluginLayouts` in the theme, so there is no cart page. +- The variants panel is a read-only table; variants are seeded from `seed/shop-sample.sql`. +- No raw-body route, so no Stripe webhook, and so no checkout. +- The sample's products name one collection each, though `reference[]` is resolved now. + +When one is built, take it off this list and prove the behaviour with a test or the smoke run. + +### Known limits of mallok 0.1.0-rc.11 that shape the code + +- Switching a plugin on or off, or changing a setting, can leave cached pages as they were — and the admin's "Clear cached pages" can report success without clearing anything. So the plugins are switched on before the first publish (`scripts/lib/local-shop.mjs`, the README), and `test/shop/helpers.ts` deletes the cached home page by hand after changing settings. Keep both until a Mallok release says the purge can be trusted. ## Testing diff --git a/README.md b/README.md index f2bf247..cac4d7e 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ Everything runs on Cloudflare Workers with D1 and R2. Local development needs no ## What works today -Nundar builds on `mallok@0.1.0-rc.9`. Some of the shop needs extension points Mallok does not have yet; those parts wait for them rather than being worked around. +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 | Waits for Mallok's next plugin API | +| | Works today | Not built yet | |---|---|---| | **Pages** | A home page with a specification finder; product pages with their sizes and SKUs; 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; no client JavaScript except two small scripts, each added to a page that is complete without it: filters for the finder, and calculators on the engineering reference page | Prices, variants and availability on the page; `Offer` structured data | | **Catalogue data** | Variants, prices as integer minor units, stock, MOQ, lead time, a made-to-order policy | Editing them in the admin (read-only for now; the sample data is loaded from SQL) | diff --git a/README.zh-CN.md b/README.zh-CN.md index ca26d2a..5f55f3e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -51,9 +51,9 @@ Nundar 不是 Mallok 旁边的第二个应用。一个商城就是一个加了 ## 现在能用什么 -Nundar 基于 `mallok@0.1.0-rc.9`。商城的一部分功能需要 Mallok 目前还没有的扩展点,这些部分会等扩展点就绪,不做绕路实现。 +Nundar 基于 `mallok@0.1.0-rc.11`,这是第一个带有商城其余部分所需插件 API 的版本。右侧一列是尚未在它之上实现的部分。 -| | 现在可用 | 等 Mallok 的下一版插件接口 | +| | 现在可用 | 尚未实现 | |---|---|---| | **页面** | 带规格查找表的首页;列出尺寸和 SKU 的商品页;聚合页、行业页、案例页、问答页、工程资料页和联系页——全部有英、德、法、西四种语言,带 `hreflang`、canonical、sitemap 和 FAQ 结构化数据;字体由站点自己提供;除了两个小脚本之外没有客户端 JavaScript,而且页面没有它们也是完整的:一个给规格查找表加筛选,一个在工程资料页上提供计算器 | 页面上的价格、规格和库存状态;`Offer` 结构化数据 | | **目录数据** | 规格、以整数最小单位存储的价格、库存、起订量、交期、按单生产策略 | 在后台编辑这些数据(目前只读,示例数据由 SQL 载入) | diff --git a/package-lock.json b/package-lock.json index f5f90f2..f43b3d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "MIT OR Apache-2.0", "dependencies": { - "mallok": "0.1.0-rc.9", + "mallok": "0.1.0-rc.11", "zod": "4.4.3" }, "devDependencies": { @@ -2267,9 +2267,9 @@ } }, "node_modules/mallok": { - "version": "0.1.0-rc.9", - "resolved": "https://registry.npmjs.org/mallok/-/mallok-0.1.0-rc.9.tgz", - "integrity": "sha512-X+UJKYeMY+h9rlRF6gLkMwU4Ub8AUVqAPJpE7WQ2YJmLEFotZdcFCp7LUf4TYvcowwyrGKtJ3pUuQ2xQ6bBPjA==", + "version": "0.1.0-rc.11", + "resolved": "https://registry.npmjs.org/mallok/-/mallok-0.1.0-rc.11.tgz", + "integrity": "sha512-OFlqNoBCTNrTOWp6eHAi3lylfERM6vLeR3Uhx48Zkp9ntBpQJGa9vBUsGX9k3miOusZJvVcWVySQE6IIgIETTQ==", "license": "Apache-2.0", "dependencies": { "@types/mdast": "4.0.4", diff --git a/package.json b/package.json index 04ae8fb..2a59aee 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "smoke:shop": "node scripts/smoke-shop.mjs" }, "dependencies": { - "mallok": "0.1.0-rc.9", + "mallok": "0.1.0-rc.11", "zod": "4.4.3" }, "devDependencies": { diff --git a/test/project.test.ts b/test/project.test.ts index f1deb33..440f543 100644 --- a/test/project.test.ts +++ b/test/project.test.ts @@ -42,13 +42,21 @@ describe('wrangler.jsonc', () => { ); assert.equal((config.assets as { binding: string }).binding, 'ASSETS'); - // Optional to the Worker, but its absence silently disables the rate - // limit on plugin routes such as inquiry submission. + // Optional to the Worker, but without the first nothing limits a plugin + // route such as inquiry submission, and without the second a route that + // asks for the relaxed tier is held to the strict one. const limits = config.ratelimits as | { name: string; namespace_id: string }[] | undefined; - assert.equal(limits?.[0]?.name, 'RATE_LIMITER'); - assert.match(limits?.[0]?.namespace_id ?? '', /^\d+$/); + assert.deepEqual( + limits?.map((limit) => limit.name), + ['RATE_LIMITER', 'RATE_LIMITER_RELAXED'], + ); + for (const limit of limits ?? []) { + assert.match(limit.namespace_id, /^\d+$/); + } + // Two tiers sharing a namespace would count against one limiter. + assert.notEqual(limits?.[0]?.namespace_id, limits?.[1]?.namespace_id); }); it('keeps the cron trigger scheduling and cleanup need', async () => { diff --git a/wrangler.jsonc b/wrangler.jsonc index a2606ae..f259026 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -41,14 +41,22 @@ "triggers": { "crons": ["* * * * *"] }, - // Best-effort protection for plugin routes such as inquiry submission. The - // namespace id must be unique within the account; `mallok create` derives a - // stable one from the site slug. + // Best-effort protection for plugin routes such as inquiry submission. + // Two tiers: a route declares "strict" (or `true`) or "relaxed", and each + // route has its own count per visitor. The limits are yours to change; + // `period` must be 10 or 60 seconds. Each namespace id must be unique + // within the account; `mallok create` derives stable ones from the site + // slug, and the relaxed tier takes the strict one's number plus one. "ratelimits": [ { "name": "RATE_LIMITER", "namespace_id": "510736757", "simple": { "limit": 10, "period": 60 } + }, + { + "name": "RATE_LIMITER_RELAXED", + "namespace_id": "510736758", + "simple": { "limit": 120, "period": 60 } } ], "vars": { From 7df73ac258e12d572b9519216d6df50b47cd08ad Mon Sep 17 00:00:00 2001 From: JasonYv Date: Thu, 8 Oct 2026 17:21:55 +0800 Subject: [PATCH 2/5] feat: label the inquiry form in German, French and Spanish Mallok's inquiry plugin now reads its labels from the theme's language pack, key by key, and keeps its own English and Chinese as the fallback. The six keys go into the three packs the plugin has no words for, and stay out of the default pack: a pack falls back to the default one, so English keys there would replace the plugin's own text in every language this theme has no pack for. --- CLAUDE.md | 2 +- scripts/smoke-shop.mjs | 13 +++- src/theme/locales/de.json | 6 ++ src/theme/locales/es.json | 6 ++ src/theme/locales/fr.json | 6 ++ test/theme/inquiry.test.ts | 139 +++++++++++++++++++++++++++++++++++++ 6 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 test/theme/inquiry.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3aa3624..75f789b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ Mallok decides the contracts on both sides. Its documentation is the reference: - **A product is one page with its sizes on it**, not a page per size. `facets` are the attributes a buyer filters by; `sizes` maps each SKU to what distinguishes it; `specs` is the full table. `partials/spec-table.liquid` (the specification finder, the catalogue, a collection's products, a product's neighbours) takes its columns from the first product that has `facets` and fills every row by attribute name, so every product in a language must use the same names. Below 72rem the same table is laid out as cards, two to a row on a tablet: seven columns need about 1100px in German. - **References resolve by slug within the same language.** An `application` and a `case` name their `product`; the product page lists them through `content.backrefs.application` and `content.backrefs.case`. A `product` names its `collection`; the collection page lists `content.backrefs.product`. Mallok resolves `reference` only, not `reference[]`. - **A link in a Markdown body is plain text to Mallok**: it is not rewritten per language and nothing reports a dead one. Write the path of the page in the same language (`/de/products/`); `test/content.test.ts` checks every one. -- `[[inquiry]]` on a line of its own becomes the inquiry plugin's form when that plugin is enabled. Its labels exist in English and Chinese only (Mallok), so the other languages show English labels. +- `[[inquiry]]` on a line of its own becomes the inquiry plugin's form when that plugin is enabled. The plugin has its own labels in English and Chinese and reads any other language's from the theme's pack: the six `inquiry_*` keys in `locales/de.json`, `fr.json` and `es.json`. They are deliberately not in `en.json` — a pack falls back to the default one, so English keys would replace the plugin's own text in every language this theme has no pack for. - The header puts the site name, the navigation, the language control and one button on a single line from 1280px. The sample's ten links fit in all four languages with little to spare (German is the longest); a longer label in `site.json` overflows that line, and only a look at the page at 1280px shows it. - **A script only adds to a page that is already whole.** There are two. `assets/finder.js` puts filters above the specification table on the home page and in the catalogue. `assets/calculators.js` runs the three fastener calculators on a `tool` page whose front matter says `calculators: fasteners`, above a text that prints the same formulas, constants and tables — and `test/theme-scripts.test.ts` holds every constant in the script, and every figure it computes, to that page. In both cases the form is in the page `hidden`, with its labels from the language pack and its numbers in `value` attributes, so the script reads no language and the page offers nothing it cannot do. A script asks for a field with `querySelector`, never through `form.elements`: that list answers `length` with a count, whatever a field is called. A script is a plain file — no build step, no imports — declared in `theme.json`'s `clientScripts` with its exact size, and loaded only as ``, only by the layout that has what it works on. It hands its pure functions to `module.exports` when a `module` exists, which is how `test/theme-scripts.test.ts` runs them under `node:vm`; what it does to a page is checked in a browser, by hand. - Fonts are files in `assets/fonts/`, declared in `style.css` and preloaded in `layouts/base.liquid`; nothing is loaded from another host. Changing any asset means bumping `version` in `theme.json`: assets are served from a versioned path and cached for good. diff --git a/scripts/smoke-shop.mjs b/scripts/smoke-shop.mjs index 6d4372a..a4ad3d7 100644 --- a/scripts/smoke-shop.mjs +++ b/scripts/smoke-shop.mjs @@ -256,7 +256,14 @@ try { } // The form is the inquiry plugin's, put where the page wrote `[[inquiry]]`. - for (const path of ['/contact', '/fr/contact']) { + // Its English is the plugin's own; the French comes from the theme's pack. + const french = JSON.parse( + await readFile('src/theme/locales/fr.json', 'utf8'), + ); + for (const [path, submit] of [ + ['/contact', 'Send inquiry'], + ['/fr/contact', french.inquiry_submit], + ]) { const contact = await page(path); expect( contact.status === 200 && @@ -264,6 +271,10 @@ try { !contact.html.includes('[[inquiry]]'), `${path} does not carry the inquiry form`, ); + expect( + contact.html.includes(``), + `${path} does not label the inquiry form in its language`, + ); } // The navigation and the footer are written in `site.json`, the pages they diff --git a/src/theme/locales/de.json b/src/theme/locales/de.json index d2d097b..1c2a48e 100644 --- a/src/theme/locales/de.json +++ b/src/theme/locales/de.json @@ -125,6 +125,12 @@ "page": "Seite", "prev": "Zurück", "next": "Weiter", + "inquiry_name": "Ihr Name", + "inquiry_email": "E-Mail", + "inquiry_company": "Unternehmen", + "inquiry_phone": "Telefon / WhatsApp", + "inquiry_message": "Ihre Nachricht", + "inquiry_submit": "Anfrage senden", "not_found_title": "Seite nicht gefunden", "not_found_body": "Die angeforderte Seite gibt es hier nicht. Sie wurde möglicherweise verschoben, oder der Link ist fehlerhaft." } diff --git a/src/theme/locales/es.json b/src/theme/locales/es.json index 7077f40..3db9411 100644 --- a/src/theme/locales/es.json +++ b/src/theme/locales/es.json @@ -125,6 +125,12 @@ "page": "página", "prev": "Anterior", "next": "Siguiente", + "inquiry_name": "Su nombre", + "inquiry_email": "Correo electrónico", + "inquiry_company": "Empresa", + "inquiry_phone": "Teléfono / WhatsApp", + "inquiry_message": "Su mensaje", + "inquiry_submit": "Enviar consulta", "not_found_title": "Página no encontrada", "not_found_body": "La página solicitada no está aquí. Puede que se haya movido o que el enlace sea incorrecto." } diff --git a/src/theme/locales/fr.json b/src/theme/locales/fr.json index ab08c53..176e704 100644 --- a/src/theme/locales/fr.json +++ b/src/theme/locales/fr.json @@ -125,6 +125,12 @@ "page": "page", "prev": "Précédent", "next": "Suivant", + "inquiry_name": "Votre nom", + "inquiry_email": "E-mail", + "inquiry_company": "Société", + "inquiry_phone": "Téléphone / WhatsApp", + "inquiry_message": "Votre message", + "inquiry_submit": "Envoyer la demande", "not_found_title": "Page introuvable", "not_found_body": "La page demandée n’est pas ici. Elle a peut-être été déplacée, ou le lien est erroné." } diff --git a/test/theme/inquiry.test.ts b/test/theme/inquiry.test.ts new file mode 100644 index 0000000..e36d299 --- /dev/null +++ b/test/theme/inquiry.test.ts @@ -0,0 +1,139 @@ +import { SELF } from 'cloudflare:test'; +import { beforeAll, describe, expect, it } from 'vitest'; +import de from '../../src/theme/locales/de.json'; +import en from '../../src/theme/locales/en.json'; +import es from '../../src/theme/locales/es.json'; +import fr from '../../src/theme/locales/fr.json'; +import { api, createContent, ensureSite, ORIGIN } from '../shop/helpers.js'; + +/** + * The inquiry form, in the page's language. + * + * The form is Mallok's inquiry plugin's: it replaces `[[inquiry]]` in a page. + * The plugin has its own labels in English and Chinese, and takes any other + * language's from the theme's language pack, one key at a time. A pack + * without those keys fails nothing — the German page simply asks for "Your + * name" — so only a request for the page shows whether a language has them. + */ + +type Pack = Readonly>; + +/** The theme's packs for the languages the plugin has no words in. */ +const TRANSLATED: Readonly> = { de, fr, es }; + +/** + * The keys the plugin reads, the form control each one labels, and the + * plugin's own English, which is what a page shows when a key is missing. + */ +const LABELS = [ + ['inquiry_name', '/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function form(path: string): Promise { + const response = await SELF.fetch(`${ORIGIN}${path}`); + const html = await response.text(); + expect(response.status, path).toBe(200); + const found = /
/.exec(html); + expect(found, `${path} carries no inquiry form`).not.toBeNull(); + return found?.[0] ?? ''; +} + +describe('the inquiry form', () => { + const paths: Record = {}; + + beforeAll(async () => { + await ensureSite(); + + // Before any page is requested: a page cached while the plugin was off + // keeps its `[[inquiry]]` marker. + const enabled = await api('POST', '/_mallok/api/plugins/inquiry/enabled', { + enabled: true, + }); + if (!enabled.ok) { + throw new Error( + `The inquiry plugin was refused: ${await enabled.text()}`, + ); + } + + const contact = await createContent({ + kind: 'page', + title: 'Contact', + slug: 'contact', + body: 'Write to us.\n\n[[inquiry]]', + }); + paths.en = contact.path; + for (const [locale, title, slug] of [ + ['de', 'Kontakt', 'kontakt'], + ['fr', 'Contact', 'contactez-nous'], + ['es', 'Contacto', 'contacto'], + ] as const) { + const translated = await createContent({ + kind: 'page', + title, + slug, + locale, + translationGroup: contact.translationGroup, + body: '[[inquiry]]', + }); + paths[locale] = translated.path; + } + }); + + it.each(Object.keys(TRANSLATED))( + 'labels every field of a %s page from that language’s pack', + async (locale) => { + const pack = TRANSLATED[locale] ?? {}; + const html = await form(paths[locale] ?? ''); + + for (const [key, control, english] of LABELS) { + const label = pack[key] ?? ''; + expect(label, `${locale} has no ${key}`).not.toBe(''); + // Different from the plugin's English, or a key that went missing + // would leave this test passing on the fallback. + expect(label, `${locale} ${key}`).not.toBe(english); + expect(html, `${locale} ${key}`).toContain( + `