@byline/core 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,21 @@ export interface UploadFileOptions {
38
38
  * Providers may use this to namespace storage paths.
39
39
  */
40
40
  collection?: string;
41
+ /**
42
+ * Explicit, fully-qualified storage path / object key the provider must
43
+ * write to verbatim. When set, providers MUST place the file at exactly
44
+ * this path — no UUID prefix, year/month rewrite, or `pathPrefix` injection.
45
+ *
46
+ * Used by the image-variant pipeline (`generateImageVariants`) to write
47
+ * sibling files alongside an already-stored original (e.g. so the
48
+ * `thumbnail` variant of `media/2026/05/abc-photo.jpg` lands at
49
+ * `media/2026/05/abc-photo-thumbnail.webp`).
50
+ *
51
+ * Always POSIX-style (forward slashes), no leading slash. Callers are
52
+ * responsible for sanitisation and collision-avoidance — providers do
53
+ * not second-guess the path when this is set.
54
+ */
55
+ targetStoragePath?: string;
41
56
  }
42
57
  /**
43
58
  * The pluggable file-storage interface.
@@ -6,6 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { ImageSize } from '../@types/collection-types.js';
9
+ import type { IStorageProvider, StoredFileLocation } from '../@types/storage-types.js';
9
10
  import type { BylineLogger } from '../logger/index.js';
10
11
  export interface ImageMeta {
11
12
  width: number | null;
@@ -35,11 +36,16 @@ export declare function isBypassMimeType(mimeType: string): boolean;
35
36
  */
36
37
  export declare function extractImageMeta(buffer: Buffer, mimeType: string): Promise<ImageMeta>;
37
38
  /**
38
- * Generate the named image variants (sizes) defined in `UploadConfig.sizes`.
39
+ * Generate the named image variants (sizes) defined in `UploadConfig.sizes`
40
+ * and persist them through the configured `IStorageProvider`.
39
41
  *
40
42
  * - SVG and GIF files are skipped entirely (bypass types).
41
- * - Each variant is written as a sibling file to the original, using the
42
- * naming convention: `<basename>-<variantName>.<ext>`
43
+ * - Each variant is uploaded as a sibling object to `storedFile.storagePath`,
44
+ * using the naming convention: `<dir>/<basename>-<variantName>.<ext>`
45
+ * (POSIX paths — both local filesystem keys and S3 object keys).
46
+ * - Variant bytes are produced in-memory by Sharp and written via
47
+ * `storage.upload(buffer, { targetStoragePath })` — no direct filesystem
48
+ * access, so the function is provider-agnostic.
43
49
  * - Returns an array of `ImageVariantResult` describing what was created.
44
50
  */
45
- export declare function generateImageVariants(sourceBuffer: Buffer, mimeType: string, absoluteOriginalPath: string, storageBaseDir: string, sizes: ImageSize[], logger?: BylineLogger): Promise<ImageVariantResult[]>;
51
+ export declare function generateImageVariants(sourceBuffer: Buffer, mimeType: string, storedFile: StoredFileLocation, storage: IStorageProvider, sizes: ImageSize[], logger?: BylineLogger): Promise<ImageVariantResult[]>;
@@ -5,7 +5,6 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import fs from 'node:fs';
9
8
  import path from 'node:path';
10
9
  // Sharp ships its own types; no @types/sharp needed.
11
10
  import sharp from 'sharp';
@@ -67,30 +66,52 @@ function tryParseSvgDimensions(buffer) {
67
66
  // Variant generation
68
67
  // ---------------------------------------------------------------------------
69
68
  /**
70
- * Generate the named image variants (sizes) defined in `UploadConfig.sizes`.
69
+ * Map a Sharp output format keyword to the corresponding image MIME type.
70
+ * Unknown formats fall back to `application/octet-stream` — the storage
71
+ * provider will accept it; only the served `Content-Type` is affected.
72
+ */
73
+ function mimeTypeForFormat(format) {
74
+ switch (format) {
75
+ case 'jpeg':
76
+ return 'image/jpeg';
77
+ case 'png':
78
+ return 'image/png';
79
+ case 'webp':
80
+ return 'image/webp';
81
+ case 'avif':
82
+ return 'image/avif';
83
+ default:
84
+ return 'application/octet-stream';
85
+ }
86
+ }
87
+ /**
88
+ * Generate the named image variants (sizes) defined in `UploadConfig.sizes`
89
+ * and persist them through the configured `IStorageProvider`.
71
90
  *
72
91
  * - SVG and GIF files are skipped entirely (bypass types).
73
- * - Each variant is written as a sibling file to the original, using the
74
- * naming convention: `<basename>-<variantName>.<ext>`
92
+ * - Each variant is uploaded as a sibling object to `storedFile.storagePath`,
93
+ * using the naming convention: `<dir>/<basename>-<variantName>.<ext>`
94
+ * (POSIX paths — both local filesystem keys and S3 object keys).
95
+ * - Variant bytes are produced in-memory by Sharp and written via
96
+ * `storage.upload(buffer, { targetStoragePath })` — no direct filesystem
97
+ * access, so the function is provider-agnostic.
75
98
  * - Returns an array of `ImageVariantResult` describing what was created.
76
99
  */
77
- export async function generateImageVariants(sourceBuffer, mimeType, absoluteOriginalPath, storageBaseDir, sizes, logger) {
100
+ export async function generateImageVariants(sourceBuffer, mimeType, storedFile, storage, sizes, logger) {
78
101
  if (isBypassMimeType(mimeType) || sizes.length === 0) {
79
102
  return [];
80
103
  }
81
- const originalExt = path.extname(absoluteOriginalPath);
82
- const originalBase = path.basename(absoluteOriginalPath, originalExt);
83
- const variantDir = path.dirname(absoluteOriginalPath);
104
+ // Storage paths are POSIX-style across providers; use `path.posix` so
105
+ // Windows local-fs hosts don't introduce backslashes into S3 object keys.
106
+ const sourcePath = storedFile.storagePath;
107
+ const originalExt = path.posix.extname(sourcePath);
108
+ const originalBase = path.posix.basename(sourcePath, originalExt);
109
+ const variantDir = path.posix.dirname(sourcePath);
84
110
  const variants = [];
85
111
  for (const size of sizes) {
86
112
  const outputFormat = size.format ?? 'webp';
87
- const outputExt = `.${outputFormat}`;
88
- const variantFilename = `${originalBase}-${size.name}${outputExt}`;
89
- const variantAbsolutePath = path.join(variantDir, variantFilename);
90
- // Derive the storage-relative path (relative to storageBaseDir).
91
- const variantStoragePath = path
92
- .relative(storageBaseDir, variantAbsolutePath)
93
- .replace(/\\/g, '/');
113
+ const variantFilename = `${originalBase}-${size.name}.${outputFormat}`;
114
+ const variantStoragePath = variantDir === '.' ? variantFilename : `${variantDir}/${variantFilename}`;
94
115
  try {
95
116
  let pipeline = sharp(sourceBuffer);
96
117
  const resizeOptions = {
@@ -118,9 +139,13 @@ export async function generateImageVariants(sourceBuffer, mimeType, absoluteOrig
118
139
  pipeline = pipeline.webp({ quality: size.quality ?? 85 });
119
140
  }
120
141
  const variantBuffer = await pipeline.toBuffer();
121
- fs.mkdirSync(path.dirname(variantAbsolutePath), { recursive: true });
122
- await fs.promises.writeFile(variantAbsolutePath, variantBuffer);
123
142
  const sharpMeta = await sharp(variantBuffer).metadata();
143
+ await storage.upload(variantBuffer, {
144
+ filename: variantFilename,
145
+ mimeType: mimeTypeForFormat(outputFormat),
146
+ size: variantBuffer.byteLength,
147
+ targetStoragePath: variantStoragePath,
148
+ });
124
149
  variants.push({
125
150
  name: size.name,
126
151
  storagePath: variantStoragePath,
@@ -75,6 +75,13 @@ declare const DOCUMENT_SORT_COLUMNS: Record<string, string>;
75
75
  * are resolved into `RelationFilter` entries; otherwise only
76
76
  * direct/operator predicates against the relation's own
77
77
  * `target_document_id` are emitted.
78
+ *
79
+ * Reserved-key rules inside a nested sub-clause: `status` and `path`
80
+ * downshift to `DocumentColumnFilter` entries against the target
81
+ * version's `document_versions` columns (the adapter wires these to
82
+ * `td${depth}.status` / `td${depth}.path` via the inner relation scope);
83
+ * `query` is dropped with a debug log because text search has no
84
+ * sensible composition through a relation hop.
78
85
  */
79
86
  export declare function parseWhere(where: WhereClause | undefined, definition: CollectionDefinition, ctx?: ParseContext): Promise<ParsedWhere>;
80
87
  /**
@@ -31,6 +31,13 @@ const DOCUMENT_SORT_COLUMNS = {
31
31
  * are resolved into `RelationFilter` entries; otherwise only
32
32
  * direct/operator predicates against the relation's own
33
33
  * `target_document_id` are emitted.
34
+ *
35
+ * Reserved-key rules inside a nested sub-clause: `status` and `path`
36
+ * downshift to `DocumentColumnFilter` entries against the target
37
+ * version's `document_versions` columns (the adapter wires these to
38
+ * `td${depth}.status` / `td${depth}.path` via the inner relation scope);
39
+ * `query` is dropped with a debug log because text search has no
40
+ * sensible composition through a relation hop.
34
41
  */
35
42
  export async function parseWhere(where, definition, ctx) {
36
43
  return parseWhereInternal(where, definition, ctx, { isNested: false, inCombinator: false });
@@ -57,10 +64,15 @@ export function mergePredicates(hookPredicate, userWhere) {
57
64
  return { $and: [hookPredicate, userWhere] };
58
65
  }
59
66
  /**
60
- * Recursion entry point. `isNested: true` disables the top-level reserved
61
- * keys (`status`, `query`, `path`) so that on a nested sub-where, those
62
- * names resolve as ordinary fields on the target collection (where `path`
63
- * and `status` are typically real text fields).
67
+ * Recursion entry point. `isNested: true` rewires the document-level
68
+ * reserved keys: `status` / `path` downshift to `DocumentColumnFilter`
69
+ * entries against the target version's columns (the adapter resolves
70
+ * those via the inner relation scope, e.g. `td${depth}.status`); `query`
71
+ * is dropped with a debug log because text search has no sensible
72
+ * composition through a relation hop. Reserved keys still take precedence
73
+ * over field lookups — a target collection that happens to declare a
74
+ * `path` or `status` field will not see those clauses resolve as field
75
+ * filters; rename the offending field if that conflict matters.
64
76
  */
65
77
  async function parseWhereInternal(where, definition, ctx, { isNested, inCombinator }) {
66
78
  const result = { filters: [] };
@@ -134,65 +146,82 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
134
146
  continue;
135
147
  }
136
148
  // --- Document-level reserved keys --------------------------------------
137
- // At the top level (not nested in a relation, not inside a combinator)
138
- // these keys map to direct adapter scalar parameters
139
- // (`ParsedWhere.status` / `query` / `pathFilter`) which compile to the
140
- // outermost WHERE clause. Inside a combinator that mapping no longer
141
- // composes — the predicate needs to OR with siblings, which the scalar
142
- // parameter form cannot express so `status` / `path` downshift to a
143
- // `DocumentColumnFilter` and `query` is dropped (text search has no
144
- // sensible OR-composing form here).
145
- if (!isNested) {
146
- if (key === 'status') {
147
- if (inCombinator) {
148
- const parsed = normaliseToOperator(rawValue);
149
- if (parsed) {
150
- result.filters.push({
151
- kind: 'docColumn',
152
- column: 'status',
153
- operator: parsed.operator,
154
- value: parsed.value === null ? null : String(parsed.value),
155
- });
156
- }
157
- continue;
158
- }
159
- if (typeof rawValue === 'string') {
160
- result.status = rawValue;
149
+ // `status` / `path` / `query` are reserved on every collection they
150
+ // never resolve to a field of the same name (no field-shadow exception),
151
+ // mirroring the consumer-intuitive rule that these names refer to the
152
+ // document version's metadata columns.
153
+ //
154
+ // Where they compile depends on scope:
155
+ //
156
+ // Top level (not nested in a relation, not inside a combinator):
157
+ // map to direct adapter scalar parameters
158
+ // (`ParsedWhere.status` / `query` / `pathFilter`) which compile to
159
+ // the outermost WHERE clause.
160
+ //
161
+ // • Inside a combinator, OR inside a relation sub-clause: the scalar-
162
+ // parameter mapping no longer composes (the predicate needs to OR
163
+ // with siblings, or anchor against the *target's* doc version), so
164
+ // `status` / `path` downshift to a `DocumentColumnFilter`. The SQL
165
+ // compiler reads `outerScope.status` / `outerScope.path`, which the
166
+ // adapter rewires per-scope (`d.path` at the top level,
167
+ // `td${depth}.path` inside a relation hop).
168
+ //
169
+ // • `query` is dropped with a debug log in both downshift cases —
170
+ // text search has no sensible OR-composing form, and a per-target
171
+ // EXISTS over `store_text` is intentionally deferred.
172
+ if (key === 'status') {
173
+ if (isNested || inCombinator) {
174
+ const parsed = normaliseToOperator(rawValue);
175
+ if (parsed) {
176
+ result.filters.push({
177
+ kind: 'docColumn',
178
+ column: 'status',
179
+ operator: parsed.operator,
180
+ value: parsed.value === null ? null : String(parsed.value),
181
+ });
161
182
  }
162
183
  continue;
163
184
  }
164
- if (key === 'query') {
165
- if (inCombinator) {
166
- ctx?.logger?.debug({ collection: definition.path }, 'parse-where: dropping `query` inside a combinator — text search does not compose with OR/AND');
167
- continue;
168
- }
169
- if (typeof rawValue === 'string') {
170
- result.query = rawValue;
171
- }
185
+ if (typeof rawValue === 'string') {
186
+ result.status = rawValue;
187
+ }
188
+ continue;
189
+ }
190
+ if (key === 'query') {
191
+ if (isNested) {
192
+ ctx?.logger?.debug({ collection: definition.path }, 'parse-where: dropping `query` inside a relation sub-clause — text search does not compose through a relation hop');
172
193
  continue;
173
194
  }
174
- if (key === 'path') {
175
- if (inCombinator) {
176
- const parsed = normaliseToOperator(rawValue);
177
- if (parsed) {
178
- result.filters.push({
179
- kind: 'docColumn',
180
- column: 'path',
181
- operator: parsed.operator,
182
- value: parsed.value === null ? null : String(parsed.value),
183
- });
184
- }
185
- continue;
186
- }
195
+ if (inCombinator) {
196
+ ctx?.logger?.debug({ collection: definition.path }, 'parse-where: dropping `query` inside a combinator — text search does not compose with OR/AND');
197
+ continue;
198
+ }
199
+ if (typeof rawValue === 'string') {
200
+ result.query = rawValue;
201
+ }
202
+ continue;
203
+ }
204
+ if (key === 'path') {
205
+ if (isNested || inCombinator) {
187
206
  const parsed = normaliseToOperator(rawValue);
188
207
  if (parsed) {
189
- result.pathFilter = {
208
+ result.filters.push({
209
+ kind: 'docColumn',
210
+ column: 'path',
190
211
  operator: parsed.operator,
191
- value: String(parsed.value),
192
- };
212
+ value: parsed.value === null ? null : String(parsed.value),
213
+ });
193
214
  }
194
215
  continue;
195
216
  }
217
+ const parsed = normaliseToOperator(rawValue);
218
+ if (parsed) {
219
+ result.pathFilter = {
220
+ operator: parsed.operator,
221
+ value: String(parsed.value),
222
+ };
223
+ }
224
+ continue;
196
225
  }
197
226
  // --- Field-level keys --------------------------------------------------
198
227
  const field = definition.fields.find((f) => f.name === key);
@@ -226,11 +255,11 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
226
255
  isNested: true,
227
256
  inCombinator: false,
228
257
  });
229
- // Flatten nested: only field-level / relation-level conditions make
230
- // sense inside a relation subclause. Document-level keys (status,
231
- // path, query) on the target are deliberately out of scope for this
232
- // first phase they can be added later by promoting them into
233
- // the nested filter list here.
258
+ // Splice nested filters straight in. The nested parse runs with
259
+ // `isNested: true`, which promotes `status` / `path` into
260
+ // `DocumentColumnFilter` entries the adapter resolves against the
261
+ // target version's columns (`td${depth}.status` / `td${depth}.path`)
262
+ // via the inner relation scope. `query` is dropped one level up.
234
263
  result.filters.push({
235
264
  kind: 'relation',
236
265
  fieldName: key,
@@ -40,7 +40,11 @@ const categoriesCollection = defineCollection({
40
40
  }),
41
41
  fields: [
42
42
  { name: 'name', type: 'text', label: 'Name', localized: true },
43
- { name: 'path', type: 'text', label: 'Path' },
43
+ // `slug`, not `path` `path` is a reserved key that resolves to the
44
+ // target version's `document_versions.path` column inside a nested
45
+ // sub-clause (same precedence as the top level), so a real `path`
46
+ // field would be unreachable through the where clause.
47
+ { name: 'slug', type: 'text', label: 'Slug' },
44
48
  {
45
49
  name: 'parent',
46
50
  type: 'relation',
@@ -220,8 +224,8 @@ describe('parseWhere', () => {
220
224
  value: ['cat-a', 'cat-b'],
221
225
  });
222
226
  });
223
- it('should emit a RelationFilter for a nested plain-object sub-where', async () => {
224
- const result = await parseWhere({ category: { path: 'news' } }, testCollection, ctx);
227
+ it('should emit a RelationFilter for a nested plain-object sub-where (field key)', async () => {
228
+ const result = await parseWhere({ category: { slug: 'news' } }, testCollection, ctx);
225
229
  expect(result.filters).toHaveLength(1);
226
230
  expect(result.filters[0]).toEqual({
227
231
  kind: 'relation',
@@ -230,7 +234,7 @@ describe('parseWhere', () => {
230
234
  nested: [
231
235
  {
232
236
  kind: 'field',
233
- fieldName: 'path',
237
+ fieldName: 'slug',
234
238
  storeType: 'text',
235
239
  valueColumn: 'value',
236
240
  operator: '$eq',
@@ -239,6 +243,55 @@ describe('parseWhere', () => {
239
243
  ],
240
244
  });
241
245
  });
246
+ it('promotes `path` inside a nested sub-where to a DocumentColumnFilter', async () => {
247
+ // Reserved-key precedence: `path` inside a relation sub-clause maps to
248
+ // the target version's `document_versions.path` column, never to a
249
+ // field of the same name. The adapter wires it to `td${depth}.path`
250
+ // via the inner relation scope.
251
+ const result = await parseWhere({ category: { path: 'news' } }, testCollection, ctx);
252
+ expect(result.filters).toHaveLength(1);
253
+ expect(result.filters[0]).toEqual({
254
+ kind: 'relation',
255
+ fieldName: 'category',
256
+ targetCollectionId: 'id-test-categories',
257
+ nested: [
258
+ {
259
+ kind: 'docColumn',
260
+ column: 'path',
261
+ operator: '$eq',
262
+ value: 'news',
263
+ },
264
+ ],
265
+ });
266
+ });
267
+ it('promotes `status` inside a nested sub-where to a DocumentColumnFilter', async () => {
268
+ const result = await parseWhere({ category: { status: 'draft' } }, testCollection, ctx);
269
+ expect(result.filters).toHaveLength(1);
270
+ expect(result.filters[0]).toEqual({
271
+ kind: 'relation',
272
+ fieldName: 'category',
273
+ targetCollectionId: 'id-test-categories',
274
+ nested: [
275
+ {
276
+ kind: 'docColumn',
277
+ column: 'status',
278
+ operator: '$eq',
279
+ value: 'draft',
280
+ },
281
+ ],
282
+ });
283
+ });
284
+ it('drops `query` inside a nested sub-where (no nested filter emitted)', async () => {
285
+ // Same rationale as `query` inside a combinator: text search has no
286
+ // sensible composition through a relation hop.
287
+ const result = await parseWhere({ category: { query: 'news' } }, testCollection, ctx);
288
+ expect(result.filters).toHaveLength(1);
289
+ const relation = result.filters[0];
290
+ expect(relation?.kind).toBe('relation');
291
+ if (relation?.kind !== 'relation')
292
+ return;
293
+ expect(relation.nested).toEqual([]);
294
+ });
242
295
  it('should support operator objects inside a nested sub-where', async () => {
243
296
  const result = await parseWhere({ category: { name: { $contains: 'news' } } }, testCollection, ctx);
244
297
  const relation = result.filters[0];
@@ -257,7 +310,7 @@ describe('parseWhere', () => {
257
310
  ]);
258
311
  });
259
312
  it('should recurse into multi-hop relation sub-wheres', async () => {
260
- const result = await parseWhere({ category: { parent: { path: 'news' } } }, testCollection, ctx);
313
+ const result = await parseWhere({ category: { parent: { slug: 'news' } } }, testCollection, ctx);
261
314
  const top = result.filters[0];
262
315
  expect(top?.kind).toBe('relation');
263
316
  if (top?.kind !== 'relation')
@@ -274,7 +327,7 @@ describe('parseWhere', () => {
274
327
  expect(inner.nested).toEqual([
275
328
  {
276
329
  kind: 'field',
277
- fieldName: 'path',
330
+ fieldName: 'slug',
278
331
  storeType: 'text',
279
332
  valueColumn: 'value',
280
333
  operator: '$eq',
@@ -282,6 +335,26 @@ describe('parseWhere', () => {
282
335
  },
283
336
  ]);
284
337
  });
338
+ it('recurses multi-hop with the doc-column form (target.path at depth 2)', async () => {
339
+ const result = await parseWhere({ category: { parent: { path: 'news' } } }, testCollection, ctx);
340
+ const top = result.filters[0];
341
+ expect(top?.kind).toBe('relation');
342
+ if (top?.kind !== 'relation')
343
+ return;
344
+ expect(top.nested).toHaveLength(1);
345
+ const inner = top.nested[0];
346
+ expect(inner?.kind).toBe('relation');
347
+ if (inner?.kind !== 'relation')
348
+ return;
349
+ expect(inner.nested).toEqual([
350
+ {
351
+ kind: 'docColumn',
352
+ column: 'path',
353
+ operator: '$eq',
354
+ value: 'news',
355
+ },
356
+ ]);
357
+ });
285
358
  it('should skip nested sub-where when ctx is not provided', async () => {
286
359
  const result = await parseWhere({ category: { path: 'news' } }, testCollection);
287
360
  expect(result.filters).toEqual([]);
@@ -526,6 +599,11 @@ describe('parseWhere — combinators', () => {
526
599
  expect(result.filters).toEqual([]);
527
600
  });
528
601
  it('parses combinators inside a nested relation sub-where', async () => {
602
+ // Inside the nested sub-where: `name` is a real field on the target
603
+ // (resolves to a FieldFilter) and `path` is a reserved key that
604
+ // downshifts to a `DocumentColumnFilter` against the target version's
605
+ // path column. Inside an `$or` either side composes — the OR is
606
+ // emitted as a CombinatorFilter wrapping both children.
529
607
  const result = await parseWhere({
530
608
  category: {
531
609
  $or: [{ name: 'News' }, { path: 'announcements' }],
@@ -541,7 +619,7 @@ describe('parseWhere — combinators', () => {
541
619
  kind: 'or',
542
620
  children: [
543
621
  { kind: 'field', fieldName: 'name' },
544
- { kind: 'field', fieldName: 'path' },
622
+ { kind: 'docColumn', column: 'path', operator: '$eq', value: 'announcements' },
545
623
  ],
546
624
  });
547
625
  });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "1.2.1",
5
+ "version": "1.4.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -79,7 +79,7 @@
79
79
  "sharp": "^0.34.5",
80
80
  "uuid": "^14.0.0",
81
81
  "zod": "^4.4.2",
82
- "@byline/auth": "1.2.1"
82
+ "@byline/auth": "1.3.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.14",