diff --git a/.circleci/config.yml b/.circleci/config.yml index 68ae7ef944..b6971ab7d1 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -83,6 +83,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/MAINTENANCE.md b/MAINTENANCE.md index 53a9054d0b..1c277f23da 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -39,16 +39,19 @@ Reference PRs: [PR #3286](https://github.com/nusmodifications/nusmods/pull/3286) ## CPEx ### Before CPEx Testing - - [ ] Update `ACADEMIC_YEAR` in `scrapers/cpex-scraper/src/index.ts`. It should be for the **next** semester - - [ ] Create PR and merge to production - - [ ] Run scraper + +- [ ] Update `ACADEMIC_YEAR` in `scrapers/cpex-scraper/src/index.ts`. It should be for the **next** semester +- [ ] Create PR and merge to production +- [ ] Run scraper ### During CPEx Testing + For `cpex-staging` deployment - - [ ] Update `MPE_SEMESTER` in `website/src/views/mpe/constants.ts` to be the semester you're configuring CPEx for (usually the next semester) - - [ ] Update dates in the ModReg schedule in `website/src/data/modreg-schedule.json` - - [ ] Enable the `enableCPExforProd` and `showCPExTab` flags in `website/src/featureFlags.ts` - - [ ] Push onto `cpex-staging` branch (Ensure synced with `master` branch first), then visit https://cpex-staging.nusmods.com/cpex and verify that NUS authentication is working + +- [ ] Update `MPE_SEMESTER` in `website/src/views/mpe/constants.ts` to be the semester you're configuring CPEx for (usually the next semester) +- [ ] Update dates in the ModReg schedule in `website/src/data/modreg-schedule.json` +- [ ] Enable the `enableCPExforProd` and `showCPExTab` flags in `website/src/featureFlags.ts` +- [ ] Push onto `cpex-staging` branch (Ensure synced with `master` branch first), then visit https://cpex-staging.nusmods.com/cpex and verify that NUS authentication is working ```bash git checkout master @@ -59,10 +62,12 @@ git push ``` ### During CPEx + - [ ] Merge `cpex-staging` into `master` via PR - [ ] Deploy latest `master` to `production` ### After CPEx + - [ ] Disable the `enableCPExforProd` and `showCPExTab` flags in `website/src/featureFlags.ts` - [ ] Merge into `master` - [ ] Deploy latest `master` to `production` diff --git a/export/.gitignore b/export/.gitignore new file mode 100644 index 0000000000..fcf5ae6914 --- /dev/null +++ b/export/.gitignore @@ -0,0 +1,2 @@ +.vercel +test-output diff --git a/export/README.md b/export/README.md index 8145257161..3208f5b7d7 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,17 +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. - -## Testing - -After running locally, you can test with the following URLs on `localhost:3000`: - -- [Example image export](http://localhost:3000/api/export/image?data=%7B%22semester%22%3A2%2C%22timetable%22%3A%7B%22CS3217%22%3A%7B%22Lecture%22%3A%5B0%2C3%5D%2C%22Tutorial%22%3A%5B1%5D%7D%2C%22CS1010%22%3A%7B%22Tutorial%22%3A%5B4%5D%2C%22Sectional%20Teaching%22%3A%5B3%5D%7D%2C%22CS1231S%22%3A%7B%22Tutorial%22%3A%5B2%5D%2C%22Lecture%22%3A%5B6%2C7%5D%7D%2C%22CS4218%22%3A%7B%22Lecture%22%3A%5B0%5D%2C%22Laboratory%22%3A%5B4%5D%7D%7D%2C%22colors%22%3A%7B%22CS3217%22%3A4%2C%22CS1010%22%3A6%2C%22CS1231S%22%3A5%2C%22CS4218%22%3A0%7D%2C%22hidden%22%3A%5B%5D%2C%22ta%22%3A%5B%5D%2C%22theme%22%3A%7B%22id%22%3A%22mocha%22%2C%22timetableOrientation%22%3A%22HORIZONTAL%22%2C%22showTitle%22%3Afalse%2C%22_persist%22%3A%7B%22version%22%3A-1%2C%22rehydrated%22%3Atrue%7D%7D%2C%22settings%22%3A%7B%22colorScheme%22%3A%22LIGHT_COLOR_SCHEME%22%7D%7D&pixelRatio=2) -- [Example PDF export](http://localhost:3000/api/export/pdf?data=%7B%22semester%22%3A2%2C%22timetable%22%3A%7B%22CS3217%22%3A%7B%22Lecture%22%3A%5B0%2C3%5D%2C%22Tutorial%22%3A%5B1%5D%7D%2C%22CS1010%22%3A%7B%22Tutorial%22%3A%5B4%5D%2C%22Sectional%20Teaching%22%3A%5B3%5D%7D%2C%22CS1231S%22%3A%7B%22Tutorial%22%3A%5B2%5D%2C%22Lecture%22%3A%5B6%2C7%5D%7D%2C%22CS4218%22%3A%7B%22Lecture%22%3A%5B0%5D%2C%22Laboratory%22%3A%5B4%5D%7D%7D%2C%22colors%22%3A%7B%22CS3217%22%3A4%2C%22CS1010%22%3A6%2C%22CS1231S%22%3A5%2C%22CS4218%22%3A0%7D%2C%22hidden%22%3A%5B%5D%2C%22ta%22%3A%5B%5D%2C%22theme%22%3A%7B%22id%22%3A%22mocha%22%2C%22timetableOrientation%22%3A%22HORIZONTAL%22%2C%22showTitle%22%3Afalse%2C%22_persist%22%3A%7B%22version%22%3A-1%2C%22rehydrated%22%3Atrue%7D%7D%2C%22settings%22%3A%7B%22colorScheme%22%3A%22LIGHT_COLOR_SCHEME%22%7D%7D&pixelRatio=2) - ## Deployment The export service is deployed on Vercel for production. @@ -108,5 +93,3 @@ $ pnpm build # Start the app $ 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 d81d899c80..8582500c49 100644 --- a/export/package.json +++ b/export/package.json @@ -10,16 +10,20 @@ "start": "node -r dotenv/config ./build/src/index.js", "build": "tsc", "nodemon": "nodemon --no-update-notifier -r dotenv/config ./build/src/index.js", + "test": "vitest run", + "test:watch": "vitest", "watch": "tsc --watch", "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", - "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": "^143.0.4", "axios": "0.30.0", "bunyan": "1.8.15", "fs-extra": "9.1.0", @@ -31,8 +35,10 @@ "koa-views": "6.3.1", "lodash": "4.17.23", "nodemon": "2.0.22", + "pdfkit": "^0.17.2", "pug": "3.0.3", - "puppeteer-core": "^24.38.0" + "react": "^19.0.0", + "satori": "^0.25.0" }, "devDependencies": { "@nkzw/eslint-plugin": "^2.0.0", @@ -44,15 +50,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": "^19.0.0", "@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", - "typescript": "5.9.3" + "typescript": "5.9.3", + "vitest": "^4.0.18" } } diff --git a/export/src/app.ts b/export/src/app.ts index 2dd41d5526..d5361cc0ce 100644 --- a/export/src/app.ts +++ b/export/src/app.ts @@ -5,53 +5,37 @@ import serve from 'koa-static'; 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('/api/export/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), - }; + .get('/image', async (ctx) => { + 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('/api/export/pdf', async (ctx) => { - const { data, page } = ctx.state; + .get('/pdf', async (ctx) => { + 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 @@ -92,7 +76,6 @@ app .use(serve(path.join(__dirname, '..', '..', 'public'))) .use(errorHandler) .use(data.parseExportData) - .use(render.openPage) .use(router.routes()) .use(router.allowedMethods()); diff --git a/export/src/chrome-executable.ts b/export/src/chrome-executable.ts deleted file mode 100644 index c08c216f2b..0000000000 --- a/export/src/chrome-executable.ts +++ /dev/null @@ -1,33 +0,0 @@ -import fs from 'fs-extra'; - -const CHROME_EXECUTABLE_CANDIDATES: Partial>> = { - darwin: [ - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Chromium.app/Contents/MacOS/Chromium', - ], - linux: [ - '/usr/bin/google-chrome-stable', - '/usr/bin/google-chrome', - '/usr/bin/chromium-browser', - '/usr/bin/chromium', - ], - win32: [ - String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`, - String.raw`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`, - ], -}; - -export async function resolveChromeExecutable(fallback?: () => Promise) { - const fallbackExecutablePath = await fallback?.(); - if (fallbackExecutablePath) { - return fallbackExecutablePath; - } - - for (const candidate of CHROME_EXECUTABLE_CANDIDATES[process.platform] || []) { - if (await fs.pathExists(candidate)) { - return candidate; - } - } - - throw new Error('Could not find a Chrome/Chromium executable for puppeteer-core.'); -} diff --git a/export/src/config.ts b/export/src/config.ts index cf6f508315..a7872a8dcc 100644 --- a/export/src/config.ts +++ b/export/src/config.ts @@ -4,12 +4,6 @@ export default { // Width of the page in pixels pageWidth: Number(process.env.PAGE_WIDTH) || 1024, - // 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 ce9d287d55..0000000000 --- a/export/src/render-serverless.ts +++ /dev/null @@ -1,89 +0,0 @@ -import chromium from '@sparticuz/chromium'; -import puppeteer, { Page } from 'puppeteer-core'; - -import { resolveChromeExecutable } from './chrome-executable'; -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 resolveChromeExecutable(() => chromium.executablePath()); - - chromium.setGraphicsMode = false; - - const browser = await puppeteer.launch({ - // devtools: !!process.env.DEVTOOLS, // TODO: Query string && NODE_ENV === 'development'? - args: chromium.args, - executablePath, - headless: 'shell', - }); - - 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 Buffer.from( - await page.screenshot({ - clip: boundingBox, - }), - ); -} - -export async function pdf(page: Page, data: ExportData) { - await page.emulateMediaType('screen'); - await injectData(page, data); - - return Buffer.from( - await page.pdf({ - format: 'a4', - landscape: data.theme.timetableOrientation === 'HORIZONTAL', - printBackground: true, - waitForFonts: true, - }), - ); -} diff --git a/export/src/render.ts b/export/src/render.ts deleted file mode 100644 index 7c24f311b2..0000000000 --- a/export/src/render.ts +++ /dev/null @@ -1,117 +0,0 @@ -import fs from 'fs-extra'; -import puppeteer, { Page } from 'puppeteer-core'; -import type { Middleware } from 'koa'; - -import { resolveChromeExecutable } from './chrome-executable'; -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 executablePath = await resolveChromeExecutable(); - const browser = await puppeteer.launch({ - args: ['--disable-gpu'], - devtools: !!process.env.DEVTOOLS, - executablePath, - headless: 'shell', - }); - - 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 Buffer.from( - await page.screenshot({ - clip: boundingBox, - }), - ); -} - -export async function pdf(page: Page, data: ExportData) { - await page.emulateMediaType('screen'); - await injectData(page, data); - - return Buffer.from( - await page.pdf({ - format: 'a4', - landscape: data.theme.timetableOrientation === 'HORIZONTAL', - printBackground: true, - waitForFonts: 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..7169670cb4 --- /dev/null +++ b/export/src/satori/render-model.test.ts @@ -0,0 +1,1218 @@ +import { describe, expect, test } from 'vitest'; + +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', + examDuration: 120, + 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', + examDuration: 90, + 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'); + + 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', () => { + const model = buildRenderableTimetable( + makeExportData({ + hidden: [], + ta: [], + timetable: { + EG1311: { Laboratory: [0] }, + }, + }), + fixtureModules, + ); + + 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', () => { + const modules = fixtureModules.map((module) => + module.moduleCode === 'EG1311' + ? { + ...module, + semesterData: [{ ...module.semesterData[0], examDate: undefined }], + } + : module, + ); + const model = buildRenderableTimetable(makeExportData({ hidden: [] }), modules); + + expect(model.moduleCards.map((module) => module.moduleCode)).toEqual([ + 'EG1311', + 'CS1010', + 'MA1101R', + ]); +}); + +test('buildRenderableTimetable sets isVertical based on orientation', () => { + const horizontal = buildRenderableTimetable(makeExportData(), fixtureModules); + expect(horizontal.isVertical).toBe(false); + + const vertical = buildRenderableTimetable( + makeExportData({ + theme: { + id: 'eighties', + showTitle: false, + timetableOrientation: 'VERTICAL', + }, + }), + fixtureModules, + ); + 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 () => { + const png = await renderSatoriImage( + makeExportData({ + hidden: [], + ta: [], + timetable: { + CS1010: { Lecture: [0], Tutorial: [1] }, + }, + }), + 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]); +}); + +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 }, + ); + + 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/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..87d7e4d2da --- /dev/null +++ b/export/src/satori/view.tsx @@ -0,0 +1,880 @@ +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} Units + +
+ )} +
+ Total Units: + {model.totalUnits} Units +
+
+
+
+ ); +} + +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/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 "" 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 abb5fc35c0..1834171181 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: ^143.0.4 - version: 143.0.4 axios: specifier: 0.30.0 version: 0.30.0 @@ -59,19 +62,25 @@ importers: version: 5.0.0 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@19.2.4) 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: ^24.38.0 - version: 24.38.0 + react: + specifier: ^19.0.0 + version: 19.2.4 + satori: + specifier: ^0.25.0 + version: 0.25.0 devDependencies: '@nkzw/eslint-plugin': specifier: ^2.0.0 @@ -100,15 +109,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: ^19.0.0 + version: 19.2.14 '@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 @@ -130,6 +142,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: @@ -160,7 +175,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 @@ -187,7 +202,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: @@ -206,7 +221,7 @@ importers: version: 22.13.5 '@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)) eslint-plugin-no-only-tests: specifier: ^3.3.0 version: 3.3.0 @@ -224,7 +239,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/nus-v2: dependencies: @@ -321,7 +336,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 @@ -354,7 +369,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: @@ -658,7 +673,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) @@ -796,10 +811,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) @@ -1728,6 +1743,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==} @@ -2476,11 +2494,6 @@ packages: webpack-plugin-serve: optional: true - '@puppeteer/browsers@2.13.0': - resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==} - engines: {node: '>=18'} - hasBin: true - '@react-leaflet/core@1.1.1': resolution: {integrity: sha512-7PGLWa9MZ5x/cWy8EH2VzI4T8q5WpuHbixzCDXqixP/WyqwIrg5NDUPgYuFnB4IEIZF+6nA265mYzswFo/h1Pw==} peerDependencies: @@ -2488,6 +2501,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'} @@ -2765,6 +2858,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==} @@ -2778,10 +2876,6 @@ packages: resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} - '@sparticuz/chromium@143.0.4': - resolution: {integrity: sha512-/6I7uQTRhRDD2/gGPQ1Gkf+Dqk0RYDACPJDZfSzz0OWk4JmUTonNHPXbrn6UIklOHlnDLf8xAAzkOZKB/cJpLA==} - engines: {node: '>=20.11.0'} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2880,6 +2974,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'} @@ -2920,9 +3017,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'} @@ -3143,6 +3237,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==} @@ -3202,6 +3299,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==} @@ -3280,9 +3380,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} @@ -3850,10 +3947,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==} @@ -3919,14 +4012,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'} @@ -3993,43 +4078,9 @@ packages: 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==} @@ -4043,10 +4094,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==} @@ -4126,6 +4173,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==} @@ -4221,6 +4271,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==} @@ -4304,11 +4357,6 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - chromium-bidi@14.0.0: - resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==} - peerDependencies: - devtools-protocol: '*' - ci-info@3.3.0: resolution: {integrity: sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==} @@ -4366,6 +4414,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'} @@ -4765,12 +4817,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==} @@ -4784,6 +4849,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'} @@ -4808,6 +4877,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'} @@ -4900,10 +4972,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'} @@ -5059,10 +5127,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'} @@ -5101,8 +5165,8 @@ packages: devtools-protocol@0.0.1140464: resolution: {integrity: sha512-I1jXnjpQh/6TBFyQ0A9dB2kXXk6DprpPFZoI8pUsxHtlNuOTQEdv9fUqYBsFtf8tOJCbdsZZyQrWeXu6GfK+Bw==} - devtools-protocol@0.0.1581282: - resolution: {integrity: sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -5254,6 +5318,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==} @@ -5416,11 +5484,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'} @@ -5629,9 +5692,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'} @@ -5670,17 +5730,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'} @@ -5708,9 +5760,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'} @@ -5720,6 +5769,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'} @@ -5810,6 +5862,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'} @@ -5932,17 +5987,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==} @@ -6161,6 +6211,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'} @@ -6443,10 +6497,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'} @@ -6816,6 +6866,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==} @@ -7016,6 +7070,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==} @@ -7150,10 +7207,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==} @@ -7510,9 +7563,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'} @@ -7607,10 +7657,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==} @@ -7922,17 +7968,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==} @@ -7943,6 +7984,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==} @@ -8050,8 +8094,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==} @@ -8111,6 +8155,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 @@ -8588,10 +8635,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==} @@ -8637,9 +8680,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==} @@ -8647,10 +8687,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - puppeteer-core@24.38.0: - resolution: {integrity: sha512-zB3S/tksIhgi2gZRndUe07AudBz5SXOB7hqG0kEa9/YXWrGwlVlYm3tZtwKgfRftBzbmLQl5iwHkQQl04n/mWw==} - engines: {node: '>=18'} - q@1.5.1: resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} engines: {node: '>=0.6.0', teleport: '>=0.2.0'} @@ -8854,6 +8890,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} @@ -9058,6 +9098,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 @@ -9080,6 +9123,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'} @@ -9240,6 +9286,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==} @@ -9436,10 +9486,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==} @@ -9458,14 +9504,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==} @@ -9573,9 +9611,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'} @@ -9606,6 +9641,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'} @@ -9797,23 +9835,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'} @@ -9851,9 +9880,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==} @@ -9879,6 +9905,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==} @@ -10033,6 +10062,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'} @@ -10085,9 +10119,6 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typed-query-selector@2.12.1: - resolution: {integrity: sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==} - typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} @@ -10135,10 +10166,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'} @@ -10452,9 +10489,6 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - webdriver-bidi-protocol@0.4.1: - resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} - webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -10725,9 +10759,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'} @@ -10744,6 +10775,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'} @@ -11811,6 +11845,8 @@ snapshots: '@eslint/js@8.57.1': {} + '@fontsource/inter@5.2.8': {} + '@hapi/hoek@9.3.0': {} '@hapi/topo@5.1.0': @@ -12472,27 +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.13.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 - 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 @@ -12776,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 @@ -12786,16 +12863,6 @@ snapshots: '@sindresorhus/merge-streams@2.3.0': {} - '@sparticuz/chromium@143.0.4': - 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)': @@ -12915,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 @@ -12958,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': {} @@ -13119,9 +13188,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': {} @@ -13220,6 +13289,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': {} @@ -13238,7 +13311,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: @@ -13246,42 +13319,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: @@ -13294,6 +13367,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 @@ -13390,11 +13467,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) @@ -13617,7 +13689,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 @@ -13629,7 +13701,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: @@ -13640,13 +13712,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: @@ -14102,10 +14174,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 @@ -14178,8 +14246,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 @@ -14253,38 +14319,7 @@ snapshots: balanced-match@4.0.4: {} - 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@0.0.8: {} base64-js@1.5.1: {} @@ -14300,8 +14335,6 @@ snapshots: baseline-browser-mapping@2.10.0: {} - basic-ftp@5.2.0: {} - batch@0.6.1: {} bfj@6.1.2: @@ -14426,6 +14459,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: @@ -14535,6 +14572,8 @@ snapshots: camelcase@6.3.0: {} + camelize@1.0.1: {} + caniuse-api@3.0.0: dependencies: browserslist: 4.24.3 @@ -14642,12 +14681,6 @@ snapshots: chrome-trace-event@1.0.4: {} - chromium-bidi@14.0.0(devtools-protocol@0.0.1581282): - dependencies: - devtools-protocol: 0.0.1581282 - mitt: 3.0.1 - zod: 3.25.76 - ci-info@3.3.0: {} class-utils@0.3.6: @@ -14710,6 +14743,8 @@ snapshots: clone@1.0.4: {} + clone@2.1.2: {} + co@4.6.0: {} coa@2.0.2: @@ -14836,13 +14871,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@19.2.4): dependencies: bluebird: 3.7.2 optionalDependencies: ejs: 3.1.10 lodash: 4.17.23 pug: 3.0.3 + react: 19.2.4 constantinople@4.0.1: dependencies: @@ -14958,15 +14994,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: @@ -14978,6 +15022,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) @@ -15016,6 +15062,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 @@ -15155,8 +15207,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 @@ -15305,12 +15355,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: {} @@ -15337,7 +15381,7 @@ snapshots: devtools-protocol@0.0.1140464: {} - devtools-protocol@0.0.1581282: {} + dfa@1.2.0: {} didyoumean@1.2.2: {} @@ -15532,6 +15576,8 @@ snapshots: electron-to-chromium@1.5.302: {} + emoji-regex-xs@2.0.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -15788,14 +15834,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)(eslint@7.32.0): dependencies: confusing-browser-globals: 1.0.11 @@ -16116,12 +16154,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: @@ -16204,20 +16236,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 @@ -16246,14 +16266,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 @@ -16348,6 +16366,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 @@ -16475,23 +16505,16 @@ 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 + optional: true get-value@2.0.6: {} @@ -16762,6 +16785,8 @@ snapshots: hex-color-regex@1.1.0: {} + hex-rgb@4.3.0: {} + highlight.js@11.11.1: {} history@4.10.1: @@ -17063,8 +17088,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - ip-address@10.1.0: {} - ipaddr.js@1.9.1: {} ipaddr.js@2.3.0: {} @@ -17408,6 +17431,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: @@ -17583,9 +17608,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@19.2.4): 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@19.2.4) debug: 4.4.3 get-paths: 0.0.7 koa-send: 5.0.1 @@ -17716,6 +17741,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: @@ -17851,8 +17881,6 @@ snapshots: dependencies: yallist: 4.0.0 - lru-cache@7.18.3: {} - lru_map@0.3.3: {} lz-string@1.5.0: {} @@ -18453,8 +18481,6 @@ snapshots: minipass@7.1.3: {} - mitt@3.0.1: {} - mixin-deep@1.3.2: dependencies: for-in: 1.0.2 @@ -18570,8 +18596,6 @@ snapshots: neo-async@2.6.2: {} - netmask@2.0.2: {} - nice-try@1.0.5: {} nightwatch-axe-verbose@2.4.0: @@ -18983,26 +19007,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: @@ -19014,6 +19022,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 @@ -19114,7 +19127,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: {} @@ -19154,6 +19173,8 @@ snapshots: dependencies: find-up: 3.0.0 + png-js@1.0.0: {} + popper.js@1.16.1: {} posix-character-classes@0.1.1: {} @@ -19657,19 +19678,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: @@ -19745,32 +19753,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@24.38.0: - dependencies: - '@puppeteer/browsers': 2.13.0 - chromium-bidi: 14.0.0(devtools-protocol@0.0.1581282) - debug: 4.4.3 - devtools-protocol: 0.0.1581282 - typed-query-selector: 2.12.1 - webdriver-bidi-protocol: 0.4.1 - 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): @@ -20052,6 +20038,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 @@ -20316,6 +20304,9 @@ snapshots: resolve-pathname@3.0.0: {} + resolve-pkg-maps@1.0.0: + optional: true + resolve-url@0.2.1: {} resolve@1.22.11: @@ -20343,6 +20334,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + restructure@3.0.2: {} + ret@0.1.15: {} retry@0.13.1: {} @@ -20546,6 +20539,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: @@ -20790,8 +20797,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 @@ -20826,19 +20831,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: {} @@ -20943,15 +20935,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: {} @@ -20983,6 +20966,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 @@ -21258,18 +21243,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 @@ -21278,17 +21251,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 @@ -21300,13 +21262,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 @@ -21331,12 +21286,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: @@ -21360,6 +21309,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: {} @@ -21493,6 +21444,14 @@ 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 + optional: true + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -21549,8 +21508,6 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typed-query-selector@2.12.1: {} - typedarray-to-buffer@3.1.5: dependencies: is-typedarray: 1.0.0 @@ -21587,8 +21544,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: @@ -21826,18 +21793,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) @@ -21851,12 +21818,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 @@ -21873,7 +21841,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 @@ -21926,8 +21894,6 @@ snapshots: web-namespaces@2.0.1: {} - webdriver-bidi-protocol@0.4.1: {} - webidl-conversions@3.0.1: {} webidl-conversions@7.0.0: {} @@ -22277,11 +22243,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: {} @@ -22290,6 +22251,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/**"], 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;