Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Mallok decides the contracts on both sides. Its documentation is the reference:
- Templates are restricted Liquid. Output is escaped; only `content.html` and `page.head` are emitted verbatim. `page.head` carries hreflang and structured data from Mallok and must stay in `layouts/base.liquid`.
- Interface strings are in `locales/*.json` (flat maps, the default locale is the fallback). Site-specific copy — the home page's headline and sections, the footer, the links behind the buttons — is a theme option, never a string in a template; per-language option values go under `themeOptions.$locales` in `site.json`, and `test/project.test.ts` fails when a language is left without one. The tagline is not an option: it is Mallok's own setting, a map of language to text in `site.json`. It follows the site's name in the home page's title, and describes that page unless the `home_description` option gives the language a fuller text.
- **Kinds**: `product`, `collection`, `application` (the sample's industry pages, at `/industries`), `case`, `faq`, `tool`, `article`, `page`. Every kind with a `base` has a `listLayout` here, because each list is a page worth having; a kind without one has no list page — its base path answers 404 — and no entry in `site.kinds`. A template links to a kind's list only through `site.kinds.<kind>`, inside `{% if %}`: the breadcrumb (`partials/crumbs.liquid`) and the home page's link to the catalogue do, and `test/theme/unlisted.test.ts` holds both on a site that serves products without a list.
- **The cart page is the layout `layouts/shop-cart.liquid`**, declared under `pluginLayouts` in `theme.json` as `shop/cart`. It reads `plugin_page`, not `content`; `plugins` is empty on it. Every control is a plain form posting to `plugin_page.action`, a quantity field carries the minimum order in `min` and `step` and the most a line may hold in `max` (`plugin_page.max_quantity`; on a product page `plugins.shop.max_quantity`), and the page has no script. The plugin has no words: a refusal or a line's problem arrives as a kind (`below_moq`, `insufficient_stock`, `unavailable`, `no_price`, `quantity_too_large`, `cart_full`), the pack has a `problem_<kind>` for each, and the number it is about is printed after the words. The layout titles itself through `{% block title %}` in `layouts/base.liquid`, since Mallok titles a plugin's page with the site's name. Below the cart it draws the form that sends it as a request for a quote, when `plugin_page.can_inquire` says the cart can go as it stands, and above it what became of one that was sent (`plugin_page.sent`, `plugin_page.inquiry_problem`). The form's words are the pack's `quote_*` keys — not `inquiry_*`, which are Mallok's inquiry form's and must stay out of the default pack. Each field carries the plugin's own rule as `required`, `maxlength` and `pattern` — the address's and the name's are the very strings the server compiles (`EMAIL_PATTERN_SOURCE`, `NAME_PATTERN_SOURCE`) — and `test/theme/cart.test.ts` holds those attributes to the constants in `lib/inquiries.ts`: a refused form comes back empty, so a browser has to refuse what the server would, and the server has to take what a browser lets through (a tab in a name, a line break counted once). The field named `website` is one no person may see or reach.
- **The cart page is the layout `layouts/shop-cart.liquid`**, declared under `pluginLayouts` in `theme.json` as `shop/cart`. It reads `plugin_page`, not `content`; `plugins` is empty on it. Every control is a plain form posting to `plugin_page.action`, a quantity field carries the minimum order in `min` and the most a line may hold in `max` (`plugin_page.max_quantity`; on a product page `plugins.shop.max_quantity`) and steps by one — the minimum is a floor, not a pack size, by the owner's decision — and the page has no script. The plugin has no words: a refusal or a line's problem arrives as a kind (`below_moq`, `insufficient_stock`, `unavailable`, `no_price`, `quantity_too_large`, `cart_full`), the pack has a `problem_<kind>` for each, and the number it is about is printed after the words. The layout titles itself through `{% block title %}` in `layouts/base.liquid`, since Mallok titles a plugin's page with the site's name. Below the cart it draws the form that sends it as a request for a quote, when `plugin_page.can_inquire` says the cart can go as it stands, and above it what became of one that was sent (`plugin_page.sent`, `plugin_page.inquiry_problem`). The form's words are the pack's `quote_*` keys — not `inquiry_*`, which are Mallok's inquiry form's and must stay out of the default pack. Each field carries the plugin's own rule as `required`, `maxlength` and `pattern` — the address's and the name's are the very strings the server compiles (`EMAIL_PATTERN_SOURCE`, `NAME_PATTERN_SOURCE`) — and `test/theme/cart.test.ts` holds those attributes to the constants in `lib/inquiries.ts`: a refused form comes back empty, so a browser has to refuse what the server would, and the server has to take what a browser lets through (a tab in a name, a line break counted once). The field named `website` is one no person may see or reach.
- **`plugins.shop` is optional everywhere.** It is absent when the plugin is off, when its read failed, in `mallok build` and in the admin's preview, and a product may have no variants. A template wraps what it prints from it in `{% if %}`, and the page is whole without it: the sizes with their SKUs, and no prices. A variant is found by walking `plugins.shop.variants`, never by `variants[sku]`: Liquid answers `size`, `first` and `last` on any collection itself. The words around a value — "Unit price", "In stock", "business days" — are the theme's, in its packs; `avail_<state>` names the three states.
- **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[]`.
Expand Down Expand Up @@ -129,7 +129,7 @@ When one is built, take it off this list and prove the behaviour with a test or
- A page is in one currency. A currency is offered only when every priced variant on the page has a price in it (`currenciesFor`). Where there is none the page prints no price, and its structured data offers none.
- The structured data offers what the page prints and nothing else: the same variants, the same amounts, the same states (`offersFor`; the test compares the two digit for digit).
- A cart line is a variant and a quantity, never a price; `priceCart` recomputes from the database and reports every problem at once.
- MOQ, stock and the most a cart line may hold — 500, the owner's figure (`MAX_LINE_QUANTITY`) — are enforced server-side in `quantityIssue`, shared by add-to-cart and cart pricing. The quantity field on a page starts at the minimum order, steps by it and stops at the ceiling (`min`, `step`, `max`); that is a convenience, and the server is the check. A minimum order above the ceiling is refused in the admin, and a size that has one is offered no form.
- MOQ, stock and the most a cart line may hold — 500, the owner's figure (`MAX_LINE_QUANTITY`) — are enforced server-side in `quantityIssue`, shared by add-to-cart and cart pricing. Between the two any whole number may be ordered: seventeen of a part whose minimum is ten (design §11, decision 12). The quantity field on a page starts at the minimum order and stops at the ceiling (`min`, `max`); that is a convenience, and the server is the check. A minimum order above the ceiling is refused in the admin, and a size that has one is offered no form.
- A refused change writes nothing and creates no cart; a sum is stated only for a cart every line of which can be ordered as it stands.
- A redirect is judged by the path the browser will read, not the text that was sent: `/.//host` is one slash as typed and another site once resolved.
- `stock` carries `CHECK (stock >= 0)`. `test/shop/schema.test.ts` proves a failing decrement rolls back its whole D1 batch, and that `WHERE stock >= qty` does not — the payment write relies on the constraint.
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ These are deliberate and should not be "simplified" away:

- **Prices are never accepted from a client.** The cart stores quantities only;
every amount is recomputed from the database when the cart is priced.
- **MOQ and stock are enforced on the server.** The form's `min` and `step` are
- **MOQ and stock are enforced on the server.** The form's `min` and `max` are
a convenience. A direct POST is refused with the same rules, and a test posts
one.
- **Stock cannot go negative.** The column carries `CHECK (stock >= 0)`, and a
Expand Down
12 changes: 9 additions & 3 deletions docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,12 @@ Decided later:
10. **The inquiry cart (2026-10-09).**
- Decision: the shop plugin builds it — a table of its own, a form on the cart page, an email to the seller (§17, left open, item 1; built in §18).
- Mallok's inquiry plugin stays what it is: the form on a content page. The two are separate stores, read in separate panels of the admin.
11. **The rounding of a derived price (2026-10-09).**
- Decision: the rule stays as it is — a converted price is rounded up to the next `.99`, or to the next whole unit where a shop sets that (2026-09-03 §4.4.4; §17, left open, item 3).
- A catalogue of parts priced at a dollar or two is served by prices entered by hand, which are never recomputed, as the sample's are.
12. **What a quantity must be (2026-10-09).**
- Decision: at least the minimum order, and no more than a line may hold. Not a multiple of the minimum: with a minimum of ten, seventeen can be ordered (§17, left open, item 8).
- This replaces the stepping of 2026-09-03 §4.5.1, where the quantity field moved in steps of the minimum. The server never required a multiple; the field's `step` did, so a browser refused what the server would have taken — and, with a ceiling of 500, let a part with a minimum of 200 be ordered as 200 or 400 and nothing else. The field steps by one now.

## 12. What phase 1A changed or found

Expand Down Expand Up @@ -625,12 +631,12 @@ Along the way the review's reading of the cart turned up a column: Mallok's `con

1. *The inquiry cart.* Decided on 2026-10-09: the shop plugin's (§11, decision 10), and built (§18).
2. *Measurements on a real account*, and whether purges evict (the table above).
3. *The rounding rule and unit prices.* A derived price is rounded up to the next `.99` or whole unit (2026-09-03 §4.4). For a part priced at two dollars that turns $1.60, $1.85 and $2.05 alike into €1.99. The rule is the owner's and is unchanged; a catalogue of small parts wants a third strategy that rounds to the cent, or hand-entered prices, which is what the sample uses.
3. *The rounding rule and unit prices.* Decided on 2026-10-09: the rule stays (§11, decision 11).
4. *Stock set in the admin is a figure, not a difference.* When the seller types one, a payment that lands between opening the form and saving it is overwritten by it. The ledger stays true — it records the difference from the stock as it was when the write landed — but the seller is not told. A save that names no figure touches no stock.
5. *The not-found page has no cart link*: it waits for Mallok (written up for it).
6. *The cart page's forms are not rate-limited beyond Mallok's relaxed tier* (120 a minute per visitor per route), which is best-effort by Cloudflare's own description.
7. *Ten thousand to a line.* Decided on 2026-10-09: five hundred (§11, decision 9).
8. *The step is the browser's.* A quantity field moves in steps of the minimum order (2026-09-03 §4.5.1); the server enforces the minimum and accepts any quantity above it. Whether multiples are a rule or a convenience is not written down.
8. *The step is the browser's.* Decided on 2026-10-09: a quantity is any whole number from the minimum order up, and the field steps by one (§11, decision 12).
9. *A price of nothing.* The admin refuses one; a row with one, from a seed or by hand, is shown and can go in a cart, where it totals zero — which meets §14's open item 4 when checkout is built.
10. *Deletions the plugin missed.* If the delete hook fails, or the plugin is off when a product is deleted, its variants stay: on no page and in no order, with their SKUs taken. Nothing tells a plugin afterwards.

Expand Down Expand Up @@ -692,7 +698,7 @@ Not changed: the export still takes up to a thousand inquiries, as Mallok's own
3. *The shop's data is not in Mallok's export.* Closed the same day: §19.
4. *A job is queued beside a conditional write that stored nothing* in a true race, and finds nothing to send. Mallok's statement takes no condition. Written up.
5. *An amount is stored twice* because a table panel has no money column. Written up.
6. *Whether a quantity must be a multiple of the minimum order* (§17, left open, item 8) and *the rounding of a derived price* (item 3) were put to the owner with this work and are not settled in it.
6. *Whether a quantity must be a multiple of the minimum order*, and *the rounding of a derived price*, were put to the owner with this work and settled afterwards: §11, decisions 11 and 12.
7. *The older tests of a race* — a payment reported twice at once, two refunds, a payment against a cancellation, nine in all — were looked at the same day, after the review. They call functions side by side rather than send requests, and such calls do overlap every time: twenty-five rounds of two payments went to the database in one and the same order, both readings before either write. So none had to be rewritten for that. They now run through `atTheSameMoment`, which fails unless the calls overlapped, so that it is seen on every run rather than taken on trust; and each of the nine, with everything that stops its second write taken away, was red eight times in eight. One thing that showed: a payment's write is stopped by several keys at once — the status condition, the event's two keys, the ledger row's id, the outbox row's id — so that removing the shared status condition turns no race between two payments red. The races between two different changes of an order — shipping against refunding, paying against cancelling — have no key in common, and the status condition is all that stops them: those go red without it. `atTheSameMoment` watches the order of each call's trips to the database it was handed, and is told how many of them are readings; it refuses calls that never write, which would not be a race.
8. *A request with no connecting address.* Whether a zone that uses Cloudflare's "Remove visitor IP headers" transform still hands a Worker that header is not stated in the documentation that was read. If it does not, every visitor of such a site shares one limit of five an hour.

Expand Down
4 changes: 2 additions & 2 deletions scripts/smoke-shop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -490,10 +490,10 @@ try {
`the product page offers no usable form for its 20 mm size: ${JSON.stringify(form)}`,
);
expect(
/<input type="number" name="quantity" min="100" step="100" max="500" value="100"/.test(
/<input type="number" name="quantity" min="100" step="1" max="500" value="100"/.test(
product.html,
),
'the quantity field does not start at the minimum order, step by it and stop at the most a line may hold',
'the quantity field does not start at the minimum order and stop at the most a line may hold',
);

// A request can skip the field: the server says no itself. It sends the
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/shop/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@
"required": true,
"default": 1,
"min": 1,
"help": "The least that can be ordered. On a page the quantity starts here and goes up in steps of it. At most 500, which is the most one cart line may hold.",
"help": "The least that can be ordered. On a page the quantity starts here, and any whole number from it up to 500 can be ordered. At most 500, which is the most one cart line may hold.",
"max": 500
},
"stock_policy": {
Expand Down
7 changes: 4 additions & 3 deletions src/plugins/shop/routes/cart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
* be switched: a page rendered by the POST itself would be listed, in every
* language, at an address that only takes a POST.
*
* A quantity field's `min`, `step` and `max` enforce the minimum order and
* the most a line may hold in the browser. This enforces both again, because
* a form can be bypassed.
* A quantity field's `min` and `max` enforce the minimum order and the most
* a line may hold in the browser. This enforces both again, because a form
* can be bypassed. Between the two any whole number may be ordered: the
* minimum is a floor, not a pack size (design §11, decision 12).
*
* The cart page is one visitor's own: Mallok serves it uncached and
* unindexed, whatever is returned here.
Expand Down
6 changes: 3 additions & 3 deletions src/theme/layouts/shop-cart.liquid
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
The page is one visitor's own: Mallok serves it uncached and unindexed.
Every control is a plain form posting to `plugin_page.action`, so the cart
works without a script, and the quantity fields carry the minimum order in
`min` and `step` and the most a line may hold in `max` — which the server
checks again. The last of them sends the cart as a request for a quote.
`min` and the most a line may hold in `max` — which the server checks
again. The last of them sends the cart as a request for a quote.
{%- endcomment -%}
{% block title %}{{ t.cart_title }} — {{ site.name }}{% endblock %}
{% block content %}
Expand Down Expand Up @@ -82,7 +82,7 @@
<form class="offer-buy" method="post" action="{{ cart.action }}">
<input type="hidden" name="action" value="set">
<input type="hidden" name="variant" value="{{ line.variant_id }}">
<label class="offer-qty"><span>{{ t.quantity }}</span><input type="number" name="quantity" min="{{ line.moq }}" step="{{ line.moq }}"{% if cart.max_quantity %} max="{{ cart.max_quantity }}"{% endif %} value="{{ line.quantity }}" required inputmode="numeric"></label>
<label class="offer-qty"><span>{{ t.quantity }}</span><input type="number" name="quantity" min="{{ line.moq }}" step="1"{% if cart.max_quantity %} max="{{ cart.max_quantity }}"{% endif %} value="{{ line.quantity }}" required inputmode="numeric"></label>
<button class="btn btn-outline btn-sm" type="submit">{{ t.update }}{% if line.sku != blank %}<span class="visually-hidden"> {{ line.sku }}</span>{% endif %}</button>
</form>
{% endif %}
Expand Down
Loading
Loading