diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index a386542..714655e 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -6,9 +6,9 @@ jobs: steps: - uses: actions/checkout@v2 - name: Use Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: - node-version: '20.x' + node-version: lts/* - run: npm ci - run: npm run lint - run: npm run test diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c788a9..965f849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## [0.5.5] - Unreleased -Minimum requirements bumped to Node 20 and npm 10. +The minimum EcmaScript version has been raised to ES2022. +Minimum build requirements have been raised to Node 20 and npm 10. ### Added @@ -26,6 +27,15 @@ Minimum requirements bumped to Node 20 and npm 10. const pdf2 = await pdfMaker.makePdf(doc2); ``` +### Changed + +- Fonts should now be registered with the `registerFont()` method on the + `PdfMaker` class. + +- The `image` property of an image block now supports `data:`, `file:`, + and `http(s):` URLs. File names are relative to a resource root that + must be set by the `setResourceRoot()` method on the `PdfMaker` class. + ### Deprecated - `TextAttrs` in favor of `TextProps`. @@ -38,6 +48,7 @@ Minimum requirements bumped to Node 20 and npm 10. - `CircleOpts` in favor of `CircleProps`. - `PathOpts` in favor of `PathProps`. - The `fonts` property in a document definition. +- The `images` property in a document definition. - The `makePdf` function in favor of the `makePdf` method on the `PdfMaker` class. diff --git a/package.json b/package.json index 7da87cf..db700b0 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "npm": ">=10" }, "scripts": { - "build": "rm -rf build/ dist/ && tsc && esbuild src/index.ts --bundle --sourcemap --platform=node --target=es2021,node18 --outdir=dist --format=esm --external:pdf-lib --external:@pdf-lib/fontkit && cp -a build/index.d.ts build/api/ dist/", + "build": "rm -rf build/ dist/ && tsc && esbuild src/index.ts --bundle --sourcemap --platform=browser --target=es2022 --outdir=dist --format=esm --external:pdf-lib --external:@pdf-lib/fontkit && cp -a build/index.d.ts build/api/ dist/", "lint": "eslint '{src,test}/**/*.{js,ts}' --max-warnings 0 && prettier --check .", "test": "vitest run test", "fix": "eslint '{src,test}/**/*.{js,ts}' --fix && prettier -w ." diff --git a/src/api/PdfMaker.ts b/src/api/PdfMaker.ts index 23bc3e0..8d96a47 100644 --- a/src/api/PdfMaker.ts +++ b/src/api/PdfMaker.ts @@ -38,6 +38,16 @@ export class PdfMaker { this.#ctx.fontStore.registerFont(data, config); } + /** + * Sets the root directory to read resources from. This allows using + * `file:/` URLs with relative paths in the document definition. + * + * @param root The root directory to read resources from. + */ + setResourceRoot(root: string): void { + this.#ctx.imageStore.setResourceRoot(root); + } + /** * Generates a PDF from the given document definition. * diff --git a/src/api/layout.ts b/src/api/layout.ts index ff25537..a56428f 100644 --- a/src/api/layout.ts +++ b/src/api/layout.ts @@ -67,7 +67,7 @@ export type ImageBlock = { /** * Creates a block that contains an image. * - * @param image The name or path of an image to display in this block. + * @param image The URL of the image to display in this block. * @param props Optional properties for the block. */ export function image(image: string, props?: Omit): ImageBlock { diff --git a/src/base64.test.ts b/src/base64.test.ts new file mode 100644 index 0000000..f74027d --- /dev/null +++ b/src/base64.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { decodeBase64 } from './base64.ts'; + +describe('decodeBase64', () => { + it('decodes base64 strings', () => { + expect(decodeBase64('')).toEqual(new Uint8Array()); + expect(decodeBase64('AA==')).toEqual(new Uint8Array([0])); + expect(decodeBase64('AAE=')).toEqual(new Uint8Array([0, 1])); + expect(decodeBase64('AAEC')).toEqual(new Uint8Array([0, 1, 2])); + }); + + it('decodes longer base64 strings', () => { + const base64 = (input: Uint8Array) => Buffer.from(input).toString('base64'); + const array1 = new Uint8Array([...Array(256).keys()]); + const array2 = new Uint8Array([...Array(257).keys()]); + const array3 = new Uint8Array([...Array(258).keys()]); + + expect(decodeBase64(base64(array1))).toEqual(array1); + expect(decodeBase64(base64(array2))).toEqual(array2); + expect(decodeBase64(base64(array3))).toEqual(array3); + }); + + it('fails if string is not a multiple of 4', () => { + expect(() => decodeBase64('A')).toThrow( + 'Invalid base64 string: length must be a multiple of 4', + ); + expect(() => decodeBase64('AA')).toThrow( + 'Invalid base64 string: length must be a multiple of 4', + ); + expect(() => decodeBase64('AAA')).toThrow( + 'Invalid base64 string: length must be a multiple of 4', + ); + }); + + it('fails if string contains invalid characters', () => { + expect(() => decodeBase64('ABØ=')).toThrow("Invalid Base64 character 'Ø' at position 2"); + }); +}); diff --git a/src/base64.ts b/src/base64.ts new file mode 100644 index 0000000..93a0fef --- /dev/null +++ b/src/base64.ts @@ -0,0 +1,54 @@ +const base64Lookup = createBase64LookupTable(); + +/** + * Decodes a Base64 encoded string into a Uint8Array. + * + * @param base64 - The Base64 encoded string. + * @returns The decoded bytes as a Uint8Array. + */ +export function decodeBase64(base64: string): Uint8Array { + if (base64.length % 4 !== 0) { + throw new Error('Invalid base64 string: length must be a multiple of 4'); + } + + const len = base64.length; + const padding = base64[len - 1] === '=' ? (base64[len - 2] === '=' ? 2 : 1) : 0; + const bufferLength = (len * 3) / 4 - padding; + const bytes = new Uint8Array(bufferLength); + + let byteIndex = 0; + for (let i = 0; i < len; i += 4) { + const encoded1 = lookup(base64, i); + const encoded2 = lookup(base64, i + 1); + const encoded3 = lookup(base64, i + 2); + const encoded4 = lookup(base64, i + 3); + + bytes[byteIndex++] = (encoded1 << 2) | (encoded2 >> 4); + if (base64[i + 2] !== '=') bytes[byteIndex++] = ((encoded2 & 15) << 4) | (encoded3 >> 2); + if (base64[i + 3] !== '=') bytes[byteIndex++] = ((encoded3 & 3) << 6) | encoded4; + } + + return bytes; +} + +function lookup(string: string, pos: number): number { + const code = string.charCodeAt(pos); + if (code === 61) return 0; // '=' padding character + if (code < base64Lookup.length) { + const value = base64Lookup[code]; + if (value !== 255) { + return value; + } + } + throw new Error(`Invalid Base64 character '${string[pos]}' at position ${pos}`); +} + +function createBase64LookupTable(): Uint8Array { + const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + // 255 indicates that code does not represent a valid base64 character + const table = new Uint8Array(256).fill(255); + for (let i = 0; i < base64Chars.length; i++) { + table[base64Chars.charCodeAt(i)] = i; + } + return table; +} diff --git a/src/data-loader.test.ts b/src/data-loader.test.ts new file mode 100644 index 0000000..96028a7 --- /dev/null +++ b/src/data-loader.test.ts @@ -0,0 +1,114 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createDataLoader } from './data-loader.ts'; + +const baseDir = import.meta.dirname; +const libertyJpg = await readFile(join(baseDir, 'test/resources/liberty.jpg')); + +describe('createDataLoader', () => { + const loader = createDataLoader(); + + it('throws for invalid URLs', async () => { + await expect(loader('')).rejects.toThrow("Invalid URL: ''"); + await expect(loader('http://')).rejects.toThrow("Invalid URL: 'http://'"); + }); + + it('throws for unsupported URL scheme', async () => { + await expect(loader('foo:bar')).rejects.toThrow("URL not supported: 'foo:bar'"); + }); + + describe('http:', () => { + beforeEach(() => { + vi.spyOn(globalThis, 'fetch').mockImplementation((req: RequestInfo | URL) => { + const url = req instanceof URL ? req.href : (req as string); + if (url.endsWith('image.jpg')) { + return Promise.resolve(new Response(new Uint8Array([1, 2, 3]))); + } + return Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not Found' })); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads http: URL', async () => { + await expect(loader('http://example.com/image.jpg')).resolves.toEqual({ + data: new Uint8Array([1, 2, 3]), + }); + }); + + it('loads https: URL', async () => { + await expect(loader('https://example.com/image.jpg')).resolves.toEqual({ + data: new Uint8Array([1, 2, 3]), + }); + }); + + it('throws if 404 received', async () => { + await expect(loader('https://example.com/not-there')).rejects.toThrow( + 'Received 404 Not Found', + ); + }); + }); + + describe('data:', () => { + it('loads data: URL', async () => { + await expect(loader('data:image/jpeg;base64,Abc=')).resolves.toEqual({ + data: new Uint8Array([1, 183]), + }); + }); + + it('throws for invalid data: URLs', async () => { + await expect(loader('data:foo')).rejects.toThrow("Invalid data URL: 'data:foo'"); + }); + + it('throws for unsupported encoding in data: URLs', async () => { + await expect(loader('data:foo,bar')).rejects.toThrow( + "Unsupported encoding in data URL: 'data:foo,bar'", + ); + }); + }); + + describe('file:', () => { + it('loads relative file: URL', async () => { + const loader = createDataLoader({ resourceRoot: baseDir }); + + const result = await loader(`file:test/resources/liberty.jpg`); + + expect(result).toEqual({ data: new Uint8Array(libertyJpg) }); + }); + + it('loads file: URL without authority', async () => { + const loader = createDataLoader({ resourceRoot: baseDir }); + + const result = await loader(`file:/test/resources/liberty.jpg`); + + expect(result).toEqual({ data: new Uint8Array(libertyJpg) }); + }); + + it('loads absolute file: URL with empty authority', async () => { + const loader = createDataLoader({ resourceRoot: baseDir }); + + const result = await loader(`file:///test/resources/liberty.jpg`); + + expect(result).toEqual({ data: new Uint8Array(libertyJpg) }); + }); + + it('loads absolute file: URL with authority', async () => { + const loader = createDataLoader({ resourceRoot: baseDir }); + + const result = await loader(`file://localhost/test/resources/liberty.jpg`); + + expect(result).toEqual({ data: new Uint8Array(libertyJpg) }); + }); + + it('rejects when no resource root directory defined', async () => { + const url = `file:/test/resources/liberty.jpg`; + + await expect(loader(url)).rejects.toThrow('No resource root defined'); + }); + }); +}); diff --git a/src/data-loader.ts b/src/data-loader.ts new file mode 100644 index 0000000..3a1fae6 --- /dev/null +++ b/src/data-loader.ts @@ -0,0 +1,83 @@ +import { decodeBase64 } from './base64.ts'; +import { readRelativeFile } from './fs.ts'; + +export type DataLoaderConfig = { + resourceRoot?: string; +}; + +export type DataLoader = ( + url: string, + config?: DataLoaderConfig, +) => DataLoaderResult | Promise; + +export type DataLoaderResult = { + data: Uint8Array; +}; + +export function createDataLoader(config?: DataLoaderConfig): DataLoader { + const loaders: Record = { + http: loadHttp, + https: loadHttp, + data: loadData, + file: loadFile, + }; + + return async function (url: string): Promise { + const schema = getUrlSchema(url).slice(0, -1); + const loader = loaders[schema]; + if (!loader) { + throw new Error(`URL not supported: '${url}'`); + } + return await loader(url, config); + }; +} + +function getUrlSchema(url: string) { + try { + return new URL(url).protocol; + } catch { + throw new Error(`Invalid URL: '${url}'`); + } +} + +function loadData(url: string) { + if (!url.startsWith('data:')) { + throw new Error(`Not a data URL: '${url}'`); + } + const endOfHeader = url.indexOf(','); + if (endOfHeader === -1) { + throw new Error(`Invalid data URL: '${url}'`); + } + const header = url.slice(5, endOfHeader); + if (!header.endsWith(';base64')) { + throw new Error(`Unsupported encoding in data URL: '${url}'`); + } + const dataPart = url.slice(endOfHeader + 1); + const data = new Uint8Array(decodeBase64(dataPart)); + return { data }; +} + +async function loadHttp(url: string) { + if (!url.startsWith('http:') && !url.startsWith('https:')) { + throw new Error(`Not a http(s) URL: '${url}'`); + } + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Received ${response.status} ${response.statusText}`); + } + const data = new Uint8Array(await response.arrayBuffer()); + return { data }; +} + +async function loadFile(url: string, config?: DataLoaderConfig) { + if (!url.startsWith('file:')) { + throw new Error(`Not a file URL: '${url}'`); + } + if (!config?.resourceRoot) { + throw new Error('No resource root defined'); + } + const urlPath = decodeURIComponent(new URL(url).pathname); + const relPath = urlPath.replace(/^\//g, ''); + const data = new Uint8Array(await readRelativeFile(config.resourceRoot, relPath)); + return { data }; +} diff --git a/src/font-store.ts b/src/font-store.ts index 2825942..e94d828 100644 --- a/src/font-store.ts +++ b/src/font-store.ts @@ -1,8 +1,8 @@ import fontkit from '@pdf-lib/fontkit'; -import { toUint8Array } from 'pdf-lib'; import type { FontConfig } from './api/PdfMaker.ts'; import type { FontStyle, FontWeight } from './api/text.ts'; +import { parseBinaryData } from './binary-data.ts'; import type { Font, FontDef, FontSelector } from './fonts.ts'; import { weightToNumber } from './fonts.ts'; import { pickDefined } from './types.ts'; @@ -41,7 +41,7 @@ export class FontStore { _loadFont(selector: FontSelector): Promise { const selectedFont = selectFont(this.#fontDefs, selector); - const data = toUint8Array(selectedFont.data); + const data = parseBinaryData(selectedFont.data); const fkFont = selectedFont.fkFont ?? fontkit.create(data); return Promise.resolve( pickDefined({ diff --git a/src/fs.ts b/src/fs.ts new file mode 100644 index 0000000..ac7171d --- /dev/null +++ b/src/fs.ts @@ -0,0 +1,22 @@ +export const readRelativeFile = async (rootDir: string, relPath: string) => { + let fs; + let path; + + try { + fs = await import('node:fs/promises'); + path = await import('node:path'); + } catch { + throw new Error('File system is not available in this environment'); + } + if (path.isAbsolute(relPath)) { + throw new Error(`Path is not relative: '${relPath}'`); + } + + const resolvedPath = path.resolve(rootDir, relPath); + const realPath = await fs.realpath(resolvedPath); + try { + return await fs.readFile(realPath); + } catch (error) { + throw new Error(`Failed to load file '${realPath}'`, { cause: error }); + } +}; diff --git a/src/image-store.test.ts b/src/image-store.test.ts index 728d546..ccad984 100644 --- a/src/image-store.test.ts +++ b/src/image-store.test.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { ImageStore } from './image-store.ts'; @@ -10,21 +10,36 @@ const baseDir = import.meta.dirname; describe('ImageStore', () => { let libertyJpg: Uint8Array; let torusPng: Uint8Array; + let store: ImageStore; beforeAll(async () => { [libertyJpg, torusPng] = await Promise.all([ readFile(join(baseDir, './test/resources/liberty.jpg')), readFile(join(baseDir, './test/resources/torus.png')), ]); + vi.spyOn(globalThis, 'fetch').mockImplementation((req: RequestInfo | URL) => { + const url = req instanceof URL ? req.href : (req as string); + if (url.endsWith('/liberty.jpg')) { + return Promise.resolve(new Response(libertyJpg)); + } + if (url.endsWith('/torus.png')) { + return Promise.resolve(new Response(torusPng)); + } + return Promise.resolve(new Response('Not found', { status: 404, statusText: 'Not Found' })); + }); + store = new ImageStore(); + store.setResourceRoot(baseDir); }); - it('rejects if image could not be loaded', async () => { - const store = new ImageStore(); + afterAll(() => { + vi.restoreAllMocks(); + }); + it('rejects if image could not be loaded', async () => { await expect(store.selectImage('foo')).rejects.toThrow("Could not load image 'foo'"); }); - it('loads registered images', async () => { + it('loads registered images (deprecated)', async () => { const store = new ImageStore([ { name: 'liberty', data: libertyJpg, format: 'jpeg' }, { name: 'torus', data: torusPng, format: 'png' }, @@ -33,53 +48,72 @@ describe('ImageStore', () => { const torus = await store.selectImage('torus'); const liberty = await store.selectImage('liberty'); - expect(torus).toEqual(expect.objectContaining({ name: 'torus', data: torusPng })); - expect(liberty).toEqual(expect.objectContaining({ name: 'liberty', data: libertyJpg })); + expect(torus).toEqual(expect.objectContaining({ url: 'torus', data: torusPng })); + expect(liberty).toEqual(expect.objectContaining({ url: 'liberty', data: libertyJpg })); }); - it('loads image from file system', async () => { - const store = new ImageStore(); - + it('loads image from file system (deprecated)', async () => { const torusPath = join(baseDir, './test/resources/torus.png'); + const image = await store.selectImage(torusPath); - expect(image).toEqual(expect.objectContaining({ name: torusPath, data: torusPng })); + expect(image).toEqual(expect.objectContaining({ url: torusPath, data: torusPng })); + }); + + it('loads image from file URL', async () => { + const fileUrl = 'file:/test/resources/torus.png'; + + const image = await store.selectImage(fileUrl); + + expect(image).toEqual(expect.objectContaining({ url: fileUrl, data: torusPng })); + }); + + it('loads image from data URL', async () => { + const dataUrl = `data:image/png;base64,${Buffer.from(torusPng).toString('base64')}`; + + const image = await store.selectImage(dataUrl); + + expect(image).toEqual(expect.objectContaining({ url: dataUrl, data: torusPng })); + }); + + it('loads image from http URL', async () => { + const httpUrl = 'http://example.com/torus.png'; + + const image = await store.selectImage(httpUrl); + + expect(image).toEqual(expect.objectContaining({ url: httpUrl, data: torusPng })); }); it('reads format, width and height from JPEG image', async () => { - const store = new ImageStore([{ name: 'liberty', data: libertyJpg, format: 'jpeg' }]); + const libertyUrl = 'file:/test/resources/liberty.jpg'; - const image = await store.selectImage('liberty'); + const image = await store.selectImage(libertyUrl); - expect(image).toEqual({ - name: 'liberty', - format: 'jpeg', - width: 160, - height: 240, - data: libertyJpg, - }); + expect(image).toEqual(expect.objectContaining({ format: 'jpeg', width: 160, height: 240 })); }); it('reads format, width and height from PNG image', async () => { - const store = new ImageStore([{ name: 'torus', data: torusPng, format: 'png' }]); + const torusUrl = 'file:/test/resources/torus.png'; - const image = await store.selectImage('torus'); + const image = await store.selectImage(torusUrl); - expect(image).toEqual({ - name: 'torus', - format: 'png', - width: 256, - height: 192, - data: torusPng, - }); + expect(image).toEqual(expect.objectContaining({ format: 'png', width: 256, height: 192 })); + }); + + it('loads image only once for one URL', async () => { + const torusUrl = 'file:/test/resources/torus.png'; + + await Promise.all([store.selectImage(torusUrl), store.selectImage(torusUrl)]); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); it('returns same image object for concurrent calls', async () => { - const store = new ImageStore([{ name: 'liberty', data: libertyJpg, format: 'jpeg' }]); + const libertyUrl = 'file:/test/resources/liberty.jpg'; const [image1, image2] = await Promise.all([ - store.selectImage('liberty'), - store.selectImage('liberty'), + store.selectImage(libertyUrl), + store.selectImage(libertyUrl), ]); expect(image1).toBe(image2); diff --git a/src/image-store.ts b/src/image-store.ts index a0e0116..dd2a29b 100644 --- a/src/image-store.ts +++ b/src/image-store.ts @@ -1,7 +1,6 @@ -import { readFile } from 'node:fs/promises'; - -import { toUint8Array } from 'pdf-lib'; - +import { parseBinaryData } from './binary-data.ts'; +import { createDataLoader, type DataLoader } from './data-loader.ts'; +import { readRelativeFile } from './fs.ts'; import type { Image, ImageDef, ImageFormat } from './images.ts'; import { isJpeg, readJpegInfo } from './images/jpeg.ts'; import { isPng, readPngInfo } from './images/png.ts'; @@ -9,31 +8,44 @@ import { isPng, readPngInfo } from './images/png.ts'; export class ImageStore { readonly #images: ImageDef[]; readonly #imageCache: Record> = {}; + #dataLoader: DataLoader; constructor(images?: ImageDef[]) { this.#images = images ?? []; + this.#dataLoader = createDataLoader(); } - selectImage(selector: string): Promise { - return (this.#imageCache[selector] ??= this.loadImage(selector)); + setResourceRoot(root: string) { + this.#dataLoader = createDataLoader({ resourceRoot: root }); } - async loadImage(selector: string): Promise { - const data = await this.loadImageData(selector); + selectImage(url: string): Promise { + return (this.#imageCache[url] ??= this.loadImage(url)); + } + + async loadImage(url: string): Promise { + const data = await this.loadImageData(url); const format = determineImageFormat(data); const { width, height } = format === 'png' ? readPngInfo(data) : readJpegInfo(data); - return { name: selector, format, data, width, height }; + return { url, format, data, width, height }; } - async loadImageData(selector: string): Promise { - const imageDef = this.#images.find((image) => image.name === selector); + async loadImageData(url: string): Promise { + const imageDef = this.#images.find((image) => image.name === url); if (imageDef) { - return toUint8Array(imageDef.data); + return parseBinaryData(imageDef.data); } + + const urlSchema = /^(\w+):/.exec(url)?.[1]; try { - return await readFile(selector); + if (urlSchema) { + const { data } = await this.#dataLoader(url); + return data; + } + const data = await readRelativeFile('/', url.replace(/^\/+/, '')); + return new Uint8Array(data); } catch (error) { - throw new Error(`Could not load image '${selector}'`, { cause: error }); + throw new Error(`Could not load image '${url}'`, { cause: error }); } } } diff --git a/src/images.test.ts b/src/images.test.ts index 2bf8007..995f372 100644 --- a/src/images.test.ts +++ b/src/images.test.ts @@ -53,7 +53,7 @@ describe('registerImage', () => { it('embeds image in PDF document and attaches ref', async () => { const doc = fakePDFDocument(); const data = await readFile(join(__dirname, './test/resources/liberty.jpg')); - const image: Image = { name: 'foo', format: 'jpeg', data, width: 100, height: 200 }; + const image: Image = { url: 'foo', format: 'jpeg', data, width: 100, height: 200 }; const pdfRef = registerImage(image, doc); diff --git a/src/images.ts b/src/images.ts index 0991653..1b906dd 100644 --- a/src/images.ts +++ b/src/images.ts @@ -14,7 +14,7 @@ export type ImageDef = { }; export type Image = { - name: string; + url: string; width: number; height: number; data: Uint8Array; @@ -46,7 +46,7 @@ export function registerImage(image: Image, pdfDoc: PDFDocument) { : JpegEmbedder.for(image.data)); embedder.embedIntoContext(pdfDoc.context, ref); } catch (error) { - throw new Error(`Could not embed image "${image.name}"`, { cause: error }); + throw new Error(`Could not embed image "${image.url}"`, { cause: error }); } }, }); diff --git a/tsconfig.json b/tsconfig.json index b23afe3..f8342a8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "module": "NodeNext", - "target": "ES6", + "target": "ES2022", "lib": ["ES2022", "DOM"], "outDir": "build", "paths": {