From ae796996c891ce10dd82d07b75186990b3758356 Mon Sep 17 00:00:00 2001 From: Daren Tan Date: Sat, 7 Mar 2026 23:09:09 +0800 Subject: [PATCH 1/5] feat: migrate from puppeteer to satori --- export/.gitignore | 1 + export/README.md | 18 +- export/api/export/image.ts | 29 +- export/api/export/pdf.ts | 6 +- export/package.json | 18 +- export/src/app.ts | 43 +- export/src/config.ts | 11 - export/src/data.ts | 6 + export/src/handler.ts | 28 +- export/src/image-options.ts | 31 + export/src/index.ts | 32 +- export/src/render-image.ts | 24 + export/src/render-pdf.ts | 70 ++ export/src/render-serverless.ts | 84 --- export/src/render.ts | 110 ---- export/src/satori/fonts.ts | 47 ++ export/src/satori/render-model.test.ts | 215 ++++++ export/src/satori/render-model.ts | 460 +++++++++++++ export/src/satori/render-satori.tsx | 51 ++ export/src/satori/theme.ts | 164 +++++ export/src/satori/view.tsx | 871 +++++++++++++++++++++++++ export/src/types.ts | 70 +- export/tsconfig.json | 3 + pnpm-lock.yaml | 834 +++++++++++------------ renovate.json | 2 +- 25 files changed, 2477 insertions(+), 751 deletions(-) create mode 100644 export/.gitignore create mode 100644 export/src/image-options.ts create mode 100644 export/src/render-image.ts create mode 100644 export/src/render-pdf.ts delete mode 100644 export/src/render-serverless.ts delete mode 100644 export/src/render.ts create mode 100644 export/src/satori/fonts.ts create mode 100644 export/src/satori/render-model.test.ts create mode 100644 export/src/satori/render-model.ts create mode 100644 export/src/satori/render-satori.tsx create mode 100644 export/src/satori/theme.ts create mode 100644 export/src/satori/view.tsx diff --git a/export/.gitignore b/export/.gitignore new file mode 100644 index 0000000000..e985853ed8 --- /dev/null +++ b/export/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/export/README.md b/export/README.md index ebb44e0416..19d4768af9 100644 --- a/export/README.md +++ b/export/README.md @@ -1,6 +1,6 @@ # NUSMods Timetable Export Service -Uses [Puppeteer][puppeteer] to render a copy of the user's timetable on the server before taking a screenshot and sending it back to them. +Renders timetables server-side using Satori and sends them back as images or PDFs. ## Getting started @@ -8,8 +8,6 @@ Uses [Puppeteer][puppeteer] to render a copy of the user's timetable on the serv ```bash # Install dependencies -# To skip Puppeteer installing Chromium, set PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 -# See https://github.com/GoogleChrome/puppeteer/blob/v0.13.0/docs/api.md#environment-variables $ pnpm install # Configure - the defaults are sufficient for development, but for @@ -23,8 +21,7 @@ $ cd ../website $ pnpm start $ pnpm start:export -# Start export server - use "pnpm devtools" if need to see the graphical browser with -# developer tools. Note that PDF export does not work in devtools mode. +# Start export server $ cd ../export $ pnpm dev ``` @@ -42,9 +39,8 @@ $ pnpm dev 1. The serialized state is added as query params to the 'Download as' link the user sees when they open the dropdown menu 1. When the user clicks on the link, the browser makes a GET request to the chosen export endpoint 1. The endpoint parses and validates the incoming data -1. The export service uses the Puppeteer instance to create a new `Page` object -1. The page loads a special version of the app that only has the `` component, and injects the data into the Redux store -1. The screenshot or PDF is taken of the page, and sent back to the user +1. The export service renders the timetable using Satori (SVG) and converts it to the requested format +1. The screenshot or PDF is sent back to the user ## API @@ -69,10 +65,6 @@ Download PNG image of the timetable. - `data` - JSON encoded timetable data - `pixelRatio = 1` (Number: 1 to 3 inclusive) - the device pixel ratio as reported in [`window.devicePixelRatio`](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio). This scales the entire image by that amount so it will appear correctly when downloaded to the user's device. -### GET `/debug` - -Returns the HTML content of the page that Puppeteer renders. - ## Deployment The export service is deployed on Vercel for production. @@ -107,5 +99,3 @@ $ pnpm deploy # Uses port 3300 for production and 3301 for staging $ pnpm start ``` - -[puppeteer]: https://github.com/GoogleChrome/puppeteer diff --git a/export/api/export/image.ts b/export/api/export/image.ts index c66ae4f49a..7b34d8277d 100644 --- a/export/api/export/image.ts +++ b/export/api/export/image.ts @@ -1,13 +1,12 @@ -import _ from 'lodash'; - import { validateExportData } from '../../src/data'; import { makeExportHandler } from '../../src/handler'; -import * as render from '../../src/render-serverless'; +import { parseViewportOptions } from '../../src/image-options'; +import { renderImage } from '../../src/render-image'; import type { ExportData } from '../../src/types'; type Data = { exportData: ExportData; - options: render.ViewportOptions; + options: import('../../src/types').ViewportOptions; }; const handler = makeExportHandler( @@ -15,29 +14,13 @@ const handler = makeExportHandler( const exportData = JSON.parse(request.query.data as never); validateExportData(exportData); - let options: render.ViewportOptions = { - pixelRatio: _.clamp(Number(request.query.pixelRatio) || 1, 1, 3), - }; - const height = Number(request.query.height); - const width = Number(request.query.width); - if ( - height !== undefined && - width !== undefined && - !Number.isNaN(height) && // accept floats - !Number.isNaN(width) && // accept floats - height > 0 && - width > 0 - ) { - options = { ...options, height, width }; - } - return { exportData, - options, + options: parseViewportOptions(request.query), }; }, - async (response, page, { exportData, options }) => { - const body = await render.image(page, exportData, options); + async (response, { exportData, options }) => { + const body = await renderImage(exportData, options); response.setHeader('Content-Disposition', 'attachment; filename="My Timetable.png"'); response.setHeader('Content-Type', 'image/png'); response.status(200).send(body); diff --git a/export/api/export/pdf.ts b/export/api/export/pdf.ts index 017fd90763..2203580984 100644 --- a/export/api/export/pdf.ts +++ b/export/api/export/pdf.ts @@ -1,6 +1,6 @@ import { validateExportData } from '../../src/data'; import { makeExportHandler } from '../../src/handler'; -import * as render from '../../src/render-serverless'; +import { renderPdf } from '../../src/render-pdf'; import type { ExportData } from '../../src/types'; const handler = makeExportHandler( @@ -9,8 +9,8 @@ const handler = makeExportHandler( validateExportData(exportData); return exportData; }, - async (response, page, exportData) => { - const body = await render.pdf(page, exportData); + async (response, exportData) => { + const body = await renderPdf(exportData); response.setHeader('Content-Disposition', 'attachment; filename="My Timetable.pdf"'); response.setHeader('Content-Type', 'application/pdf'); response.status(200).send(body); diff --git a/export/package.json b/export/package.json index f065bee9b3..3ce43ab3e5 100644 --- a/export/package.json +++ b/export/package.json @@ -10,17 +10,19 @@ "start": "pm2 start ecosystem.config.js", "build": "tsc", "nodemon": "nodemon --no-update-notifier -r dotenv/config ./build/src/index.js", + "test": "tsx --test src/**/*.test.ts", "watch": "tsc --watch", "dev": "run-p nodemon watch", - "devtools": "cross-env DEVTOOLS=1 pnpm dev", + "check": "run-s lint typecheck", "deploy": "rsync -avu --delete-after . ../../nusmods-export && pm2 restart ecosystem.config.js", + "devtools": "cross-env DEVTOOLS=1 pnpm dev", "lint": "oxlint -c oxlint.config.mjs src", - "typecheck": "tsc --noEmit", - "check": "run-s lint typecheck" + "typecheck": "tsc --noEmit" }, "dependencies": { + "@fontsource/inter": "^5.2.8", + "@resvg/resvg-js": "^2.6.2", "@sentry/node": "5.30.0", - "@sparticuz/chromium": "^123.0.0", "axios": "0.30.0", "bunyan": "1.8.15", "fs-extra": "9.1.0", @@ -31,8 +33,10 @@ "koa-views": "6.3.1", "lodash": "4.17.23", "nodemon": "2.0.22", + "pdfkit": "^0.17.2", "pug": "3.0.3", - "puppeteer-core": "22.15.0" + "react": "18.3.1", + "satori": "^0.25.0" }, "devDependencies": { "@nkzw/eslint-plugin": "^2.0.0", @@ -43,15 +47,17 @@ "@types/koa-views": "2.0.4", "@types/lodash": "4.17.14", "@types/node": "22.13.5", + "@types/pdfkit": "^0.17.5", "@types/pug": "2.0.10", + "@types/react": "18.3.18", "@vercel/node": "1.15.4", - "cross-env": "7.0.3", "dotenv": "8.6.0", "eslint-plugin-no-only-tests": "^3.3.0", "eslint-plugin-perfectionist": "^5.6.0", "eslint-plugin-unused-imports": "^4.4.1", "npm-run-all": "4.1.5", "oxlint": "^1.51.0", + "tsx": "^4.21.0", "typescript": "5.9.3" } } diff --git a/export/src/app.ts b/export/src/app.ts index 2fbd2ab756..42e15a7902 100644 --- a/export/src/app.ts +++ b/export/src/app.ts @@ -3,53 +3,37 @@ import Router from 'koa-router'; import views from 'koa-views'; import * as Sentry from '@sentry/node'; -import _ from 'lodash'; - -import * as render from './render'; import * as data from './data'; import config from './config'; +import { parseViewportOptions } from './image-options'; +import { renderImage } from './render-image'; +import { renderPdf } from './render-pdf'; import type { State } from './types'; // Start router const app = new Koa(); -const router = new Router(); +const router = new Router({ prefix: '/api/export' }); router .get('/image', async (ctx) => { - const { data, page } = ctx.state; - const { height, width } = ctx.query; - - // Validate options - let options: render.ViewportOptions = { - pixelRatio: _.clamp(Number(ctx.query.pixelRatio) || 1, 1, 3), - }; + const { data } = ctx.state; + const options = parseViewportOptions(ctx.query); - if ( - height !== undefined && - width !== undefined && - !Number.isNaN(height) && // accept floats - !Number.isNaN(width) && // accept floats - Number(height) > 0 && - Number(width) > 0 - ) { - options = { - ...options, - height: Number(height), - width: Number(width), - }; - } + ctx.body = await renderImage(data, options); - ctx.body = await render.image(page, data, options); ctx.attachment('My Timetable.png'); }) .get('/pdf', async (ctx) => { - const { data, page } = ctx.state; + const { data } = ctx.state; + + ctx.body = await renderPdf(data); - ctx.body = await render.pdf(page, data); + ctx.set('Content-Type', 'application/pdf'); ctx.attachment('My Timetable.pdf'); }) .get('/debug', async (ctx) => { - ctx.body = await ctx.state.page.content(); + ctx.status = 501; + ctx.body = 'Debug HTML is unavailable as browser renderer is disabled.'; }); // Error handling @@ -89,7 +73,6 @@ app ) .use(errorHandler) .use(data.parseExportData) - .use(render.openPage) .use(router.routes()) .use(router.allowedMethods()); diff --git a/export/src/config.ts b/export/src/config.ts index fa74f43ae0..a7872a8dcc 100644 --- a/export/src/config.ts +++ b/export/src/config.ts @@ -4,17 +4,6 @@ export default { // Width of the page in pixels pageWidth: Number(process.env.PAGE_WIDTH) || 1024, - // Path to the Chrome executable - for Puppeteer 0.13, use Chrome 64. - // If left blank this will use the version of Chromium that comes with Puppeteer - // instead - chromeExecutable: process.env.CHROME_EXECUTABLE, - - // If set to a local path, the page will be loaded using setContent - use this - // for production - // If set to a URL, the page will be loaded instead - use localhost:8081 in - // development with Webpack hot reload server - page: process.env.PAGE!, - // Path to a folder containing module data. If null, during development the // NUSMods API will be used instead. In production leaving this as null will // throw an error. diff --git a/export/src/data.ts b/export/src/data.ts index 126ed64942..68a07fd988 100644 --- a/export/src/data.ts +++ b/export/src/data.ts @@ -43,7 +43,13 @@ export async function getModules(moduleCodes: Array) { return modules.filter(Boolean); } +const EXPORT_ROUTES = /\/api\/export\/(image|pdf)$/; + export const parseExportData: Middleware = (ctx, next) => { + if (EXPORT_ROUTES.test(ctx.path) && !ctx.query.data) { + ctx.throw(422, 'Missing timetable data'); + } + if (ctx.query.data) { try { if (typeof ctx.query.data !== 'string') { diff --git a/export/src/handler.ts b/export/src/handler.ts index 22d750c57a..b3ea63c562 100644 --- a/export/src/handler.ts +++ b/export/src/handler.ts @@ -1,8 +1,6 @@ import * as Sentry from '@sentry/node'; import type { VercelApiHandler, VercelRequest, VercelResponse } from '@vercel/node'; -import type { Page } from 'puppeteer-core'; -import * as render from './render-serverless'; import config from './config'; import { render422, render500 } from './views'; import { HttpError } from './HttpError'; @@ -39,14 +37,14 @@ function setUpSentry() { */ export function makeExportHandler( parseExportData: (request: VercelRequest) => T, - performExport: (response: VercelResponse, page: Page, data: T) => void | Promise, + performExport: (response: VercelResponse, data: T) => void | Promise, ): VercelApiHandler { return async function handler(request, response) { try { throwIfAcademicYearNotSet(); setUpSentry(); - // Validate input before starting the browser (which is expensive) + // Validate input before rendering let data = undefined; try { data = parseExportData(request); @@ -54,27 +52,7 @@ export function makeExportHandler( throw new HttpError(422, 'Invalid timetable data', error); } - // Prepare browser for export - const url = config.page; - let page: Page; - try { - page = await render.open(url); - } catch (error) { - if (error.message.includes('ERR_CONNECTION_REFUSED')) { - throw new HttpError( - 500, - `Could not open the page located at process.env.PAGE (${url}). Try opening it in your browser?`, - error, - ); - } - throw new HttpError(500, 'Cannot start browser', error); - } - - // Export - await performExport(response, page, data); - - // Cleanup - await page.close(); + await performExport(response, data); } catch (error) { const eventId = Sentry.captureException(error.original || error); diff --git a/export/src/image-options.ts b/export/src/image-options.ts new file mode 100644 index 0000000000..da4378f4f7 --- /dev/null +++ b/export/src/image-options.ts @@ -0,0 +1,31 @@ +import _ from 'lodash'; + +import type { ViewportOptions } from './types'; + +type SizeSource = { + height?: unknown; + pixelRatio?: unknown; + width?: unknown; +}; + +export function parseViewportOptions(source: SizeSource): ViewportOptions { + let options: ViewportOptions = { + pixelRatio: _.clamp(Number(source.pixelRatio) || 1, 1, 3), + }; + + const height = Number(source.height); + const width = Number(source.width); + + if ( + typeof source.height !== 'undefined' && + typeof source.width !== 'undefined' && + !Number.isNaN(height) && + !Number.isNaN(width) && + height > 0 && + width > 0 + ) { + options = { ...options, height, width }; + } + + return options; +} diff --git a/export/src/index.ts b/export/src/index.ts index a294573fc8..6123b06d2e 100644 --- a/export/src/index.ts +++ b/export/src/index.ts @@ -4,7 +4,6 @@ import gracefulShutdown from 'http-graceful-shutdown'; import config from './config'; import app from './app'; -import * as render from './render'; // Config check if (!config.academicYear || !/\d{4}-\d{4}/.test(config.academicYear)) { @@ -36,32 +35,7 @@ if (process.env.NODE_ENV === 'production') { } } -// Wait for the browser to finish launching before starting the server -render - .launch() - .then(async (browser) => { - // Attach the page and browser objects to context - app.context.browser = browser; +const server = app.listen(Number(process.env.PORT) || 3000, process.env.HOST); +console.log('Export server started'); - // Attach page content or URL - if (/^https?:\/\//.test(config.page)) { - app.context.pageUrl = config.page; - } else { - app.context.pageContent = await fs.readFile(config.page, 'utf8'); - } - - const server = app.listen(Number(process.env.PORT) || 3000, process.env.HOST); - console.log('Export server started'); - - gracefulShutdown(server); - }) - .catch((error) => { - console.error('Cannot start browser:'); - console.error(error); - - if (error.message.includes('ERR_CONNECTION_REFUSED')) { - console.error('Check that the export page dev server has been started'); - } - - process.exit(1); - }); +gracefulShutdown(server); diff --git a/export/src/render-image.ts b/export/src/render-image.ts new file mode 100644 index 0000000000..51e4ab451c --- /dev/null +++ b/export/src/render-image.ts @@ -0,0 +1,24 @@ +import { getModules } from './data'; +import { renderSatoriImage } from './satori/render-satori'; +import type { ExportData, ViewportOptions } from './types'; + +export async function renderImage(exportData: ExportData, options: ViewportOptions = {}) { + const startedAt = Date.now(); + + const moduleLoadStartedAt = Date.now(); + const modules = await getModules(Object.keys(exportData.timetable)); + const modulesLoadedAt = Date.now(); + + const image = await renderSatoriImage(exportData, modules, options); + console.info( + JSON.stringify({ + durationMs: Date.now() - startedAt, + moduleCount: modules.length, + moduleLoadMs: modulesLoadedAt - moduleLoadStartedAt, + renderMs: Date.now() - modulesLoadedAt, + renderer: 'satori', + type: 'export-image', + }), + ); + return image; +} diff --git a/export/src/render-pdf.ts b/export/src/render-pdf.ts new file mode 100644 index 0000000000..fb0bed45c6 --- /dev/null +++ b/export/src/render-pdf.ts @@ -0,0 +1,70 @@ +import { Resvg } from '@resvg/resvg-js'; +import PDFDocument from 'pdfkit'; + +import { getModules } from './data'; +import { renderSatoriSvg } from './satori/render-satori'; +import type { ExportData } from './types'; + +const A4_WIDTH_PT = 595.28; +const A4_HEIGHT_PT = 841.89; +const PAGE_MARGIN_PT = 24; +const PAGE_MARGIN_VERTICAL_X_PT = 10; +const PDF_IMAGE_SCALE = 2; + +export async function renderPdf(exportData: ExportData): Promise { + const startedAt = Date.now(); + const modules = await getModules(Object.keys(exportData.timetable)); + const modulesLoadedAt = Date.now(); + + const isLandscape = exportData.theme.timetableOrientation === 'HORIZONTAL'; + const pageWidth = isLandscape ? A4_HEIGHT_PT : A4_WIDTH_PT; + const pageHeight = isLandscape ? A4_WIDTH_PT : A4_HEIGHT_PT; + + const { svg, layout } = await renderSatoriSvg(exportData, modules); + + const resvg = new Resvg(svg, { + fitTo: { mode: 'zoom', value: PDF_IMAGE_SCALE }, + }); + const pngBuffer = resvg.render().asPng(); + + const marginX = isLandscape ? PAGE_MARGIN_PT : PAGE_MARGIN_VERTICAL_X_PT; + const marginY = PAGE_MARGIN_PT; + const availableWidth = pageWidth - marginX * 2; + const availableHeight = pageHeight - marginY * 2; + const scale = Math.min(availableWidth / layout.width, availableHeight / layout.height); + const fitWidth = layout.width * scale; + const fitHeight = layout.height * scale; + const x = (pageWidth - fitWidth) / 2; + const y = isLandscape ? (pageHeight - fitHeight) / 2 : marginY; + + const doc = new PDFDocument({ + autoFirstPage: true, + layout: isLandscape ? 'landscape' : 'portrait', + margin: 0, + size: 'A4', + }); + + doc.image(pngBuffer, x, y, { width: fitWidth, height: fitHeight }); + doc.end(); + + const chunks: Buffer[] = []; + const pdfBuffer = await new Promise((resolve, reject) => { + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + }); + + const renderFinishedAt = Date.now(); + console.info( + JSON.stringify({ + durationMs: renderFinishedAt - startedAt, + moduleCount: modules.length, + moduleLoadMs: modulesLoadedAt - startedAt, + renderMs: renderFinishedAt - modulesLoadedAt, + renderer: 'satori-pdf', + type: 'export-pdf', + }), + ); + + return pdfBuffer; +} diff --git a/export/src/render-serverless.ts b/export/src/render-serverless.ts deleted file mode 100644 index ca88878151..0000000000 --- a/export/src/render-serverless.ts +++ /dev/null @@ -1,84 +0,0 @@ -import chromium from '@sparticuz/chromium'; -import puppeteer, { Page } from 'puppeteer-core'; - -import { getModules } from './data'; -import config from './config'; -import type { ExportData } from './types'; - -// Arbitrarily high number - just make sure it doesn't clip the timetable -const VIEWPORT_HEIGHT = 2000; - -export interface ViewportOptions { - height?: number; - pixelRatio?: number; - width?: number; -} - -async function setViewport(page: Page, options: ViewportOptions = {}) { - await page.setViewport({ - deviceScaleFactor: options.pixelRatio || 1, - height: options.height || VIEWPORT_HEIGHT, - width: options.width || config.pageWidth, - }); -} - -export async function open(url: string) { - const executablePath = await chromium.executablePath(); - - chromium.setGraphicsMode = false; - - const browser = await puppeteer.launch({ - // devtools: !!process.env.DEVTOOLS, // TODO: Query string && NODE_ENV === 'development'? - args: chromium.args, - defaultViewport: chromium.defaultViewport, - executablePath: executablePath, - headless: chromium.headless, - }); - - const page = await browser.newPage(); - await page.goto(url, { waitUntil: 'load' }); - await setViewport(page); - - return page; -} - -async function injectData(page: Page, data: ExportData) { - const moduleCodes = Object.keys(data.timetable); - const modules = await getModules(moduleCodes); - - await page.evaluate(` - new Promise((resolve) => - window.setData(${JSON.stringify(modules)}, ${JSON.stringify(data)}, resolve) - ) - `); - - // Calculate element height to get bounding box for screenshot - const appEle = await page.$('#timetable-only'); - if (!appEle) { - throw new Error('#timetable-only element not found'); - } - - return (await appEle.boundingBox()) || undefined; -} - -export async function image(page: Page, data: ExportData, options: ViewportOptions = {}) { - if (options.pixelRatio || (options.height && options.width)) { - await setViewport(page, options); - } - - const boundingBox = await injectData(page, data); - return await page.screenshot({ - clip: boundingBox, - }); -} - -export async function pdf(page: Page, data: ExportData) { - await injectData(page, data); - await page.emulateMediaType('screen'); - - return await page.pdf({ - format: 'a4', - landscape: data.theme.timetableOrientation === 'HORIZONTAL', - printBackground: true, - }); -} diff --git a/export/src/render.ts b/export/src/render.ts deleted file mode 100644 index 8f91177cf5..0000000000 --- a/export/src/render.ts +++ /dev/null @@ -1,110 +0,0 @@ -import fs from 'fs-extra'; -import puppeteer, { Page } from 'puppeteer-core'; -import type { Middleware } from 'koa'; - -import { getModules } from './data'; -import config from './config'; -import type { ExportData, State } from './types'; - -// Arbitrarily high number - just make sure it doesn't clip the timetable -const VIEWPORT_HEIGHT = 2000; - -export interface ViewportOptions { - height?: number; - pixelRatio?: number; - width?: number; -} - -async function setViewport(page: Page, options: ViewportOptions = {}) { - await page.setViewport({ - deviceScaleFactor: options.pixelRatio || 1, - height: options.height || VIEWPORT_HEIGHT, - width: options.width || config.pageWidth, - }); -} - -export async function launch() { - const browser = await puppeteer.launch({ - args: ['--disable-gpu'], - devtools: !!process.env.DEVTOOLS, - executablePath: config.chromeExecutable, - headless: true, - }); - - const page = await browser.newPage(); - - // If config.page is local, use setContent. Otherwise connect to the page using goto. - if (/^https?:\/\//.test(config.page)) { - await page.goto(config.page); - } else { - const content = await fs.readFile(config.page, 'utf8'); - await page.setContent(content); - } - - return browser; -} - -export const openPage: Middleware = async (ctx, next) => { - let page: Page; - try { - page = await ctx.browser.newPage(); - } catch { - // Try launching a new browser object - ctx.app.context.browser = await launch(); - page = await ctx.browser.newPage(); - } - - if (ctx.pageUrl) { - await page.goto(ctx.pageUrl, { waitUntil: 'load' }); - } else { - await page.setContent(ctx.pageContent); - } - - await setViewport(page); - ctx.state.page = page; - - await next(); - - await ctx.state.page.close(); -}; - -async function injectData(page: Page, data: ExportData) { - const moduleCodes = Object.keys(data.timetable); - const modules = await getModules(moduleCodes); - - await page.evaluate(` - new Promise((resolve) => - window.setData(${JSON.stringify(modules)}, ${JSON.stringify(data)}, resolve) - ) - `); - - // Calculate element height to get bounding box for screenshot - const appEle = await page.$('#timetable-only'); - if (!appEle) { - throw new Error('#timetable-only element not found'); - } - - return (await appEle.boundingBox()) || undefined; -} - -export async function image(page: Page, data: ExportData, options: ViewportOptions = {}) { - if (options.pixelRatio || (options.height && options.width)) { - await setViewport(page, options); - } - - const boundingBox = await injectData(page, data); - return await page.screenshot({ - clip: boundingBox, - }); -} - -export async function pdf(page: Page, data: ExportData) { - await injectData(page, data); - await page.emulateMediaType('screen'); - - return await page.pdf({ - format: 'a4', - landscape: data.theme.timetableOrientation === 'HORIZONTAL', - printBackground: true, - }); -} diff --git a/export/src/satori/fonts.ts b/export/src/satori/fonts.ts new file mode 100644 index 0000000000..9ae4b0adb5 --- /dev/null +++ b/export/src/satori/fonts.ts @@ -0,0 +1,47 @@ +import { readFile } from 'node:fs/promises'; + +type FontDefinition = { + data: Buffer; + name: string; + style: 'normal'; + weight: 400 | 600 | 700; +}; + +let fontsPromise: Promise | null = null; + +async function loadFonts(): Promise { + const [regular, semibold, bold] = await Promise.all([ + readFile(require.resolve('@fontsource/inter/files/inter-latin-400-normal.woff')), + readFile(require.resolve('@fontsource/inter/files/inter-latin-600-normal.woff')), + readFile(require.resolve('@fontsource/inter/files/inter-latin-700-normal.woff')), + ]); + + return [ + { + data: regular, + name: 'Inter', + style: 'normal', + weight: 400, + }, + { + data: semibold, + name: 'Inter', + style: 'normal', + weight: 600, + }, + { + data: bold, + name: 'Inter', + style: 'normal', + weight: 700, + }, + ]; +} + +export async function getSatoriFonts() { + if (!fontsPromise) { + fontsPromise = loadFonts(); + } + + return fontsPromise; +} diff --git a/export/src/satori/render-model.test.ts b/export/src/satori/render-model.test.ts new file mode 100644 index 0000000000..67d52d4f68 --- /dev/null +++ b/export/src/satori/render-model.test.ts @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { ExportData, Module } from '../types'; +import { buildRenderableTimetable } from './render-model'; +import { renderSatoriImage } from './render-satori'; + +const fixtureModules: Module[] = [ + { + moduleCode: 'CS1010', + moduleCredit: '4', + semesterData: [ + { + examDate: '2026-05-01T01:00:00.000Z', + semester: 1, + timetable: [ + { + classNo: '01', + day: 'Monday', + endTime: '1200', + lessonType: 'Lecture', + startTime: '1000', + venue: 'LT19', + weeks: [1, 2, 3], + }, + { + classNo: '02', + day: 'Tuesday', + endTime: '1300', + lessonType: 'Tutorial', + startTime: '1200', + venue: 'COM1-0208', + weeks: [1, 2, 3], + }, + ], + }, + ], + title: 'Programming Methodology', + }, + { + moduleCode: 'MA1101R', + moduleCredit: '4', + semesterData: [ + { + examDate: '2026-05-03T01:00:00.000Z', + semester: 1, + timetable: [ + { + classNo: '01', + day: 'Monday', + endTime: '1300', + lessonType: 'Lecture', + startTime: '1100', + venue: 'LT27', + weeks: [1, 2, 3], + }, + ], + }, + ], + title: 'Linear Algebra I', + }, + { + moduleCode: 'EG1311', + moduleCredit: '4', + semesterData: [ + { + examDate: '2026-05-05T01:00:00.000Z', + semester: 1, + timetable: [ + { + classNo: '01', + day: 'Saturday', + endTime: '1200', + lessonType: 'Laboratory', + startTime: '1000', + venue: 'E-Learn_A', + weeks: [1, 2, 3], + }, + ], + }, + ], + title: 'Engineering Principles', + }, +]; + +function makeExportData(overrides: Partial = {}): ExportData { + return { + colors: {}, + hidden: ['EG1311'], + semester: 1, + settings: { + colorScheme: 'LIGHT_COLOR_SCHEME', + }, + ta: ['MA1101R'], + theme: { + id: 'eighties', + showTitle: false, + timetableOrientation: 'HORIZONTAL', + }, + timetable: { + CS1010: { Lecture: [0], Tutorial: [1] }, + EG1311: { Laboratory: [0] }, + MA1101R: { Lecture: [0] }, + }, + ...overrides, + }; +} + +test('buildRenderableTimetable excludes hidden lessons and keeps overlapping rows separate', () => { + const model = buildRenderableTimetable(makeExportData(), fixtureModules); + const monday = model.days.find((day) => day.day === 'Monday'); + + assert.ok(monday); + assert.equal(monday.rows.length, 2); + assert.equal( + model.days.some((day) => day.day === 'Saturday'), + false, + ); + assert.equal(model.activeUnits, 4); + assert.equal(model.totalUnits, 12); + assert.equal(monday.rows[0]?.[0]?.weekText, 'Weeks 1, 2, 3'); + assert.equal(model.moduleCards.find((module) => module.moduleCode === 'MA1101R')?.isTa, true); + assert.equal(model.moduleCards.find((module) => module.moduleCode === 'EG1311')?.isHidden, true); +}); + +test('buildRenderableTimetable includes saturday when a visible lesson is scheduled there', () => { + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { + EG1311: { Laboratory: [0] }, + }, + }), + fixtureModules, + ); + + assert.equal( + model.days.some((day) => day.day === 'Saturday'), + true, + ); +}); + +test('buildRenderableTimetable sorts modules without exams before dated exams', () => { + const modules = fixtureModules.map((module) => + module.moduleCode === 'EG1311' + ? { + ...module, + semesterData: [{ ...module.semesterData[0], examDate: undefined }], + } + : module, + ); + const model = buildRenderableTimetable(makeExportData({ hidden: [] }), modules); + + assert.deepEqual( + model.moduleCards.map((module) => module.moduleCode), + ['EG1311', 'CS1010', 'MA1101R'], + ); +}); + +test('buildRenderableTimetable sets isVertical based on orientation', () => { + const horizontal = buildRenderableTimetable(makeExportData(), fixtureModules); + assert.equal(horizontal.isVertical, false); + + const vertical = buildRenderableTimetable( + makeExportData({ + theme: { + id: 'eighties', + showTitle: false, + timetableOrientation: 'VERTICAL', + }, + }), + fixtureModules, + ); + assert.equal(vertical.isVertical, true); +}); + +test('renderSatoriImage returns a PNG buffer for horizontal', async () => { + const png = await renderSatoriImage( + makeExportData({ + hidden: [], + ta: [], + timetable: { + CS1010: { Lecture: [0], Tutorial: [1] }, + }, + }), + fixtureModules, + { width: 900 }, + ); + + assert.ok(Buffer.isBuffer(png)); + assert.deepEqual(Array.from(png.subarray(0, 8)), [137, 80, 78, 71, 13, 10, 26, 10]); +}); + +test('renderSatoriImage returns a PNG buffer for vertical', async () => { + const png = await renderSatoriImage( + makeExportData({ + hidden: [], + ta: [], + theme: { + id: 'eighties', + showTitle: false, + timetableOrientation: 'VERTICAL', + }, + timetable: { + CS1010: { Lecture: [0], Tutorial: [1] }, + }, + }), + fixtureModules, + { width: 900 }, + ); + + assert.ok(Buffer.isBuffer(png)); + assert.deepEqual(Array.from(png.subarray(0, 8)), [137, 80, 78, 71, 13, 10, 26, 10]); +}); diff --git a/export/src/satori/render-model.ts b/export/src/satori/render-model.ts new file mode 100644 index 0000000000..351943080c --- /dev/null +++ b/export/src/satori/render-model.ts @@ -0,0 +1,460 @@ +import type { + ColorMapping, + ColorScheme, + ExportData, + LessonIndex, + LessonType, + Module, + ModuleCode, + Semester, +} from '../types'; +import { getLessonPalette } from './theme'; + +export type RenderableLesson = { + classNo: string; + color: string; + displayTitle: string; + endIndex: number; + isTa: boolean; + key: string; + lessonMeta: string; + moduleCode: ModuleCode; + startIndex: number; + venue: string; + weekText: string | null; +}; + +export type RenderableDay = { + day: string; + rows: RenderableLesson[][]; +}; + +export type RenderableModuleCard = { + color: string; + isHidden: boolean; + isTa: boolean; + metaLine: string; + moduleCode: ModuleCode; + moduleCredit: number; + sortKey: number; + title: string; +}; + +export type RenderableTimetable = { + activeUnits: number; + colorScheme: ColorScheme; + days: RenderableDay[]; + endingIndex: number; + isVertical: boolean; + timeLabels: { index: number; label: string }[]; + moduleCards: RenderableModuleCard[]; + showTitle: boolean; + startingIndex: number; + themeId: string; + totalUnits: number; +}; + +const NUM_DIFFERENT_COLORS = 8; +const INTERVALS_PER_HOUR = 4; +const DEFAULT_EARLIEST_INDEX = 10 * INTERVALS_PER_HOUR; +const DEFAULT_LATEST_INDEX = 18 * INTERVALS_PER_HOUR; +const SCHOOLDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const; + +const LESSON_TYPE_ABBREV: Record = { + 'Design Lecture': 'DLEC', + Laboratory: 'LAB', + Lecture: 'LEC', + 'Packaged Laboratory': 'PLAB', + 'Packaged Lecture': 'PLEC', + 'Packaged Tutorial': 'PTUT', + Recitation: 'REC', + 'Sectional Teaching': 'SEC', + 'Seminar-Style Module Class': 'SEM', + Tutorial: 'TUT', + 'Tutorial Type 2': 'TUT2', + 'Tutorial Type 3': 'TUT3', + Workshop: 'WS', +}; + +type LessonWithDisplay = { + classNo: string; + color: string; + day: string; + displayTitle: string; + endTime: string; + isTa: boolean; + lessonType: LessonType; + moduleCode: ModuleCode; + startTime: string; + title: string; + venue: string; + weekText: string | null; +}; + +function getNewColor(currentColors: number[]): number { + let availableColors = Array.from({ length: NUM_DIFFERENT_COLORS }, (_, index) => index); + for (const index of currentColors) { + availableColors = availableColors.filter((color) => color !== index); + if (availableColors.length === 0) { + availableColors = Array.from({ length: NUM_DIFFERENT_COLORS }, (_, color) => color); + } + } + return availableColors[0] ?? 0; +} + +function fillColorMapping( + timetable: ExportData['timetable'], + original: ColorMapping, +): ColorMapping { + const colorMap: ColorMapping = {}; + const colorsUsed: number[] = []; + + for (const moduleCode of Object.keys(timetable)) { + if (moduleCode in original) { + colorMap[moduleCode] = original[moduleCode]; + colorsUsed.push(Number(original[moduleCode])); + } else { + const color = getNewColor(colorsUsed); + colorMap[moduleCode] = color; + colorsUsed.push(color); + } + } + + return colorMap; +} + +function getSemesterData(module: Module, semester: Semester) { + return module.semesterData.find((semesterData) => semesterData.semester === semester); +} + +function convertTimeToIndex(time: string): number { + const hour = Number(time.slice(0, 2)); + const minute = Number(time.slice(2)); + return hour * INTERVALS_PER_HOUR + Math.round(minute / 15); +} + +function convertIndexToTime(index: number) { + const hour = Math.floor(index / INTERVALS_PER_HOUR); + const minute = (index % INTERVALS_PER_HOUR) * 15; + return `${hour.toString().padStart(2, '0')}${minute.toString().padStart(2, '0')}`; +} + +function calculateBorderTimings(lessons: readonly LessonWithDisplay[]) { + let earliestIndex = DEFAULT_EARLIEST_INDEX; + let latestIndex = DEFAULT_LATEST_INDEX; + + for (const lesson of lessons) { + earliestIndex = Math.min(earliestIndex, convertTimeToIndex(lesson.startTime)); + latestIndex = Math.max(latestIndex, convertTimeToIndex(lesson.endTime)); + } + + return { + startingIndex: earliestIndex - (earliestIndex % INTERVALS_PER_HOUR), + endingIndex: Math.ceil(latestIndex / INTERVALS_PER_HOUR) * INTERVALS_PER_HOUR, + }; +} + +function doLessonsOverlap(left: LessonWithDisplay, right: LessonWithDisplay) { + return left.day === right.day && left.startTime < right.endTime && right.startTime < left.endTime; +} + +function arrangeLessonsWithinDay(lessons: LessonWithDisplay[]) { + const rows: LessonWithDisplay[][] = [[]]; + + if (!lessons.length) { + return rows; + } + + const sortedLessons = [...lessons].sort((left, right) => { + const timeDiff = left.startTime.localeCompare(right.startTime); + return timeDiff !== 0 ? timeDiff : left.classNo.localeCompare(right.classNo); + }); + + sortedLessons.forEach((lesson) => { + for (const row of rows) { + const previousLesson = row[row.length - 1]; + if (!previousLesson || !doLessonsOverlap(previousLesson, lesson)) { + row.push(lesson); + return; + } + } + + rows.push([lesson]); + }); + + return rows; +} + +function formatModuleUnits(moduleCredit: number) { + return `${moduleCredit} ${moduleCredit === 1 ? 'Unit' : 'Units'}`; +} + +function formatNumericWeeks(unprocessedWeeks: number[]): string | null { + const weeks = unprocessedWeeks.filter( + (value, index) => unprocessedWeeks.indexOf(value) === index, + ); + + if (weeks.length === 13) return null; + if (weeks.length === 1) return `Week ${weeks[0]}`; + + const deltas = weeks.slice(1).map((week, index) => week - weeks[index]); + if (deltas.every((delta) => delta === 2)) { + if (weeks[0] % 2 === 0 && weeks.length >= 6) return 'Even Weeks'; + if (weeks[0] % 2 === 1 && weeks.length >= 7) return 'Odd Weeks'; + } + + const processed: (number | string)[] = []; + let start = weeks[0] ?? 0; + let end = start; + + const mergeConsecutive = () => { + if (end - start > 2) { + processed.push(`${start}-${end}`); + } else { + for (let week = start; week <= end; week += 1) { + processed.push(week); + } + } + }; + + weeks.slice(1).forEach((week) => { + if (week === end + 1) { + end = week; + } else { + mergeConsecutive(); + start = week; + end = week; + } + }); + + mergeConsecutive(); + return `Weeks ${processed.join(', ')}`; +} + +function formatWeeks( + weeks: Module['semesterData'][number]['timetable'][number]['weeks'], +): string | null { + if (Array.isArray(weeks)) { + return formatNumericWeeks(weeks); + } + + if (weeks.weeks?.length) { + return formatNumericWeeks(weeks.weeks); + } + + if (weeks.weekInterval === 2) { + const startWeek = 1; + return startWeek % 2 === 0 ? 'Even Weeks' : 'Odd Weeks'; + } + + return null; +} + +function formatExamDate(examDate?: string) { + if (!examDate) return 'No Exam'; + + const date = new Date(examDate); + const datePart = new Intl.DateTimeFormat('en-SG', { + day: '2-digit', + month: 'short', + timeZone: 'Asia/Singapore', + year: 'numeric', + }) + .format(date) + .replace(/ /g, '-'); + + const timePart = new Intl.DateTimeFormat('en-SG', { + hour: 'numeric', + hour12: true, + minute: '2-digit', + timeZone: 'Asia/Singapore', + }).format(date); + + return `${datePart} ${timePart}`; +} + +function formatExamDuration(examDuration?: number) { + if (!examDuration) return null; + if (examDuration < 60 || examDuration % 30 !== 0) return `${examDuration} mins`; + + const hours = examDuration / 60; + return `${hours} ${hours === 1 ? 'hr' : 'hrs'}`; +} + +function buildLessonMeta(lessonType: LessonType, classNo: string) { + return `${LESSON_TYPE_ABBREV[lessonType] ?? lessonType} [${classNo}]`; +} + +function buildLessonDisplayTitle( + moduleCode: ModuleCode, + title: string, + showTitle: boolean, + isTa: boolean, +) { + const baseTitle = showTitle ? `${moduleCode} ${title}` : moduleCode; + return isTa ? `${baseTitle} (TA)` : baseTitle; +} + +function buildRenderableLessons( + exportData: ExportData, + modulesByCode: Map, + colorMap: ColorMapping, + effectiveShowTitle: boolean, +) { + const palette = getLessonPalette(exportData.theme.id); + const lessons: LessonWithDisplay[] = []; + + for (const [moduleCode, lessonConfig] of Object.entries(exportData.timetable)) { + const module = modulesByCode.get(moduleCode); + if (!module || exportData.hidden.includes(moduleCode)) { + continue; + } + + const semesterData = getSemesterData(module, exportData.semester); + if (!semesterData?.timetable?.length) { + continue; + } + + const colorIndex = colorMap[moduleCode] ?? 0; + const color = palette[colorIndex % palette.length] ?? palette[0] ?? '#6699cc'; + const isTa = exportData.ta.includes(moduleCode); + + for (const lessonIndices of Object.values(lessonConfig)) { + for (const lessonIndex of lessonIndices) { + const lesson = semesterData.timetable[lessonIndex as LessonIndex]; + if (!lesson) continue; + + lessons.push({ + classNo: lesson.classNo, + color, + day: lesson.day, + displayTitle: buildLessonDisplayTitle( + module.moduleCode, + module.title, + effectiveShowTitle, + isTa, + ), + endTime: lesson.endTime, + isTa, + lessonType: lesson.lessonType, + moduleCode, + startTime: lesson.startTime, + title: module.title, + venue: lesson.venue.startsWith('E-Learn') ? 'E-Learning' : lesson.venue, + weekText: formatWeeks(lesson.weeks), + }); + } + } + } + + return lessons; +} + +function buildModuleCards( + exportData: ExportData, + modulesByCode: Map, + colorMap: ColorMapping, +) { + const palette = getLessonPalette(exportData.theme.id); + + return Object.keys(exportData.timetable) + .map((moduleCode): RenderableModuleCard | null => { + const module = modulesByCode.get(moduleCode); + if (!module) return null; + + const semesterData = getSemesterData(module, exportData.semester); + const moduleCredit = Number.parseFloat(module.moduleCredit); + const colorIndex = colorMap[moduleCode] ?? 0; + const examDate = semesterData?.examDate; + const examDuration = formatExamDuration(semesterData?.examDuration); + const metaParts = [examDate ? `Exam: ${formatExamDate(examDate)}` : 'No Exam']; + + if (examDuration) metaParts.push(examDuration); + metaParts.push(formatModuleUnits(moduleCredit)); + + return { + color: palette[colorIndex % palette.length] ?? palette[0] ?? '#6699cc', + isHidden: exportData.hidden.includes(moduleCode), + isTa: exportData.ta.includes(moduleCode), + metaLine: metaParts.join(' • '), + moduleCode, + moduleCredit, + sortKey: examDate ? new Date(examDate).getTime() : Number.MIN_SAFE_INTEGER, + title: module.title, + }; + }) + .filter((module): module is RenderableModuleCard => Boolean(module)) + .sort((left, right) => { + if (left.sortKey !== right.sortKey) return left.sortKey - right.sortKey; + return left.moduleCode.localeCompare(right.moduleCode); + }); +} + +export function buildRenderableTimetable( + exportData: ExportData, + modules: Module[], +): RenderableTimetable { + const modulesByCode = new Map(modules.map((module) => [module.moduleCode, module])); + const colorMap = fillColorMapping(exportData.timetable, exportData.colors); + const isVertical = exportData.theme.timetableOrientation === 'VERTICAL'; + const effectiveShowTitle = !isVertical && exportData.theme.showTitle; + const lessons = buildRenderableLessons(exportData, modulesByCode, colorMap, effectiveShowTitle); + const { startingIndex, endingIndex } = calculateBorderTimings(lessons); + + const lessonsByDay = new Map(); + for (const lesson of lessons) { + const dayLessons = lessonsByDay.get(lesson.day) ?? []; + dayLessons.push(lesson); + lessonsByDay.set(lesson.day, dayLessons); + } + + const days = SCHOOLDAYS.filter((day) => day !== 'Saturday' || lessonsByDay.has('Saturday')).map( + (day) => ({ + day, + rows: arrangeLessonsWithinDay(lessonsByDay.get(day) ?? []).map((row) => + row.map((lesson) => ({ + classNo: lesson.classNo, + color: lesson.color, + displayTitle: lesson.displayTitle, + endIndex: convertTimeToIndex(lesson.endTime), + isTa: lesson.isTa, + key: `${lesson.moduleCode}-${lesson.lessonType}-${lesson.classNo}-${lesson.startTime}`, + lessonMeta: buildLessonMeta(lesson.lessonType, lesson.classNo), + moduleCode: lesson.moduleCode, + startIndex: convertTimeToIndex(lesson.startTime), + venue: lesson.venue, + weekText: lesson.weekText, + })), + ), + }), + ); + + const moduleCards = buildModuleCards(exportData, modulesByCode, colorMap); + const totalUnits = moduleCards.reduce((sum, module) => sum + module.moduleCredit, 0); + const activeUnits = moduleCards + .filter((module) => !module.isHidden && !module.isTa) + .reduce((sum, module) => sum + module.moduleCredit, 0); + + return { + activeUnits, + colorScheme: exportData.settings.colorScheme, + days, + endingIndex, + isVertical, + timeLabels: Array.from( + { length: (endingIndex - startingIndex) / INTERVALS_PER_HOUR }, + (_, offset) => { + const index = startingIndex + offset * INTERVALS_PER_HOUR; + return { + index, + label: convertIndexToTime(index), + }; + }, + ), + moduleCards, + showTitle: effectiveShowTitle, + startingIndex, + themeId: exportData.theme.id, + totalUnits, + }; +} diff --git a/export/src/satori/render-satori.tsx b/export/src/satori/render-satori.tsx new file mode 100644 index 0000000000..5bb9662a5d --- /dev/null +++ b/export/src/satori/render-satori.tsx @@ -0,0 +1,51 @@ +import { Resvg } from '@resvg/resvg-js'; +import satori from 'satori'; + +import config from '../config'; +import type { ExportData, Module, ViewportOptions } from '../types'; +import { getSatoriFonts } from './fonts'; +import { buildRenderableTimetable } from './render-model'; +import { + estimateImageLayout, + estimateVerticalImageLayout, + TimetableImage, + type TimetableImageLayout, +} from './view'; + +export async function renderSatoriSvg( + exportData: ExportData, + modules: Module[], + options: ViewportOptions = {}, +): Promise<{ svg: string; layout: TimetableImageLayout }> { + const width = options.width || config.pageWidth; + const model = buildRenderableTimetable(exportData, modules); + const layout = model.isVertical + ? estimateVerticalImageLayout(model, width, options.height) + : estimateImageLayout(model, width, options.height); + const fonts = await getSatoriFonts(); + + const svg = await satori(, { + fonts, + height: layout.height, + width: layout.width, + }); + + return { svg, layout }; +} + +export async function renderSatoriImage( + exportData: ExportData, + modules: Module[], + options: ViewportOptions = {}, +) { + const { svg } = await renderSatoriSvg(exportData, modules, options); + + const resvg = new Resvg( + svg, + options.pixelRatio && options.pixelRatio > 1 + ? { fitTo: { mode: 'zoom', value: options.pixelRatio } } + : undefined, + ); + + return resvg.render().asPng(); +} diff --git a/export/src/satori/theme.ts b/export/src/satori/theme.ts new file mode 100644 index 0000000000..15c646a5c0 --- /dev/null +++ b/export/src/satori/theme.ts @@ -0,0 +1,164 @@ +import type { ColorScheme } from '../types'; + +type ThemeColors = { + background: string; + border: string; + bodyColor: string; + dayLabelBackground: string; + gray: string; + grayLight: string; + grayLighter: string; + grayLightest: string; + mutedText: string; + text: string; +}; + +function parseHex(hex: string): [number, number, number] { + const h = hex.replace('#', ''); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} + +function rgbToHsl(r: number, g: number, b: number): [number, number, number] { + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + if (max === min) return [0, 0, l]; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h = 0; + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + else if (max === g) h = ((b - r) / d + 2) / 6; + else h = ((r - g) / d + 4) / 6; + return [h, s, l]; +} + +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + if (s === 0) { + const v = Math.round(l * 255); + return [v, v, v]; + } + const hue2rgb = (p: number, q: number, t: number) => { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + return [ + Math.round(hue2rgb(p, q, h + 1 / 3) * 255), + Math.round(hue2rgb(p, q, h) * 255), + Math.round(hue2rgb(p, q, h - 1 / 3) * 255), + ]; +} + +function toHex(r: number, g: number, b: number): string { + return `#${[r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('')}`; +} + +export function darken(hex: string, percent: number): string { + const [r, g, b] = parseHex(hex); + const [h, s, l] = rgbToHsl(r, g, b); + const newL = Math.max(0, l - percent / 100); + const [nr, ng, nb] = hslToRgb(h, s, newL); + return toHex(nr, ng, nb); +} + +const TIMETABLE_THEME_COLORS: Record = { + ashes: ['#c7ae95', '#c7c795', '#aec795', '#95c7ae', '#95aec7', '#ae95c7', '#c795ae', '#c79595'], + chalk: ['#fb9fb1', '#eda987', '#ddb26f', '#acc267', '#12cfc0', '#6fc2ef', '#e1a3ee', '#deaf8f'], + eighties: [ + '#f2777a', + '#f99157', + '#ffcc66', + '#99cc99', + '#66cccc', + '#6699cc', + '#cc99cc', + '#d27b53', + ], + google: ['#cc342b', '#f96a38', '#fba922', '#198844', '#3971ed', '#79a4f9', '#a36ac7', '#ec9998'], + mocha: ['#cb6077', '#d28b71', '#f4bc87', '#beb55b', '#7bbda4', '#8ab3b5', '#a89bb9', '#bb9584'], + monokai: ['#f92672', '#fd971f', '#f4bf75', '#a6e22e', '#a1efe4', '#66d9ef', '#ae81ff', '#cc6633'], + ocean: ['#bf616a', '#d08770', '#ebcb8b', '#a3be8c', '#96b5b4', '#8fa1b3', '#b48ead', '#ab7967'], + 'oceanic-next': [ + '#ec5f67', + '#f99157', + '#fac863', + '#99c794', + '#5fb3b3', + '#6699cc', + '#c594c5', + '#ab7967', + ], + paraiso: ['#ef6155', '#f99b15', '#fec418', '#48b685', '#5bc4bf', '#06b6ef', '#815ba4', '#e96ba8'], + railscasts: [ + '#da4939', + '#cc7833', + '#ffc66d', + '#a5c261', + '#519f50', + '#6d9cbe', + '#b6b3eb', + '#bc9458', + ], + tomorrow: [ + '#cc6666', + '#de935f', + '#f0c674', + '#b5bd68', + '#8abeb7', + '#81a2be', + '#b294bb', + '#a3685a', + ], + twilight: [ + '#cf6a4c', + '#cda869', + '#f9ee98', + '#8f9d6a', + '#afc4db', + '#7587a6', + '#9b859d', + '#9b703f', + ], +}; + +const LIGHT_THEME: ThemeColors = { + background: '#ffffff', + bodyColor: '#69707a', + border: '#d3d6db', + dayLabelBackground: '#ffffff', + gray: '#69707a', + grayLight: '#aeb1b5', + grayLighter: '#d3d6db', + grayLightest: '#f3f5f8', + mutedText: '#aeb1b5', + text: '#69707a', +}; + +const DARK_THEME: ThemeColors = { + background: '#222324', + bodyColor: '#aaaaaa', + border: '#474747', + dayLabelBackground: '#222324', + gray: '#aaaaaa', + grayLight: '#666666', + grayLighter: '#474747', + grayLightest: '#292929', + mutedText: '#666666', + text: '#aaaaaa', +}; + +export function getLessonPalette(themeId: string): string[] { + return TIMETABLE_THEME_COLORS[themeId] ?? TIMETABLE_THEME_COLORS.eighties; +} + +export function getSurfaceColors(colorScheme: ColorScheme): ThemeColors { + return colorScheme === 'DARK_COLOR_SCHEME' ? DARK_THEME : LIGHT_THEME; +} diff --git a/export/src/satori/view.tsx b/export/src/satori/view.tsx new file mode 100644 index 0000000000..71b10a9f71 --- /dev/null +++ b/export/src/satori/view.tsx @@ -0,0 +1,871 @@ +import type { RenderableLesson, RenderableTimetable } from './render-model'; +import { darken, getSurfaceColors } from './theme'; + +const OUTER_PADDING = 16; +const DAY_LABEL_WIDTH = 56; +const HOUR_HEADER_HEIGHT = 20; +const DAY_GAP = 0; +const MODULE_TABLE_GAP = 12; +const MODULE_SECTION_GAP = 16; +const ROW_MARGIN_BOTTOM = 1; +const TIMETABLE_ROW_HEIGHT = 57.6; +const BORDER_RADIUS = 4; +const MODULE_CARD_HEIGHT = 54; +const MODULE_ROW_GAP = 10; +const FOOTER_HEIGHT = 48; +const BOTTOM_PADDING = 14; + +const FONT_SIZE_S = 13.6; +const FONT_SIZE_XS = 12; +const FONT_SIZE_XXS = 10.4; +const CELL_LINE_HEIGHT = 13.6; +const CELL_PADDING = 4.8; +const CELL_BORDER_BOTTOM_WIDTH = 3.2; +const DAY_ROW_PADDING_BOTTOM = 4.8; + +const VERTICAL_HEIGHT_PER_HOUR = 76.8; +const VERTICAL_DAY_LABEL_HEIGHT = 40; +const VERTICAL_TIME_LABEL_WIDTH = 60; +const VERTICAL_ROW_MARGIN_RIGHT = 1; +const VERTICAL_DAY_COL_UNIT_WIDTH = 100; + +const INTER_SEMIBOLD_AVG_CHAR_WIDTH_RATIO = 0.58; + +function estimateTextWidth(text: string, fontSize: number): number { + return text.length * fontSize * INTER_SEMIBOLD_AVG_CHAR_WIDTH_RATIO; +} + +function estimateTitleLines(title: string, cellWidthPx: number): number { + const availableWidth = cellWidthPx - CELL_PADDING * 2; + if (availableWidth <= 0) return 1; + const titleWidth = estimateTextWidth(title, FONT_SIZE_XS); + return Math.max(1, Math.ceil(titleWidth / availableWidth)); +} + +function computeRowHeight( + row: RenderableLesson[], + gridContentWidth: number, + startingIndex: number, + endingIndex: number, +): number { + const hasWeekText = row.some((lesson) => lesson.weekText); + const baseLineCount = hasWeekText ? 4 : 3; + const totalCols = endingIndex - startingIndex; + + let maxTitleExtraLines = 0; + for (const lesson of row) { + const lessonCols = Math.max(lesson.endIndex - lesson.startIndex, 1); + const cellWidthPx = (lessonCols / totalCols) * gridContentWidth; + const titleLines = estimateTitleLines(lesson.displayTitle, cellWidthPx); + maxTitleExtraLines = Math.max(maxTitleExtraLines, titleLines - 1); + } + + const lineCount = baseLineCount + maxTitleExtraLines; + const contentHeight = lineCount * CELL_LINE_HEIGHT + CELL_PADDING * 2 + CELL_BORDER_BOTTOM_WIDTH; + return Math.max(TIMETABLE_ROW_HEIGHT, contentHeight); +} + +function ModuleColorMarker({ + color, + isHidden, + isTa, + surface, +}: { + color: string; + isHidden: boolean; + isTa: boolean; + surface: ReturnType; +}) { + return ( +
+
+
+ ); +} + +export type TimetableImageLayout = { + cardColumns: number; + cardWidth: number; + height: number; + sidebarWidth?: number; + width: number; +}; + +function LessonBlock({ + lesson, + rowHeight, + style, +}: { + lesson: RenderableLesson; + rowHeight: number; + style: React.CSSProperties; +}) { + const borderColor = darken(lesson.color, 20); + const textColor = darken(lesson.color, 40); + + return ( +
+
+ {lesson.displayTitle} +
+
+ {lesson.lessonMeta} +
+
+ {lesson.venue} +
+ {lesson.weekText && ( +
+ {lesson.weekText} +
+ )} +
+ ); +} + +function LessonRow({ + endingIndex, + row, + rowHeight, + startingIndex, + isLastRow, +}: { + endingIndex: number; + row: RenderableLesson[]; + rowHeight: number; + startingIndex: number; + isLastRow: boolean; +}) { + const totalCols = endingIndex - startingIndex; + + return ( +
+ {row.map((lesson) => { + const offset = ((lesson.startIndex - startingIndex) / totalCols) * 100; + const width = (Math.max(lesson.endIndex - lesson.startIndex, 1) / totalCols) * 100; + + return ( + + ); + })} +
+ ); +} + +function VerticalLessonBlock({ + lesson, + style, +}: { + lesson: RenderableLesson; + style: React.CSSProperties; +}) { + const borderColor = darken(lesson.color, 20); + const textColor = darken(lesson.color, 40); + + return ( +
+
+ {lesson.displayTitle} +
+
+ {lesson.lessonMeta} +
+
+ {lesson.venue} +
+ {lesson.weekText && ( +
+ {lesson.weekText} +
+ )} +
+ ); +} + +function VerticalLessonRow({ + endingIndex, + row, + startingIndex, + isLastRow, + dayHeight, +}: { + endingIndex: number; + row: RenderableLesson[]; + startingIndex: number; + isLastRow: boolean; + dayHeight: number; +}) { + const totalCols = endingIndex - startingIndex; + + return ( +
+ {row.map((lesson) => { + const offset = ((lesson.startIndex - startingIndex) / totalCols) * 100; + const height = (Math.max(lesson.endIndex - lesson.startIndex, 1) / totalCols) * 100; + + return ( + + ); + })} +
+ ); +} + +function computeVerticalDayHeight(totalCols: number): number { + return (VERTICAL_HEIGHT_PER_HOUR / 4) * totalCols; +} + +const VERTICAL_SIDEBAR_GAP = 16; +const VERTICAL_SIDEBAR_WIDTH = 300; + +export function estimateVerticalImageLayout( + model: RenderableTimetable, + _width: number, + minHeight?: number, +) { + const sidebarWidth = VERTICAL_SIDEBAR_WIDTH; + const showActiveUnits = model.activeUnits !== model.totalUnits; + + const totalCols = model.endingIndex - model.startingIndex; + const timetableHeight = VERTICAL_DAY_LABEL_HEIGHT + computeVerticalDayHeight(totalCols); + + const totalDayUnits = model.days.reduce((sum, day) => sum + Math.max(day.rows.length, 1), 0); + const gridWidth = + VERTICAL_TIME_LABEL_WIDTH + + totalDayUnits * VERTICAL_DAY_COL_UNIT_WIDTH + + Math.max(model.days.length - 1, 0) + + 2; + + const moduleListHeight = + model.moduleCards.length * MODULE_CARD_HEIGHT + + Math.max(model.moduleCards.length - 1, 0) * MODULE_ROW_GAP; + const sidebarContentHeight = + moduleListHeight + FOOTER_HEIGHT + (showActiveUnits ? 18 : 0) + BOTTOM_PADDING; + + const contentHeight = Math.max(timetableHeight, sidebarContentHeight); + const totalWidth = OUTER_PADDING * 2 + gridWidth + VERTICAL_SIDEBAR_GAP + sidebarWidth; + + return { + cardColumns: 1, + cardWidth: sidebarWidth, + height: Math.max(OUTER_PADDING * 2 + contentHeight, minHeight ?? 0), + sidebarWidth, + width: totalWidth, + } satisfies TimetableImageLayout; +} + +export function estimateImageLayout(model: RenderableTimetable, width: number, minHeight?: number) { + const cardColumns = width >= 992 ? 3 : width >= 576 ? 2 : 1; + const cardWidth = + (width - OUTER_PADDING * 2 - MODULE_TABLE_GAP * (cardColumns - 1)) / cardColumns; + const showActiveUnits = model.activeUnits !== model.totalUnits; + + const gridContentWidth = width - OUTER_PADDING * 2 - DAY_LABEL_WIDTH; + const timetableHeight = + HOUR_HEADER_HEIGHT + + model.days.reduce( + (sum, day) => + sum + + day.rows.reduce( + (rowSum, row) => + rowSum + + computeRowHeight(row, gridContentWidth, model.startingIndex, model.endingIndex), + 0, + ) + + Math.max(day.rows.length - 1, 0) * ROW_MARGIN_BOTTOM, + 0, + ) + + model.days.length * DAY_ROW_PADDING_BOTTOM + + Math.max(model.days.length - 1, 0) * DAY_GAP; + + const cardRows = Math.max(Math.ceil(Math.max(model.moduleCards.length, 1) / cardColumns), 1); + const moduleSectionHeight = + cardRows * MODULE_CARD_HEIGHT + + Math.max(cardRows - 1, 0) * MODULE_ROW_GAP + + MODULE_SECTION_GAP + + FOOTER_HEIGHT + + (showActiveUnits ? 18 : 0) + + BOTTOM_PADDING; + + return { + cardColumns, + cardWidth, + height: Math.max(OUTER_PADDING * 2 + timetableHeight + moduleSectionHeight, minHeight ?? 0), + width, + } satisfies TimetableImageLayout; +} + +function HorizontalTimetableGrid({ + gridContentWidth, + model, + surface, +}: { + gridContentWidth: number; + model: RenderableTimetable; + surface: ReturnType; +}) { + const hourSegmentCount = (model.endingIndex - model.startingIndex) / 4; + + return ( +
+ {/* Time labels row */} +
+ {model.timeLabels.map((time) => { + const pct = + ((time.index - model.startingIndex) / (model.endingIndex - model.startingIndex)) * 100; + return ( +
+ {time.label} +
+ ); + })} +
+ + {/* Timetable grid */} +
+ {model.days.map((day, dayIndex) => { + const rowHeights = day.rows.map((row) => + computeRowHeight(row, gridContentWidth, model.startingIndex, model.endingIndex), + ); + const dayHeight = + rowHeights.reduce((sum, h) => sum + h, 0) + + Math.max(day.rows.length - 1, 0) * ROW_MARGIN_BOTTOM + + DAY_ROW_PADDING_BOTTOM; + + return ( +
+
+ {day.day.slice(0, 3)} +
+
+ {day.rows.map((row, index) => ( + + ))} +
+
+ ); + })} +
+
+ ); +} + +function VerticalTimetableGrid({ + model, + surface, +}: { + model: RenderableTimetable; + surface: ReturnType; +}) { + const totalCols = model.endingIndex - model.startingIndex; + const hourSegmentCount = totalCols / 4; + const dayHeight = computeVerticalDayHeight(totalCols); + + return ( +
+ {/* Time labels column */} +
+ {model.timeLabels.map((time) => { + const pct = + ((time.index - model.startingIndex) / (model.endingIndex - model.startingIndex)) * 100; + return ( +
+ {time.label} +
+ ); + })} +
+ + {/* Timetable grid — days as fixed-width columns */} +
+ {model.days.map((day, dayIndex) => { + const colWidth = Math.max(day.rows.length, 1) * VERTICAL_DAY_COL_UNIT_WIDTH; + return ( +
+ {/* Day label — horizontal text, top of column */} +
+ {day.day.slice(0, 3)} +
+ {/* Lesson rows — side by side within this day column */} +
+ {day.rows.map((row, index) => ( + + ))} +
+
+ ); + })} +
+
+ ); +} + +function ModuleCardsList({ + layout, + model, + surface, +}: { + layout: TimetableImageLayout; + model: RenderableTimetable; + surface: ReturnType; +}) { + const showActiveUnits = model.activeUnits !== model.totalUnits; + + return ( +
+ {model.moduleCards.length ? ( +
+ {model.moduleCards.map((module) => ( +
+ +
+
+ {`${module.moduleCode} ${module.title}`} +
+
+ {module.metaLine} + {module.isTa ? ' • TA' : ''} +
+
+
+ ))} +
+ ) : ( +
+ No courses added. +
+ )} +
+
+ {showActiveUnits && ( +
+ Active Units: + {model.activeUnits} +
+ )} +
+ Total Units: + {model.totalUnits} +
+
+
+
+ ); +} + +export function TimetableImage({ + layout, + model, +}: { + layout: TimetableImageLayout; + model: RenderableTimetable; +}) { + const surface = getSurfaceColors(model.colorScheme); + + if (model.isVertical) { + return ( +
+ +
+ +
+
+ ); + } + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/export/src/types.ts b/export/src/types.ts index b90c4a637a..7d78168ba6 100644 --- a/export/src/types.ts +++ b/export/src/types.ts @@ -1,9 +1,8 @@ -import type { Page } from 'puppeteer-core'; +// These types are duplicated from `website/src/types`. -// These types are duplicated from `website/`. -// TODO: Move these types to a shared package. export type TimetableOrientation = 'HORIZONTAL' | 'VERTICAL'; export type ColorIndex = number; +export type LessonIndex = number; export type ColorMapping = { [moduleCode: string]: ColorIndex }; export type ThemeState = Readonly<{ id: string; @@ -12,33 +11,74 @@ export type ThemeState = Readonly<{ }>; export type ColorScheme = 'LIGHT_COLOR_SCHEME' | 'DARK_COLOR_SCHEME'; export type Semester = number; -export type ClassNo = string; // E.g. "1", "A" -export type LessonType = string; // E.g. "Lecture", "Tutorial" -export type ModuleCode = string; // E.g. "CS3216" +export type ClassNo = string; +export type DayText = string; +export type LessonTime = string; +export type LessonType = string; +export type ModuleCode = string; +export type ModuleTitle = string; +export type Venue = string; + +export type WeekRange = { + start: string; + end: string; + weekInterval?: number; + weeks?: number[]; +}; + +export type Weeks = number[] | WeekRange; + +export type RawLesson = Readonly<{ + classNo: ClassNo; + day: DayText; + startTime: LessonTime; + endTime: LessonTime; + lessonType: LessonType; + venue: Venue; + weeks: Weeks; +}>; + +export type SemesterData = { + semester: Semester; + timetable: readonly RawLesson[]; + examDate?: string; + examDuration?: number; +}; + +export type Module = { + moduleCode: ModuleCode; + title: ModuleTitle; + moduleCredit: string; + semesterData: readonly SemesterData[]; +}; + +export type ModuleLessonConfig = { + [lessonType: LessonType]: LessonIndex[]; +}; + export type SemTimetableConfig = { [moduleCode: ModuleCode]: ModuleLessonConfig; }; -export interface ModuleLessonConfig { - [lessonType: LessonType]: ClassNo; -} -export type TaModulesConfig = { - [moduleCode: ModuleCode]: Array<[lessonType: LessonType, classNo: ClassNo]>; -}; // `ExportData` is duplicated from `website/src/types/export.ts`. export type ExportData = { readonly colors: ColorMapping; - readonly hidden: Array; + readonly hidden: ModuleCode[]; readonly semester: Semester; readonly settings: { colorScheme: ColorScheme; }; - readonly ta: TaModulesConfig; + readonly ta: ModuleCode[]; readonly theme: ThemeState; readonly timetable: SemTimetableConfig; }; +export type ViewportOptions = { + pixelRatio?: number; + width?: number; + height?: number; +}; + export interface State { data: ExportData; - page: Page; } diff --git a/export/tsconfig.json b/export/tsconfig.json index a5232afbbb..65813e7c95 100644 --- a/export/tsconfig.json +++ b/export/tsconfig.json @@ -2,6 +2,9 @@ "compilerOptions": { "outDir": "build", "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, "strict": true, "useUnknownInCatchVariables": false, "skipLibCheck": true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b983bfe62..4c483c4793 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,12 +27,15 @@ importers: export: dependencies: + '@fontsource/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@resvg/resvg-js': + specifier: ^2.6.2 + version: 2.6.2 '@sentry/node': specifier: 5.30.0 version: 5.30.0 - '@sparticuz/chromium': - specifier: ^123.0.0 - version: 123.0.1 axios: specifier: 0.30.0 version: 0.30.0 @@ -56,19 +59,25 @@ importers: version: 10.1.1 koa-views: specifier: 6.3.1 - version: 6.3.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3) + version: 6.3.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@18.3.1) lodash: specifier: 4.17.23 version: 4.17.23 nodemon: specifier: 2.0.22 version: 2.0.22 + pdfkit: + specifier: ^0.17.2 + version: 0.17.2 pug: specifier: 3.0.3 version: 3.0.3 - puppeteer-core: - specifier: 22.15.0 - version: 22.15.0 + react: + specifier: 18.3.1 + version: 18.3.1 + satori: + specifier: ^0.25.0 + version: 0.25.0 devDependencies: '@nkzw/eslint-plugin': specifier: ^2.0.0 @@ -94,15 +103,18 @@ importers: '@types/node': specifier: 22.13.5 version: 22.13.5 + '@types/pdfkit': + specifier: ^0.17.5 + version: 0.17.5 '@types/pug': specifier: 2.0.10 version: 2.0.10 + '@types/react': + specifier: 18.3.18 + version: 18.3.18 '@vercel/node': specifier: 1.15.4 version: 1.15.4 - cross-env: - specifier: 7.0.3 - version: 7.0.3 dotenv: specifier: 8.6.0 version: 8.6.0 @@ -121,6 +133,9 @@ importers: oxlint: specifier: ^1.51.0 version: 1.51.0 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: 5.9.3 version: 5.9.3 @@ -154,7 +169,7 @@ importers: version: 1.0.1(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(oxlint@1.51.0)(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) documentation: specifier: 14.0.3 version: 14.0.3 @@ -181,7 +196,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) + version: 4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) scrapers/cpex-scraper: dependencies: @@ -300,7 +315,7 @@ importers: version: 17.0.33 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) antlr4ts-cli: specifier: ^0.5.0-alpha.4 version: 0.5.0-alpha.4 @@ -333,7 +348,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) + version: 4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) website: dependencies: @@ -631,7 +646,7 @@ importers: version: 1.15.4 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) babel-loader: specifier: 9.2.1 version: 9.2.1(@babel/core@7.26.0)(webpack@5.104.1) @@ -772,10 +787,10 @@ importers: version: 4.1.1(webpack@5.104.1) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) + version: 4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) webpack: specifier: 5.104.1 version: 5.104.1(webpack-cli@5.1.4) @@ -1704,6 +1719,9 @@ packages: resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@fontsource/inter@5.2.8': + resolution: {integrity: sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==} + '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} @@ -2452,11 +2470,6 @@ packages: webpack-plugin-serve: optional: true - '@puppeteer/browsers@2.3.0': - resolution: {integrity: sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==} - engines: {node: '>=18'} - hasBin: true - '@react-leaflet/core@1.1.1': resolution: {integrity: sha512-7PGLWa9MZ5x/cWy8EH2VzI4T8q5WpuHbixzCDXqixP/WyqwIrg5NDUPgYuFnB4IEIZF+6nA265mYzswFo/h1Pw==} peerDependencies: @@ -2464,6 +2477,86 @@ packages: react: ^17.0.1 react-dom: ^17.0.1 + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + '@rollup/plugin-alias@3.1.9': resolution: {integrity: sha512-QI5fsEvm9bDzt32k39wpOwZhVzRcL5ydcffUHMyLVaVaLeC70I8TJZ17F1z1eMoLu4E/UOcH9BWVkKpIKdrfiw==} engines: {node: '>=8.0.0'} @@ -2741,6 +2834,11 @@ packages: resolution: {integrity: sha512-ju/Cvyeu/vkfC5/XBV30UNet5kLEicZmXSyuLwZu95hEbL+foPdxN+re7pCI/eNqfe3B2vz7lvz5afLVOlQ2Hg==} engines: {node: '>=8'} + '@shuding/opentype.js@1.4.0-beta.0': + resolution: {integrity: sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA==} + engines: {node: '>= 8.0.0'} + hasBin: true + '@sideway/address@4.1.5': resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} @@ -2754,10 +2852,6 @@ packages: resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} - '@sparticuz/chromium@123.0.1': - resolution: {integrity: sha512-RPrA99xrddbXXZjhUH1WZkTCK3QDQ77t/0y+vULtiW1uAlWLBxorxGnNTuzBDk6eNolxOVFrXZ2aoAN+/eB5TA==} - engines: {node: '>= 16'} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2856,6 +2950,9 @@ packages: resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} engines: {node: '>=14'} + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -2896,9 +2993,6 @@ packages: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} - '@tootallnate/quickjs-emscripten@0.23.0': - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -3110,6 +3204,9 @@ packages: '@types/parse5@6.0.3': resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==} + '@types/pdfkit@0.17.5': + resolution: {integrity: sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==} + '@types/promise-queue@2.2.3': resolution: {integrity: sha512-CuEQpGSYKvHr3SQ7C7WkluLg9CFjVORbn8YFRsQ5u6mqGbZVfSOv03ic9t95HtZuMchnlNqnIsQGFOpxqdhjTQ==} @@ -3247,9 +3344,6 @@ packages: '@types/yargs@17.0.33': resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@4.33.0': resolution: {integrity: sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -3817,10 +3911,6 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - ast-v8-to-istanbul@0.3.12: resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} @@ -3886,14 +3976,6 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - b4a@1.8.0: - resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} - peerDependencies: - react-native-b4a: '*' - peerDependenciesMeta: - react-native-b4a: - optional: true - babel-loader@9.2.1: resolution: {integrity: sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==} engines: {node: '>= 14.15.0'} @@ -3959,6 +4041,10 @@ packages: balanced-match@2.0.0: resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -4013,10 +4099,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - basic-ftp@5.2.0: - resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} - engines: {node: '>=10.0.0'} - batch@0.6.1: resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} @@ -4096,6 +4178,9 @@ packages: resolution: {integrity: sha512-uA9fOtlTRC0iqKfzff1W34DXUA3GyVqbUaeo3Rw3d4gd1eavKVCETXrn3NzO74W+UVkG3UHu8WxUi+XvKI/huA==} engines: {node: '>= 10.16.0'} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + browser-stdout@1.3.1: resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} @@ -4191,6 +4276,9 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} + camelize@1.0.1: + resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} @@ -4274,11 +4362,6 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - chromium-bidi@0.6.3: - resolution: {integrity: sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==} - peerDependencies: - devtools-protocol: '*' - ci-info@3.3.0: resolution: {integrity: sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==} @@ -4336,6 +4419,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -4735,12 +4822,25 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + css-animation@1.6.1: resolution: {integrity: sha512-/48+/BaEaHRY6kNQ2OIPzKf9A6g8WjZYjhiNDNuIVbsm5tXCGIAsHDjB4Xu1C4vXJtUWZo26O68OQkDpNBaPog==} + css-background-parser@0.1.0: + resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==} + css-box-model@1.2.1: resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + css-box-shadow@1.0.0-3: + resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==} + + css-color-keywords@1.0.0: + resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} + engines: {node: '>=4'} + css-color-names@0.0.4: resolution: {integrity: sha512-zj5D7X1U2h2zsXOAM8EyUREBnnts6H+Jm+d1M2DbiQQcUtnqgQsMrdo8JW9R80YFUmIdBZeMu5wvYM7hcgWP/Q==} @@ -4754,6 +4854,10 @@ packages: peerDependencies: postcss: ^8.0.9 + css-gradient-parser@0.0.17: + resolution: {integrity: sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg==} + engines: {node: '>=16'} + css-loader@6.11.0: resolution: {integrity: sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==} engines: {node: '>= 12.13.0'} @@ -4778,6 +4882,9 @@ packages: css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-to-react-native@3.2.0: + resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + css-tree@1.0.0-alpha.37: resolution: {integrity: sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==} engines: {node: '>=8.0.0'} @@ -4870,10 +4977,6 @@ packages: damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -5029,10 +5132,6 @@ packages: resolution: {integrity: sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==} engines: {node: '>=0.10.0'} - degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -5071,8 +5170,8 @@ packages: devtools-protocol@0.0.1140464: resolution: {integrity: sha512-I1jXnjpQh/6TBFyQ0A9dB2kXXk6DprpPFZoI8pUsxHtlNuOTQEdv9fUqYBsFtf8tOJCbdsZZyQrWeXu6GfK+Bw==} - devtools-protocol@0.0.1312386: - resolution: {integrity: sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -5224,6 +5323,10 @@ packages: electron-to-chromium@1.5.302: resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + emoji-regex-xs@2.0.1: + resolution: {integrity: sha512-1QFuh8l7LqUcKe24LsPUNzjrzJQ7pgRwp1QMcZ5MX6mFplk2zQ08NVCM84++1cveaUUYtcCYHmeFEuNg16sU4g==} + engines: {node: '>=10.0.0'} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -5386,11 +5489,6 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - eslint-config-airbnb-base@14.2.1: resolution: {integrity: sha512-GOrQyDtVEc1Xy20U7vsB2yAoB4nBlfH5HZJeatRXHleO+OS5Ot+MWij4Dpltw4/DyIkqUfqz1epfhVR5XWWQPA==} engines: {node: '>= 6'} @@ -5599,9 +5697,6 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - events-universal@1.0.1: - resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} - events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -5640,17 +5735,9 @@ packages: resolution: {integrity: sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==} engines: {node: '>=0.10.0'} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -5678,9 +5765,6 @@ packages: resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} engines: {node: '>=0.8.0'} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -5690,6 +5774,9 @@ packages: picomatch: optional: true + fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + figures@1.7.0: resolution: {integrity: sha512-UxKlfCRuCBxSXU4C6t9scbDyWZ4VlaFFdojKtzJuSkuOBQ5CNFum+zZXFwHjo+CxBC1t6zlYPgHIgFjL8ggoEQ==} engines: {node: '>=0.10.0'} @@ -5780,6 +5867,9 @@ packages: resolution: {integrity: sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==} engines: {node: '>=4.0'} + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -5902,17 +5992,12 @@ packages: resolution: {integrity: sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==} engines: {node: '>=10'} - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-uri@6.0.5: - resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} - engines: {node: '>= 14'} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} get-value@2.0.6: resolution: {integrity: sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==} @@ -6131,6 +6216,10 @@ packages: hex-color-regex@1.1.0: resolution: {integrity: sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ==} + hex-rgb@4.3.0: + resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} + engines: {node: '>=6'} + highlight.js@11.11.1: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} @@ -6413,10 +6502,6 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -6786,6 +6871,10 @@ packages: joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + jpeg-exif@1.1.4: + resolution: {integrity: sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + jquery@3.7.1: resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==} @@ -6982,6 +7071,9 @@ packages: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -7113,10 +7205,6 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - lru_map@0.3.3: resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==} @@ -7473,9 +7561,6 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} - mitt@3.0.1: - resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} - mixin-deep@1.3.2: resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} engines: {node: '>=0.10.0'} @@ -7570,10 +7655,6 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - netmask@2.0.2: - resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} - engines: {node: '>= 0.4.0'} - nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -7885,17 +7966,12 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - pac-proxy-agent@7.2.0: - resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} - engines: {node: '>= 14'} - - pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -7906,6 +7982,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-css-color@0.2.1: + resolution: {integrity: sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg==} + parse-entities@2.0.0: resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==} @@ -8013,8 +8092,8 @@ packages: pathval@1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + pdfkit@0.17.2: + resolution: {integrity: sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==} performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -8074,6 +8153,9 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + png-js@1.0.0: + resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==} + popper.js@1.16.1: resolution: {integrity: sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==} deprecated: You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1 @@ -8551,10 +8633,6 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} - proxy-agent@6.5.0: - resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} - engines: {node: '>= 14'} - proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -8600,9 +8678,6 @@ packages: pug@3.0.3: resolution: {integrity: sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==} - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - punycode@1.4.1: resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} @@ -8610,10 +8685,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - puppeteer-core@22.15.0: - resolution: {integrity: sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==} - engines: {node: '>=18'} - q@1.5.1: resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} engines: {node: '>=0.6.0', teleport: '>=0.2.0'} @@ -9024,6 +9095,9 @@ packages: resolve-pathname@3.0.0: resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve-url@0.2.1: resolution: {integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==} deprecated: https://github.com/lydell/resolve-url#deprecated @@ -9046,6 +9120,9 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + ret@0.1.15: resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} engines: {node: '>=0.12'} @@ -9206,6 +9283,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + satori@0.25.0: + resolution: {integrity: sha512-utINfLxrYrmSnLvxFT4ZwgwWa8KOjrz7ans32V5wItgHVmzESl/9i33nE38uG0miycab8hUqQtDlOpqrIpB/iw==} + engines: {node: '>=16'} + sax@1.2.4: resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} @@ -9402,10 +9483,6 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -9424,14 +9501,6 @@ packages: sockjs@0.3.24: resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-list-map@2.0.1: resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} @@ -9539,9 +9608,6 @@ packages: stream-events@1.0.5: resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==} - streamx@2.23.0: - resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} - strict-uri-encode@2.0.0: resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} engines: {node: '>=4'} @@ -9572,6 +9638,9 @@ packages: resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} + string.prototype.codepointat@0.2.1: + resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -9763,23 +9832,14 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - tar-fs@3.1.1: - resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} - tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} - tar-stream@3.1.8: - resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==} - teeny-request@7.1.1: resolution: {integrity: sha512-iwY6rkW5DDGq8hE2YgNQlKbptYpY5Nn2xecjQiNjOXWbKzPGUfmeUBCSQbbr306d7Z7U2N0TPl+/SwYRfua1Dg==} engines: {node: '>=10'} - teex@1.0.1: - resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} - terser-webpack-plugin@5.3.11: resolution: {integrity: sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==} engines: {node: '>= 10.13.0'} @@ -9817,9 +9877,6 @@ packages: engines: {node: '>=10'} hasBin: true - text-decoder@1.2.7: - resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -9836,9 +9893,6 @@ packages: peerDependencies: tslib: ^2 - through@2.3.8: - resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} - thunky@1.1.0: resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} @@ -9848,6 +9902,9 @@ packages: tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -10002,6 +10059,11 @@ packages: peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -10079,9 +10141,6 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - unbzip2-stream@1.4.3: - resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} - unc-path-regex@0.1.2: resolution: {integrity: sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==} engines: {node: '>=0.10.0'} @@ -10104,10 +10163,16 @@ packages: resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} engines: {node: '>=4'} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + unicode-property-aliases-ecmascript@2.2.0: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -10217,9 +10282,6 @@ packages: urlgrey@1.0.0: resolution: {integrity: sha512-hJfIzMPJmI9IlLkby8QrsCykQ+SXDeO2W5Q9QTW3QpqZVTx4a/K7p8/5q+/isD8vsbVaFgql/gvAoQCRQ2Cb5w==} - urlpattern-polyfill@10.0.0: - resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==} - use-memo-one@1.1.3: resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==} peerDependencies: @@ -10694,9 +10756,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - ylru@1.4.0: resolution: {integrity: sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==} engines: {node: '>= 4.0.0'} @@ -10713,6 +10772,9 @@ packages: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zip-stream@4.1.1: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} @@ -11783,6 +11845,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@fontsource/inter@5.2.8': {} + '@hapi/hoek@9.3.0': {} '@hapi/topo@5.1.0': @@ -12444,28 +12508,63 @@ snapshots: type-fest: 2.19.0 webpack-dev-server: 5.2.1(tslib@2.8.1)(webpack-cli@5.1.4)(webpack@5.104.1) - '@puppeteer/browsers@2.3.0': - dependencies: - debug: 4.4.3 - extract-zip: 2.0.1 - progress: 2.0.3 - proxy-agent: 6.5.0 - semver: 7.7.4 - tar-fs: 3.1.1 - unbzip2-stream: 1.4.3 - yargs: 17.7.2 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - - supports-color - '@react-leaflet/core@1.1.1(leaflet@1.9.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: leaflet: 1.9.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.6.2 + '@resvg/resvg-js-android-arm64': 2.6.2 + '@resvg/resvg-js-darwin-arm64': 2.6.2 + '@resvg/resvg-js-darwin-x64': 2.6.2 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + '@rollup/plugin-alias@3.1.9(rollup@2.80.0)': dependencies: rollup: 2.80.0 @@ -12749,6 +12848,11 @@ snapshots: dependencies: '@sentry/types': 7.119.1 + '@shuding/opentype.js@1.4.0-beta.0': + dependencies: + fflate: 0.7.4 + string.prototype.codepointat: 0.2.1 + '@sideway/address@4.1.5': dependencies: '@hapi/hoek': 9.3.0 @@ -12759,16 +12863,6 @@ snapshots: '@sindresorhus/merge-streams@2.3.0': {} - '@sparticuz/chromium@123.0.1': - dependencies: - follow-redirects: 1.15.11 - tar-fs: 3.1.1 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - debug - - react-native-b4a - '@standard-schema/spec@1.1.0': {} '@stylelint/postcss-css-in-js@0.37.3(postcss-syntax@0.36.2)(postcss@7.0.39)': @@ -12888,6 +12982,10 @@ snapshots: - supports-color - typescript + '@swc/helpers@0.5.19': + dependencies: + tslib: 2.8.1 + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.29.0 @@ -12931,8 +13029,6 @@ snapshots: '@tootallnate/once@1.1.2': {} - '@tootallnate/quickjs-emscripten@0.23.0': {} - '@trysound/sax@0.2.0': {} '@tsconfig/node10@1.0.12': {} @@ -13180,6 +13276,10 @@ snapshots: '@types/parse5@6.0.3': {} + '@types/pdfkit@0.17.5': + dependencies: + '@types/node': 22.13.5 + '@types/promise-queue@2.2.3': {} '@types/prop-types@15.7.15': {} @@ -13349,11 +13449,6 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 22.13.5 - optional: true - '@typescript-eslint/eslint-plugin@4.33.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.9.3))(eslint@7.32.0)(typescript@5.9.3)': dependencies: '@typescript-eslint/experimental-utils': 4.33.0(eslint@7.32.0)(typescript@5.9.3) @@ -13568,7 +13663,7 @@ snapshots: ts-node: 8.9.1(typescript@4.3.4) typescript: 4.3.4 - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -13580,7 +13675,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/expect@4.0.18': dependencies: @@ -13591,13 +13686,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -14053,10 +14148,6 @@ snapshots: ast-types-flow@0.0.8: {} - ast-types@0.13.4: - dependencies: - tslib: 2.8.1 - ast-v8-to-istanbul@0.3.12: dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -14129,8 +14220,6 @@ snapshots: axobject-query@4.1.0: {} - b4a@1.8.0: {} - babel-loader@9.2.1(@babel/core@7.26.0)(webpack@5.104.1): dependencies: '@babel/core': 7.26.0 @@ -14214,6 +14303,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@0.0.8: {} + bare-events@2.8.2: {} bare-fs@4.5.5: @@ -14261,8 +14352,6 @@ snapshots: baseline-browser-mapping@2.10.0: {} - basic-ftp@5.2.0: {} - batch@0.6.1: {} bfj@6.1.2: @@ -14387,6 +14476,10 @@ snapshots: dependencies: duplexer: 0.1.1 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + browser-stdout@1.3.1: {} browserslist@4.14.2: @@ -14496,6 +14589,8 @@ snapshots: camelcase@6.3.0: {} + camelize@1.0.1: {} + caniuse-api@3.0.0: dependencies: browserslist: 4.24.3 @@ -14603,13 +14698,6 @@ snapshots: chrome-trace-event@1.0.4: {} - chromium-bidi@0.6.3(devtools-protocol@0.0.1312386): - dependencies: - devtools-protocol: 0.0.1312386 - mitt: 3.0.1 - urlpattern-polyfill: 10.0.0 - zod: 3.23.8 - ci-info@3.3.0: {} class-utils@0.3.6: @@ -14672,6 +14760,8 @@ snapshots: clone@1.0.4: {} + clone@2.1.2: {} + co@4.6.0: {} coa@2.0.2: @@ -14798,13 +14888,14 @@ snapshots: connect-history-api-fallback@2.0.0: {} - consolidate@0.15.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3): + consolidate@0.15.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@18.3.1): dependencies: bluebird: 3.7.2 optionalDependencies: ejs: 3.1.10 lodash: 4.17.23 pug: 3.0.3 + react: 18.3.1 constantinople@4.0.1: dependencies: @@ -14920,15 +15011,23 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + css-animation@1.6.1: dependencies: babel-runtime: 6.26.0 component-classes: 1.2.6 + css-background-parser@0.1.0: {} + css-box-model@1.2.1: dependencies: tiny-invariant: 1.3.3 + css-box-shadow@1.0.0-3: {} + + css-color-keywords@1.0.0: {} + css-color-names@0.0.4: {} css-declaration-sorter@4.0.1: @@ -14940,6 +15039,8 @@ snapshots: dependencies: postcss: 8.4.49 + css-gradient-parser@0.0.17: {} + css-loader@6.11.0(webpack@5.104.1): dependencies: icss-utils: 5.1.0(postcss@8.4.49) @@ -14978,6 +15079,12 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-to-react-native@3.2.0: + dependencies: + camelize: 1.0.1 + css-color-keywords: 1.0.0 + postcss-value-parser: 4.2.0 + css-tree@1.0.0-alpha.37: dependencies: mdn-data: 2.0.4 @@ -15117,8 +15224,6 @@ snapshots: damerau-levenshtein@1.0.8: {} - data-uri-to-buffer@6.0.2: {} - data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -15267,12 +15372,6 @@ snapshots: is-descriptor: 1.0.3 isobject: 3.0.1 - degenerator@5.0.1: - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - delayed-stream@1.0.0: {} delegates@1.0.0: {} @@ -15299,7 +15398,7 @@ snapshots: devtools-protocol@0.0.1140464: {} - devtools-protocol@0.0.1312386: {} + dfa@1.2.0: {} didyoumean@1.2.2: {} @@ -15494,6 +15593,8 @@ snapshots: electron-to-chromium@1.5.302: {} + emoji-regex-xs@2.0.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -15758,6 +15859,22 @@ snapshots: optionalDependencies: source-map: 0.6.1 + eslint-config-airbnb-base@14.2.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.9.3))(eslint@7.32.0))(eslint@7.32.0): + dependencies: + confusing-browser-globals: 1.0.11 + eslint: 7.32.0 + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.9.3))(eslint@7.32.0) + object.assign: 4.1.7 + object.entries: 1.1.9 + + eslint-config-airbnb-base@14.2.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.9.3))(eslint@7.32.0))(eslint@7.32.0): + dependencies: + confusing-browser-globals: 1.0.11 + eslint: 7.32.0 + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.9.3))(eslint@7.32.0) + object.assign: 4.1.7 + object.entries: 1.1.9 + eslint-config-airbnb-base@14.2.1(eslint-plugin-import@2.31.0)(eslint@7.32.0): dependencies: confusing-browser-globals: 1.0.11 @@ -16078,12 +16195,6 @@ snapshots: eventemitter3@5.0.4: {} - events-universal@1.0.1: - dependencies: - bare-events: 2.8.2 - transitivePeerDependencies: - - bare-abort-controller - events@3.3.0: {} execall@2.0.0: @@ -16166,20 +16277,8 @@ snapshots: transitivePeerDependencies: - supports-color - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - fast-deep-equal@3.1.3: {} - fast-fifo@1.3.2: {} - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -16208,14 +16307,12 @@ snapshots: dependencies: websocket-driver: 0.7.4 - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 + fflate@0.7.4: {} + figures@1.7.0: dependencies: escape-string-regexp: 1.0.5 @@ -16310,6 +16407,18 @@ snapshots: transitivePeerDependencies: - supports-color + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.19 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -16437,23 +16546,15 @@ snapshots: get-stdin@8.0.0: {} - get-stream@5.2.0: - dependencies: - pump: 3.0.4 - get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-uri@6.0.5: + get-tsconfig@4.13.6: dependencies: - basic-ftp: 5.2.0 - data-uri-to-buffer: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color + resolve-pkg-maps: 1.0.0 get-value@2.0.6: {} @@ -16724,6 +16825,8 @@ snapshots: hex-color-regex@1.1.0: {} + hex-rgb@4.3.0: {} + highlight.js@11.11.1: {} history@4.10.1: @@ -17025,8 +17128,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - ip-address@10.1.0: {} - ipaddr.js@1.9.1: {} ipaddr.js@2.3.0: {} @@ -17370,6 +17471,8 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + jpeg-exif@1.1.4: {} + jquery@3.7.1: {} js-beautify@1.15.4: @@ -17538,9 +17641,9 @@ snapshots: transitivePeerDependencies: - supports-color - koa-views@6.3.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3): + koa-views@6.3.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@18.3.1): dependencies: - consolidate: 0.15.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3) + consolidate: 0.15.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@18.3.1) debug: 4.4.3 get-paths: 0.0.7 koa-send: 5.0.1 @@ -17671,6 +17774,11 @@ snapshots: lilconfig@2.1.0: {} + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + lines-and-columns@1.2.4: {} lint-staged@16.3.2: @@ -17804,8 +17912,6 @@ snapshots: dependencies: yallist: 4.0.0 - lru-cache@7.18.3: {} - lru_map@0.3.3: {} lz-string@1.5.0: {} @@ -18406,8 +18512,6 @@ snapshots: minipass@7.1.3: {} - mitt@3.0.1: {} - mixin-deep@1.3.2: dependencies: for-in: 1.0.2 @@ -18522,8 +18626,6 @@ snapshots: neo-async@2.6.2: {} - netmask@2.0.2: {} - nice-try@1.0.5: {} nightwatch-axe-verbose@2.4.0: @@ -18935,26 +19037,10 @@ snapshots: p-try@2.2.0: {} - pac-proxy-agent@7.2.0: - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.4 - debug: 4.4.3 - get-uri: 6.0.5 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - pac-resolver@7.0.1: - dependencies: - degenerator: 5.0.1 - netmask: 2.0.2 - package-json-from-dist@1.0.1: {} + pako@0.2.9: {} + pako@1.0.11: {} param-case@3.0.4: @@ -18966,6 +19052,11 @@ snapshots: dependencies: callsites: 3.1.0 + parse-css-color@0.2.1: + dependencies: + color-name: 1.1.4 + hex-rgb: 4.3.0 + parse-entities@2.0.0: dependencies: character-entities: 1.2.4 @@ -19066,7 +19157,13 @@ snapshots: pathval@1.1.1: {} - pend@1.2.0: {} + pdfkit@0.17.2: + dependencies: + crypto-js: 4.2.0 + fontkit: 2.0.4 + jpeg-exif: 1.1.4 + linebreak: 1.1.0 + png-js: 1.0.0 performance-now@2.1.0: {} @@ -19106,6 +19203,8 @@ snapshots: dependencies: find-up: 3.0.0 + png-js@1.0.0: {} + popper.js@1.16.1: {} posix-character-classes@0.1.1: {} @@ -19609,19 +19708,6 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 - proxy-agent@6.5.0: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - proxy-from-env@1.1.0: {} psl@1.15.0: @@ -19697,30 +19783,10 @@ snapshots: pug-runtime: 3.0.1 pug-strip-comments: 2.0.0 - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - punycode@1.4.1: {} punycode@2.3.1: {} - puppeteer-core@22.15.0: - dependencies: - '@puppeteer/browsers': 2.3.0 - chromium-bidi: 0.6.3(devtools-protocol@0.0.1312386) - debug: 4.4.3 - devtools-protocol: 0.0.1312386 - ws: 8.19.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - bufferutil - - react-native-b4a - - supports-color - - utf-8-validate - q@1.5.1: {} qrcode.react@4.2.0(react@18.3.1): @@ -20268,6 +20334,8 @@ snapshots: resolve-pathname@3.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve-url@0.2.1: {} resolve@1.22.11: @@ -20295,6 +20363,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + restructure@3.0.2: {} + ret@0.1.15: {} retry@0.13.1: {} @@ -20498,6 +20568,20 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 + satori@0.25.0: + dependencies: + '@shuding/opentype.js': 1.4.0-beta.0 + css-background-parser: 0.1.0 + css-box-shadow: 1.0.0-3 + css-gradient-parser: 0.0.17 + css-to-react-native: 3.2.0 + emoji-regex-xs: 2.0.1 + escape-html: 1.0.3 + linebreak: 1.1.0 + parse-css-color: 0.2.1 + postcss-value-parser: 4.2.0 + yoga-layout: 3.2.1 + sax@1.2.4: {} saxes@6.0.0: @@ -20742,8 +20826,6 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - smart-buffer@4.2.0: {} - snake-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -20778,19 +20860,6 @@ snapshots: uuid: 8.3.2 websocket-driver: 0.7.4 - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color - - socks@2.8.7: - dependencies: - ip-address: 10.1.0 - smart-buffer: 4.2.0 - source-list-map@2.0.1: {} source-map-js@1.2.1: {} @@ -20895,15 +20964,6 @@ snapshots: dependencies: stubs: 3.0.0 - streamx@2.23.0: - dependencies: - events-universal: 1.0.1 - fast-fifo: 1.3.2 - text-decoder: 1.2.7 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - strict-uri-encode@2.0.0: {} string-argv@0.3.2: {} @@ -20935,6 +20995,8 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string.prototype.codepointat@0.2.1: {} + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.8 @@ -21210,18 +21272,6 @@ snapshots: tapable@2.3.0: {} - tar-fs@3.1.1: - dependencies: - pump: 3.0.4 - tar-stream: 3.1.8 - optionalDependencies: - bare-fs: 4.5.5 - bare-path: 3.0.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -21230,17 +21280,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - tar-stream@3.1.8: - dependencies: - b4a: 1.8.0 - bare-fs: 4.5.5 - fast-fifo: 1.3.2 - streamx: 2.23.0 - transitivePeerDependencies: - - bare-abort-controller - - bare-buffer - - react-native-b4a - teeny-request@7.1.1: dependencies: http-proxy-agent: 4.0.1 @@ -21252,13 +21291,6 @@ snapshots: - encoding - supports-color - teex@1.0.1: - dependencies: - streamx: 2.23.0 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - terser-webpack-plugin@5.3.11(webpack@5.104.1): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -21283,12 +21315,6 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - text-decoder@1.2.7: - dependencies: - b4a: 1.8.0 - transitivePeerDependencies: - - react-native-b4a - text-table@0.2.0: {} thenify-all@1.6.0: @@ -21303,8 +21329,6 @@ snapshots: dependencies: tslib: 2.8.1 - through@2.3.8: {} - thunky@1.1.0: {} timsort@0.3.0: {} @@ -21314,6 +21338,8 @@ snapshots: globalyzer: 0.1.0 globrex: 0.1.2 + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} tiny-json-http@7.5.1: {} @@ -21447,6 +21473,13 @@ snapshots: tslib: 1.14.1 typescript: 5.9.3 + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -21524,11 +21557,6 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - unbzip2-stream@1.4.3: - dependencies: - buffer: 5.7.1 - through: 2.3.8 - unc-path-regex@0.1.2: {} undefsafe@2.0.5: {} @@ -21544,8 +21572,18 @@ snapshots: unicode-match-property-value-ecmascript@2.2.1: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + unicode-property-aliases-ecmascript@2.2.0: {} + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unicorn-magic@0.3.0: {} unified@10.1.2: @@ -21677,8 +21715,6 @@ snapshots: dependencies: fast-url-parser: 1.1.3 - urlpattern-polyfill@10.0.0: {} - use-memo-one@1.1.3(react@18.3.1): dependencies: react: 18.3.1 @@ -21785,18 +21821,18 @@ snapshots: unist-util-stringify-position: 3.0.3 vfile-message: 3.1.4 - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2): + vite@7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -21810,12 +21846,13 @@ snapshots: jiti: 1.21.7 sass: 1.83.1 terser: 5.46.0 + tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2): + vitest@4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -21832,7 +21869,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.13.5)(jiti@1.21.7)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.13.5 @@ -22234,11 +22271,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - ylru@1.4.0: {} yn@3.1.1: {} @@ -22247,6 +22279,8 @@ snapshots: yocto-queue@1.2.2: {} + yoga-layout@3.2.1: {} + zip-stream@4.1.1: dependencies: archiver-utils: 3.0.4 diff --git a/renovate.json b/renovate.json index 8db234a031..ea28b8ee8d 100644 --- a/renovate.json +++ b/renovate.json @@ -65,7 +65,7 @@ "groupName": "definitelyTyped" } ], - "ignoreDeps": ["@material/snackbar", "@material/fab", "puppeteer", "searchkit", "samlify"], + "ignoreDeps": ["@material/snackbar", "@material/fab", "searchkit", "samlify"], "pathRules": [ { "paths": ["packages/**"], From d8368bcdc4e1373e71d1ecd9885944c6128462b5 Mon Sep 17 00:00:00 2001 From: Daren Tan Date: Sat, 7 Mar 2026 23:30:08 +0800 Subject: [PATCH 2/5] chore: add unit for units --- export/src/satori/view.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/export/src/satori/view.tsx b/export/src/satori/view.tsx index 71b10a9f71..035b3b66ec 100644 --- a/export/src/satori/view.tsx +++ b/export/src/satori/view.tsx @@ -779,12 +779,14 @@ function ModuleCardsList({ {showActiveUnits && (
Active Units: - {model.activeUnits} + + {model.activeUnits} Units +
)}
Total Units: - {model.totalUnits} + {model.totalUnits} Units
From 4b5998c15581844a5d80e627d996bf13bbe7f814 Mon Sep 17 00:00:00 2001 From: Daren Tan Date: Sat, 7 Mar 2026 23:39:09 +0800 Subject: [PATCH 3/5] migrate: tests to vitest --- export/package.json | 6 +- export/src/satori/render-model.test.ts | 1055 +++++++++++++++++++++++- export/vitest.config.ts | 8 + pnpm-lock.yaml | 109 +-- 4 files changed, 1047 insertions(+), 131 deletions(-) create mode 100644 export/vitest.config.ts diff --git a/export/package.json b/export/package.json index 3ce43ab3e5..4e27dc50e5 100644 --- a/export/package.json +++ b/export/package.json @@ -10,7 +10,8 @@ "start": "pm2 start ecosystem.config.js", "build": "tsc", "nodemon": "nodemon --no-update-notifier -r dotenv/config ./build/src/index.js", - "test": "tsx --test src/**/*.test.ts", + "test": "vitest run", + "test:watch": "vitest", "watch": "tsc --watch", "dev": "run-p nodemon watch", "check": "run-s lint typecheck", @@ -58,6 +59,7 @@ "npm-run-all": "4.1.5", "oxlint": "^1.51.0", "tsx": "^4.21.0", - "typescript": "5.9.3" + "typescript": "5.9.3", + "vitest": "^4.0.18" } } diff --git a/export/src/satori/render-model.test.ts b/export/src/satori/render-model.test.ts index 67d52d4f68..7169670cb4 100644 --- a/export/src/satori/render-model.test.ts +++ b/export/src/satori/render-model.test.ts @@ -1,5 +1,4 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; +import { describe, expect, test } from 'vitest'; import type { ExportData, Module } from '../types'; import { buildRenderableTimetable } from './render-model'; @@ -12,6 +11,7 @@ const fixtureModules: Module[] = [ semesterData: [ { examDate: '2026-05-01T01:00:00.000Z', + examDuration: 120, semester: 1, timetable: [ { @@ -43,6 +43,7 @@ const fixtureModules: Module[] = [ semesterData: [ { examDate: '2026-05-03T01:00:00.000Z', + examDuration: 90, semester: 1, timetable: [ { @@ -110,17 +111,14 @@ test('buildRenderableTimetable excludes hidden lessons and keeps overlapping row const model = buildRenderableTimetable(makeExportData(), fixtureModules); const monday = model.days.find((day) => day.day === 'Monday'); - assert.ok(monday); - assert.equal(monday.rows.length, 2); - assert.equal( - model.days.some((day) => day.day === 'Saturday'), - false, - ); - assert.equal(model.activeUnits, 4); - assert.equal(model.totalUnits, 12); - assert.equal(monday.rows[0]?.[0]?.weekText, 'Weeks 1, 2, 3'); - assert.equal(model.moduleCards.find((module) => module.moduleCode === 'MA1101R')?.isTa, true); - assert.equal(model.moduleCards.find((module) => module.moduleCode === 'EG1311')?.isHidden, true); + expect(monday).toBeDefined(); + expect(monday!.rows.length).toBe(2); + expect(model.days.some((day) => day.day === 'Saturday')).toBe(false); + expect(model.activeUnits).toBe(4); + expect(model.totalUnits).toBe(12); + expect(monday!.rows[0]?.[0]?.weekText).toBe('Weeks 1, 2, 3'); + expect(model.moduleCards.find((module) => module.moduleCode === 'MA1101R')?.isTa).toBe(true); + expect(model.moduleCards.find((module) => module.moduleCode === 'EG1311')?.isHidden).toBe(true); }); test('buildRenderableTimetable includes saturday when a visible lesson is scheduled there', () => { @@ -135,10 +133,78 @@ test('buildRenderableTimetable includes saturday when a visible lesson is schedu fixtureModules, ); - assert.equal( - model.days.some((day) => day.day === 'Saturday'), - true, + expect(model.days.some((day) => day.day === 'Saturday')).toBe(true); +}); + +test('buildRenderableTimetable handles Saturday classes', () => { + const ind5005a: Module = { + moduleCode: 'IND5005A', + moduleCredit: '0', + semesterData: [ + { + semester: 1, + timetable: [ + { + classNo: '1', + day: 'Tuesday', + endTime: '1400', + lessonType: 'Lecture', + startTime: '1200', + venue: '', + weeks: [2, 4, 6], + }, + { + classNo: '1', + day: 'Saturday', + endTime: '1300', + lessonType: 'Lecture', + startTime: '0900', + venue: '', + weeks: [9, 10], + }, + ], + }, + ], + title: 'Professional Career Development', + }; + + const model = buildRenderableTimetable( + { + colors: { IND5005A: 3 }, + hidden: [], + semester: 1, + settings: { colorScheme: 'LIGHT_COLOR_SCHEME' }, + ta: [], + theme: { + id: 'eighties', + showTitle: false, + timetableOrientation: 'HORIZONTAL', + }, + timetable: { IND5005A: { Lecture: [0, 1] } }, + }, + [ind5005a], ); + + expect(model.days.map((d) => d.day)).toContain('Saturday'); + expect(model.days.map((d) => d.day)).not.toContain('Sunday'); + + const saturday = model.days.find((d) => d.day === 'Saturday')!; + expect(saturday.rows[0]?.length).toBe(1); + expect(saturday.rows[0]?.[0]?.weekText).toBe('Weeks 9, 10'); + expect(saturday.rows[0]?.[0]?.moduleCode).toBe('IND5005A'); + + const tuesday = model.days.find((d) => d.day === 'Tuesday')!; + expect(tuesday.rows[0]?.[0]?.weekText).toBe('Weeks 2, 4, 6'); + + expect(model.startingIndex).toBe(9 * 4); + expect(model.timeLabels[0]?.label).toBe('0900'); + + const card = model.moduleCards.find((m) => m.moduleCode === 'IND5005A')!; + expect(card.moduleCredit).toBe(0); + expect(card.metaLine).toContain('No Exam'); + expect(card.metaLine).toContain('0 Units'); + expect(model.totalUnits).toBe(0); + expect(model.activeUnits).toBe(0); }); test('buildRenderableTimetable sorts modules without exams before dated exams', () => { @@ -152,15 +218,16 @@ test('buildRenderableTimetable sorts modules without exams before dated exams', ); const model = buildRenderableTimetable(makeExportData({ hidden: [] }), modules); - assert.deepEqual( - model.moduleCards.map((module) => module.moduleCode), - ['EG1311', 'CS1010', 'MA1101R'], - ); + expect(model.moduleCards.map((module) => module.moduleCode)).toEqual([ + 'EG1311', + 'CS1010', + 'MA1101R', + ]); }); test('buildRenderableTimetable sets isVertical based on orientation', () => { const horizontal = buildRenderableTimetable(makeExportData(), fixtureModules); - assert.equal(horizontal.isVertical, false); + expect(horizontal.isVertical).toBe(false); const vertical = buildRenderableTimetable( makeExportData({ @@ -172,7 +239,919 @@ test('buildRenderableTimetable sets isVertical based on orientation', () => { }), fixtureModules, ); - assert.equal(vertical.isVertical, true); + expect(vertical.isVertical).toBe(true); +}); + +test('buildRenderableTimetable uses dark color scheme', () => { + const model = buildRenderableTimetable( + makeExportData({ + settings: { colorScheme: 'DARK_COLOR_SCHEME' }, + theme: { + id: 'monokai', + showTitle: true, + timetableOrientation: 'HORIZONTAL', + }, + }), + fixtureModules, + ); + + expect(model.colorScheme).toBe('DARK_COLOR_SCHEME'); + expect(model.themeId).toBe('monokai'); +}); + +test('buildRenderableTimetable includes module title when showTitle is true and horizontal', () => { + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + theme: { + id: 'eighties', + showTitle: true, + timetableOrientation: 'HORIZONTAL', + }, + timetable: { CS1010: { Lecture: [0] } }, + }), + fixtureModules, + ); + + expect(model.showTitle).toBe(true); + const monday = model.days.find((d) => d.day === 'Monday'); + expect(monday!.rows[0]?.[0]?.displayTitle).toBe('CS1010 Programming Methodology'); +}); + +test('buildRenderableTimetable suppresses showTitle when vertical', () => { + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + theme: { + id: 'eighties', + showTitle: true, + timetableOrientation: 'VERTICAL', + }, + timetable: { CS1010: { Lecture: [0] } }, + }), + fixtureModules, + ); + + expect(model.showTitle).toBe(false); + const monday = model.days.find((d) => d.day === 'Monday'); + expect(monday!.rows[0]?.[0]?.displayTitle).toBe('CS1010'); +}); + +test('buildRenderableTimetable appends (TA) to display title', () => { + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: ['CS1010'], + theme: { + id: 'eighties', + showTitle: true, + timetableOrientation: 'HORIZONTAL', + }, + timetable: { CS1010: { Lecture: [0] } }, + }), + fixtureModules, + ); + + const monday = model.days.find((d) => d.day === 'Monday'); + expect(monday!.rows[0]?.[0]?.displayTitle).toBe('CS1010 Programming Methodology (TA)'); +}); + +test('buildRenderableTimetable assigns colors when colors mapping is empty', () => { + const model = buildRenderableTimetable( + makeExportData({ + colors: {}, + hidden: [], + ta: [], + timetable: { + CS1010: { Lecture: [0] }, + EG1311: { Laboratory: [0] }, + MA1101R: { Lecture: [0] }, + }, + }), + fixtureModules, + ); + + const colors = model.moduleCards.map((m) => m.color); + expect(colors.length).toBe(3); + colors.forEach((c) => expect(c).toMatch(/^#[0-9a-f]{6}$/)); +}); + +test('buildRenderableTimetable uses provided color indices', () => { + const model = buildRenderableTimetable( + makeExportData({ + colors: { CS1010: 0, EG1311: 6, MA1101R: 3 }, + hidden: [], + ta: [], + timetable: { + CS1010: { Lecture: [0] }, + EG1311: { Laboratory: [0] }, + MA1101R: { Lecture: [0] }, + }, + }), + fixtureModules, + ); + + const cs1010 = model.moduleCards.find((m) => m.moduleCode === 'CS1010'); + const eg1311 = model.moduleCards.find((m) => m.moduleCode === 'EG1311'); + const ma1101r = model.moduleCards.find((m) => m.moduleCode === 'MA1101R'); + expect(cs1010!.color).not.toBe(eg1311!.color); + expect(cs1010!.color).not.toBe(ma1101r!.color); +}); + +test('buildRenderableTimetable rewrites E-Learn venues to E-Learning', () => { + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { EG1311: { Laboratory: [0] } }, + }), + fixtureModules, + ); + + const saturday = model.days.find((d) => d.day === 'Saturday'); + expect(saturday!.rows[0]?.[0]?.venue).toBe('E-Learning'); +}); + +test('buildRenderableTimetable preserves normal venue names', () => { + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { CS1010: { Lecture: [0] } }, + }), + fixtureModules, + ); + + const monday = model.days.find((d) => d.day === 'Monday'); + expect(monday!.rows[0]?.[0]?.venue).toBe('LT19'); +}); + +describe('time boundary calculations', () => { + test('expands boundaries for early morning lessons', () => { + const earlyModule: Module[] = [ + { + moduleCode: 'EARLY', + moduleCredit: '4', + semesterData: [ + { + semester: 1, + timetable: [ + { + classNo: '01', + day: 'Monday', + endTime: '0900', + lessonType: 'Lecture', + startTime: '0800', + venue: 'LT1', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Early Module', + }, + ]; + + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { EARLY: { Lecture: [0] } }, + }), + earlyModule, + ); + + expect(model.startingIndex).toBe(8 * 4); + expect(model.timeLabels[0]?.label).toBe('0800'); + }); + + test('expands boundaries for late evening lessons', () => { + const lateModule: Module[] = [ + { + moduleCode: 'LATE', + moduleCredit: '4', + semesterData: [ + { + semester: 1, + timetable: [ + { + classNo: '01', + day: 'Wednesday', + endTime: '2100', + lessonType: 'Lecture', + startTime: '1900', + venue: 'LT2', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Late Module', + }, + ]; + + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { LATE: { Lecture: [0] } }, + }), + lateModule, + ); + + expect(model.endingIndex).toBe(21 * 4); + const lastLabel = model.timeLabels[model.timeLabels.length - 1]; + expect(lastLabel?.label).toBe('2000'); + }); +}); + +describe('week text formatting', () => { + function moduleWithWeeks( + weeks: number[] | { start: string; end: string; weekInterval?: number; weeks?: number[] }, + ): Module[] { + return [ + { + moduleCode: 'WK', + moduleCredit: '4', + semesterData: [ + { + semester: 1, + timetable: [ + { + classNo: '01', + day: 'Monday', + endTime: '1200', + lessonType: 'Lecture', + startTime: '1000', + venue: 'LT1', + weeks, + }, + ], + }, + ], + title: 'Week Test', + }, + ]; + } + + function getWeekText( + weeks: number[] | { start: string; end: string; weekInterval?: number; weeks?: number[] }, + ) { + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { WK: { Lecture: [0] } }, + }), + moduleWithWeeks(weeks), + ); + const monday = model.days.find((d) => d.day === 'Monday'); + return monday!.rows[0]?.[0]?.weekText; + } + + test('returns null for all 13 weeks', () => { + expect(getWeekText([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13])).toBeNull(); + }); + + test('returns "Week N" for a single week', () => { + expect(getWeekText([7])).toBe('Week 7'); + }); + + test('returns "Odd Weeks" for odd weeks', () => { + expect(getWeekText([1, 3, 5, 7, 9, 11, 13])).toBe('Odd Weeks'); + }); + + test('returns "Even Weeks" for even weeks', () => { + expect(getWeekText([2, 4, 6, 8, 10, 12])).toBe('Even Weeks'); + }); + + test('collapses consecutive ranges', () => { + expect(getWeekText([1, 2, 3, 4, 10, 11, 12])).toBe('Weeks 1-4, 10, 11, 12'); + expect(getWeekText([1, 2, 3, 4, 5, 10, 11, 12, 13])).toBe('Weeks 1-5, 10-13'); + }); + + test('lists individual non-consecutive weeks', () => { + expect(getWeekText([1, 3, 5])).toBe('Weeks 1, 3, 5'); + }); + + test('handles WeekRange with weeks array', () => { + expect(getWeekText({ start: '2026-01-13', end: '2026-04-14', weeks: [1, 3, 5] })).toBe( + 'Weeks 1, 3, 5', + ); + }); + + test('handles WeekRange with weekInterval 2', () => { + expect(getWeekText({ start: '2026-01-13', end: '2026-04-14', weekInterval: 2 })).toBe( + 'Odd Weeks', + ); + }); +}); + +describe('module card metadata', () => { + test('includes exam date, duration, and units', () => { + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { CS1010: { Lecture: [0] } }, + }), + fixtureModules, + ); + + const card = model.moduleCards.find((m) => m.moduleCode === 'CS1010'); + expect(card!.metaLine).toContain('Exam:'); + expect(card!.metaLine).toContain('2 hrs'); + expect(card!.metaLine).toContain('4 Units'); + }); + + test('shows "No Exam" for modules without exam date', () => { + const noExamModules: Module[] = [ + { + moduleCode: 'CS3216', + moduleCredit: '5', + semesterData: [ + { + semester: 1, + timetable: [ + { + classNo: '01', + day: 'Monday', + endTime: '1800', + lessonType: 'Lecture', + startTime: '1600', + venue: 'COM1-0212', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Software Product Engineering for Digital Markets', + }, + ]; + + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { CS3216: { Lecture: [0] } }, + }), + noExamModules, + ); + + const card = model.moduleCards.find((m) => m.moduleCode === 'CS3216'); + expect(card!.metaLine).toContain('No Exam'); + expect(card!.metaLine).toContain('5 Units'); + }); + + test('formats 1 unit module correctly', () => { + const oneUnitModule: Module[] = [ + { + moduleCode: 'CFG1002', + moduleCredit: '1', + semesterData: [ + { + semester: 1, + timetable: [ + { + classNo: '01', + day: 'Friday', + endTime: '1200', + lessonType: 'Lecture', + startTime: '1100', + venue: 'LT1', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Career Catalyst', + }, + ]; + + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { CFG1002: { Lecture: [0] } }, + }), + oneUnitModule, + ); + + const card = model.moduleCards.find((m) => m.moduleCode === 'CFG1002'); + expect(card!.metaLine).toContain('1 Unit'); + expect(card!.metaLine).not.toContain('1 Units'); + }); + + test('formats sub-hour exam duration in minutes', () => { + const modules: Module[] = [ + { + moduleCode: 'SHORT', + moduleCredit: '2', + semesterData: [ + { + examDate: '2026-05-01T01:00:00.000Z', + examDuration: 45, + semester: 1, + timetable: [ + { + classNo: '01', + day: 'Monday', + endTime: '1100', + lessonType: 'Lecture', + startTime: '1000', + venue: 'LT1', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Short Exam', + }, + ]; + + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { SHORT: { Lecture: [0] } }, + }), + modules, + ); + + const card = model.moduleCards.find((m) => m.moduleCode === 'SHORT'); + expect(card!.metaLine).toContain('45 mins'); + }); +}); + +test('buildRenderableTimetable handles multiple lesson types per module', () => { + const modules: Module[] = [ + { + moduleCode: 'CS1010S', + moduleCredit: '4', + semesterData: [ + { + examDate: '2026-05-01T01:00:00.000Z', + semester: 2, + timetable: [ + { + classNo: '01', + day: 'Monday', + endTime: '1200', + lessonType: 'Lecture', + startTime: '1000', + venue: 'LT15', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + { + classNo: '03', + day: 'Tuesday', + endTime: '1300', + lessonType: 'Tutorial', + startTime: '1200', + venue: 'COM1-0210', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + { + classNo: '05', + day: 'Friday', + endTime: '1100', + lessonType: 'Recitation', + startTime: '1000', + venue: 'SR1', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Programming Methodology', + }, + ]; + + const model = buildRenderableTimetable( + { + colors: { CS1010S: 0 }, + hidden: [], + semester: 2, + settings: { colorScheme: 'LIGHT_COLOR_SCHEME' }, + ta: ['CS1010S'], + theme: { id: 'ashes', showTitle: true, timetableOrientation: 'VERTICAL' }, + timetable: { CS1010S: { Lecture: [0], Recitation: [2], Tutorial: [1] } }, + }, + modules, + ); + + const allLessons = model.days.flatMap((d) => d.rows.flat()); + expect(allLessons.length).toBe(3); + expect(allLessons.map((l) => l.lessonMeta).sort()).toEqual( + ['LEC [01]', 'REC [05]', 'TUT [03]'].sort(), + ); +}); + +test('buildRenderableTimetable produces correct output for empty timetable', () => { + const model = buildRenderableTimetable( + makeExportData({ hidden: [], ta: [], timetable: {} }), + fixtureModules, + ); + + expect(model.days.length).toBe(5); + expect(model.moduleCards.length).toBe(0); + expect(model.activeUnits).toBe(0); + expect(model.totalUnits).toBe(0); + expect(model.startingIndex).toBe(10 * 4); + expect(model.endingIndex).toBe(18 * 4); +}); + +test('buildRenderableTimetable skips modules not found in module list', () => { + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { + CS1010: { Lecture: [0] }, + GHOST9999: { Lecture: [0] }, + }, + }), + fixtureModules, + ); + + const allLessons = model.days.flatMap((d) => d.rows.flat()); + expect(allLessons.every((l) => l.moduleCode === 'CS1010')).toBe(true); + expect(model.moduleCards.find((m) => m.moduleCode === 'GHOST9999')).toBeUndefined(); +}); + +test('buildRenderableTimetable filters by semester', () => { + const multiSemModule: Module[] = [ + { + moduleCode: 'CP3880', + moduleCredit: '12', + semesterData: [ + { + semester: 1, + timetable: [ + { + classNo: '01', + day: 'Monday', + endTime: '1200', + lessonType: 'Lecture', + startTime: '1000', + venue: 'LT1', + weeks: [1, 2, 3], + }, + ], + }, + { + semester: 3, + timetable: [ + { + classNo: '01', + day: 'Wednesday', + endTime: '1400', + lessonType: 'Lecture', + startTime: '1200', + venue: 'LT5', + weeks: [1, 2, 3, 4, 5, 6], + }, + ], + }, + ], + title: 'Industrial Attachment', + }, + ]; + + const model = buildRenderableTimetable( + { + colors: { CP3880: 2 }, + hidden: [], + semester: 3, + settings: { colorScheme: 'LIGHT_COLOR_SCHEME' }, + ta: [], + theme: { + id: 'paraiso', + showTitle: false, + timetableOrientation: 'HORIZONTAL', + }, + timetable: { CP3880: { Lecture: [0] } }, + }, + multiSemModule, + ); + + const wednesday = model.days.find((d) => d.day === 'Wednesday'); + expect(wednesday).toBeDefined(); + expect(wednesday!.rows[0]?.[0]?.moduleCode).toBe('CP3880'); + expect(model.themeId).toBe('paraiso'); +}); + +test('buildRenderableTimetable generates correct lesson abbreviations', () => { + const modules: Module[] = [ + { + moduleCode: 'HSI1000', + moduleCredit: '4', + semesterData: [ + { + semester: 2, + timetable: [ + { + classNo: '72', + day: 'Monday', + endTime: '1200', + lessonType: 'Workshop', + startTime: '1000', + venue: 'AS7-0101', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + { + classNo: '87', + day: 'Wednesday', + endTime: '1200', + lessonType: 'Workshop', + startTime: '1000', + venue: 'AS7-0102', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + { + classNo: '27', + day: 'Thursday', + endTime: '1200', + lessonType: 'Lecture', + startTime: '1000', + venue: 'LT14', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Understanding the Chinese Community in Southeast Asia', + }, + ]; + + const model = buildRenderableTimetable( + { + colors: { HSI1000: 7 }, + hidden: [], + semester: 2, + settings: { colorScheme: 'DARK_COLOR_SCHEME' }, + ta: [], + theme: { + id: 'tomorrow', + showTitle: false, + timetableOrientation: 'HORIZONTAL', + }, + timetable: { HSI1000: { Lecture: [2], Workshop: [0, 1] } }, + }, + modules, + ); + + const allLessons = model.days.flatMap((d) => d.rows.flat()); + const workshops = allLessons.filter((l) => l.lessonMeta.startsWith('WS')); + const lectures = allLessons.filter((l) => l.lessonMeta.startsWith('LEC')); + expect(workshops.length).toBe(2); + expect(lectures.length).toBe(1); + expect(workshops[0]?.lessonMeta).toBe('WS [72]'); +}); + +describe('activeUnits and totalUnits calculations', () => { + test('TA modules do not count toward activeUnits', () => { + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: ['CS1010', 'MA1101R', 'EG1311'], + timetable: { + CS1010: { Lecture: [0] }, + EG1311: { Laboratory: [0] }, + MA1101R: { Lecture: [0] }, + }, + }), + fixtureModules, + ); + + expect(model.totalUnits).toBe(12); + expect(model.activeUnits).toBe(0); + }); + + test('hidden modules do not count toward activeUnits', () => { + const model = buildRenderableTimetable( + makeExportData({ + hidden: ['CS1010', 'MA1101R', 'EG1311'], + ta: [], + timetable: { + CS1010: { Lecture: [0] }, + EG1311: { Laboratory: [0] }, + MA1101R: { Lecture: [0] }, + }, + }), + fixtureModules, + ); + + expect(model.totalUnits).toBe(12); + expect(model.activeUnits).toBe(0); + }); +}); + +describe('heavy timetable with 5 modules', () => { + const heavyModules: Module[] = [ + { + moduleCode: 'CS3230', + moduleCredit: '4', + semesterData: [ + { + examDate: '2026-05-02T01:00:00.000Z', + semester: 2, + timetable: [ + { + classNo: '11', + day: 'Monday', + endTime: '1200', + lessonType: 'Tutorial', + startTime: '1100', + venue: 'COM1-0201', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + { + classNo: '07', + day: 'Wednesday', + endTime: '1400', + lessonType: 'Lecture', + startTime: '1200', + venue: 'LT19', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Design and Analysis of Algorithms', + }, + { + moduleCode: 'CS3235', + moduleCredit: '4', + semesterData: [ + { + examDate: '2026-05-04T01:00:00.000Z', + semester: 2, + timetable: [ + { + classNo: '01', + day: 'Tuesday', + endTime: '1200', + lessonType: 'Lecture', + startTime: '1000', + venue: 'LT15', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + { + classNo: '02', + day: 'Tuesday', + endTime: '1500', + lessonType: 'Tutorial', + startTime: '1400', + venue: 'COM1-0208', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Computer Security', + }, + { + moduleCode: 'CS2108', + moduleCredit: '4', + semesterData: [ + { + examDate: '2026-05-06T01:00:00.000Z', + semester: 2, + timetable: [ + { + classNo: '02', + day: 'Thursday', + endTime: '1200', + lessonType: 'Lecture', + startTime: '1000', + venue: 'LT17', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + { + classNo: '05', + day: 'Thursday', + endTime: '1400', + lessonType: 'Tutorial', + startTime: '1300', + venue: 'COM1-0210', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Introduction to Media Computing', + }, + { + moduleCode: 'CS2100', + moduleCredit: '4', + semesterData: [ + { + examDate: '2026-05-08T01:00:00.000Z', + examDuration: 120, + semester: 2, + timetable: [ + { + classNo: '01', + day: 'Monday', + endTime: '1400', + lessonType: 'Lecture', + startTime: '1200', + venue: 'LT19', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + { + classNo: '08', + day: 'Friday', + endTime: '1100', + lessonType: 'Tutorial', + startTime: '1000', + venue: 'COM1-0208', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + { + classNo: '03', + day: 'Friday', + endTime: '1300', + lessonType: 'Laboratory', + startTime: '1100', + venue: 'COM1-0113', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Computer Organisation', + }, + { + moduleCode: 'DSA1101', + moduleCredit: '4', + semesterData: [ + { + examDate: '2026-05-10T01:00:00.000Z', + semester: 2, + timetable: [ + { + classNo: '03', + day: 'Wednesday', + endTime: '1100', + lessonType: 'Tutorial', + startTime: '1000', + venue: 'S16-0436', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + { + classNo: '08', + day: 'Thursday', + endTime: '1600', + lessonType: 'Lecture', + startTime: '1400', + venue: 'LT27', + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }, + ], + }, + ], + title: 'Introduction to Data Science', + }, + ]; + + test('builds correct model for heavy CS load (google theme)', () => { + const model = buildRenderableTimetable( + { + colors: { CS2100: 6, CS2108: 2, CS3230: 4, CS3235: 0, DSA1101: 5 }, + hidden: [], + semester: 2, + settings: { colorScheme: 'LIGHT_COLOR_SCHEME' }, + ta: [], + theme: { + id: 'google', + showTitle: false, + timetableOrientation: 'HORIZONTAL', + }, + timetable: { + CS2100: { Laboratory: [2], Lecture: [0], Tutorial: [1] }, + CS2108: { Lecture: [0], Tutorial: [1] }, + CS3230: { Lecture: [1], Tutorial: [0] }, + CS3235: { Lecture: [0], Tutorial: [1] }, + DSA1101: { Lecture: [1], Tutorial: [0] }, + }, + }, + heavyModules, + ); + + expect(model.moduleCards.length).toBe(5); + expect(model.totalUnits).toBe(20); + expect(model.activeUnits).toBe(20); + + const allLessons = model.days.flatMap((d) => d.rows.flat()); + expect(allLessons.length).toBe(11); + + expect(model.days.some((d) => d.day === 'Saturday')).toBe(false); + expect(model.themeId).toBe('google'); + }); }); test('renderSatoriImage returns a PNG buffer for horizontal', async () => { @@ -188,8 +1167,8 @@ test('renderSatoriImage returns a PNG buffer for horizontal', async () => { { width: 900 }, ); - assert.ok(Buffer.isBuffer(png)); - assert.deepEqual(Array.from(png.subarray(0, 8)), [137, 80, 78, 71, 13, 10, 26, 10]); + expect(Buffer.isBuffer(png)).toBe(true); + expect(Array.from(png.subarray(0, 8))).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); }); test('renderSatoriImage returns a PNG buffer for vertical', async () => { @@ -210,6 +1189,30 @@ test('renderSatoriImage returns a PNG buffer for vertical', async () => { { width: 900 }, ); - assert.ok(Buffer.isBuffer(png)); - assert.deepEqual(Array.from(png.subarray(0, 8)), [137, 80, 78, 71, 13, 10, 26, 10]); + expect(Buffer.isBuffer(png)).toBe(true); + expect(Array.from(png.subarray(0, 8))).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); +}); + +test('renderSatoriImage works with dark mode and monokai theme', async () => { + const png = await renderSatoriImage( + makeExportData({ + hidden: [], + settings: { colorScheme: 'DARK_COLOR_SCHEME' }, + ta: ['MA1101R'], + theme: { + id: 'monokai', + showTitle: true, + timetableOrientation: 'HORIZONTAL', + }, + timetable: { + CS1010: { Lecture: [0], Tutorial: [1] }, + MA1101R: { Lecture: [0] }, + }, + }), + fixtureModules, + { width: 900 }, + ); + + expect(Buffer.isBuffer(png)).toBe(true); + expect(Array.from(png.subarray(0, 8))).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); }); diff --git a/export/vitest.config.ts b/export/vitest.config.ts new file mode 100644 index 0000000000..57ca0e7b44 --- /dev/null +++ b/export/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + exclude: ['**/node_modules/**', '**/build/**'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c483c4793..8fac4b2020 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: typescript: specifier: 5.9.3 version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.13.5)(jiti@1.21.7)(jsdom@24.1.3)(sass@1.83.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/browserslist-config-nusmods: devDependencies: @@ -4041,51 +4044,13 @@ packages: balanced-match@2.0.0: resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} - base64-js@0.0.8: - resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} - engines: {node: '>= 0.4'} - balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - bare-events@2.8.2: - resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} - peerDependencies: - bare-abort-controller: '*' - peerDependenciesMeta: - bare-abort-controller: - optional: true - - bare-fs@4.5.5: - resolution: {integrity: sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==} - engines: {bare: '>=1.16.0'} - peerDependencies: - bare-buffer: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - - bare-os@3.7.0: - resolution: {integrity: sha512-64Rcwj8qlnTZU8Ps6JJEdSmxBEUGgI7g8l+lMtsJLl4IsfTcHMTfJ188u2iGV6P6YPRZrtv72B2kjn+hp+Yv3g==} - engines: {bare: '>=1.14.0'} - - bare-path@3.0.0: - resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} - - bare-stream@2.8.0: - resolution: {integrity: sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==} - peerDependencies: - bare-buffer: '*' - bare-events: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - bare-events: - optional: true - - bare-url@2.3.2: - resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -10785,9 +10750,6 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@3.23.8: - resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -14305,39 +14267,6 @@ snapshots: base64-js@0.0.8: {} - bare-events@2.8.2: {} - - bare-fs@4.5.5: - dependencies: - bare-events: 2.8.2 - bare-path: 3.0.0 - bare-stream: 2.8.0(bare-events@2.8.2) - bare-url: 2.3.2 - fast-fifo: 1.3.2 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - - bare-os@3.7.0: {} - - bare-path@3.0.0: - dependencies: - bare-os: 3.7.0 - - bare-stream@2.8.0(bare-events@2.8.2): - dependencies: - streamx: 2.23.0 - teex: 1.0.1 - optionalDependencies: - bare-events: 2.8.2 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - - bare-url@2.3.2: - dependencies: - bare-path: 3.0.0 - base64-js@1.5.1: {} base@0.11.2: @@ -15851,30 +15780,6 @@ snapshots: escape-string-regexp@5.0.0: {} - escodegen@2.1.0: - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - - eslint-config-airbnb-base@14.2.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.9.3))(eslint@7.32.0))(eslint@7.32.0): - dependencies: - confusing-browser-globals: 1.0.11 - eslint: 7.32.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.9.3))(eslint@7.32.0) - object.assign: 4.1.7 - object.entries: 1.1.9 - - eslint-config-airbnb-base@14.2.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.9.3))(eslint@7.32.0))(eslint@7.32.0): - dependencies: - confusing-browser-globals: 1.0.11 - eslint: 7.32.0 - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.9.3))(eslint@7.32.0) - object.assign: 4.1.7 - object.entries: 1.1.9 - eslint-config-airbnb-base@14.2.1(eslint-plugin-import@2.31.0)(eslint@7.32.0): dependencies: confusing-browser-globals: 1.0.11 @@ -22291,8 +22196,6 @@ snapshots: dependencies: zod: 3.25.76 - zod@3.23.8: {} - zod@3.25.76: {} zwitch@1.0.5: {} From 283ad593a0bab1738772b07642f6dd94b768c4bf Mon Sep 17 00:00:00 2001 From: Daren Tan Date: Sat, 7 Mar 2026 23:39:42 +0800 Subject: [PATCH 4/5] testing: add performance benchmark --- export/.gitignore | 1 + export/utils/endpoint-performance-test.sh | 286 ++++++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100755 export/utils/endpoint-performance-test.sh diff --git a/export/.gitignore b/export/.gitignore index e985853ed8..fcf5ae6914 100644 --- a/export/.gitignore +++ b/export/.gitignore @@ -1 +1,2 @@ .vercel +test-output diff --git a/export/utils/endpoint-performance-test.sh b/export/utils/endpoint-performance-test.sh new file mode 100755 index 0000000000..ca608344be --- /dev/null +++ b/export/utils/endpoint-performance-test.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash +set -euo pipefail + +OLD_BASE="https://export.nusmods.com/api/export" +# NEW_BASE="http://localhost:3000/api/export" +NEW_BASE="https://nusmods-export.vercel.app/api/export" + +DELAY=1 # 1 second between requests + +# 10 different realistic payloads +PAYLOADS=( + # 1: Typical CS student, semester 1, light mode, eighties theme + '{"semester":1,"timetable":{"CS1010":{"Lecture":[0],"Tutorial":[3]},"MA1521":{"Lecture":[1],"Tutorial":[5]},"GER1000":{"Seminar":[2]},"CS1231S":{"Lecture":[0],"Tutorial":[7]},"IS1108":{"Lecture":[0],"Tutorial":[1]}},"colors":{"CS1010":0,"MA1521":1,"GER1000":2,"CS1231S":3,"IS1108":4},"hidden":[],"ta":[],"theme":{"id":"eighties","timetableOrientation":"HORIZONTAL","showTitle":false},"settings":{"colorScheme":"LIGHT_COLOR_SCHEME"}}' + + # 2: Engineering student, semester 2, dark mode, monokai theme + '{"semester":2,"timetable":{"EG1311":{"Laboratory":[19],"Lecture":[3]},"CS2040":{"Lecture":[1],"Tutorial":[4]},"MA1101R":{"Lecture":[0],"Tutorial":[2]}},"colors":{"EG1311":6,"CS2040":5,"MA1101R":3},"hidden":[],"ta":["EG1311"],"theme":{"id":"monokai","timetableOrientation":"HORIZONTAL","showTitle":true},"settings":{"colorScheme":"DARK_COLOR_SCHEME"}}' + + # 3: Business student, semester 1, ocean theme, vertical + '{"semester":1,"timetable":{"ACC1006":{"Lecture":[0],"Tutorial":[2]},"ACC2002":{"Lecture":[1],"Tutorial":[3]},"BFS1001":{"Lecture":[0]}},"colors":{"ACC1006":0,"ACC2002":1,"BFS1001":7},"hidden":[],"ta":[],"theme":{"id":"ocean","timetableOrientation":"VERTICAL","showTitle":false},"settings":{"colorScheme":"LIGHT_COLOR_SCHEME"}}' + + # 4: Heavy CS load, semester 2, google theme + '{"semester":2,"timetable":{"CS3230":{"Tutorial":[11],"Lecture":[7]},"CS3235":{"Lecture":[0],"Tutorial":[1]},"CS2108":{"Lecture":[2],"Tutorial":[5]},"CS2100":{"Lecture":[0],"Tutorial":[8],"Laboratory":[3]},"DSA1101":{"Tutorial":[3],"Lecture":[8]}},"colors":{"CS3230":4,"CS3235":0,"CS2108":2,"CS2100":6,"DSA1101":5},"hidden":[],"ta":[],"theme":{"id":"google","timetableOrientation":"HORIZONTAL","showTitle":false},"settings":{"colorScheme":"LIGHT_COLOR_SCHEME"}}' + + # 5: Single module, semester 1, chalk theme + '{"semester":1,"timetable":{"CS3216":{"Lecture":[0]}},"colors":{"CS3216":3},"hidden":[],"ta":[],"theme":{"id":"chalk","timetableOrientation":"HORIZONTAL","showTitle":true},"settings":{"colorScheme":"LIGHT_COLOR_SCHEME"}}' + + # 6: Mixed faculties, semester 2, tomorrow theme, dark mode + '{"semester":2,"timetable":{"HSI1000":{"Workshop":[72,87],"Lecture":[27]},"PC1222":{"Lecture":[0],"Tutorial":[2]},"GES1021":{"Lecture":[1]}},"colors":{"HSI1000":7,"PC1222":1,"GES1021":4},"hidden":["GES1021"],"ta":[],"theme":{"id":"tomorrow","timetableOrientation":"HORIZONTAL","showTitle":false},"settings":{"colorScheme":"DARK_COLOR_SCHEME"}}' + + # 7: CS internship + light load, semester 3 (special term), paraiso theme + '{"semester":3,"timetable":{"CP3880":{"Lecture":[0]},"CS4243":{"Lecture":[1],"Tutorial":[0]}},"colors":{"CP3880":2,"CS4243":5},"hidden":[],"ta":[],"theme":{"id":"paraiso","timetableOrientation":"HORIZONTAL","showTitle":false},"settings":{"colorScheme":"LIGHT_COLOR_SCHEME"}}' + + # 8: Full example with TA + hidden, semester 2, ashes theme + '{"semester":2,"timetable":{"CS1010S":{"Lecture":[0],"Tutorial":[1],"Recitation":[2]},"MA1521":{"Lecture":[0],"Tutorial":[3]},"CS2040":{"Lecture":[1],"Tutorial":[6]}},"colors":{"CS1010S":0,"MA1521":4,"CS2040":7},"hidden":["MA1521"],"ta":["CS1010S"],"theme":{"id":"ashes","timetableOrientation":"VERTICAL","showTitle":true},"settings":{"colorScheme":"LIGHT_COLOR_SCHEME"}}' + + # 9: Semester 1, railscasts theme, show title + '{"semester":1,"timetable":{"CS1010A":{"Lecture":[0],"Tutorial":[2]},"CS1231S":{"Lecture":[0],"Tutorial":[4]},"MA1101R":{"Lecture":[0],"Tutorial":[1]},"GER1000":{"Seminar":[0]}},"colors":{"CS1010A":0,"CS1231S":1,"MA1101R":6,"GER1000":3},"hidden":[],"ta":[],"theme":{"id":"railscasts","timetableOrientation":"HORIZONTAL","showTitle":true},"settings":{"colorScheme":"LIGHT_COLOR_SCHEME"}}' + + # 10: Semester 2, twilight theme, dark mode, 2 modules only + '{"semester":2,"timetable":{"AC5010":{"Lecture":[0]},"CS3216":{"Lecture":[0]}},"colors":{"AC5010":1,"CS3216":5},"hidden":[],"ta":[],"theme":{"id":"twilight","timetableOrientation":"HORIZONTAL","showTitle":false},"settings":{"colorScheme":"DARK_COLOR_SCHEME"}}' +) + +ENDPOINTS=("pdf" "image") + +OUTDIR="test-output" +rm -rf "$OUTDIR" +mkdir -p "$OUTDIR" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +DIM='\033[2m' +RESET='\033[0m' + +declare -a RESULTS=() + +run_test() { + local test_num=$1 + local base_label=$2 + local base_url=$3 + local endpoint=$4 + local payload=$5 + local extra_qs="" + + if [[ "$endpoint" == "image" ]]; then + extra_qs="&pixelRatio=2" + fi + + local encoded + encoded=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$payload") + local url="${base_url}/${endpoint}?data=${encoded}${extra_qs}" + + local start_ms + start_ms=$(python3 -c "import time; print(int(time.time()*1000))") + + local label_lower + label_lower=$(echo "$base_label" | tr '[:upper:]' '[:lower:]') + local ext="png" + if [[ "$endpoint" == "pdf" ]]; then ext="pdf"; fi + local outfile="${OUTDIR}/${test_num}-${label_lower}-${endpoint}.${ext}" + + local http_code content_type size_bytes + + http_code=$(curl -s -o "$outfile" -w "%{http_code}" --max-time 30 "$url" 2>/dev/null) || http_code="TIMEOUT" + + local end_ms + end_ms=$(python3 -c "import time; print(int(time.time()*1000))") + local elapsed_ms=$(( end_ms - start_ms )) + + if [[ "$http_code" == "TIMEOUT" ]]; then + size_bytes=0 + content_type="N/A" + rm -f "$outfile" + else + size_bytes=$(wc -c < "$outfile" | tr -d ' ') + content_type=$(file -b --mime-type "$outfile" 2>/dev/null || echo "unknown") + if [[ "$http_code" != "200" ]]; then + mv "$outfile" "${outfile}.error.txt" 2>/dev/null || true + fi + fi + + local status_icon + if [[ "$http_code" == "200" ]]; then + status_icon="${GREEN}PASS${RESET}" + elif [[ "$http_code" == "TIMEOUT" ]]; then + status_icon="${RED}TOUT${RESET}" + else + status_icon="${RED}FAIL${RESET}" + fi + + local size_display + if (( size_bytes > 1048576 )); then + size_display="$(echo "scale=1; $size_bytes / 1048576" | bc)MB" + elif (( size_bytes > 1024 )); then + size_display="$(echo "scale=1; $size_bytes / 1024" | bc)KB" + else + size_display="${size_bytes}B" + fi + + printf " %-4s │ %-4s │ %-5s │ %5s │ %8s │ %-24s │ %s\n" \ + "$test_num" "$base_label" "$endpoint" "${http_code}" "${elapsed_ms}ms" "$content_type" "$size_display" + + RESULTS+=("${test_num}|${base_label}|${endpoint}|${http_code}|${elapsed_ms}|${content_type}|${size_bytes}") +} + +echo "" +echo -e "${BOLD}═══════════════════════════════════════════════════════════════════════════════════${RESET}" +echo -e "${BOLD} NUSMods Export API Load Test${RESET}" +echo -e "${BOLD}═══════════════════════════════════════════════════════════════════════════════════${RESET}" +echo -e " ${DIM}OLD: ${OLD_BASE}${RESET}" +echo -e " ${DIM}NEW: ${NEW_BASE}${RESET}" +echo -e " ${DIM}10 payloads × 2 endpoints × 2 hosts = 40 requests, ${DELAY}s apart${RESET}" +echo -e " ${DIM}Output: ${OUTDIR}/ (e.g. 1-old.pdf, 1-new.png)${RESET}" +echo "" + +echo -e "${BOLD} # │ Host │ Type │ HTTP │ Latency │ Content-Type │ Size${RESET}" +echo " ─────┼──────┼───────┼───────┼──────────┼──────────────────────────┼─────────" + +test_counter=0 + +for endpoint in "${ENDPOINTS[@]}"; do + ep_upper=$(echo "$endpoint" | tr '[:lower:]' '[:upper:]') + echo -e " ${CYAN}--- ${ep_upper} tests ---${RESET}" + for i in "${!PAYLOADS[@]}"; do + idx=$((i + 1)) + + # Old host + test_counter=$((test_counter + 1)) + run_test "$idx" "OLD" "$OLD_BASE" "$endpoint" "${PAYLOADS[$i]}" + sleep "$DELAY" + + # New host + test_counter=$((test_counter + 1)) + run_test "$idx" "NEW" "$NEW_BASE" "$endpoint" "${PAYLOADS[$i]}" + + if (( i < ${#PAYLOADS[@]} - 1 )) || [[ "$endpoint" == "pdf" ]]; then + sleep "$DELAY" + fi + done +done + +echo " ─────┴──────┴───────┴───────┴──────────┴──────────────────────────┴─────────" +echo "" + +# Summary +old_pass=0; old_fail=0; old_total_ms=0; old_count=0 +new_pass=0; new_fail=0; new_total_ms=0; new_count=0 +old_pdf_ms=0; old_pdf_n=0; new_pdf_ms=0; new_pdf_n=0 +old_img_ms=0; old_img_n=0; new_img_ms=0; new_img_n=0 +old_min=999999; old_max=0; new_min=999999; new_max=0 +old_pdf_min=999999; old_pdf_max=0; new_pdf_min=999999; new_pdf_max=0 +old_img_min=999999; old_img_max=0; new_img_min=999999; new_img_max=0 + +for r in "${RESULTS[@]}"; do + IFS='|' read -r _num host ep code ms _ct _sz <<< "$r" + if [[ "$host" == "OLD" ]]; then + old_count=$((old_count + 1)) + old_total_ms=$((old_total_ms + ms)) + (( ms < old_min )) && old_min=$ms + (( ms > old_max )) && old_max=$ms + if [[ "$code" == "200" ]]; then old_pass=$((old_pass + 1)); else old_fail=$((old_fail + 1)); fi + if [[ "$ep" == "pdf" ]]; then + old_pdf_ms=$((old_pdf_ms + ms)); old_pdf_n=$((old_pdf_n + 1)) + (( ms < old_pdf_min )) && old_pdf_min=$ms + (( ms > old_pdf_max )) && old_pdf_max=$ms + fi + if [[ "$ep" == "image" ]]; then + old_img_ms=$((old_img_ms + ms)); old_img_n=$((old_img_n + 1)) + (( ms < old_img_min )) && old_img_min=$ms + (( ms > old_img_max )) && old_img_max=$ms + fi + else + new_count=$((new_count + 1)) + new_total_ms=$((new_total_ms + ms)) + (( ms < new_min )) && new_min=$ms + (( ms > new_max )) && new_max=$ms + if [[ "$code" == "200" ]]; then new_pass=$((new_pass + 1)); else new_fail=$((new_fail + 1)); fi + if [[ "$ep" == "pdf" ]]; then + new_pdf_ms=$((new_pdf_ms + ms)); new_pdf_n=$((new_pdf_n + 1)) + (( ms < new_pdf_min )) && new_pdf_min=$ms + (( ms > new_pdf_max )) && new_pdf_max=$ms + fi + if [[ "$ep" == "image" ]]; then + new_img_ms=$((new_img_ms + ms)); new_img_n=$((new_img_n + 1)) + (( ms < new_img_min )) && new_img_min=$ms + (( ms > new_img_max )) && new_img_max=$ms + fi + fi +done + +old_avg=$( (( old_count > 0 )) && echo $((old_total_ms / old_count)) || echo 0 ) +new_avg=$( (( new_count > 0 )) && echo $((new_total_ms / new_count)) || echo 0 ) +old_pdf_avg=$( (( old_pdf_n > 0 )) && echo $((old_pdf_ms / old_pdf_n)) || echo 0 ) +new_pdf_avg=$( (( new_pdf_n > 0 )) && echo $((new_pdf_ms / new_pdf_n)) || echo 0 ) +old_img_avg=$( (( old_img_n > 0 )) && echo $((old_img_ms / old_img_n)) || echo 0 ) +new_img_avg=$( (( new_img_n > 0 )) && echo $((new_img_ms / new_img_n)) || echo 0 ) + +# Guard min values in case no results +(( old_min == 999999 )) && old_min=0 +(( new_min == 999999 )) && new_min=0 +(( old_pdf_min == 999999 )) && old_pdf_min=0 +(( new_pdf_min == 999999 )) && new_pdf_min=0 +(( old_img_min == 999999 )) && old_img_min=0 +(( new_img_min == 999999 )) && new_img_min=0 + +W=30 # label width +C=14 # column width + +echo -e "${BOLD}══════════════════════════════════════════════════════════════════${RESET}" +echo -e "${BOLD} SUMMARY${RESET}" +echo -e "${BOLD}══════════════════════════════════════════════════════════════════${RESET}" +echo "" +printf " %-${W}s │ ${YELLOW}%-${C}s${RESET} │ ${CYAN}%-${C}s${RESET}\n" "" "OLD HOST" "NEW HOST" +printf " %${W}s─┼─%${C}s─┼─%${C}s\n" "" "" "" | tr ' ' '─' +printf " %-${W}s │ %-${C}s │ %-${C}s\n" "Pass / Total" "${old_pass} / ${old_count}" "${new_pass} / ${new_count}" +printf " %-${W}s │ %-${C}s │ %-${C}s\n" "Fail count" "${old_fail}" "${new_fail}" +printf " %${W}s─┼─%${C}s─┼─%${C}s\n" "" "" "" | tr ' ' '─' +printf " %-${W}s │ %-${C}s │ %-${C}s\n" "Avg latency (all)" "${old_avg}ms" "${new_avg}ms" +printf " %-${W}s │ %-${C}s │ %-${C}s\n" "Min latency (all)" "${old_min}ms" "${new_min}ms" +printf " %-${W}s │ %-${C}s │ %-${C}s\n" "Max latency (all)" "${old_max}ms" "${new_max}ms" +printf " %${W}s─┼─%${C}s─┼─%${C}s\n" "" "" "" | tr ' ' '─' +printf " %-${W}s │ %-${C}s │ %-${C}s\n" "Avg latency (PDF)" "${old_pdf_avg}ms" "${new_pdf_avg}ms" +printf " %-${W}s │ %-${C}s │ %-${C}s\n" "Min latency (PDF)" "${old_pdf_min}ms" "${new_pdf_min}ms" +printf " %-${W}s │ %-${C}s │ %-${C}s\n" "Max latency (PDF)" "${old_pdf_max}ms" "${new_pdf_max}ms" +printf " %${W}s─┼─%${C}s─┼─%${C}s\n" "" "" "" | tr ' ' '─' +printf " %-${W}s │ %-${C}s │ %-${C}s\n" "Avg latency (Image)" "${old_img_avg}ms" "${new_img_avg}ms" +printf " %-${W}s │ %-${C}s │ %-${C}s\n" "Min latency (Image)" "${old_img_min}ms" "${new_img_min}ms" +printf " %-${W}s │ %-${C}s │ %-${C}s\n" "Max latency (Image)" "${old_img_max}ms" "${new_img_max}ms" +printf " %${W}s─┴─%${C}s─┴─%${C}s\n" "" "" "" | tr ' ' '─' + +if (( new_avg > 0 && old_avg > 0 )); then + if (( new_avg < old_avg )); then + pct=$(( (old_avg - new_avg) * 100 / old_avg )) + echo -e "\n ${GREEN}New host is ~${pct}% faster overall${RESET}" + elif (( new_avg > old_avg )); then + pct=$(( (new_avg - old_avg) * 100 / old_avg )) + echo -e "\n ${RED}New host is ~${pct}% slower overall${RESET}" + else + echo -e "\n ${YELLOW}Both hosts have similar latency${RESET}" + fi +fi + +CSVFILE="${OUTDIR}/results.csv" +{ + echo "test_num,host,endpoint,http_status,latency_ms,content_type,size_bytes,saved_file" + for r in "${RESULTS[@]}"; do + IFS='|' read -r num host ep code ms ct sz <<< "$r" + local_lower=$(echo "$host" | tr '[:upper:]' '[:lower:]') + fext="png"; [[ "$ep" == "pdf" ]] && fext="pdf" + fname="${num}-${local_lower}.${fext}" + if [[ "$code" != "200" ]]; then + if [[ "$code" == "TIMEOUT" ]]; then fname=""; else fname="${fname}.error.txt"; fi + fi + echo "${num},${host},${ep},${code},${ms},${ct},${sz},${fname}" + done +} > "$CSVFILE" + +echo "" +echo -e "${DIM} Done. ${test_counter} requests completed.${RESET}" +echo -e "${DIM} Files saved to: $(pwd)/${OUTDIR}/${RESET}" +echo -e "${DIM} CSV results: $(pwd)/${CSVFILE}${RESET}" +echo "" +ls -lh "$OUTDIR"/ 2>/dev/null || true +echo "" From 0bf7878478511498034a16ef9edab1bea819439f Mon Sep 17 00:00:00 2001 From: Daren Tan Date: Sun, 8 Mar 2026 11:10:05 +0800 Subject: [PATCH 5/5] fix(export): latest react, add vite test to circleci, comment on duplicated types --- .circleci/config.yml | 1 + export/package.json | 6 ++-- pnpm-lock.yaml | 63 +++++++++++++++++++++-------------- website/src/types/global.d.ts | 3 ++ 4 files changed, 44 insertions(+), 29 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0604b55c16..e5d373e21f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -66,6 +66,7 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm typecheck + - run: pnpm test - run: name: Build export service environment: diff --git a/export/package.json b/export/package.json index 4e27dc50e5..1217ecd742 100644 --- a/export/package.json +++ b/export/package.json @@ -16,7 +16,6 @@ "dev": "run-p nodemon watch", "check": "run-s lint typecheck", "deploy": "rsync -avu --delete-after . ../../nusmods-export && pm2 restart ecosystem.config.js", - "devtools": "cross-env DEVTOOLS=1 pnpm dev", "lint": "oxlint -c oxlint.config.mjs src", "typecheck": "tsc --noEmit" }, @@ -36,7 +35,7 @@ "nodemon": "2.0.22", "pdfkit": "^0.17.2", "pug": "3.0.3", - "react": "18.3.1", + "react": "^19.0.0", "satori": "^0.25.0" }, "devDependencies": { @@ -50,7 +49,7 @@ "@types/node": "22.13.5", "@types/pdfkit": "^0.17.5", "@types/pug": "2.0.10", - "@types/react": "18.3.18", + "@types/react": "^19.0.0", "@vercel/node": "1.15.4", "dotenv": "8.6.0", "eslint-plugin-no-only-tests": "^3.3.0", @@ -58,7 +57,6 @@ "eslint-plugin-unused-imports": "^4.4.1", "npm-run-all": "4.1.5", "oxlint": "^1.51.0", - "tsx": "^4.21.0", "typescript": "5.9.3", "vitest": "^4.0.18" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fac4b2020..712d8e254b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,7 +59,7 @@ importers: version: 10.1.1 koa-views: specifier: 6.3.1 - version: 6.3.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@18.3.1) + version: 6.3.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@19.2.4) lodash: specifier: 4.17.23 version: 4.17.23 @@ -73,8 +73,8 @@ importers: specifier: 3.0.3 version: 3.0.3 react: - specifier: 18.3.1 - version: 18.3.1 + specifier: ^19.0.0 + version: 19.2.4 satori: specifier: ^0.25.0 version: 0.25.0 @@ -110,8 +110,8 @@ importers: specifier: 2.0.10 version: 2.0.10 '@types/react': - specifier: 18.3.18 - version: 18.3.18 + specifier: ^19.0.0 + version: 19.2.14 '@vercel/node': specifier: 1.15.4 version: 1.15.4 @@ -133,9 +133,6 @@ importers: oxlint: specifier: ^1.51.0 version: 1.51.0 - tsx: - specifier: ^4.21.0 - version: 4.21.0 typescript: specifier: 5.9.3 version: 5.9.3 @@ -3269,6 +3266,9 @@ packages: '@types/react@18.3.18': resolution: {integrity: sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==} + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/redux-mock-store@1.5.0': resolution: {integrity: sha512-jcscBazm6j05Hs6xYCca6psTUBbFT2wqMxT7wZEHAYFxHB/I8jYk7d5msrHUlDiSL02HdTqTmkK2oIV8i3C8DA==} @@ -8853,6 +8853,10 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + read-package-json-fast@4.0.0: resolution: {integrity: sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -13150,9 +13154,9 @@ snapshots: '@types/history@4.7.11': {} - '@types/hoist-non-react-statics@3.3.7(@types/react@18.3.18)': + '@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14)': dependencies: - '@types/react': 18.3.18 + '@types/react': 19.2.14 hoist-non-react-statics: 3.3.2 '@types/html-minifier-terser@6.1.0': {} @@ -13260,7 +13264,7 @@ snapshots: '@types/react-beautiful-dnd@13.1.8': dependencies: - '@types/react': 18.3.18 + '@types/react': 19.2.14 '@types/react-dom@18.3.5(@types/react@18.3.18)': dependencies: @@ -13268,42 +13272,42 @@ snapshots: '@types/react-helmet@6.1.11': dependencies: - '@types/react': 18.3.18 + '@types/react': 19.2.14 '@types/react-kawaii@0.17.3': dependencies: - '@types/react': 18.3.18 + '@types/react': 19.2.14 '@types/react-loadable@5.5.11': dependencies: - '@types/react': 18.3.18 + '@types/react': 19.2.14 '@types/webpack': 4.41.40 '@types/react-modal@3.16.3': dependencies: - '@types/react': 18.3.18 + '@types/react': 19.2.14 '@types/react-redux@7.1.34': dependencies: - '@types/hoist-non-react-statics': 3.3.7(@types/react@18.3.18) - '@types/react': 18.3.18 + '@types/hoist-non-react-statics': 3.3.7(@types/react@19.2.14) + '@types/react': 19.2.14 hoist-non-react-statics: 3.3.2 redux: 4.2.1 '@types/react-router-dom@5.3.3': dependencies: '@types/history': 4.7.11 - '@types/react': 18.3.18 + '@types/react': 19.2.14 '@types/react-router': 5.1.20 '@types/react-router@5.1.20': dependencies: '@types/history': 4.7.11 - '@types/react': 18.3.18 + '@types/react': 19.2.14 '@types/react-scrollspy@3.3.9': dependencies: - '@types/react': 18.3.18 + '@types/react': 19.2.14 '@types/react@16.14.69': dependencies: @@ -13316,6 +13320,10 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + '@types/redux-mock-store@1.5.0': dependencies: redux: 4.2.1 @@ -14817,14 +14825,14 @@ snapshots: connect-history-api-fallback@2.0.0: {} - consolidate@0.15.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@18.3.1): + consolidate@0.15.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@19.2.4): dependencies: bluebird: 3.7.2 optionalDependencies: ejs: 3.1.10 lodash: 4.17.23 pug: 3.0.3 - react: 18.3.1 + react: 19.2.4 constantinople@4.0.1: dependencies: @@ -16460,6 +16468,7 @@ snapshots: get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 + optional: true get-value@2.0.6: {} @@ -17546,9 +17555,9 @@ snapshots: transitivePeerDependencies: - supports-color - koa-views@6.3.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@18.3.1): + koa-views@6.3.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@19.2.4): dependencies: - consolidate: 0.15.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@18.3.1) + consolidate: 0.15.1(ejs@3.1.10)(lodash@4.17.23)(pug@3.0.3)(react@19.2.4) debug: 4.4.3 get-paths: 0.0.7 koa-send: 5.0.1 @@ -19973,6 +19982,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + react@19.2.4: {} + read-package-json-fast@4.0.0: dependencies: json-parse-even-better-errors: 4.0.0 @@ -20239,7 +20250,8 @@ snapshots: resolve-pathname@3.0.0: {} - resolve-pkg-maps@1.0.0: {} + resolve-pkg-maps@1.0.0: + optional: true resolve-url@0.2.1: {} @@ -21384,6 +21396,7 @@ snapshots: get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 + optional: true type-check@0.4.0: dependencies: diff --git a/website/src/types/global.d.ts b/website/src/types/global.d.ts index 49868f2ef7..621883b190 100644 --- a/website/src/types/global.d.ts +++ b/website/src/types/global.d.ts @@ -16,6 +16,9 @@ * - `test`: we are in a test environment, e.g. a jest test run. * - `development`: all other situations. */ + +// NOTE: These globals are also duplicated in export/src/types.ts + declare const NUSMODS_ENV: 'development' | 'production' | 'staging' | 'preview' | 'test'; declare const DATA_API_BASE_URL: string | undefined;