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
3 changes: 2 additions & 1 deletion scripts/strict-typecheck-ratchet.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { spawnSync } from "node:child_process";
import { ROOT_DIR } from "#lib/paths.js";

// Current baseline - lower this as you fix errors
const CURRENT_ERROR_COUNT = 367;
const CURRENT_ERROR_COUNT = 336;

// Files that currently pass strict mode (must not regress)
const STRICT_CLEAN_FILES = [
Expand Down Expand Up @@ -68,6 +68,7 @@ const STRICT_CLEAN_FILES = [
"src/_lib/eleventy/js-config.js",
"src/_lib/eleventy/layout-aliases.js",
"src/_lib/eleventy/opening-times.js",
"src/_lib/eleventy/pdf.js",
"src/_lib/eleventy/recurring-events.js",
"src/_lib/eleventy/video.js",
"src/_lib/filters/filter-core.js",
Expand Down
193 changes: 156 additions & 37 deletions src/_lib/eleventy/pdf.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,54 +18,148 @@ import { uniqueDietaryKeys } from "#utils/dietary-utils.js";
import { buildPdfFilename } from "#utils/slug-utils.js";
import { sortItems } from "#utils/sorting.js";

const getPdfRenderer = memoize(
/** @typedef {import("#lib/types").EleventyCollectionItem} EleventyCollectionItem */
/** @typedef {import("#lib/types").EleventyCollectionApi} EleventyCollectionApi */
/** @typedef {import("#lib/types").MenuItemCollectionItem} MenuItemCollectionItem */
/** @typedef {import("#lib/types").MenuCategoryCollectionItem} MenuCategoryCollectionItem */
/** @typedef {import("#lib/types").DietaryKey} DietaryKey */

/**
* Menu category collection items also expose Eleventy's rendered
* `templateContent`, used as the category description in the PDF.
* @typedef {MenuCategoryCollectionItem & { templateContent?: string | null }} MenuCategoryItem
*/

/**
* @typedef {Object} PdfState
* @property {EleventyCollectionItem[]} menus
* @property {MenuCategoryItem[]} menuCategories
* @property {MenuItemCollectionItem[]} menuItems
*/

/**
* @typedef {Object} PdfMenuItem
* @property {string | undefined} name
* @property {string | number | undefined} price
* @property {string | null | undefined} description
* @property {string} dietarySymbols
*/

/**
* @typedef {Object} PdfCategory
* @property {string | undefined} name
* @property {string} description
* @property {PdfMenuItem[]} items
*/

/**
* @typedef {Object} PdfData
* @property {string} businessName
* @property {string | undefined} menuTitle
* @property {string | null | undefined} subtitle
* @property {PdfCategory[]} categories
* @property {string} dietaryKeyString
* @property {boolean} hasDietaryKeys
*/

/** @typedef {{ pipe(stream: NodeJS.WritableStream): unknown; end(): void }} PdfDoc */

/** @typedef {(template: object, data: object) => PdfDoc | null} RenderPdfFn */

/**
* @template T
* @param {EleventyCollectionApi} api
* @param {string} tag
* @returns {T[]}
*/
const getTaggedAs = (api, tag) =>
/** @type {T[]} */ (api.getFilteredByTag(tag));

const renderPdfRaw = memoize(
async () => (await import("json-to-pdf")).renderPdfTemplate,
);

const getPdfRenderer = /** @type {() => Promise<RenderPdfFn>} */ (
/** @type {unknown} */ (renderPdfRaw)
);

/** @type {PdfState | null} */
let pdfState = null;

/**
* @param {EleventyCollectionItem} menu
* @param {Pick<PdfState, "menuCategories" | "menuItems">} state
* @returns {PdfData}
*/
function buildMenuPdfData(menu, { menuCategories, menuItems }) {
const items = menuItems;

const categories = pipe(
filter((cat) => cat.data.menus?.includes(menu.fileSlug)),
filter(
/** @param {MenuCategoryItem} cat */
(cat) => cat.data.menus?.includes(menu.fileSlug) === true,
),
sort(sortItems),
)(menuCategories);

/**
* @param {MenuCategoryItem} category
* @returns {(item: MenuItemCollectionItem) => boolean}
*/
const inCategory = (category) => (item) =>
item.data.menu_categories?.includes(category.fileSlug);
item.data.menu_categories?.includes(category.fileSlug) === true;

/**
* @param {MenuCategoryItem} category
* @returns {PdfMenuItem[]}
*/
const itemsInCategory = (category) =>
pipe(
filter(inCategory(category)),
map((item) => ({
name: item.data.name,
price: item.data.price,
description: item.data.description,
dietarySymbols: pipe(
map((k) => k.symbol),
join(" "),
)(item.data.dietaryKeys),
})),
map(
/**
* @param {MenuItemCollectionItem} item
* @returns {PdfMenuItem}
*/
(item) => ({
name: item.data.name,
price: item.data.price,
description: item.data.description,
dietarySymbols: pipe(
map(/** @param {DietaryKey} k */ (k) => k.symbol),
join(" "),
)(item.data.dietaryKeys),
}),
),
)(items);

const pdfCategories = map((category) => ({
name: category.data.name,
description: category.templateContent
? category.templateContent.replace(/<[^>]*>/g, "").trim()
: "",
items: itemsInCategory(category),
}))(categories);
const pdfCategories = map(
/**
* @param {MenuCategoryItem} category
* @returns {PdfCategory}
*/
(category) => ({
name: category.data.name,
description: category.templateContent
? category.templateContent.replace(/<[^>]*>/g, "").trim()
: "",
items: itemsInCategory(category),
}),
)(categories);

const allDietaryKeys = pipe(
flatMap((category) =>
items
.filter(inCategory(category))
.flatMap((item) => item.data.dietaryKeys),
flatMap(
/** @param {MenuCategoryItem} category */
(category) =>
items
.filter(inCategory(category))
.flatMap((item) => item.data.dietaryKeys),
),
uniqueDietaryKeys,
)(categories);

const dietaryKeyString = pipe(
map((k) => `(${k.symbol}) ${k.label}`),
map(/** @param {DietaryKey} k */ (k) => `(${k.symbol}) ${k.label}`),
join(", "),
)(allDietaryKeys);

Expand All @@ -80,6 +174,11 @@ function buildMenuPdfData(menu, { menuCategories, menuItems }) {
}

function createMenuPdfTemplate() {
/**
* @param {string} text
* @param {string} style
* @param {[number, number, number, number]} margin
*/
const centeredText = (text, style, margin) => ({
text,
style,
Expand Down Expand Up @@ -213,6 +312,12 @@ function createMenuPdfTemplate() {
};
}

/**
* @param {EleventyCollectionItem} menu
* @param {PdfState} state
* @param {string} outputDir
* @returns {Promise<string | null>}
*/
async function generateMenuPdf(menu, state, outputDir) {
const data = buildMenuPdfData(menu, state);
const template = createMenuPdfTemplate();
Expand Down Expand Up @@ -243,25 +348,39 @@ async function generateMenuPdf(menu, state, outputDir) {
});
}

/** @param {*} eleventyConfig */
export const configurePdf = (eleventyConfig) => {
let state = null;
pdfState = null;

/**
* @param {PdfState} state
* @param {string} outputDir
*/
const writePdfs = (state, outputDir) => {
return mapAsync(
/** @param {EleventyCollectionItem} menu */
(menu) => generateMenuPdf(menu, state, outputDir),
)(state.menus);
};

eleventyConfig.addCollection("_pdfMenuData", (collectionApi) => {
state = {
menus: collectionApi.getFilteredByTag("menus"),
menuCategories: collectionApi.getFilteredByTag("menu-categories"),
menuItems: collectionApi.getFilteredByTag("menu-items"),
/** @param {EleventyCollectionApi} api */
const captureState = (api) => {
pdfState = {
menus: api.getFilteredByTag("menus"),
menuCategories: getTaggedAs(api, "menu-categories"),
menuItems: getTaggedAs(api, "menu-items"),
};
return [];
});
};

eleventyConfig.on("eleventy.after", async ({ dir }) => {
if (!state?.menus || state.menus.length === 0) return;
/** @param {{ dir: { output: string } }} event */
const onAfter = async ({ dir }) => {
if (pdfState === null || pdfState.menus.length === 0) return;
await writePdfs(pdfState, dir.output);
};

await mapAsync((menu) => generateMenuPdf(menu, state, dir.output))(
state.menus,
);
});
eleventyConfig.addCollection("_pdfMenuData", captureState);
eleventyConfig.on("eleventy.after", onAfter);
};

export { buildMenuPdfData, generateMenuPdf };
14 changes: 14 additions & 0 deletions src/_lib/types/eleventy.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,26 @@ export type NewsItemData = BaseItemData & {
date?: Date;
};

/**
* Dietary indicator displayed on a menu item.
*/
export type DietaryKey = {
symbol: string;
label: string;
};

/**
* Menu item data fields (for restaurant menus, etc.)
*/
export type MenuItemData = BaseItemData & {
/** Menu categories this item belongs to */
menu_categories?: string[];
/** Display price (e.g., "$8.99" or numeric) */
price?: string | number;
/** Item description */
description?: string | null;
/** Dietary indicator keys (guaranteed [] by 11tydata boundary) */
dietaryKeys: DietaryKey[];
};

/**
Expand Down
1 change: 1 addition & 0 deletions src/_lib/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type {
NewsItemData,
MenuItemData,
MenuCategoryItemData,
DietaryKey,

// Specific collection item types (preferred for type safety)
ProductCollectionItem,
Expand Down
Loading