diff --git a/scripts/strict-typecheck-ratchet.js b/scripts/strict-typecheck-ratchet.js index 79ec01aa0..24b156aa2 100644 --- a/scripts/strict-typecheck-ratchet.js +++ b/scripts/strict-typecheck-ratchet.js @@ -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 = [ @@ -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", diff --git a/src/_lib/eleventy/pdf.js b/src/_lib/eleventy/pdf.js index 4b4d6dd4a..1aba8a681 100644 --- a/src/_lib/eleventy/pdf.js +++ b/src/_lib/eleventy/pdf.js @@ -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} */ ( + /** @type {unknown} */ (renderPdfRaw) +); + +/** @type {PdfState | null} */ +let pdfState = null; + +/** + * @param {EleventyCollectionItem} menu + * @param {Pick} 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); @@ -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, @@ -213,6 +312,12 @@ function createMenuPdfTemplate() { }; } +/** + * @param {EleventyCollectionItem} menu + * @param {PdfState} state + * @param {string} outputDir + * @returns {Promise} + */ async function generateMenuPdf(menu, state, outputDir) { const data = buildMenuPdfData(menu, state); const template = createMenuPdfTemplate(); @@ -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 }; diff --git a/src/_lib/types/eleventy.d.ts b/src/_lib/types/eleventy.d.ts index a0c805ff1..a02c8fab5 100644 --- a/src/_lib/types/eleventy.d.ts +++ b/src/_lib/types/eleventy.d.ts @@ -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[]; }; /** diff --git a/src/_lib/types/index.d.ts b/src/_lib/types/index.d.ts index a33e88366..06ef4feec 100644 --- a/src/_lib/types/index.d.ts +++ b/src/_lib/types/index.d.ts @@ -41,6 +41,7 @@ export type { NewsItemData, MenuItemData, MenuCategoryItemData, + DietaryKey, // Specific collection item types (preferred for type safety) ProductCollectionItem,