/** Product catalog frontmatter parsing. * * Turns one markdown file's text (frontmatter only) into the records the * importer stores: the product's fields, its rental options, its filter * attributes, and a category file's title. Pure data-in/data-out, so every * rule is testable without the site. */ import { parse } from "jsr:@std/yaml@1"; import { errorMessage } from "#shared/error-message.ts"; import { MAX_DURATION_DAYS } from "#types"; /** One rental-period option as the site stores it. */ export type CatalogOption = { days: number; max_quantity: number; name: string; unit_price: number; }; export type CatalogFilterAttribute = { name: string; value: string }; export type CatalogProduct = { categories: string[]; features: string[]; /** The product file name without its extension, e.g. "tumble-tower-hire". */ filename: string; filterAttributes: CatalogFilterAttribute[]; order: number; options: CatalogOption[]; specs: { name: string; value: string }[]; subtitle: string; title: string; }; /** Attribute names that mean the same thing; the first name wins. */ const ATTRIBUTE_NAME_ALIASES: Record = { "Mains power required": "Power Required", }; const canonicalAttributeName = (name: string): string => ATTRIBUTE_NAME_ALIASES[name] ?? name; const asString = (value: unknown): string => typeof value === "string" ? value.trim() : ""; /** The text entries of one frontmatter list. A supplied container that is no * list, or an entry that is no text, is malformed external data and stops * the import naming the file and the field; a missing or empty container * holds no entries. */ const asStringList = ( filename: string, field: string, value: unknown, ): string[] => { if (value === undefined || value === null) return []; if (!Array.isArray(value)) { throw new Error(`${filename}: ${field} is not a list`); } return value .map((item) => { if (typeof item !== "string") { throw new Error( `${filename}: ${field} holds an entry that is not text`, ); } return item.trim(); }) .filter((item) => item !== ""); }; /** The frontmatter block of a markdown file, or null when the file opens with * no closing fence. Windows line endings read like Unix ones. */ export const frontmatterBlock = (text: string): string | null => { const normalized = text.replaceAll("\r\n", "\n"); return /^---\n([\s\S]*?)\n---/.exec(normalized)?.[1] ?? null; }; /** One option number, read strictly: a missing field falls back only where a * fallback is given, a price never defaults, and anything non-numeric stops * the import naming the file and the option. */ const optionNumber = ( filename: string, optionName: string, field: string, raw: unknown, fallback: number | undefined, ): number => { if (raw === undefined || raw === null || raw === "") { if (fallback === undefined) { throw new Error(`${filename}: option '${optionName}' needs a ${field}`); } return fallback; } if (typeof raw === "boolean") { throw new Error( `${filename}: option '${optionName}' has a non-numeric ${field}`, ); } const parsed = Number(raw); if (!Number.isFinite(parsed)) { throw new Error( `${filename}: option '${optionName}' has a non-numeric ${field}`, ); } // A day count or a quantity below one, or a negative price, would book // nonsense; days and max_quantity count whole days and whole bookings. if (field === "unit_price") { if (parsed < 0) { throw new Error( `${filename}: option '${optionName}' has a negative ${field}`, ); } return parsed; } if (!Number.isInteger(parsed) || parsed < 1) { throw new Error( `${filename}: option '${optionName}' has a ${field} that is not a positive whole number`, ); } // The application clamps bookings to MAX_DURATION_DAYS and silently drops // day prices past it, so a longer catalog option would import half-lost. if (field === "days" && parsed > MAX_DURATION_DAYS) { throw new Error( `${filename}: option '${optionName}' books ${parsed} days, above the maximum of ${MAX_DURATION_DAYS}`, ); } return parsed; }; /** The product's site order: a missing field sorts first, and anything * non-numeric stops the import naming the file. */ const orderNumber = (filename: string, value: unknown): number => { if (value === undefined || value === null || value === "") return 0; if (typeof value === "boolean") { throw new Error(`${filename}: order is not a number`); } const parsed = Number(value); if (!Number.isFinite(parsed)) { throw new Error(`${filename}: order is not a number`); } return parsed; }; /** The frontmatter mapping of one file, or an empty one when the file holds * none. A YAML error names its file: the importer stops before it writes. */ const parseFrontmatter = ( filename: string, block: string, ): Record => { let parsed: unknown; try { parsed = parse(block); } catch (error) { throw new Error( `${filename}: unparseable frontmatter: ${errorMessage(error)}`, ); } if (parsed === null || typeof parsed !== "object") return {}; return parsed as Record; }; /** The entries of one frontmatter list. A supplied container that is no list, * or an entry that holds no fields (null, a scalar, an array), is malformed * external data and stops the import naming the file and the field; it must * not read as an absent entry. */ const recordEntries = ( filename: string, field: string, raw: unknown, ): Record[] => { if (raw === undefined || raw === null) return []; if (!Array.isArray(raw)) { throw new Error(`${filename}: ${field} is not a list`); } for (const entry of raw) { if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { throw new Error( `${filename}: ${field} holds an entry that is not a name/value record`, ); } } return raw as Record[]; }; /** A product's rental options, validated: every supplied option carries a * name, a price is required, two options that book one day count would * silently replace each other's price in the day-price map, and an * option-less product would write nonsense into the listing body. Each * failure names the file. */ const parseOptions = ( filename: string, raw: unknown, ): CatalogProduct["options"] => { const named = recordEntries(filename, "options", raw).map((option) => { const name = asString(option.name); // A supplied option without a name would silently drop a rental period // and its price from the listing. if (name === "") { throw new Error(`${filename}: an option needs a name`); } return { name, option }; }); if (named.length === 0) { throw new Error(`${filename}: a product needs at least one rental option`); } const options = named.map(({ name, option }) => ({ days: optionNumber(filename, name, "days", option.days, 1), max_quantity: optionNumber( filename, name, "max_quantity", option.max_quantity, 10, ), name, unit_price: optionNumber( filename, name, "unit_price", option.unit_price, undefined, ), })); const seenDays = new Set(); for (const option of options) { if (seenDays.has(option.days)) { throw new Error(`${filename}: two options book ${option.days} day(s)`); } seenDays.add(option.days); } return options; }; /** One category file name: the frontmatter may prefix `src/` or `categories/` * and end in `.md`, but the result must be a bare name — a path would read * files the catalog never named and post their titles to the site. */ const categorySlug = (filename: string, path: string): string => { const slug = path .replace(/^src\//, "") .replace(/\.md$/, "") .replace(/^categories\//, ""); if ( slug === "" || slug.includes("/") || slug.includes("\\") || slug.startsWith(".") ) { throw new Error( `${filename}: category "${path}" must be a bare category file name`, ); } return slug; }; /** Parse one product file's frontmatter, or null when it holds no product. */ export const parseProductFile = ( filename: string, text: string, ): CatalogProduct | null => { if (!filename.endsWith(".md")) return null; const block = frontmatterBlock(text); if (block === null) return null; const front = parseFrontmatter(filename, block); const title = asString(front.title); if (title === "") return null; return { categories: asStringList(filename, "categories", front.categories).map( (path) => categorySlug(filename, path), ), features: asStringList(filename, "features", front.features), filename: filename.replace(/\.md$/, ""), filterAttributes: recordEntries( filename, "filter_attributes", front.filter_attributes, ) .map((attribute) => { const name = canonicalAttributeName(asString(attribute.name)); const value = asString(attribute.value); // A half-filled entry (a misspelled field, say `values:`) must not // read as no selection: the import would lose the attribute quietly. if ((name === "") !== (value === "")) { throw new Error( `${filename}: a filter attribute needs both a name and a value`, ); } return { name, value }; }) .filter((attribute) => attribute.name !== ""), options: parseOptions(filename, front.options), order: orderNumber(filename, front.order), specs: recordEntries(filename, "specs", front.specs) .map((spec) => ({ name: asString(spec.name), value: asString(spec.value), })) .filter((spec) => spec.name !== ""), subtitle: asString(front.subtitle), title, }; }; /** The site's own title of one category file's text, or the file path's slug * when the text has no usable title. An unparseable file stops the import: * a stale path must not quietly create a wrongly named group. */ export const parseCategoryTitle = (file: string, text: string): string => { const slug = file.replace(/\.md$/, "").replace(/.*\//, ""); const front = frontmatterBlock(text); let parsed: unknown; try { parsed = front === null ? {} : parse(front); } catch (error) { throw new Error(`${file}: unparseable frontmatter: ${errorMessage(error)}`); } if (parsed === null || typeof parsed !== "object") return slug; const record = parsed as Record; const title = typeof record.title === "string" ? record.title.trim() : ""; return title === "" ? slug : title; };