diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f209304..ccb52cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,5 +40,9 @@ jobs: - run: npm run smoke # The whole shop on a real Worker: settings, content published with the - # Mallok CLI, the seed, pages in two languages, the cart + # Mallok CLI, the seed, every kind of page in four languages, the cart - run: npm run smoke:shop + + # The one command a newcomer runs first still ends with a filled shop + # and an admin login that works + - run: npm run preview -- --check diff --git a/CLAUDE.md b/CLAUDE.md index f19beec..5a85d57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,8 @@ Nundar is in development. Several features wait for extension points Mallok does ```bash npm ci npm run build # mallok prepare (stages admin + theme assets) then a deploy dry run -npm run dev # wrangler dev with local D1 and R2; needs .dev.vars +npm run preview # a filled local shop on a throwaway database; prints an admin login +npm run dev # wrangler dev with local D1 and R2 that keep their data; starts empty, needs .dev.vars npm run lint # biome check . (npm run lint:fix to apply) npm run typecheck npm test # test:project, then test:shop @@ -39,8 +40,8 @@ CI (`.github/workflows/ci.yml`) runs `npm ci` → lint → typecheck → test - `src/worker/index.ts` is the whole site: `createMallok({ theme: nundarTheme, plugins: [inquiry, shop] })`. Theme and plugins are build-time choices. - `src/plugins/shop/` — commerce logic and nothing about pages. `plugin.json` is the manifest (validated strictly by `definePlugin`: unknown fields are rejected, every declared hook and route needs an implementation and vice versa). `migrations/` holds SQL, `lib/` the logic, `routes/` the HTTP handlers, `index.ts` wires them. -- `src/theme/` — how pages look and nothing else: `theme.json` (kinds, fields, options), `layouts/`, `partials/`, `locales/.json`, `assets/`. It has no tables, no routes and no JavaScript. -- `content/` and `seed/` — the sample catalogue. `site.json` — the site's languages, kinds, navigation and theme options. +- `src/theme/` — how pages look and nothing else: `theme.json` (kinds, fields, options), `layouts/`, `partials/`, `locales/.json`, `assets/` (the stylesheet, four images, the fonts and their licences, and the scripts `theme.json` declares). It has no tables and no routes. +- `content/` and `seed/` — the sample catalogue, a fictional titanium fastener supplier: 31 bundles in four languages, and one variant per SKU. `site.json` — the site's languages, kinds, navigation and theme options, which is where this site's own copy lives. Mallok decides the contracts on both sides. Its documentation is the reference: `PLUGIN_API.md`, `THEME_FORMAT.md` and `CONTENT_FORMAT.md` in the Mallok repository (`node_modules/mallok/types/worker.d.ts` has the types). @@ -59,9 +60,16 @@ Mallok decides the contracts on both sides. Its documentation is the reference: ### The theme and content - 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 is a theme option; per-language option values go under `themeOptions.$locales` in `site.json`. -- **References resolve by slug within the same language.** An `application` names its `product`; the product page lists them through `content.backrefs.application`. A `product` names its `collection`; the collection page lists `content.backrefs.product`. Mallok resolves `reference` only, not `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: the product's fixes the `translation_group` that `seed/shop-sample.sql` attaches variants to. `test/content.test.ts` checks the reference rule and keeps any identity file in step with its bundle. +- 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. +- **Kinds**: `product`, `collection`, `application` (the sample's industry pages, at `/industries`), `case`, `faq`, `tool`, `article`, `page`. A kind with a `base` needs a `listLayout`: without one Mallok answers its base path with a 500. +- **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. +- 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. +- 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 @@ -81,7 +89,9 @@ Each is a task in Mallok's plan for plugin API 2. When Mallok ships one, upgrade - A test for a fix must be seen failing without the fix. For new guards, break the guard and confirm the test goes red. - A race is tested by running the calls with `Promise.all`, and such a test only counts once breaking the guard turns it red: that is the proof the two calls really interleave. - `countD1Calls` in `test/shop/helpers.ts` counts round trips; use it wherever the number is a design constraint. `interceptBatches` runs a hook around each batch: it is how a test changes the data between a function's reading and its writing, or loses the answer to a write that committed. -- `npm run smoke:shop` is the only place theme, plugin, content and the Mallok CLI run together; run it when touching any of them. +- `test/theme/pages.test.ts` renders every layout from content it creates itself, with everything set; `test/theme/bare.test.ts` does the same for a site that has filled in almost nothing, and fails on any empty element or `href=""`. Neither reads `content/`. A template that prints a wrapper has to check that there is something to put in it — and `content.html` is not a string, so capture it before comparing it with `blank`. The sample is checked by `test/content.test.ts` and `test/project.test.ts` (files only, no Worker) and by the smoke run. +- `npm run smoke:shop` is the only place theme, plugin, content and the Mallok CLI run together; run it when touching any of them. It publishes the real sample, requests every kind of page, and follows every header and footer link in all four languages. +- `scripts/lib/local-shop.mjs` is the one place that brings a local shop up — administrator, plugins, settings, content, variants, in that order and before any page is requested. The smoke run and `npm run preview` both use it; do not grow a second copy. Locally nothing purges the page cache (`s-maxage=3600`, persisted under the state directory), so a page requested before the set-up finished stays as it was. ## Commerce invariants (do not "simplify" them away) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbb8b82..1f63b2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,12 +6,13 @@ Thanks for taking the time to contribute. ```bash npm ci -npm run build # stages the admin and the theme's assets -npm run smoke:shop # the whole shop on a throwaway local Worker +npm run preview # a local shop with the sample catalogue, to look at +npm run smoke:shop # the same shop, checked end to end ``` No Cloudflare account is needed for local development: D1 and R2 are simulated -locally. The README has the steps for a local shop you can browse. +locally. `preview` runs on a throwaway database; the README has the steps for a +local site that keeps its data. **npm, not pnpm or yarn.** Nundar is a Mallok site, and Mallok's CLI installs and upgrades with npm only; it refuses a project that carries another @@ -71,10 +72,13 @@ same pull request and explain the new reasoning. | Webhooks | Verified against the bytes as received before anything is parsed. 5xx only for what delivering again could change; everything else is answered 200 and written down. | | Language | Decided by the URL alone. Never redirect or switch by IP — crawlers would see one language. | | References | A `reference` field names the target's slug **in the same language**. `test/content.test.ts` checks the sample content. | -| Bundle identity | A bundle needs a `mallok.json` only when something outside the content names it: the sample variants attach to the product by its translation group. Where the file exists, `test/content.test.ts` keeps it in step with the bundle. | +| Links in content | A link in a Markdown body is the path of the page in the same language. Mallok does not rewrite it and does not report a dead one; `test/content.test.ts` does. | +| Product attributes | Every product in a language names the same `facets`, in the same order: they are the columns of the specification finder. A size is a row in `sizes`, not a page of its own. | +| Bundle identity | A bundle needs a `mallok.json` only when something outside the content names it: the sample variants attach to a product by its translation group. Where the file exists, `test/content.test.ts` keeps it in step with the bundle, and holds the seeded variants to the SKUs the page lists. | +| Site copy | Words a visitor reads that belong to this site, not to the theme, are theme options in `site.json`, with a value per language under `$locales`. `test/project.test.ts` fails when a language has none. | | Database access | Raw SQL through D1, no ORM. Batch reads and writes: a tick of the cron shares one invocation's CPU budget with every other plugin. | | Migrations | Additive only, idempotent, and a comment has a line to itself — Mallok's migrator drops whole-line comments and then splits on semicolons. | -| Theme | No `{% endif %} {% endblock %} diff --git a/src/theme/layouts/list.liquid b/src/theme/layouts/list.liquid index dbfba0e..943303f 100644 --- a/src/theme/layouts/list.liquid +++ b/src/theme/layouts/list.liquid @@ -6,14 +6,17 @@ {% if list.items.size > 0 %}
- {% for item in list.items %}{% render "partials/card", item: item %}{% endfor %} + {% for item in list.items %}{% render "partials/card", item: item, eyebrow: item.frontmatter.sector %}{% endfor %}
{% else %}

{{ t.empty }}

{% endif %} + {% if list.prev_path != "" or list.has_next %} + {% endif %} +{% if theme.options.cta_title != blank %}{% render "partials/closing" %}{% endif %} {% endblock %} diff --git a/src/theme/layouts/page.liquid b/src/theme/layouts/page.liquid index da260c9..616c055 100644 --- a/src/theme/layouts/page.liquid +++ b/src/theme/layouts/page.liquid @@ -5,6 +5,14 @@

{{ content.title }}

{% if content.description != "" %}

{{ content.description }}

{% endif %} -
{{ content.html }}
+ {% if content.cover != blank %} +
+ {% endif %} + {%- comment -%} + `content.html` is not a string, so it cannot be compared with one: the + body is captured first, and a page without one gets no empty box. + {%- endcomment -%} + {% capture body %}{{ content.html }}{% endcapture %} + {% if body != blank %}
{{ content.html }}
{% endif %} {% endblock %} diff --git a/src/theme/layouts/product.liquid b/src/theme/layouts/product.liquid index 8e27f56..d2276f8 100644 --- a/src/theme/layouts/product.liquid +++ b/src/theme/layouts/product.liquid @@ -7,68 +7,106 @@ {{ content.title }} -
-

{{ content.title }}

- {% if content.description != "" %}

{{ content.description }}

{% endif %} -
+ {% assign shots = content.frontmatter.gallery %} + {% assign has_gallery = false %} + {% for path in shots %}{% if content.images[path] %}{% assign has_gallery = true %}{% endif %}{% endfor %} + diff --git a/src/theme/partials/icon.liquid b/src/theme/partials/icon.liquid new file mode 100644 index 0000000..fafedea --- /dev/null +++ b/src/theme/partials/icon.liquid @@ -0,0 +1,16 @@ +{%- comment -%} + The theme's icons, inline so that they cost no request and take the colour + of the text around them. Called as {% render "partials/icon", name: "arrow" %}. + They are decoration: the text beside each one says what it means. +{%- endcomment -%} + diff --git a/src/theme/partials/spec-table.liquid b/src/theme/partials/spec-table.liquid new file mode 100644 index 0000000..538cd8a --- /dev/null +++ b/src/theme/partials/spec-table.liquid @@ -0,0 +1,71 @@ +{%- comment -%} + The specification finder: every product in `items` as a row, one column per + filterable attribute. + + The columns are the attribute names of the first product that has any. + Every row is then filled by name, not by position, so a product that lacks + an attribute leaves that cell empty instead of pushing its other values + under the wrong headings (see `facets` in theme.json). + + A product's value is found by walking its own attributes rather than by + `facets[name]`: Liquid answers `size`, `first` and `last` on any map with a + count or an entry of its own, so an attribute called "size" would put a + number in the cell of every product that does not have one. + + On a narrow screen the stylesheet lays each row out as a card, which takes + the table's own display values away. Two things in the markup exist for + that: `data-label` lets a cell name its column once the header row is out + of sight, and the explicit roles keep the table a table for a screen + reader, which otherwise stops announcing rows and columns as soon as + `display` changes. + + With `filters: true` the table is preceded by a form to narrow it. The + form is in the page hidden, and `assets/finder.js` fills its lists from the + rows below and shows it: a form that cannot filter is not offered, and a + page without the script is the whole table, as it is without `filters`. + The layout that asks for filters also loads that script. + + Called as {% render "partials/spec-table", items: recent.product %}. +{%- endcomment -%} +{% assign lead = nil %} +{% for item in items %}{% if lead == nil and item.frontmatter.facets != blank %}{% assign lead = item %}{% endif %}{% endfor %} +{% if filters %} +
+ +{% endif %} +
+ + + + + {% for pair in lead.frontmatter.facets %}{% endfor %} + + + + + + {% for item in items %} + + + {% for pair in lead.frontmatter.facets %}{% assign name = pair[0] %}{% assign value = "" %}{% for own in item.frontmatter.facets %}{% if own[0] == name %}{% assign value = own[1] %}{% endif %}{% endfor %}{% endfor %} + + + + {% endfor %} + +
{{ t.col_product }}{{ pair[0] }}{{ t.col_sizes }}{{ t.view_specs }}
+
+ {% if item.cover != blank %}{% endif %} + {{ item.title }} +
+
{{ value }}{% if item.frontmatter.sizes != blank %}
{% for pair in item.frontmatter.sizes %}{{ pair[1] }}{% endfor %}
{% endif %}
{{ t.view_specs }} {% render "partials/icon", name: "arrow" %}
+
+{% if filters %} + +
+{% endif %} diff --git a/src/theme/theme.json b/src/theme/theme.json index 3c80adc..5d0c18f 100644 --- a/src/theme/theme.json +++ b/src/theme/theme.json @@ -1,8 +1,8 @@ { "id": "nundar", "name": "Nundar", - "description": "A commerce theme: specification-first product pages, application notes that stand as their own landing pages, and collections that gather products by attribute. Script-free.", - "version": "0.1.0", + "description": "A commerce theme for a technical catalogue: a specification finder, product pages built around a size table, landing pages for the applications a part is used in, collections by type, case studies and reference pages. HTML first: every page is complete without a script, and the two it declares only add to what is already there — filters for a table, calculators beside the tables of an engineering reference.", + "version": "0.4.0", "themeApi": 1, "home": "layouts/home.liquid", "kinds": { @@ -18,33 +18,34 @@ }, "product": { "layout": "layouts/product.liquid", - "listLayout": "layouts/list.liquid", + "listLayout": "layouts/products.liquid", "label": "Product", "base": "products", "fields": { - "standard": { - "type": "string", - "label": "Standard", - "help": "The standard the product is made to, for example EN 10204 3.1.", - "group": "Specification" + "facets": { + "type": "keyvalue", + "label": "Filterable attributes", + "group": "Specification", + "help": "What a buyer filters the catalogue by, for example Head type, Thread, Material. Use the same names, in the same order, on every product: they become the columns of the specification finder." }, - "material": { - "type": "string", - "label": "Material", - "group": "Specification" + "sizes": { + "type": "keyvalue", + "label": "Sizes", + "group": "Specification", + "help": "One row per size on offer: the SKU, then what distinguishes it, for example TI-SHC-M5-20 and 20 mm." }, "specs": { "type": "keyvalue", "label": "Specifications", - "help": "Shown as a table under the description.", - "group": "Specification" + "group": "Specification", + "help": "The full specification, shown as a table on the product page." }, "collection": { "type": "reference", "kind": "collection", "label": "Collection", - "help": "Slug of the collection this product is listed in.", - "group": "Classification" + "group": "Classification", + "help": "Slug of the collection this product is listed in." }, "gallery": { "type": "image[]", @@ -55,23 +56,29 @@ "datasheet": { "type": "file", "label": "Datasheet", - "accept": [".pdf"], + "accept": ["pdf"], "group": "Specification" } } }, + "collection": { + "layout": "layouts/collection.liquid", + "listLayout": "layouts/list.liquid", + "label": "Collection", + "base": "collections" + }, "application": { "layout": "layouts/application.liquid", "listLayout": "layouts/list.liquid", "label": "Application", - "base": "applications", + "base": "industries", "fields": { "product": { "type": "reference", "kind": "product", "label": "Product", - "help": "Slug of the product this application note is about.", - "required": true + "required": true, + "help": "Slug of the product this application note is about." }, "spec_highlights": { "type": "keyvalue", @@ -80,37 +87,237 @@ } } }, - "collection": { - "layout": "layouts/collection.liquid", + "case": { + "layout": "layouts/case.liquid", "listLayout": "layouts/list.liquid", - "label": "Collection", - "base": "collections" + "label": "Case study", + "base": "case-studies", + "fields": { + "sector": { + "type": "string", + "label": "Sector", + "help": "The industry the work was done for, shown above the title." + }, + "product": { + "type": "reference", + "kind": "product", + "label": "Product", + "help": "Slug of the product the case study used." + }, + "results": { + "type": "keyvalue", + "label": "Results", + "help": "The measured outcomes, each a short label and a figure." + } + } + }, + "faq": { + "layout": "layouts/faq.liquid", + "listLayout": "layouts/faq-list.liquid", + "label": "FAQ", + "base": "faq", + "fields": { + "faq": { + "type": "keyvalue", + "label": "Questions and answers", + "help": "Each question with its answer. They are shown on the page and published as FAQ structured data." + } + } + }, + "tool": { + "layout": "layouts/tool.liquid", + "listLayout": "layouts/list.liquid", + "label": "Engineering tool", + "base": "tools", + "fields": { + "calculators": { + "type": "select", + "label": "Calculators", + "choices": ["none", "fasteners"], + "default": "none", + "help": "Shows interactive calculators above the text. \"fasteners\" is mass reduction, tightening torque and thread engagement; the text should give the same formulas, constants and tables, because that is what a visitor without the script sees." + } + } } }, "options": { "accent": { "type": "color", "label": "Accent colour", - "default": "#0e5a6e" + "default": "#b45309" + }, + "tagline": { + "type": "string", + "label": "Tagline, after the site name in the home page title", + "default": "", + "help": "Empty uses the site's own tagline. That one has a single value for every language; this option can be given per language." + }, + "home_description": { + "type": "text", + "label": "Home page description, for search results and link previews", + "default": "", + "help": "Empty uses the tagline." }, "hero_title": { "type": "string", "label": "Home page headline", - "default": "Specified for the application, not just the catalogue." + "default": "Built around your requirements." }, "hero_lede": { "type": "text", "label": "Home page paragraph", - "default": "Browse products by what they are, by where they are used, and by the properties that matter for your project." + "default": "Find a part by specification, or discuss a custom one with our team." + }, + "hero_badge": { + "type": "string", + "label": "Caption on the home page picture", + "default": "" + }, + "prop_1_title": { + "type": "string", + "label": "First strength: title", + "default": "" + }, + "prop_1_text": { + "type": "string", + "label": "First strength: one line", + "default": "" + }, + "prop_2_title": { + "type": "string", + "label": "Second strength: title", + "default": "" + }, + "prop_2_text": { + "type": "string", + "label": "Second strength: one line", + "default": "" + }, + "prop_3_title": { + "type": "string", + "label": "Third strength: title", + "default": "" + }, + "prop_3_text": { + "type": "string", + "label": "Third strength: one line", + "default": "" + }, + "custom_title": { + "type": "string", + "label": "Custom work: headline", + "default": "" + }, + "custom_text": { + "type": "text", + "label": "Custom work: paragraph", + "default": "" + }, + "quality_1_title": { + "type": "string", + "label": "First assurance: title", + "default": "" + }, + "quality_1_text": { + "type": "text", + "label": "First assurance: text", + "default": "" + }, + "quality_2_title": { + "type": "string", + "label": "Second assurance: title", + "default": "" + }, + "quality_2_text": { + "type": "text", + "label": "Second assurance: text", + "default": "" + }, + "quality_3_title": { + "type": "string", + "label": "Third assurance: title", + "default": "" + }, + "quality_3_text": { + "type": "text", + "label": "Third assurance: text", + "default": "" + }, + "cta_title": { + "type": "string", + "label": "Closing banner: headline", + "default": "" + }, + "cta_text": { + "type": "string", + "label": "Closing banner: one line", + "default": "" + }, + "footer_blurb": { + "type": "text", + "label": "Footer: what the company does", + "default": "" + }, + "footer_note": { + "type": "string", + "label": "Footer: a line under it, for example certifications held", + "default": "" + }, + "contact_email": { + "type": "string", + "label": "Footer: email address", + "default": "" + }, + "contact_phone": { + "type": "string", + "label": "Footer: telephone number", + "default": "" + }, + "quote_href": { + "type": "string", + "label": "Link for the request-a-quote buttons", + "default": "/contact" }, "contact_href": { "type": "string", - "label": "Link for the request-a-quote button", + "label": "Link for the contact buttons", "default": "/contact" + }, + "catalogue_href": { + "type": "string", + "label": "Link to the full catalogue, under the home page finder", + "default": "/products", + "help": "The home page shows the ten newest products. Give this per language, for example /de/products; empty shows no link." + }, + "terms_href": { + "type": "string", + "label": "Footer: link to the terms", + "default": "" + }, + "privacy_href": { + "type": "string", + "label": "Footer: link to the privacy policy", + "default": "" + }, + "compliance_href": { + "type": "string", + "label": "Footer: link to the compliance statement", + "default": "" } }, "locales": ["en", "de", "fr", "es"], "defaultLocale": "en", "imageWidths": [480, 960, 1440], - "clientScripts": [] + "clientScripts": [ + { + "path": "assets/finder.js", + "purpose": "Home page and catalogue only: filters and a search box above the specification table. The table is complete without it.", + "bytes": 5315 + }, + { + "path": "assets/calculators.js", + "purpose": "Engineering reference pages that ask for it only: three fastener calculators above a page that prints the same formulas, constants and tables of results.", + "bytes": 9426 + } + ] } diff --git a/test/content.test.ts b/test/content.test.ts index a69e18b..a894894 100644 --- a/test/content.test.ts +++ b/test/content.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { readdir, readFile } from 'node:fs/promises'; +import { access, readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { describe, it } from 'node:test'; @@ -7,10 +7,12 @@ import { describe, it } from 'node:test'; * Checks on the sample content that only this project can get wrong. * * Mallok resolves a `reference` field by slug **within the same language**, so - * a German application note has to name the German product's slug. Get that + * a German industry page has to name the German product's slug. Get that * wrong and the page still renders — it just quietly loses its link, which is * exactly the kind of mistake nobody notices until the long-tail pages stop - * pointing at each other. + * pointing at each other. The same holds for a link written in a body, for a + * product whose attributes are named differently from its neighbours', and + * for a SKU on a page that no variant in the shop carries. */ const CONTENT = 'content'; @@ -20,23 +22,95 @@ interface Item { readonly bundle: string; readonly locale: string; readonly slug: string; + readonly file: string; readonly fields: ReadonlyMap; + readonly frontmatter: string; + readonly body: string; +} + +interface Site { + readonly defaultLocale: string; + readonly locales: string[]; + readonly kinds: Record; +} + +/** A YAML scalar as written, without the quotes it may be wrapped in. */ +function unquote(value: string): string { + const trimmed = value.trim(); + const quoted = /^"((?:[^"\\]|\\.)*)"$|^'((?:[^']|'')*)'$/.exec(trimmed); + if (quoted === null) { + return trimmed; + } + return quoted[1] !== undefined + ? quoted[1].replace(/\\(.)/g, '$1') + : (quoted[2] ?? '').replace(/''/g, "'"); } /** The scalar front-matter fields of an `index[.].md`. */ -function scalarFields(markdown: string): Map { - const block = /^---\n([\s\S]*?)\n---/.exec(markdown)?.[1] ?? ''; +function scalarFields(frontmatter: string): Map { const fields = new Map(); - for (const line of block.split('\n')) { + for (const line of frontmatter.split('\n')) { // Top-level keys only: nested maps such as `specs` are indented. const match = /^([a-z_]+):\s*(\S.*)$/.exec(line); if (match?.[1] !== undefined && match[2] !== undefined) { - fields.set(match[1], match[2].trim()); + fields.set(match[1], unquote(match[2])); } } return fields; } +/** + * The entries of a nested map such as `facets` or `sizes`, in file order. + * + * A reader for the one shape these files use — a key, then two-space-indented + * `name: value` lines — rather than a YAML parser, so the project's own tests + * need nothing installed. + */ +function mapField(frontmatter: string, name: string): [string, string][] { + const lines = frontmatter.split('\n'); + const start = lines.indexOf(`${name}:`); + const entries: [string, string][] = []; + if (start === -1) { + return entries; + } + for (const line of lines.slice(start + 1)) { + const match = + /^ {2}(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([^:]+?)):\s+(.*)$/.exec(line); + if (match === null) { + break; + } + const key = match[1] ?? match[2] ?? match[3] ?? ''; + entries.push([key.replace(/\\(.)/g, '$1'), unquote(match[4] ?? '')]); + } + return entries; +} + +/** The image paths a bundle's front matter and body name. */ +function imagePaths(item: Item): string[] { + const paths: string[] = []; + const cover = item.fields.get('cover'); + if (cover !== undefined) { + paths.push(cover); + } + const lines = item.frontmatter.split('\n'); + const gallery = lines.indexOf('gallery:'); + if (gallery !== -1) { + for (const line of lines.slice(gallery + 1)) { + const match = /^ {2}- (.+)$/.exec(line); + if (match?.[1] === undefined) { + break; + } + paths.push(unquote(match[1])); + } + } + for (const match of item.body.matchAll(/!\[[^\]]*\]\(([^)\s]+)/g)) { + if (match[1] !== undefined) { + paths.push(match[1]); + } + } + return paths; +} + async function loadItems(defaultLocale: string): Promise { const items: Item[] = []; const kinds = await readdir(CONTENT, { withFileTypes: true }); @@ -53,16 +127,21 @@ async function loadItems(defaultLocale: string): Promise { if (match === null) { continue; } - const fields = scalarFields( - await readFile(join(CONTENT, kind.name, bundle.name, file), 'utf8'), - ); + const path = join(CONTENT, kind.name, bundle.name, file); + const markdown = await readFile(path, 'utf8'); + const parts = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(markdown); + const frontmatter = parts?.[1] ?? ''; + const fields = scalarFields(frontmatter); items.push({ kind: kind.name, bundle: bundle.name, locale: match[1] ?? defaultLocale, // The default language's slug defaults to the bundle's own name. slug: fields.get('slug') ?? bundle.name, + file: path, fields, + frontmatter, + body: parts?.[2] ?? '', }); } } @@ -70,14 +149,21 @@ async function loadItems(defaultLocale: string): Promise { return items; } -async function readSite(): Promise<{ - defaultLocale: string; - locales: string[]; - kinds: Record; -}> { +async function readSite(): Promise { return JSON.parse(await readFile('site.json', 'utf8')); } +/** Where a language's pages start: nothing for the default, `/de` for German. */ +function prefixOf(site: Site, locale: string): string { + return locale === site.defaultLocale ? '' : `/${locale}`; +} + +/** The public path Mallok gives an item. */ +function pathOf(site: Site, item: Item): string { + const base = site.kinds[item.kind]?.base ?? ''; + return `${prefixOf(site, item.locale)}${base === '' ? '' : `/${base}`}/${item.slug}`; +} + describe('sample content', () => { it('uses only kinds and languages the site declares', async () => { const site = await readSite(); @@ -96,6 +182,28 @@ describe('sample content', () => { } }); + it('carries every bundle in every language of the site', async () => { + // The sample is what a visitor of the demo switches languages on: a + // bundle missing one would drop out of that language's lists and leave + // its other versions without that hreflang. + const site = await readSite(); + const items = await loadItems(site.defaultLocale); + const bundles = new Set(items.map((item) => `${item.kind}/${item.bundle}`)); + + for (const bundle of bundles) { + for (const locale of site.locales) { + assert.ok( + items.some( + (item) => + `${item.kind}/${item.bundle}` === bundle && + item.locale === locale, + ), + `content/${bundle} has no "${locale}" version`, + ); + } + } + }); + it('gives every translation its own slug within its kind and language', async () => { const site = await readSite(); const items = await loadItems(site.defaultLocale); @@ -105,6 +213,11 @@ describe('sample content', () => { const key = `${item.kind}/${item.locale}/${item.slug}`; assert.ok(!seen.has(key), `Two items share ${key}`); seen.add(key); + if (item.locale !== site.defaultLocale) { + // Without its own `slug` a translation would take the bundle's name + // and collide with nothing, but answer at an English address. + assert.ok(item.fields.has('slug'), `${item.file} declares no slug`); + } } }); @@ -113,11 +226,29 @@ describe('sample content', () => { const items = await loadItems(site.defaultLocale); // The reference fields the commerce theme declares, and the kind each - // points at (src/theme/theme.json). - const references: Record> = { - application: { product: 'product' }, - product: { collection: 'collection' }, + // points at, read from the theme itself: a copy kept here would stay + // behind the first time a kind gained a reference. + const theme = JSON.parse( + await readFile(join('src', 'theme', 'theme.json'), 'utf8'), + ) as { + kinds: Record< + string, + { fields?: Record } + >; }; + const references: Record> = {}; + for (const [kind, definition] of Object.entries(theme.kinds)) { + for (const [field, declared] of Object.entries(definition.fields ?? {})) { + if (declared.type === 'reference' && declared.kind !== undefined) { + references[kind] = { ...references[kind], [field]: declared.kind }; + } + } + } + assert.deepEqual(Object.keys(references).sort(), [ + 'application', + 'case', + 'product', + ]); let checked = 0; for (const item of items) { @@ -143,7 +274,7 @@ describe('sample content', () => { assert.ok(checked > 0); }); - it('gives every application note a product', async () => { + it('gives every industry page a product', async () => { const site = await readSite(); const items = await loadItems(site.defaultLocale); @@ -154,46 +285,243 @@ describe('sample content', () => { ); } }); + + it('links, in every body, only to pages that exist in that language', async () => { + // A link in Markdown is plain text to Mallok: nothing rewrites it for the + // language and nothing reports it when its target is renamed. + const site = await readSite(); + const items = await loadItems(site.defaultLocale); + + const pages = new Map>(); + for (const locale of site.locales) { + const prefix = prefixOf(site, locale); + const known = new Set([prefix === '' ? '/' : prefix, `${prefix}/`]); + for (const kind of Object.values(site.kinds)) { + if (kind.base !== '') { + known.add(`${prefix}/${kind.base}`); + } + } + pages.set(locale, known); + } + for (const item of items) { + pages.get(item.locale)?.add(pathOf(site, item)); + } + + let checked = 0; + for (const item of items) { + for (const match of item.body.matchAll( + /(? 0); + }); + + it('is what the navigation and the buttons in site.json point at', async () => { + // The header, the footer and every call to action take their addresses + // from `site.json`. A page renamed here and not there is a dead link on + // every page of that language. + const site = await readSite(); + const items = await loadItems(site.defaultLocale); + const settings = JSON.parse(await readFile('site.json', 'utf8')) as { + nav: Record; + themeOptions: Record & { + $locales?: Record>; + }; + }; + + let checked = 0; + for (const locale of site.locales) { + const prefix = prefixOf(site, locale); + const known = new Set([prefix === '' ? '/' : prefix, `${prefix}/`]); + for (const kind of Object.values(site.kinds)) { + if (kind.base !== '') { + known.add(`${prefix}/${kind.base}`); + } + } + for (const item of items.filter((entry) => entry.locale === locale)) { + known.add(pathOf(site, item)); + } + + const options = + locale === site.defaultLocale + ? settings.themeOptions + : (settings.themeOptions.$locales?.[locale] ?? {}); + const links = [ + ...(settings.nav[locale] ?? []).map((entry) => entry.href), + ...Object.entries(options) + .filter(([name]) => name.endsWith('_href')) + .map(([, href]) => String(href)), + ]; + for (const href of links) { + assert.ok( + known.has(href.replace(/[#?].*$/, '')), + `site.json sends "${locale}" to ${href}, which is not a page in that language`, + ); + checked += 1; + } + } + assert.ok(checked > 0); + }); + + it('keeps every image a bundle names inside that bundle', async () => { + const site = await readSite(); + const items = await loadItems(site.defaultLocale); + + let checked = 0; + for (const item of items) { + for (const path of imagePaths(item)) { + assert.ok( + !/^[a-z]+:/i.test(path) && !path.startsWith('/'), + `${item.file} names ${path}; an image is a path inside the bundle`, + ); + await assert.doesNotReject( + access(join(CONTENT, item.kind, item.bundle, path)), + `${item.file} names ${path}, which is not in the bundle`, + ); + checked += 1; + } + } + assert.ok(checked > 0); + }); +}); + +describe('sample products', () => { + it('name the same attributes, in the same order, within a language', async () => { + // The attribute names of the first product are the columns of the + // specification finder, and what its filters are built from. + const site = await readSite(); + const items = await loadItems(site.defaultLocale); + + for (const locale of site.locales) { + const products = items.filter( + (item) => item.kind === 'product' && item.locale === locale, + ); + const expected = mapField(products[0]?.frontmatter ?? '', 'facets').map( + ([name]) => name, + ); + assert.ok(expected.length > 0, `no product attributes in "${locale}"`); + for (const product of products) { + assert.deepEqual( + mapField(product.frontmatter, 'facets').map(([name]) => name), + expected, + `${product.file} names different attributes from ${products[0]?.file}`, + ); + } + } + }); + + it('offer the same sizes under the same SKUs in every language', async () => { + const site = await readSite(); + const items = await loadItems(site.defaultLocale); + const products = items.filter((item) => item.kind === 'product'); + + for (const source of products.filter( + (item) => item.locale === site.defaultLocale, + )) { + const sizes = mapField(source.frontmatter, 'sizes'); + assert.ok(sizes.length > 0, `${source.file} offers no size`); + for (const version of products.filter( + (item) => item.bundle === source.bundle, + )) { + assert.deepEqual( + mapField(version.frontmatter, 'sizes'), + sizes, + `${version.file} lists different sizes from ${source.file}`, + ); + } + } + }); }); describe('seed/shop-sample.sql', () => { - it('attaches its variants to a product bundle that exists', async () => { + /** product_group to the SKUs of its variants, as the seed inserts them. */ + async function seededVariants(): Promise> { const sql = await readFile(join('seed', 'shop-sample.sql'), 'utf8'); - const groups = new Set( - [...sql.matchAll(/'([0-9a-f]{8}-[0-9a-f-]{27})'/g)].map( - (match) => match[1], - ), - ); - assert.ok(groups.size > 0); + const variants = new Map(); + for (const match of sql.matchAll( + /\('[^']+',\s*'([0-9a-f]{8}-[0-9a-f-]{27})',\s*'([^']+)'/g, + )) { + const group = match[1] ?? ''; + variants.set(group, [...(variants.get(group) ?? []), match[2] ?? '']); + } + return variants; + } + + async function translationGroup(bundle: string): Promise { + try { + const identity = JSON.parse( + await readFile(join(CONTENT, 'product', bundle, 'mallok.json'), 'utf8'), + ) as { translation_group?: string }; + return identity.translation_group ?? null; + } catch { + // A bundle without an identity file gets one assigned on import, so + // nothing in the seed could name it. + return null; + } + } + + it('attaches its variants to a product bundle that exists', async () => { + const variants = await seededVariants(); + assert.ok(variants.size > 0); const products = await readdir(join(CONTENT, 'product'), { withFileTypes: true, }); const known = new Set(); for (const bundle of products.filter((entry) => entry.isDirectory())) { - try { - const identity = JSON.parse( - await readFile( - join(CONTENT, 'product', bundle.name, 'mallok.json'), - 'utf8', - ), - ) as { translation_group?: string }; - if (identity.translation_group !== undefined) { - known.add(identity.translation_group); - } - } catch { - // A bundle without an identity file gets one assigned on import, so - // nothing in the seed could name it. + const group = await translationGroup(bundle.name); + if (group !== null) { + known.add(group); } } - for (const group of groups) { + for (const group of variants.keys()) { assert.ok( - known.has(group ?? ''), + known.has(group), `seed/shop-sample.sql names product_group ${group}, which no product bundle's mallok.json declares`, ); } }); + + it('carries one variant for each SKU a product page lists, and no other', async () => { + // The page prints the SKU a buyer quotes; the shop sells the variant + // that carries it. A size on the page with no variant could never be + // bought, and a variant with no size on the page could never be found. + const site = await readSite(); + const items = await loadItems(site.defaultLocale); + const variants = await seededVariants(); + + const products = items.filter( + (item) => item.kind === 'product' && item.locale === site.defaultLocale, + ); + assert.ok(products.length > 0); + for (const product of products) { + const group = await translationGroup(product.bundle); + assert.ok(group !== null, `${product.file} has no mallok.json`); + assert.deepEqual( + [...(variants.get(group) ?? [])].sort(), + mapField(product.frontmatter, 'sizes') + .map(([sku]) => sku) + .sort(), + `the variants seeded for ${product.bundle} are not the SKUs its page lists`, + ); + } + }); }); /** @@ -201,7 +529,7 @@ describe('seed/shop-sample.sql', () => { * * It is optional: Mallok puts every language of a bundle into one translation * group on its own. A bundle carries one here only when something outside the - * content has to name it — the sample variants attach to the product by its + * content has to name it — the sample variants attach to a product by its * translation group. Where the file exists, it has to agree with the bundle. */ describe('bundle identity', () => { @@ -239,14 +567,10 @@ describe('bundle identity', () => { `content/${item.kind}/${item.bundle}/mallok.json has no entry for "${item.locale}"`, ); assert.equal(entry.slug, item.slug); - assert.ok( - entry.path?.endsWith(`/${item.slug}`), - `${entry.path} does not end in the slug ${item.slug}`, - ); - // The default language is unprefixed, every other one carries its code. assert.equal( - entry.path?.startsWith(`/${item.locale}/`), - item.locale !== site.defaultLocale, + entry.path, + pathOf(site, item), + `content/${item.kind}/${item.bundle}/mallok.json gives "${item.locale}" a path that is not where the page is`, ); assert.ok( entry.id !== undefined && !ids.has(entry.id), @@ -256,4 +580,25 @@ describe('bundle identity', () => { } assert.ok(ids.size > 0); }); + + it('gives every translation group to one bundle only', async () => { + const site = await readSite(); + const items = await loadItems(site.defaultLocale); + const owners = new Map(); + + for (const item of items) { + const group = (await readIdentity(item))?.translation_group; + if (group === undefined) { + continue; + } + const bundle = `${item.kind}/${item.bundle}`; + assert.equal( + owners.get(group) ?? bundle, + bundle, + `the translation group ${group} is claimed by two bundles`, + ); + owners.set(group, bundle); + } + assert.ok(owners.size > 0); + }); }); diff --git a/test/project.test.ts b/test/project.test.ts index b1212a7..f1deb33 100644 --- a/test/project.test.ts +++ b/test/project.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { readdir, readFile } from 'node:fs/promises'; +import { readdir, readFile, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { describe, it } from 'node:test'; @@ -71,6 +71,252 @@ describe('site.json', () => { }); }); +/** + * `site.json` carries this site's own copy: the navigation and the theme's + * options. Mallok renders whatever is there, so a language that was left out + * shows English on its most important page and nothing reports it. + */ +describe('site.json, in every language', () => { + interface Settings { + defaultLocale: string; + locales: string[]; + nav: Record; + themeOptions: Record & { + $locales?: Record>; + }; + } + + /** The option types that hold words or links rather than a setting. */ + const WRITTEN = new Set(['string', 'text']); + // The same in every language: an address, a number, a material grade. + const LANGUAGE_INDEPENDENT = new Set([ + 'contact_email', + 'contact_phone', + 'hero_badge', + ]); + + async function readSettings(): Promise { + return JSON.parse(await readFile('site.json', 'utf8')) as Settings; + } + + async function writtenOptions(): Promise { + const theme = JSON.parse( + await readFile(join('src', 'theme', 'theme.json'), 'utf8'), + ) as { options: Record }; + return Object.keys(theme.options).filter( + (name) => + WRITTEN.has(theme.options[name]?.type ?? '') && + !LANGUAGE_INDEPENDENT.has(name), + ); + } + + it('sets only options the theme declares', async () => { + const site = await readSettings(); + const theme = JSON.parse( + await readFile(join('src', 'theme', 'theme.json'), 'utf8'), + ) as { options: Record }; + + const sets = [ + site.themeOptions, + ...Object.values(site.themeOptions.$locales ?? {}), + ]; + for (const options of sets) { + for (const name of Object.keys(options)) { + assert.ok( + name === '$locales' || Object.hasOwn(theme.options, name), + `site.json sets the theme option "${name}", which the theme does not have`, + ); + } + } + }); + + it('gives each language its own words for every written option', async () => { + const site = await readSettings(); + const names = await writtenOptions(); + const others = site.locales.filter( + (locale) => locale !== site.defaultLocale, + ); + assert.ok(others.length > 0); + + let checked = 0; + for (const name of names) { + const original = site.themeOptions[name]; + for (const locale of others) { + const own = site.themeOptions.$locales?.[locale]?.[name]; + if (name === 'tagline') { + // The default language falls back to the site's own tagline; the + // others have nothing to fall back to in their language. + assert.ok(own, `site.json has no "${locale}" tagline`); + continue; + } + if (typeof original !== 'string' || original === '') { + continue; + } + assert.ok( + typeof own === 'string' && own !== '', + `site.json sets ${name} but gives "${locale}" no value for it`, + ); + assert.notEqual( + own, + original, + `site.json gives "${locale}" the default language's ${name}`, + ); + checked += 1; + } + } + assert.ok(checked > 0); + }); + + it('keeps each language’s links inside that language', async () => { + const site = await readSettings(); + + for (const locale of site.locales) { + const prefix = locale === site.defaultLocale ? '/' : `/${locale}/`; + const options = + locale === site.defaultLocale + ? site.themeOptions + : (site.themeOptions.$locales?.[locale] ?? {}); + const links = [ + ...(site.nav[locale] ?? []).map((item) => item.href), + ...Object.entries(options) + .filter(([name]) => name.endsWith('_href')) + .map(([, href]) => String(href)), + ]; + assert.ok(links.length > 0, `site.json has no links for "${locale}"`); + for (const href of links) { + assert.ok( + href.startsWith(prefix), + `site.json sends "${locale}" to ${href}`, + ); + if (locale === site.defaultLocale) { + assert.ok( + !site.locales.some( + (other) => + other !== locale && + (href === `/${other}` || href.startsWith(`/${other}/`)), + ), + `site.json sends the default language to ${href}`, + ); + } + } + } + }); + + it('offers the same navigation in every language', async () => { + const site = await readSettings(); + const expected = site.nav[site.defaultLocale]?.length ?? 0; + + assert.ok(expected > 0); + for (const locale of site.locales) { + assert.equal( + site.nav[locale]?.length, + expected, + `site.json gives "${locale}" a different number of navigation items`, + ); + for (const item of site.nav[locale] ?? []) { + assert.ok( + item.label.trim() !== '', + `site.json has a navigation item without a label in "${locale}"`, + ); + } + } + }); +}); + +/** + * What the theme runs in a visitor's browser is the list in `theme.json`, + * which Mallok shows the site's owner. The list is only worth showing if it + * is the whole truth: every script a template loads is on it, at the size it + * says, and nothing on it is dead weight. + */ +describe('the theme’s client scripts', () => { + const THEME = join('src', 'theme'); + + interface Declared { + path: string; + purpose: string; + bytes?: number; + } + + async function declared(): Promise { + const theme = JSON.parse( + await readFile(join(THEME, 'theme.json'), 'utf8'), + ) as { clientScripts: Declared[] }; + return theme.clientScripts; + } + + /** Every `