cli/product-catalog/parse.ts
Rama main · réplica de lectura del repositorio original.
/** 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<string, string> = {
"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<string, unknown> => {
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<string, unknown>;
};
/** 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<string, unknown>[] => {
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<string, unknown>[];
};
/** 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<number>();
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<string, unknown>;
const title = typeof record.title === "string" ? record.title.trim() : "";
return title === "" ? slug : title;
};