| src | ||
| test | ||
| .gitignore | ||
| .woodpecker.yml | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
@wl.cx/content
Content model shared by the wl.cx sites (template: alice/landing), their local dev admin, and ctrl.
media originals in Git LFS + .meta.json with a stable 8-char `id`
entries src/content/entries/<slug>/index.md — one shape, `type` picks the layout
add-ons optional frontmatter blocks: artwork{}, product{}, wall
views filter + sort + layout over entries; blog, wall, shop, catalogue built in
Install
Pin a tag via Forgejo's archive URL. It works without git in the CI image, unlike git+https:
"@wl.cx/content": "https://git.wl.cx/wl.cx/content/archive/v0.3.0.tar.gz"
A tag is a release. Bump the URL to upgrade.
Use
import { normalizeEntry, normalizeView, applyView, paginate } from '@wl.cx/content';
import { createFsSource } from '@wl.cx/content/fs'; // Node only
import { loadEntries, loadViews } from '@wl.cx/content';
const source = createFsSource(process.cwd());
const entries = await loadEntries(source);
const views = await loadViews(source);
The main export is browser-safe. @wl.cx/content/fs needs Node.
A source is any object with async read, write, list and remove on repo-relative paths (see src/source.js). ctrl provides one backed by the Forgejo API, so it validates content with the same code the build uses.
Entry
---
type: artwork # post | photo | artwork | page (default post)
title: Haus Elfenwinkel
date: 2026-10-19
images:
- media: k7f2q9ab # media id; a plain string works too
- media: a91xz3cd
role: source
cover: k7f2q9ab # default: first image
tags: [installation, hexenhaus]
visibility: public # public | unlisted | draft
excerpt: …
layout: artwork # default: type
nav: true # or a label
artwork: { medium: oil on linen, dimensions: 80 × 60 cm, year: 2019 }
product: { price: 450, currency: EUR, availability: in_stock, edition: unique }
wall: true # or { caption: … }
---
Markdown body.
Invalid content throws ContentError, so the build fails and ctrl refuses to commit it.
artwork uses short names. Outputs map them to schema.org: medium → artMedium, dimensions → height/width/depth, year → dateCreated.
Product add-on
product marks an entry as for sale and puts it in the built-in shop view. Every field that existed before keeps working; madeToOrder and variants are new in 0.3.0 and both are optional.
| field | type | default | notes |
|---|---|---|---|
price |
number ≥ 0 | required | numeric strings accepted, as before; the base price when variants is empty |
currency |
string | EUR |
|
sku |
string | '' |
|
availability |
in_stock | sold | preorder |
in_stock |
|
edition |
string | '' |
free text, e.g. unique, 1/25 |
shipping |
string | '' |
free text |
madeToOrder |
boolean | false |
produced after the order (Printful) instead of shipped from stock; only real booleans, "yes" fails the build |
variants |
list of variant mappings | [] |
size/colour combinations; empty means one form, priced by price |
A variant is a mapping with at most these keys — unknown keys are rejected, not silently dropped:
| key | type | notes |
|---|---|---|
size |
string, trimmed | |
color |
string, trimmed | |
sku |
string, trimmed | |
price |
finite number ≥ 0 | this variant's price; consumers fall back to product.price when absent |
printfulVariantId |
positive integer | Printful's variant id for fulfilment |
Rules, all enforced with ContentError:
- blank
size/color/skustrings are dropped from the output, not kept as'' priceandprintfulVariantIdinside a variant are strict:price: "34.50"is an error (the top-levelproduct.pricestill accepts numeric strings for compatibility), andprintfulVariantIdmust be an integer, not a string- every variant needs at least one of
size,color,sku,printfulVariantId; a price alone does not identify anything - at most 100 variants.
VARIANT_KEYSandMAX_VARIANTSare exported
---
type: artwork
title: Frei Hoodie
date: 2026-11-01
images: [{ media: m4k2p9xq }]
product:
price: 42
currency: EUR
availability: preorder
madeToOrder: true
variants:
- { size: S, color: white, price: 42, printfulVariantId: 4011 }
- { size: M, color: white, price: 42, printfulVariantId: 4012 }
- { size: L, color: black, price: 44, sku: HOOD-L-BLK, printfulVariantId: 4020 }
---
Unknown keys
Frontmatter keys the model does not know are kept verbatim in entry.extra (unchanged since 0.1.0) — the marie wall's takenAt is one. ctrl relies on this: its entry editor clones the raw frontmatter and rewrites only the fields it owns, so an unmodelled block — a Printful snapshot, an import marker — survives a save and a round trip through normalizeEntry. Unknown keys inside product are not preserved; product is a modelled shape and only the fields above come out.
Visibility
| views, nav, sitemap, feeds | rendered at its URL | |
|---|---|---|
public |
yes | yes |
unlisted |
no | yes, noindex — must not be mentioned publicly |
embedded |
no (may be given by a view's entries: list) |
yes, noindex |
draft |
no | no |
Views
Built in: blog (posts), wall (wall add-on), shop (product), catalogue (artwork). Nav entries are opt-in. A site overrides or adds views in src/content/views/<name>.md:
---
title: Garten
filter: { tags: [garten] } # also: type, has (add-on names)
sort: date-desc # date-asc | title | year-desc | year-asc | manual
layout: grid # list | grid | wall | sections
pageSize: 24 # 0 = one page
nav: true
# entries: [slug, slug] # explicit list instead of a filter; implies sort: manual
---
Intro text.
Develop
npm install
npm test # node:test, no extra deps