From 1024d83d8b599ebf941c5c705d90bd50c7fbbe44 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 17:46:23 +0100 Subject: [PATCH 01/12] refactor(schemas): rename schemas and improve validation tests - Renamed `BaseItemSchema` to `FileEntryBaseSchema` for clarity. - Updated `FileEntrySchema` tests to use `toMatchSchema` for better readability. - Added tests for `FileEntryListSchema` and `UCDStoreManifestSchema` to enhance coverage. - Improved validation logic for expected files in the manifest schema. --- packages/schemas/src/fs.ts | 52 ++++++--- packages/schemas/test/fs.test.ts | 185 ++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 22 deletions(-) diff --git a/packages/schemas/src/fs.ts b/packages/schemas/src/fs.ts index 4e9600f88..777aa5010 100644 --- a/packages/schemas/src/fs.ts +++ b/packages/schemas/src/fs.ts @@ -3,17 +3,15 @@ import { z } from "zod"; export const UCDStoreManifestSchema = z.record( z.string(), - z - .object({ - /** - * List of expected file paths for this version. - * Defaults to an empty array when not provided. - */ - expectedFiles: z.array(z.string()).default([]), - }) - .default({ - expectedFiles: [], - }), + z.object({ + /** + * List of expected file paths for this version. + * Defaults to an empty array when not provided. + */ + expectedFiles: z.array(z.string()).default([]), + }).default({ + expectedFiles: [], + }), ).meta({ id: "UCDStoreManifest", description: dedent` @@ -37,30 +35,50 @@ export const UCDStoreManifestSchema = z.record( export type UCDStoreManifest = z.output; -const BaseItemSchema = z.object({ +const FileEntryBaseSchema = z.object({ name: z.string(), path: z.string(), - lastModified: z.number(), + lastModified: z.number().or(z.null()), }); -const DirectoryResponseSchema = BaseItemSchema.extend({ +export const FileEntryDirectorySchema = FileEntryBaseSchema.extend({ type: z.literal("directory"), }); -const FileResponseSchema = BaseItemSchema.extend({ +export const FileEntryFileSchema = FileEntryBaseSchema.extend({ type: z.literal("file"), }); export const FileEntrySchema = z.union([ - DirectoryResponseSchema, - FileResponseSchema, + FileEntryDirectorySchema, + FileEntryFileSchema, ]).meta({ + id: "FileEntry", description: dedent` Response schema for a file entry in the UCD store. This schema represents either a directory listing or a file response. `, }); +// TODO: Add this to the FileEntrySchema +// But we need to add more of the features of #420, before we can do that. +/* .superRefine((data, ctx) => { + // Ensure that directory paths end with a slash + if (data.type === "directory" && !data.path.endsWith("/")) { + ctx.addIssue({ + code: "custom", + message: "Directory paths must end with a trailing slash ('/').", + }); + } + + // If the path doesn't start with a slash. + if (!data.path.startsWith("/")) { + ctx.addIssue({ + code: "custom", + message: "Paths must start with a leading slash ('/').", + }); + } +}); */ export type FileEntry = z.infer; diff --git a/packages/schemas/test/fs.test.ts b/packages/schemas/test/fs.test.ts index f4674d1bd..ea4064bf1 100644 --- a/packages/schemas/test/fs.test.ts +++ b/packages/schemas/test/fs.test.ts @@ -1,5 +1,7 @@ +/// + import { describe, expect, it } from "vitest"; -import { FileEntrySchema } from "../src/fs"; +import { FileEntryListSchema, FileEntrySchema, UCDStoreManifestSchema } from "../src/fs"; // eslint-disable-next-line test/prefer-lowercase-title describe("FileEntrySchema", () => { @@ -10,8 +12,14 @@ describe("FileEntrySchema", () => { lastModified: Date.now(), type: "directory", }; - const result = FileEntrySchema.safeParse(validDirectory); - expect(result.success).toBe(true); + expect(validDirectory).toMatchSchema({ + schema: FileEntrySchema, + success: true, + data: { + type: "directory", + name: "docs", + }, + }); }); it("should validate a file entry", () => { @@ -21,8 +29,14 @@ describe("FileEntrySchema", () => { lastModified: Date.now(), type: "file", }; - const result = FileEntrySchema.safeParse(validFile); - expect(result.success).toBe(true); + expect(validFile).toMatchSchema({ + schema: FileEntrySchema, + success: true, + data: { + type: "file", + name: "README.md", + }, + }); }); it("should invalidate an entry with missing fields", () => { @@ -30,7 +44,168 @@ describe("FileEntrySchema", () => { name: "README.md", path: "/docs/README.md", }; + expect(invalidEntry).toMatchSchema({ + schema: FileEntrySchema, + success: false, + }); + }); + + it("should invalidate an entry with invalid type", () => { + const invalidEntry = { + name: "README.md", + path: "/docs/README.md", + lastModified: Date.now(), + type: "symlink", // invalid type + }; + expect(invalidEntry).toMatchSchema({ + schema: FileEntrySchema, + success: false, + }); + }); + + it("should reject entry without name", () => { + const invalidEntry = { + path: "/docs/README.md", + lastModified: Date.now(), + type: "file", + }; const result = FileEntrySchema.safeParse(invalidEntry); expect(result.success).toBe(false); }); + + it("should reject entry without path", () => { + const invalidEntry = { + name: "README.md", + lastModified: Date.now(), + type: "file", + }; + const result = FileEntrySchema.safeParse(invalidEntry); + expect(result.success).toBe(false); + }); +}); + +// eslint-disable-next-line test/prefer-lowercase-title +describe("FileEntryListSchema", () => { + it("should validate a list of file entries", () => { + const validList = [ + { + name: "file1.txt", + path: "/file1.txt", + lastModified: Date.now(), + type: "file", + }, + { + name: "folder", + path: "/folder", + lastModified: Date.now(), + type: "directory", + }, + ]; + expect(validList).toMatchSchema({ + schema: FileEntryListSchema, + success: true, + }); + }); + + it("should validate an empty list", () => { + expect([]).toMatchSchema({ + schema: FileEntryListSchema, + success: true, + }); + }); + + it("should reject list with invalid entries", () => { + const invalidList = [ + { + name: "file1.txt", + path: "/file1.txt", + lastModified: Date.now(), + type: "file", + }, + { + name: "invalid", + // missing required fields + }, + ]; + expect(invalidList).toMatchSchema({ + schema: FileEntryListSchema, + success: false, + }); + }); +}); + +// eslint-disable-next-line test/prefer-lowercase-title +describe("UCDStoreManifestSchema", () => { + it("should validate a manifest with multiple versions", () => { + const validManifest = { + "16.0.0": { + expectedFiles: ["UnicodeData.txt", "PropList.txt"], + }, + "15.1.0": { + expectedFiles: ["UnicodeData.txt"], + }, + }; + expect(validManifest).toMatchSchema({ + schema: UCDStoreManifestSchema, + success: true, + }); + }); + + it("should use default empty array when expectedFiles is missing", () => { + const manifestWithDefaults = { + "16.0.0": {}, + }; + expect(manifestWithDefaults).toMatchSchema({ + schema: UCDStoreManifestSchema, + success: true, + }); + }); + + it("should validate empty manifest", () => { + const emptyManifest = {}; + expect(emptyManifest).toMatchSchema({ + schema: UCDStoreManifestSchema, + success: true, + }); + }); + + it("should validate manifest with version that has empty expectedFiles", () => { + const manifest = { + "16.0.0": { + expectedFiles: [], + }, + }; + expect(manifest).toMatchSchema({ + schema: UCDStoreManifestSchema, + success: true, + }); + }); + + it("should reject manifest with invalid expectedFiles type", () => { + const invalidManifest = { + "16.0.0": { + expectedFiles: "not-an-array", + }, + }; + expect(invalidManifest).toMatchSchema({ + schema: UCDStoreManifestSchema, + success: false, + }); + }); + + it("should validate manifest with nested file paths", () => { + const manifest = { + "16.0.0": { + expectedFiles: [ + "UnicodeData.txt", + "extracted/DerivedAge.txt", + "extracted/emoji/emoji-data.txt", + ], + }, + }; + expect(manifest).toMatchSchema({ + schema: UCDStoreManifestSchema, + success: true, + }); + }); }); From b11e692dc6d86eca61616bc13f899206b733e09e Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 19:04:04 +0100 Subject: [PATCH 02/12] refactor(schemas): rename Unicode tree schemas and add DeepOmit type --- packages/schemas/src/index.ts | 9 ++- packages/schemas/src/types.ts | 7 ++ packages/schemas/src/unicode.ts | 138 +++++++++++++++++++------------- 3 files changed, 95 insertions(+), 59 deletions(-) create mode 100644 packages/schemas/src/types.ts diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 9bee57a56..8d1a9b293 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -15,15 +15,16 @@ export type { Lockfile, LockfileInput, Snapshot } from "./lockfile"; export { UCDStoreVersionManifestSchema } from "./manifest"; export type { UCDStoreVersionManifest } from "./manifest"; export { - UnicodeTreeNodeSchema, - UnicodeTreeSchema, + UnicodeFileTreeNodeSchema, + UnicodeFileTreeSchema, UnicodeVersionDetailsSchema, UnicodeVersionListSchema, UnicodeVersionSchema, } from "./unicode"; export type { - UnicodeTree, - UnicodeTreeNode, + UnicodeFileTree, + UnicodeFileTreeNode, + UnicodeFileTreeNodeWithoutLastModified, UnicodeVersion, UnicodeVersionDetails, UnicodeVersionList, diff --git a/packages/schemas/src/types.ts b/packages/schemas/src/types.ts new file mode 100644 index 000000000..70fd071ee --- /dev/null +++ b/packages/schemas/src/types.ts @@ -0,0 +1,7 @@ +export type DeepOmit = T extends object + ? T extends any[] + ? DeepOmit[] + : { + [P in Exclude]: DeepOmit; + } + : T; diff --git a/packages/schemas/src/unicode.ts b/packages/schemas/src/unicode.ts index 826f52165..c0dcafb6a 100644 --- a/packages/schemas/src/unicode.ts +++ b/packages/schemas/src/unicode.ts @@ -1,4 +1,6 @@ +import type { DeepOmit } from "./types"; import { z } from "zod"; +import { FileEntryDirectorySchema, FileEntryFileSchema } from "./fs"; export const UnicodeVersionSchema = z.object({ version: z.string().meta({ @@ -62,67 +64,34 @@ export type UnicodeVersionList = z.output; // WE CAN'T USE RECURSIVE TYPES IN HONO ZOD OPENAPI, SO WE HAVE TO DEFINE THE INTERFACES MANUALLY type TreeNode = DirectoryTreeNode | FileTreeNode; -interface DirectoryTreeNode { - type: "directory"; +interface BaseTreeNode { + /** + * The name of the file or directory. + */ name: string; - path: string; - children: TreeNode[]; - lastModified?: number; // Unix timestamp -} -interface FileTreeNode { - type: "file"; - name: string; + /** + * The path to the file or directory. + * + * If the node is a directory, it will always end with a trailing slash (`/`). + */ path: string; - lastModified?: number; // Unix timestamp -} - -const BaseTreeNodeSchema = z.object({ - name: z.string().meta({ - description: "The name of the file or directory.", - }), - path: z.string().meta({ - description: "The path to the file or directory.", - }), - lastModified: z.number().optional().meta({ - description: "The last modified date of the directory, if available.", - }), -}); - -const DirectoryTreeNodeSchema: z.ZodType = BaseTreeNodeSchema.extend({ - type: z.literal("directory").meta({ - description: "The type of the entry, which is a directory.", - }), - // eslint-disable-next-line ts/no-use-before-define - children: z.array(z.lazy(() => UnicodeTreeNodeSchema)).meta({ - description: "The children of the directory.", - type: "array", - items: { - $ref: "#/components/schemas/UnicodeTreeNode", - }, - }), -}); - -const FileTreeNodeSchema = BaseTreeNodeSchema.extend({ - type: z.literal("file").meta({ - description: "The type of the entry, which is a file.", - }), -}); - -export const UnicodeTreeNodeSchema = z.union([DirectoryTreeNodeSchema, FileTreeNodeSchema]).meta({ - id: "UnicodeTreeNode", - description: "A node in the Unicode file tree.", -}); + /** + * The last modified date of the directory, if available. + */ + lastModified: number | null; // Unix timestamp -export type UnicodeTreeNode = z.output; +} -export const UnicodeTreeSchema = z.array(UnicodeTreeNodeSchema).meta({ - id: "UnicodeTree", - description: "A tree structure representing files and directories in a Unicode version.", -}); +interface DirectoryTreeNode extends BaseTreeNode { + type: "directory"; + children: TreeNode[]; +} -export type UnicodeTree = z.output; +interface FileTreeNode extends BaseTreeNode { + type: "file"; +} export const UnicodeVersionDetailsSchema = UnicodeVersionSchema.extend({ statistics: z.object({ @@ -144,7 +113,14 @@ export const UnicodeVersionDetailsSchema = UnicodeVersionSchema.extend({ newScripts: z.int().nonnegative().meta({ description: "Number of new scripts added in this version.", }), - }).optional().meta({ + }).default({ + newBlocks: 0, + newCharacters: 0, + newScripts: 0, + totalBlocks: 0, + totalCharacters: 0, + totalScripts: 0, + }).meta({ description: "Statistics about this Unicode version. May be null if statistics are not available.", }), }).meta({ @@ -171,3 +147,55 @@ export const UnicodeVersionDetailsSchema = UnicodeVersionSchema.extend({ }); export type UnicodeVersionDetails = z.output; + +const UnicodeFileTreeFileSchema = FileEntryFileSchema; + +const UnicodeFileTreeDirectorySchema: z.ZodType<{ + name: string; + path: string; + lastModified: number | null; + type: "directory"; + children: UnicodeFileTreeNode[]; +}> = FileEntryDirectorySchema.extend({ + // eslint-disable-next-line ts/no-use-before-define + children: z.array(z.lazy(() => UnicodeFileTreeNodeSchema)).meta({ + description: "The children of the directory.", + type: "array", + items: { + $ref: "#/components/schemas/UnicodeFileTreeNode", + }, + }), +}); + +export const UnicodeFileTreeNodeSchema = z.union([ + UnicodeFileTreeDirectorySchema, + UnicodeFileTreeFileSchema, +]).meta({ + id: "UnicodeFileTreeNode", + description: "A recursive file tree node; directories include children, files do not.", +}).superRefine((data, ctx) => { + if (data.type === "directory" && !("children" in data)) { + ctx.addIssue({ + code: "custom", + message: "Directory nodes must include children.", + }); + } + + if (data.type === "file" && "children" in data) { + ctx.addIssue({ + code: "custom", + message: "File nodes cannot have children.", + }); + } +}); + +export type UnicodeFileTreeNode = z.infer; + +export type UnicodeFileTreeNodeWithoutLastModified = DeepOmit; + +export const UnicodeFileTreeSchema = z.array(UnicodeFileTreeNodeSchema).meta({ + id: "UnicodeFileTree", + description: "A recursive file tree structure rooted at an array of entries.", +}); + +export type UnicodeFileTree = z.infer; From 8adafb3688081e28b49167ab6f4faa2c29e335e8 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 19:04:18 +0100 Subject: [PATCH 03/12] test(schemas): add comprehensive validation tests for schemas --- packages/schemas/test/api.test.ts | 195 ++++++++++++ packages/schemas/test/manifest.test.ts | 87 ++++++ packages/schemas/test/unicode.test.ts | 407 +++++++++++++++++++++++++ 3 files changed, 689 insertions(+) create mode 100644 packages/schemas/test/api.test.ts create mode 100644 packages/schemas/test/manifest.test.ts create mode 100644 packages/schemas/test/unicode.test.ts diff --git a/packages/schemas/test/api.test.ts b/packages/schemas/test/api.test.ts new file mode 100644 index 000000000..38c7d2053 --- /dev/null +++ b/packages/schemas/test/api.test.ts @@ -0,0 +1,195 @@ +/// + +import { describe, expect, it } from "vitest"; +import { ApiErrorSchema, UCDWellKnownConfigSchema } from "../src/api"; + +// eslint-disable-next-line test/prefer-lowercase-title +describe("ApiErrorSchema", () => { + it("should validate a complete error object", () => { + const validError = { + message: "File not found", + status: 404, + timestamp: "2024-01-01T00:00:00Z", + }; + + expect(validError).toMatchSchema({ + schema: ApiErrorSchema, + success: true, + data: { + message: "File not found", + status: 404, + timestamp: "2024-01-01T00:00:00Z", + }, + }); + }); + + it.each([ + { status: 400, expected: true }, + { status: 404, expected: true }, + { status: 500, expected: true }, + { status: 503, expected: true }, + ])("should validate different HTTP status codes: $status", ({ status, expected }) => { + const apiError = { + message: "Error", + status, + timestamp: "2024-01-01T00:00:00Z", + }; + + expect(apiError).toMatchSchema({ + schema: ApiErrorSchema, + success: expected, + data: { + message: "Error", + status, + timestamp: "2024-01-01T00:00:00Z", + }, + }); + }); + + it("should reject missing required fields", () => { + const invalidErrors = [ + { status: 404, timestamp: "2024-01-01T00:00:00Z" }, // missing message + { message: "Error", timestamp: "2024-01-01T00:00:00Z" }, // missing status + { message: "Error", status: 404 }, // missing timestamp + ]; + + for (const error of invalidErrors) { + const result = ApiErrorSchema.safeParse(error); + expect(result.success).toBe(false); + } + }); + + it("should reject invalid types", () => { + const invalidError = { + message: 123, // should be string + status: "404", // should be number + timestamp: new Date(), // should be string + }; + const result = ApiErrorSchema.safeParse(invalidError); + expect(result.success).toBe(false); + }); +}); + +// eslint-disable-next-line test/prefer-lowercase-title +describe("UCDWellKnownConfigSchema", () => { + it("should validate a complete config", () => { + const validConfig = { + version: "1.0", + endpoints: { + files: "/api/v1/files", + manifest: "/.well-known/ucd-store/{version}.json", + versions: "/api/v1/versions", + }, + versions: ["16.0.0", "15.1.0", "15.0.0"], + }; + expect(validConfig).toMatchSchema({ + schema: UCDWellKnownConfigSchema, + success: true, + data: { + version: "1.0", + endpoints: { + files: "/api/v1/files", + manifest: "/.well-known/ucd-store/{version}.json", + versions: "/api/v1/versions", + }, + versions: ["16.0.0", "15.1.0", "15.0.0"], + }, + }); + }); + + it("should validate config with minimal data", () => { + const minimalConfig = { + endpoints: { + files: "/api/v1/files", + manifest: "/.well-known/ucd-store/{version}.json", + versions: "/api/v1/versions", + }, + }; + expect(minimalConfig).toMatchSchema({ + schema: UCDWellKnownConfigSchema, + success: true, + data: { + version: "1.0", // default value + versions: [], // default value + }, + }); + }); + + it("should validate config with empty versions", () => { + const configWithEmptyVersions = { + version: "1.0", + endpoints: { + files: "/api/v1/files", + manifest: "/.well-known/ucd-store/{version}.json", + versions: "/api/v1/versions", + }, + versions: [], + }; + expect(configWithEmptyVersions).toMatchSchema({ + schema: UCDWellKnownConfigSchema, + success: true, + data: { + versions: [], + }, + }); + }); + + it("should reject missing endpoints", () => { + const invalidConfig = { + version: "1.0", + versions: ["16.0.0"], + // missing endpoints + }; + expect(invalidConfig).toMatchSchema({ + schema: UCDWellKnownConfigSchema, + success: false, + }); + }); + + it("should reject incomplete endpoints", () => { + const invalidConfigs = [ + { + endpoints: { + files: "/api/v1/files", + manifest: "/.well-known/ucd-store/{version}.json", + // missing versions + }, + }, + { + endpoints: { + files: "/api/v1/files", + // missing manifest + versions: "/api/v1/versions", + }, + }, + { + endpoints: { + // missing files + manifest: "/.well-known/ucd-store/{version}.json", + versions: "/api/v1/versions", + }, + }, + ]; + + for (const config of invalidConfigs) { + const result = UCDWellKnownConfigSchema.safeParse(config); + expect(result.success).toBe(false); + } + }); + + it("should reject invalid types", () => { + const invalidConfig = { + version: 1, // should be string + endpoints: { + files: "/api/v1/files", + manifest: "/.well-known/ucd-store/{version}.json", + versions: "/api/v1/versions", + }, + versions: "16.0.0", // should be array + }; + expect(invalidConfig).toMatchSchema({ + schema: UCDWellKnownConfigSchema, + success: false, + }); + }); +}); diff --git a/packages/schemas/test/manifest.test.ts b/packages/schemas/test/manifest.test.ts new file mode 100644 index 000000000..7b794d969 --- /dev/null +++ b/packages/schemas/test/manifest.test.ts @@ -0,0 +1,87 @@ +/// + +import { describe, expect, it } from "vitest"; +import { UCDStoreVersionManifestSchema } from "../src/manifest"; + +// eslint-disable-next-line test/prefer-lowercase-title +describe("UCDStoreVersionManifestSchema", () => { + it("should validate a manifest with expected files", () => { + const validManifest = { + expectedFiles: [ + "UnicodeData.txt", + "PropList.txt", + "DerivedAge.txt", + ], + }; + expect(validManifest).toMatchSchema({ + schema: UCDStoreVersionManifestSchema, + success: true, + data: { + expectedFiles: [ + "UnicodeData.txt", + "PropList.txt", + "DerivedAge.txt", + ], + }, + }); + }); + + it("should validate a manifest with empty files array", () => { + const emptyManifest = { + expectedFiles: [], + }; + expect(emptyManifest).toMatchSchema({ + schema: UCDStoreVersionManifestSchema, + success: true, + data: { + expectedFiles: [], + }, + }); + }); + + it("should validate manifest with nested paths", () => { + const manifestWithPaths = { + expectedFiles: [ + "UnicodeData.txt", + "extracted/DerivedAge.txt", + "extracted/emoji/emoji-data.txt", + ], + }; + expect(manifestWithPaths).toMatchSchema({ + schema: UCDStoreVersionManifestSchema, + success: true, + }); + }); + + it("should reject missing expectedFiles", () => { + const invalidManifest = {}; + expect(invalidManifest).toMatchSchema({ + schema: UCDStoreVersionManifestSchema, + success: false, + }); + }); + + it("should reject non-array expectedFiles", () => { + const invalidManifest = { + expectedFiles: "UnicodeData.txt", + }; + expect(invalidManifest).toMatchSchema({ + schema: UCDStoreVersionManifestSchema, + success: false, + }); + }); + + it("should reject expectedFiles with non-string elements", () => { + const invalidManifest = { + expectedFiles: [ + "UnicodeData.txt", + 123, // should be string + "PropList.txt", + ], + }; + expect(invalidManifest).toMatchSchema({ + schema: UCDStoreVersionManifestSchema, + success: false, + }); + }); +}); diff --git a/packages/schemas/test/unicode.test.ts b/packages/schemas/test/unicode.test.ts new file mode 100644 index 000000000..26087ef28 --- /dev/null +++ b/packages/schemas/test/unicode.test.ts @@ -0,0 +1,407 @@ +/// +import { describe, expect, it } from "vitest"; +import { + UnicodeFileTreeNodeSchema, + UnicodeFileTreeSchema, + UnicodeVersionDetailsSchema, + UnicodeVersionListSchema, + UnicodeVersionSchema, +} from "../src/unicode"; + +// eslint-disable-next-line test/prefer-lowercase-title +describe("UnicodeVersionSchema", () => { + it("should validate a stable version", () => { + const validVersion = { + version: "16.0.0", + documentationUrl: "https://www.unicode.org/versions/Unicode16.0.0/", + date: "2024", + url: "https://www.unicode.org/Public/16.0.0", + mappedUcdVersion: null, + type: "stable", + }; + expect(validVersion).toMatchSchema({ + schema: UnicodeVersionSchema, + success: true, + data: { + version: "16.0.0", + type: "stable", + }, + }); + }); + + it("should validate a draft version", () => { + const draftVersion = { + version: "17.0.0", + documentationUrl: "https://www.unicode.org/versions/Unicode17.0.0/", + date: null, + url: "https://www.unicode.org/Public/17.0.0", + mappedUcdVersion: null, + type: "draft", + }; + expect(draftVersion).toMatchSchema({ + schema: UnicodeVersionSchema, + success: true, + data: { + type: "draft", + date: null, + }, + }); + }); + + it("should validate an unsupported version", () => { + const unsupportedVersion = { + version: "1.0.0", + documentationUrl: "https://www.unicode.org/versions/Unicode1.0.0/", + date: "1991", + url: "https://www.unicode.org/Public/1.0.0", + mappedUcdVersion: null, + type: "unsupported", + }; + expect(unsupportedVersion).toMatchSchema({ + schema: UnicodeVersionSchema, + success: true, + data: { + type: "unsupported", + }, + }); + }); + + it("should validate version with mapped UCD version", () => { + const versionWithMapping = { + version: "2.0.0", + documentationUrl: "https://www.unicode.org/versions/Unicode2.0.0/", + date: "1996", + url: "https://www.unicode.org/Public/2.0.0", + mappedUcdVersion: "2.0.14", + type: "stable", + }; + expect(versionWithMapping).toMatchSchema({ + schema: UnicodeVersionSchema, + success: true, + data: { + mappedUcdVersion: "2.0.14", + }, + }); + }); + + it("should reject invalid date format", () => { + const invalidVersion = { + version: "16.0.0", + documentationUrl: "https://www.unicode.org/versions/Unicode16.0.0/", + date: "24", // should be 4 digits + url: "https://www.unicode.org/Public/16.0.0", + mappedUcdVersion: null, + type: "stable", + }; + expect(invalidVersion).toMatchSchema({ + schema: UnicodeVersionSchema, + success: false, + }); + }); + + it("should reject invalid URL", () => { + const invalidVersion = { + version: "16.0.0", + documentationUrl: "not-a-url", + date: "2024", + url: "https://www.unicode.org/Public/16.0.0", + mappedUcdVersion: null, + type: "stable", + }; + expect(invalidVersion).toMatchSchema({ + schema: UnicodeVersionSchema, + success: false, + }); + }); + + it("should reject invalid type", () => { + const invalidVersion = { + version: "16.0.0", + documentationUrl: "https://www.unicode.org/versions/Unicode16.0.0/", + date: "2024", + url: "https://www.unicode.org/Public/16.0.0", + mappedUcdVersion: null, + type: "beta", // invalid type + }; + expect(invalidVersion).toMatchSchema({ + schema: UnicodeVersionSchema, + success: false, + }); + }); +}); + +// eslint-disable-next-line test/prefer-lowercase-title +describe("UnicodeVersionListSchema", () => { + it("should validate a list of versions", () => { + const validList = [ + { + version: "16.0.0", + documentationUrl: "https://www.unicode.org/versions/Unicode16.0.0/", + date: "2024", + url: "https://www.unicode.org/Public/16.0.0", + mappedUcdVersion: null, + type: "stable", + }, + { + version: "15.1.0", + documentationUrl: "https://www.unicode.org/versions/Unicode15.1.0/", + date: "2023", + url: "https://www.unicode.org/Public/15.1.0", + mappedUcdVersion: null, + type: "stable", + }, + ]; + expect(validList).toMatchSchema({ + schema: UnicodeVersionListSchema, + success: true, + }); + }); + + it("should validate an empty list", () => { + expect([]).toMatchSchema({ + schema: UnicodeVersionListSchema, + success: true, + }); + }); +}); + +// eslint-disable-next-line test/prefer-lowercase-title +describe("UnicodeFileTreeNodeSchema", () => { + it("should validate a file node", () => { + const fileNode = { + type: "file", + name: "UnicodeData.txt", + path: "/16.0.0/UnicodeData.txt", + lastModified: 1704067200000, + }; + expect(fileNode).toMatchSchema({ + schema: UnicodeFileTreeNodeSchema, + success: true, + data: { + type: "file", + name: "UnicodeData.txt", + }, + }); + }); + + it("should validate a file node without lastModified", () => { + const fileNode = { + type: "file", + name: "UnicodeData.txt", + path: "/16.0.0/UnicodeData.txt", + }; + + expect(fileNode).toMatchSchema({ + schema: UnicodeFileTreeNodeSchema, + success: false, + }); + }); + + it("should validate a directory node", () => { + const directoryNode = { + type: "directory", + name: "extracted", + path: "/16.0.0/extracted/", + lastModified: 1704067200000, + children: [ + { + type: "file", + name: "UnicodeData.txt", + path: "/16.0.0/extracted/UnicodeData.txt", + lastModified: 1704067200000, + }, + ], + }; + expect(directoryNode).toMatchSchema({ + schema: UnicodeFileTreeNodeSchema, + success: true, + data: { + type: "directory", + }, + }); + }); + + it("should validate nested directory structure", () => { + const nestedStructure = { + type: "directory", + name: "root", + path: "/root/", + lastModified: null, + children: [ + { + type: "directory", + name: "level1", + path: "/root/level1/", + lastModified: null, + children: [ + { + type: "directory", + name: "level2", + path: "/root/level1/level2/", + lastModified: null, + children: [ + { + type: "file", + name: "deep.txt", + path: "/root/level1/level2/deep.txt", + lastModified: null, + }, + ], + }, + ], + }, + ], + }; + expect(nestedStructure).toMatchSchema({ + schema: UnicodeFileTreeNodeSchema, + success: true, + }); + }); + + it("should validate directory with empty children", () => { + const emptyDirectory = { + type: "directory", + name: "empty", + path: "/empty/", + children: [], + lastModified: null, + }; + expect(emptyDirectory).toMatchSchema({ + schema: UnicodeFileTreeNodeSchema, + success: true, + }); + }); + + it("should reject file node with children", () => { + const invalidFile = { + type: "file", + name: "file.txt", + path: "/file.txt", + children: [], // files shouldn't have children + }; + expect(invalidFile).toMatchSchema({ + schema: UnicodeFileTreeNodeSchema, + success: false, + }); + }); + + it("should reject directory without children", () => { + const invalidDirectory = { + type: "directory", + name: "folder", + path: "/folder", + // missing children + }; + expect(invalidDirectory).toMatchSchema({ + schema: UnicodeFileTreeNodeSchema, + success: false, + }); + }); +}); + +// eslint-disable-next-line test/prefer-lowercase-title +describe("UnicodeFileTreeSchema", () => { + it("should validate a tree structure", () => { + const validTree = [ + { + type: "file", + name: "README.txt", + path: "/README.txt", + lastModified: null, + }, + { + type: "directory", + name: "docs", + path: "/docs/", + lastModified: 1704067200000, + children: [ + { + type: "file", + name: "index.html", + path: "/docs/index.html", + lastModified: 1704067200000, + }, + ], + }, + ]; + + expect(validTree).toMatchSchema({ + schema: UnicodeFileTreeSchema, + success: true, + }); + }); + + it("should validate an empty tree", () => { + expect([]).toMatchSchema({ + schema: UnicodeFileTreeSchema, + success: true, + }); + }); +}); + +// eslint-disable-next-line test/prefer-lowercase-title +describe("UnicodeVersionDetailsSchema", () => { + it("should validate version details with statistics", () => { + const validDetails = { + version: "16.0.0", + documentationUrl: "https://www.unicode.org/versions/Unicode16.0.0/", + date: "2024", + url: "https://www.unicode.org/Public/16.0.0", + mappedUcdVersion: null, + type: "stable", + statistics: { + totalCharacters: 149813, + newCharacters: 5185, + totalBlocks: 337, + newBlocks: 8, + totalScripts: 161, + newScripts: 4, + }, + }; + expect(validDetails).toMatchSchema({ + schema: UnicodeVersionDetailsSchema, + success: true, + data: { + version: "16.0.0", + }, + }); + }); + + it("should use default statistics when not provided", () => { + const detailsWithoutStats = { + version: "16.0.0", + documentationUrl: "https://www.unicode.org/versions/Unicode16.0.0/", + date: "2024", + url: "https://www.unicode.org/Public/16.0.0", + mappedUcdVersion: null, + type: "stable", + }; + expect(detailsWithoutStats).toMatchSchema({ + schema: UnicodeVersionDetailsSchema, + success: true, + }); + }); + + it("should reject negative statistics", () => { + const invalidDetails = { + version: "16.0.0", + documentationUrl: "https://www.unicode.org/versions/Unicode16.0.0/", + date: "2024", + url: "https://www.unicode.org/Public/16.0.0", + mappedUcdVersion: null, + type: "stable", + statistics: { + totalCharacters: -100, + newCharacters: 5185, + totalBlocks: 337, + newBlocks: 8, + totalScripts: 161, + newScripts: 4, + }, + }; + expect(invalidDetails).toMatchSchema({ + schema: UnicodeVersionDetailsSchema, + success: false, + }); + }); +}); From e9a730237c847d46e349defb893c9a75b77f653e Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 19:10:33 +0100 Subject: [PATCH 04/12] chore: upgrade zod to fix issues with duplicate ids --- pnpm-lock.yaml | 106 +++++++++++++++++++++++--------------------- pnpm-workspace.yaml | 2 +- 2 files changed, 57 insertions(+), 51 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 790d0584d..37fa11832 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,8 +110,8 @@ catalogs: specifier: 22.0.0 version: 22.0.0 zod: - specifier: 4.2.1 - version: 4.2.1 + specifier: 4.3.5 + version: 4.3.5 testing: '@cloudflare/vitest-pool-workers': specifier: https://pkg.pr.new/@cloudflare/vitest-pool-workers@11632 @@ -308,7 +308,7 @@ importers: dependencies: '@hono/zod-openapi': specifier: catalog:workers - version: 1.2.0(hono@4.11.1)(zod@4.2.1) + version: 1.2.0(hono@4.11.1)(zod@4.3.5) '@luxass/utils': specifier: catalog:prod version: 2.7.2 @@ -347,7 +347,7 @@ importers: version: 0.2.0 zod: specifier: catalog:prod - version: 4.2.1 + version: 4.3.5 devDependencies: '@cloudflare/vitest-pool-workers': specifier: catalog:testing @@ -435,13 +435,13 @@ importers: version: 2.1.1 fumadocs-core: specifier: catalog:docs - version: 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.2.1) + version: 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) fumadocs-mdx: specifier: catalog:docs - version: 14.2.2(@types/react@19.2.7)(fumadocs-core@16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.2.1))(react@19.2.3)(vite@7.3.0(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 14.2.2(@types/react@19.2.7)(fumadocs-core@16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5))(react@19.2.3)(vite@7.3.0(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)) fumadocs-ui: specifier: catalog:docs - version: 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.2.1) + version: 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5) lucide-react: specifier: catalog:web version: 0.562.0(react@19.2.3) @@ -462,7 +462,7 @@ importers: version: 1.4.0 zod: specifier: catalog:prod - version: 4.2.1 + version: 4.3.5 devDependencies: '@luxass/eslint-config': specifier: catalog:linting @@ -691,7 +691,7 @@ importers: version: 2.0.3 zod: specifier: catalog:prod - version: 4.2.1 + version: 4.3.5 devDependencies: '@luxass/eslint-config': specifier: catalog:linting @@ -802,7 +802,7 @@ importers: dependencies: '@ai-sdk/openai': specifier: catalog:prod - version: 3.0.0(zod@4.2.1) + version: 3.0.0(zod@4.3.5) '@luxass/unicode-utils-old': specifier: catalog:prod version: '@luxass/unicode-utils@0.11.0' @@ -814,13 +814,13 @@ importers: version: link:../shared ai: specifier: catalog:prod - version: 6.0.1(zod@4.2.1) + version: 6.0.1(zod@4.3.5) knitwork: specifier: catalog:prod version: 1.3.0 zod: specifier: catalog:prod - version: 4.2.1 + version: 4.3.5 devDependencies: '@luxass/eslint-config': specifier: catalog:linting @@ -854,7 +854,7 @@ importers: version: 2.7.2 zod: specifier: catalog:prod - version: 4.2.1 + version: 4.3.5 devDependencies: '@luxass/eslint-config': specifier: catalog:linting @@ -906,7 +906,7 @@ importers: version: 4.0.3 zod: specifier: catalog:prod - version: 4.2.1 + version: 4.3.5 devDependencies: '@luxass/eslint-config': specifier: catalog:linting @@ -958,7 +958,7 @@ importers: version: 2.12.4(@types/node@24.3.1)(typescript@5.9.3) zod: specifier: catalog:prod - version: 4.2.1 + version: 4.3.5 devDependencies: '@luxass/eslint-config': specifier: catalog:linting @@ -1028,7 +1028,7 @@ importers: version: 2.0.3 zod: specifier: catalog:prod - version: 4.2.1 + version: 4.3.5 devDependencies: '@luxass/eslint-config': specifier: catalog:linting @@ -1092,7 +1092,7 @@ importers: version: 2.0.3 zod: specifier: catalog:prod - version: 4.2.1 + version: 4.3.5 devDependencies: '@luxass/eslint-config': specifier: catalog:linting @@ -1270,7 +1270,7 @@ importers: version: 1.5.1 zod: specifier: catalog:prod - version: 4.2.1 + version: 4.3.5 packages: @@ -9243,6 +9243,9 @@ packages: zod@4.2.1: resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + zod@4.3.5: + resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9251,25 +9254,25 @@ snapshots: '@acemir/cssom@0.9.29': optional: true - '@ai-sdk/gateway@3.0.0(zod@4.2.1)': + '@ai-sdk/gateway@3.0.0(zod@4.3.5)': dependencies: '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.0(zod@4.2.1) + '@ai-sdk/provider-utils': 4.0.0(zod@4.3.5) '@vercel/oidc': 3.0.5 - zod: 4.2.1 + zod: 4.3.5 - '@ai-sdk/openai@3.0.0(zod@4.2.1)': + '@ai-sdk/openai@3.0.0(zod@4.3.5)': dependencies: '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.0(zod@4.2.1) - zod: 4.2.1 + '@ai-sdk/provider-utils': 4.0.0(zod@4.3.5) + zod: 4.3.5 - '@ai-sdk/provider-utils@4.0.0(zod@4.2.1)': + '@ai-sdk/provider-utils@4.0.0(zod@4.3.5)': dependencies: '@ai-sdk/provider': 3.0.0 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 4.2.1 + zod: 4.3.5 '@ai-sdk/provider@3.0.0': dependencies: @@ -9308,10 +9311,10 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': optional: true - '@asteasolutions/zod-to-openapi@8.2.0(patch_hash=abfc1e6cb22ebf21d3877734ff544a712b7a8cd4fe22639ee9bff0b0b5d18557)(zod@4.2.1)': + '@asteasolutions/zod-to-openapi@8.2.0(patch_hash=abfc1e6cb22ebf21d3877734ff544a712b7a8cd4fe22639ee9bff0b0b5d18557)(zod@4.3.5)': dependencies: openapi3-ts: 4.5.0 - zod: 4.2.1 + zod: 4.3.5 '@asyncapi/specs@6.10.0': dependencies: @@ -10289,9 +10292,9 @@ snapshots: '@formatjs/fast-memoize': 3.0.1 tslib: 2.8.1 - '@fumadocs/ui@16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.2.1)': + '@fumadocs/ui@16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5)': dependencies: - fumadocs-core: 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.2.1) + fumadocs-core: 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) lodash.merge: 4.6.2 next-themes: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) postcss-selector-parser: 7.1.1 @@ -10312,18 +10315,18 @@ snapshots: - waku - zod - '@hono/zod-openapi@1.2.0(hono@4.11.1)(zod@4.2.1)': + '@hono/zod-openapi@1.2.0(hono@4.11.1)(zod@4.3.5)': dependencies: - '@asteasolutions/zod-to-openapi': 8.2.0(patch_hash=abfc1e6cb22ebf21d3877734ff544a712b7a8cd4fe22639ee9bff0b0b5d18557)(zod@4.2.1) - '@hono/zod-validator': 0.7.6(hono@4.11.1)(zod@4.2.1) + '@asteasolutions/zod-to-openapi': 8.2.0(patch_hash=abfc1e6cb22ebf21d3877734ff544a712b7a8cd4fe22639ee9bff0b0b5d18557)(zod@4.3.5) + '@hono/zod-validator': 0.7.6(hono@4.11.1)(zod@4.3.5) hono: 4.11.1 openapi3-ts: 4.5.0 - zod: 4.2.1 + zod: 4.3.5 - '@hono/zod-validator@0.7.6(hono@4.11.1)(zod@4.2.1)': + '@hono/zod-validator@0.7.6(hono@4.11.1)(zod@4.3.5)': dependencies: hono: 4.11.1 - zod: 4.2.1 + zod: 4.3.5 '@humanfs/core@0.19.1': {} @@ -11428,7 +11431,7 @@ snapshots: '@scalar/helpers': 0.2.4 nanoid: 5.1.5 type-fest: 5.0.0 - zod: 4.2.1 + zod: 4.3.5 '@sec-ant/readable-stream@0.4.1': {} @@ -12910,13 +12913,13 @@ snapshots: agent-base@7.1.4: {} - ai@6.0.1(zod@4.2.1): + ai@6.0.1(zod@4.3.5): dependencies: - '@ai-sdk/gateway': 3.0.0(zod@4.2.1) + '@ai-sdk/gateway': 3.0.0(zod@4.3.5) '@ai-sdk/provider': 3.0.0 - '@ai-sdk/provider-utils': 4.0.0(zod@4.2.1) + '@ai-sdk/provider-utils': 4.0.0(zod@4.3.5) '@opentelemetry/api': 1.9.0 - zod: 4.2.1 + zod: 4.3.5 ajv-draft-04@1.0.0(ajv@8.17.1): optionalDependencies: @@ -14510,7 +14513,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.2.1): + fumadocs-core@16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5): dependencies: '@formatjs/intl-localematcher': 0.7.3 '@orama/orama': 3.1.17 @@ -14536,18 +14539,18 @@ snapshots: lucide-react: 0.562.0(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - zod: 4.2.1 + zod: 4.3.5 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.2.2(@types/react@19.2.7)(fumadocs-core@16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.2.1))(react@19.2.3)(vite@7.3.0(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)): + fumadocs-mdx@14.2.2(@types/react@19.2.7)(fumadocs-core@16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5))(react@19.2.3)(vite@7.3.0(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1)): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.27.2 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.2.1) + fumadocs-core: 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) js-yaml: 4.1.1 mdast-util-to-markdown: 2.1.2 picocolors: 1.1.1 @@ -14559,7 +14562,7 @@ snapshots: unist-util-remove-position: 5.0.0 unist-util-visit: 5.0.0 vfile: 6.0.3 - zod: 4.2.1 + zod: 4.3.5 optionalDependencies: '@types/react': 19.2.7 react: 19.2.3 @@ -14567,9 +14570,9 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.2.1): + fumadocs-ui@16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5): dependencies: - '@fumadocs/ui': 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.2.1) + '@fumadocs/ui': 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.18)(zod@4.3.5) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -14581,7 +14584,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.7)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) class-variance-authority: 0.7.1 - fumadocs-core: 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.2.1) + fumadocs-core: 16.3.2(@tanstack/react-router@1.142.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.562.0(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.5) next-themes: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -18325,6 +18328,9 @@ snapshots: zod@3.25.76: {} - zod@4.2.1: {} + zod@4.2.1: + optional: true + + zod@4.3.5: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b615d27fe..0c459a7ee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -53,7 +53,7 @@ catalogs: farver: 0.4.2 yargs-parser: 22.0.0 defu: 6.1.4 - zod: 4.2.1 + zod: 4.3.5 "@ai-sdk/openai": 3.0.0 ai: 6.0.1 knitwork: 1.3.0 From 3224787c99151a9cacac7fdc73aaa22811f9a739 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 19:11:03 +0100 Subject: [PATCH 05/12] chore: perfom renames for unicode file tree --- apps/api/src/routes/v1_versions/$version.ts | 5 +++-- packages/client/src/resources/versions.ts | 4 ++-- packages/test-utils/src/mock-store/types.ts | 4 ++-- packages/test-utils/test/mock-store/mock-store.test.ts | 10 +++++----- packages/ucd-store-v2/src/operations/files/tree.ts | 6 +++--- packages/ucd-store-v2/src/types.ts | 4 ++-- packages/ucd-store/src/store.ts | 4 ++-- packages/ucd-store/test/internal/files.test.ts | 4 ++-- packages/ucd-store/test/maintenance/analyze.test.ts | 4 ++-- vscode/src/lib/files.ts | 4 ++-- 10 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/api/src/routes/v1_versions/$version.ts b/apps/api/src/routes/v1_versions/$version.ts index 66982daaf..01678468d 100644 --- a/apps/api/src/routes/v1_versions/$version.ts +++ b/apps/api/src/routes/v1_versions/$version.ts @@ -2,7 +2,7 @@ import type { OpenAPIHono } from "@hono/zod-openapi"; import type { HonoEnv } from "../../types"; import { createRoute } from "@hono/zod-openapi"; import { dedent } from "@luxass/utils"; -import { UnicodeTreeSchema, UnicodeVersionDetailsSchema } from "@ucdjs/schemas"; +import { UnicodeFileTreeNodeSchema, UnicodeFileTreeSchema, UnicodeVersionDetailsSchema } from "@ucdjs/schemas"; import { hasUCDFolderPath, resolveUCDVersion, @@ -108,7 +108,7 @@ export const GET_VERSION_FILE_TREE_ROUTE = createRoute({ 200: { content: { "application/json": { - schema: UnicodeTreeSchema, + schema: UnicodeFileTreeSchema, examples: { default: { summary: "File tree for a Unicode version", @@ -221,6 +221,7 @@ export function registerGetVersionRoute(router: OpenAPIHono) { } export function registerVersionFileTreeRoute(router: OpenAPIHono) { + router.openAPIRegistry.register("UnicodeFileTreeNode", UnicodeFileTreeNodeSchema); router.openapi(GET_VERSION_FILE_TREE_ROUTE, async (c) => { try { let version = c.req.param("version"); diff --git a/packages/client/src/resources/versions.ts b/packages/client/src/resources/versions.ts index 02b41ae33..b94b35171 100644 --- a/packages/client/src/resources/versions.ts +++ b/packages/client/src/resources/versions.ts @@ -2,7 +2,7 @@ import type { SafeFetchResponse } from "@ucdjs-internal/shared"; import type { UCDWellKnownConfig } from "@ucdjs/schemas"; import type { paths } from "../.generated/api"; import { customFetch } from "@ucdjs-internal/shared"; -import { UnicodeTreeSchema, UnicodeVersionListSchema } from "@ucdjs/schemas"; +import { UnicodeFileTreeSchema, UnicodeVersionListSchema } from "@ucdjs/schemas"; type VersionsListResponse = paths["/api/v1/versions"]["get"]["responses"][200]["content"]["application/json"]; type FileTreeResponse = paths["/api/v1/versions/{version}/file-tree"]["get"]["responses"][200]["content"]["application/json"]; @@ -46,7 +46,7 @@ export function createVersionsResource(options: CreateVersionsResourceOptions): return customFetch.safe(url.toString(), { parseAs: "json", - schema: UnicodeTreeSchema, + schema: UnicodeFileTreeSchema, }); }, }; diff --git a/packages/test-utils/src/mock-store/types.ts b/packages/test-utils/src/mock-store/types.ts index 0e77d2579..485d077cb 100644 --- a/packages/test-utils/src/mock-store/types.ts +++ b/packages/test-utils/src/mock-store/types.ts @@ -1,5 +1,5 @@ import type { MockFetchFn } from "@luxass/msw-utils"; -import type { UnicodeTree } from "@ucdjs/schemas"; +import type { UnicodeFileTree } from "@ucdjs/schemas"; import type { AsyncResponseResolverReturnType, DefaultBodyType, HttpResponseResolver, PathParams } from "msw"; import type { paths } from "../.generated/api"; import type { MOCK_ROUTES } from "./handlers"; @@ -81,7 +81,7 @@ type PartialRecord = { [P in K]?: T; }; -export type MockStoreFiles = PartialRecord; +export type MockStoreFiles = PartialRecord; export interface MockStoreConfig { /** diff --git a/packages/test-utils/test/mock-store/mock-store.test.ts b/packages/test-utils/test/mock-store/mock-store.test.ts index 38b88e1c4..18bddeb93 100644 --- a/packages/test-utils/test/mock-store/mock-store.test.ts +++ b/packages/test-utils/test/mock-store/mock-store.test.ts @@ -1,4 +1,4 @@ -import type { UCDWellKnownConfig, UnicodeTree, UnicodeVersionList } from "@ucdjs/schemas"; +import type { UCDWellKnownConfig, UnicodeFileTree, UnicodeVersionList } from "@ucdjs/schemas"; import { HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; import { configure, mockStoreApi } from "../../src/mock-store"; @@ -200,7 +200,7 @@ describe("mockStoreApi", () => { mockStoreApi({ responses: { "/api/v1/versions/{version}/file-tree": vi.fn(async ({ params }) => { - const tree: UnicodeTree = [ + const tree: UnicodeFileTree = [ { type: "file", name: `test-${params.version}.txt`, @@ -208,7 +208,7 @@ describe("mockStoreApi", () => { }, ]; - return HttpResponse.json(tree); + return HttpResponse.json(tree); }), }, }); @@ -406,7 +406,7 @@ describe("mockStoreApi", () => { }); it("should handle mix of enabled, disabled, and custom endpoints", async () => { - const customTree: UnicodeTree = [ + const customTree: UnicodeFileTree = [ { type: "file", name: "custom.txt", @@ -974,7 +974,7 @@ describe("mockStoreApi", () => { it("should work with configure() hooks and default resolver using files option", async () => { const beforeHook = vi.fn(); const afterHook = vi.fn(); - const files: UnicodeTree = [ + const files: UnicodeFileTree = [ { type: "file", name: "test.txt", diff --git a/packages/ucd-store-v2/src/operations/files/tree.ts b/packages/ucd-store-v2/src/operations/files/tree.ts index 390757871..ebad46e91 100644 --- a/packages/ucd-store-v2/src/operations/files/tree.ts +++ b/packages/ucd-store-v2/src/operations/files/tree.ts @@ -1,5 +1,5 @@ import type { OperationResult } from "@ucdjs-internal/shared"; -import type { UnicodeTreeNode } from "@ucdjs/schemas"; +import type { UnicodeFileTreeNode } from "@ucdjs/schemas"; import type { StoreError } from "../../errors"; import type { InternalUCDStoreContext, SharedOperationOptions } from "../../types"; import { @@ -28,13 +28,13 @@ export interface GetFileTreeOptions extends SharedOperationOptions { * @param {InternalUCDStoreContext} context - Internal store context with client, filters, and configuration * @param {string} version - The Unicode version to fetch the file tree for * @param {GetFileTreeOptions} [options] - Optional filters and API fallback behavior - * @returns {Promise>} Operation result with filtered file tree or error + * @returns {Promise>} Operation result with filtered file tree or error */ export async function getFileTree( context: InternalUCDStoreContext, version: string, options?: GetFileTreeOptions, -): Promise> { +): Promise> { return wrapTry(async () => { // Validate version exists in store if (!context.versions.includes(version)) { diff --git a/packages/ucd-store-v2/src/types.ts b/packages/ucd-store-v2/src/types.ts index a5b169834..1c370526e 100644 --- a/packages/ucd-store-v2/src/types.ts +++ b/packages/ucd-store-v2/src/types.ts @@ -1,7 +1,7 @@ import type { OperationResult, PathFilter, PathFilterOptions } from "@ucdjs-internal/shared"; import type { UCDClient } from "@ucdjs/client"; import type { FileSystemBridge } from "@ucdjs/fs-bridge"; -import type { UCDWellKnownConfig, UnicodeTreeNode } from "@ucdjs/schemas"; +import type { UCDWellKnownConfig, UnicodeFileTreeNode } from "@ucdjs/schemas"; import type { StoreError } from "./errors"; import type { AnalysisReport, AnalyzeOptions } from "./operations/analyze"; import type { GetFileOptions } from "./operations/files/get"; @@ -170,7 +170,7 @@ export interface UCDStoreFileOperations { * Get the file tree structure for a Unicode version. * Returns a hierarchical tree of files and directories. */ - tree: (version: string, options?: GetFileTreeOptions) => Promise>; + tree: (version: string, options?: GetFileTreeOptions) => Promise>; } export interface UCDStoreOperations { diff --git a/packages/ucd-store/src/store.ts b/packages/ucd-store/src/store.ts index e71e6bd5f..2c97ccf30 100644 --- a/packages/ucd-store/src/store.ts +++ b/packages/ucd-store/src/store.ts @@ -1,7 +1,7 @@ import type { OperationResult, PathFilter, PathFilterOptions } from "@ucdjs-internal/shared"; import type { UCDClient } from "@ucdjs/client"; import type { FileSystemBridge } from "@ucdjs/fs-bridge"; -import type { UCDStoreManifest, UnicodeTreeNode } from "@ucdjs/schemas"; +import type { UCDStoreManifest, UnicodeFileTreeNode } from "@ucdjs/schemas"; import type { StoreError } from "./errors"; import type { AnalyzeOptions, AnalyzeResult } from "./internal/analyze"; import type { CleanOptions, CleanResult } from "./internal/clean"; @@ -139,7 +139,7 @@ export class UCDStore { return this.#manifestPath; } - async getFileTree(version: string, extraFilters?: Pick): Promise> { + async getFileTree(version: string, extraFilters?: Pick): Promise> { return wrapTry(async () => { await this.#ensureClient(); diff --git a/packages/ucd-store/test/internal/files.test.ts b/packages/ucd-store/test/internal/files.test.ts index 1832a15ab..1e22dadd5 100644 --- a/packages/ucd-store/test/internal/files.test.ts +++ b/packages/ucd-store/test/internal/files.test.ts @@ -1,4 +1,4 @@ -import type { ApiError, UnicodeTree } from "@ucdjs/schemas"; +import type { ApiError, UnicodeFileTree } from "@ucdjs/schemas"; import { HttpResponse, mockFetch } from "#test-utils/msw"; import { createUCDClient } from "@ucdjs/client"; import { UCDJS_API_BASE_URL } from "@ucdjs/env"; @@ -39,7 +39,7 @@ describe("getExpectedFilePaths", async () => { }, ], }, - ] satisfies UnicodeTree); + ] satisfies UnicodeFileTree); }], ]); diff --git a/packages/ucd-store/test/maintenance/analyze.test.ts b/packages/ucd-store/test/maintenance/analyze.test.ts index d6d02226a..1eb04e3e9 100644 --- a/packages/ucd-store/test/maintenance/analyze.test.ts +++ b/packages/ucd-store/test/maintenance/analyze.test.ts @@ -1,4 +1,4 @@ -import type { UnicodeTree } from "@ucdjs/schemas"; +import type { UnicodeFileTree } from "@ucdjs/schemas"; import { createMemoryMockFS } from "#test-utils/fs-bridges"; import { mockStoreApi } from "#test-utils/mock-store"; import { HttpResponse, mockFetch } from "#test-utils/msw"; @@ -38,7 +38,7 @@ const MOCK_FILES = [ }, ], }, -] satisfies UnicodeTree; +] satisfies UnicodeFileTree; describe("analyze operations", () => { beforeEach(() => { diff --git a/vscode/src/lib/files.ts b/vscode/src/lib/files.ts index 9c7831ce9..8e219fa59 100644 --- a/vscode/src/lib/files.ts +++ b/vscode/src/lib/files.ts @@ -1,4 +1,4 @@ -import type { UnicodeTreeNode } from "@ucdjs/schemas"; +import type { UnicodeFileTreeNode } from "@ucdjs/schemas"; import type { UCDStore } from "@ucdjs/ucd-store"; import type { TreeViewNode } from "reactive-vscode"; import type { UCDTreeItem } from "../composables/useUCDExplorer"; @@ -7,7 +7,7 @@ import { ThemeIcon, TreeItemCollapsibleState } from "vscode"; import * as Meta from "../generated/meta"; import { logger } from "../logger"; -function mapEntryToTreeNode(version: string, entry: UnicodeTreeNode, parentPath?: string): TreeViewNode { +function mapEntryToTreeNode(version: string, entry: UnicodeFileTreeNode, parentPath?: string): TreeViewNode { if (entry == null) { throw new Error("Entry is null or undefined"); } From 9da39c018084d3811a9b7d66fff026705c9053df Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 19:15:14 +0100 Subject: [PATCH 06/12] chore: update apache-autoindex-parse to version 5.0.2 --- pnpm-lock.yaml | 28 +++++++++++----------------- pnpm-workspace.yaml | 2 +- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37fa11832..4c53fb0a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,8 +83,8 @@ catalogs: specifier: 6.0.1 version: 6.0.1 apache-autoindex-parse: - specifier: 4.1.0 - version: 4.1.0 + specifier: 5.0.2 + version: 5.0.2 defu: specifier: 6.1.4 version: 6.1.4 @@ -338,7 +338,7 @@ importers: version: 0.12.0-beta.18 apache-autoindex-parse: specifier: catalog:prod - version: 4.1.0 + version: 5.0.2 hono: specifier: catalog:workers version: 4.11.1 @@ -4778,8 +4778,8 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - apache-autoindex-parse@4.1.0: - resolution: {integrity: sha512-SRMLp9a4QJeqg64qpt4/SyezssBXS6R/A3vPAq7R6W6XB+0QGc8CWvGcl60FfXHG3RTtp2ShgRnyFIdC5Gimaw==} + apache-autoindex-parse@5.0.2: + resolution: {integrity: sha512-cEw6EWV4qIZrdq0iWaTqU7EBwfcvVoTZbS4EEBtljyzK5KUEVJqiB6PsZDFUKif9SncFq69flO5B8UMA/CJa0w==} are-docs-informative@0.0.2: resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} @@ -9240,9 +9240,6 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.2.1: - resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} - zod@4.3.5: resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} @@ -10188,7 +10185,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) ts-pattern: 5.9.0 typescript: 5.9.3 - zod: 4.2.1 + zod: 4.3.5 transitivePeerDependencies: - supports-color optional: true @@ -12978,7 +12975,7 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - apache-autoindex-parse@4.1.0: {} + apache-autoindex-parse@5.0.2: {} are-docs-informative@0.0.2: {} @@ -14009,8 +14006,8 @@ snapshots: '@babel/parser': 7.28.5 eslint: 9.39.2(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 4.2.1 - zod-validation-error: 4.0.2(zod@4.2.1) + zod: 4.3.5 + zod-validation-error: 4.0.2(zod@4.3.5) transitivePeerDependencies: - supports-color optional: true @@ -18319,18 +18316,15 @@ snapshots: dependencies: zod: 3.25.76 - zod-validation-error@4.0.2(zod@4.2.1): + zod-validation-error@4.0.2(zod@4.3.5): dependencies: - zod: 4.2.1 + zod: 4.3.5 optional: true zod@3.22.3: {} zod@3.25.76: {} - zod@4.2.1: - optional: true - zod@4.3.5: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0c459a7ee..f576b0aed 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -58,7 +58,7 @@ catalogs: ai: 6.0.1 knitwork: 1.3.0 picomatch: 4.0.3 - apache-autoindex-parse: 4.1.0 + apache-autoindex-parse: 5.0.2 pathe: 2.0.3 "@clack/prompts": 1.0.0-alpha.8 obug: 2.1.1 From 45e759eea03210dd381c2ffaf7cff32f8fec1d04 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 19:15:22 +0100 Subject: [PATCH 07/12] refactor(api): improve statistics handling and type casting --- apps/api/src/routes/v1_versions/$version.ts | 25 +++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/v1_versions/$version.ts b/apps/api/src/routes/v1_versions/$version.ts index 01678468d..f78209c4e 100644 --- a/apps/api/src/routes/v1_versions/$version.ts +++ b/apps/api/src/routes/v1_versions/$version.ts @@ -1,4 +1,5 @@ import type { OpenAPIHono } from "@hono/zod-openapi"; +import type { UnicodeFileTree } from "@ucdjs/schemas"; import type { HonoEnv } from "../../types"; import { createRoute } from "@hono/zod-openapi"; import { dedent } from "@luxass/utils"; @@ -208,14 +209,26 @@ export function registerGetVersionRoute(router: OpenAPIHono) { // Try to get statistics from bucket if available const bucket = c.env.UCD_BUCKET; - let statistics = null; + let statistics = { + newBlocks: 0, + newCharacters: 0, + newScripts: 0, + totalBlocks: 0, + totalCharacters: 0, + totalScripts: 0, + }; + + // This is so bad.... but we have to do it for now. if (bucket) { - statistics = await calculateStatistics(bucket, version); + const tmp = await calculateStatistics(bucket, version); + if (tmp) { + statistics = tmp; + } } return c.json({ ...versionInfo, - statistics: statistics ?? undefined, + statistics, }, 200); }); } @@ -244,7 +257,11 @@ export function registerVersionFileTreeRoute(router: OpenAPIHono) { format: "F2", }); - return c.json(result, 200); + // We cast the result to UnicodeFileTree because the traverse function + // returns entries that uses lastModified as `number | undefined`. + // But we can't use the `number | undefined` type in the API schema. + // So we need to return lastModified as `number | null` always. + return c.json(result as UnicodeFileTree, 200); } catch (error) { console.error("Error processing directory:", error); return internalServerError(c, { From 9598878d7fadbbb541c7ec90fce3bd320702b327 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 19:42:49 +0100 Subject: [PATCH 08/12] feat(shared): add file search and flattening functions Introduce `findFileByPath` and `flattenFilePaths` functions to enhance file tree manipulation capabilities. The new functions allow for recursive searching of files by path and flattening of hierarchical file structures into an array of paths. Additionally, remove the old `flatten` implementation to streamline the codebase. --- packages/shared/src/files.ts | 73 +++++ packages/shared/src/flatten.ts | 57 ---- packages/shared/src/index.ts | 6 +- packages/shared/test/files.test.ts | 437 +++++++++++++++++++++++++++++ 4 files changed, 512 insertions(+), 61 deletions(-) create mode 100644 packages/shared/src/files.ts delete mode 100644 packages/shared/src/flatten.ts create mode 100644 packages/shared/test/files.test.ts diff --git a/packages/shared/src/files.ts b/packages/shared/src/files.ts new file mode 100644 index 000000000..6facd4ed1 --- /dev/null +++ b/packages/shared/src/files.ts @@ -0,0 +1,73 @@ +import type { UnicodeFileTreeNodeWithoutLastModified } from "@ucdjs/schemas"; +import { prependLeadingSlash } from "@luxass/utils"; + +/** + * Recursively find a node (file or directory) by its path in the tree. + * + * @template T - A tree node type that extends the base TreeNode interface + * @param {T[]} entries - Array of file tree nodes that may contain nested children + * @param {string} targetPath - The path to search for + * @returns {T | undefined} The found node or undefined + */ +export function findFileByPath(entries: T[], targetPath: string): T | undefined { + for (const fileOrDirectory of entries) { + // Use path property directly as it already contains the full path + const filePath = fileOrDirectory.path ?? fileOrDirectory.name; + + // Check if this node matches the target path + if (filePath === targetPath) { + return fileOrDirectory; + } + + // If it's a directory, also search in children + if (fileOrDirectory.type === "directory" && fileOrDirectory.children) { + const found = findFileByPath(fileOrDirectory.children as T[], targetPath); + if (found) { + return found; + } + } + } + return undefined; +} + +/** + * Recursively flattens a hierarchical file structure into an array of file paths. + * + * @template T - A tree node type that extends the base TreeNode interface + * @param {T[]} entries - Array of file tree nodes that may contain nested children + * @param {string} [prefix] - Optional path prefix to prepend to each file path (default: "") + * @returns {string[]} Array of flattened file paths as strings + * + * @example + * ```typescript + * import { flattenFilePaths } from "@ucdjs-internal/shared"; + * + * const files = [ + * { type: "directory", name: "folder1", path: "/folder1", children: [{ type: "file", name: "file1.txt", path: "/folder1/file1.txt" }] }, + * { type: "file", name: "file2.txt", path: "/file2.txt" } + * ]; + * const paths = flattenFilePaths(files); + * // Returns: ["/folder1/file1.txt", "/file2.txt"] + * ``` + */ +export function flattenFilePaths(entries: T[], prefix: string = ""): string[] { + const paths: string[] = []; + + if (!Array.isArray(entries)) { + throw new TypeError("Expected 'entries' to be an array of file tree nodes."); + } + + for (const file of entries) { + const fullPath = prefix + ? `${prefix}${prependLeadingSlash(file.path)}` + : file.path; + + if (file.type === "directory" && file.children) { + paths.push(...flattenFilePaths(file.children, prefix)); + } else { + paths.push(fullPath); + } + } + + return paths; +} diff --git a/packages/shared/src/flatten.ts b/packages/shared/src/flatten.ts deleted file mode 100644 index 885a7cb10..000000000 --- a/packages/shared/src/flatten.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { prependLeadingSlash } from "@luxass/utils"; - -/** - * A tree node structure that can be flattened. - */ -export interface TreeNode { - /** The type of the node */ - type: string; - /** The name of the node */ - name: string; - /** The path of the node (optional, falls back to name) */ - path?: string; - /** Child nodes (required for directory types) */ - children?: TreeNode[]; -} - -/** - * Recursively flattens a hierarchical file structure into an array of file paths. - * - * @template T - A tree node type that extends the base TreeNode interface - * @param {T[]} entries - Array of file tree nodes that may contain nested children - * @param {string} [prefix] - Optional path prefix to prepend to each file path (default: "") - * @returns {string[]} Array of flattened file paths as strings - * - * @example - * ```typescript - * import { flattenFilePaths } from "@ucdjs-internal/shared"; - * - * const files = [ - * { name: "folder1", type: "directory", children: [{ name: "file1.txt", type: "file" }] }, - * { name: "file2.txt", type: "file" } - * ]; - * const paths = flattenFilePaths(files); - * // Returns: ["folder1/file1.txt", "file2.txt"] - * ``` - */ -export function flattenFilePaths(entries: T[], prefix: string = ""): string[] { - const paths: string[] = []; - - if (!Array.isArray(entries)) { - throw new TypeError("Expected 'entries' to be an array of TreeNode"); - } - - for (const file of entries) { - const fullPath = prefix - ? `${prefix}${prependLeadingSlash(file.path ?? file.name)}` - : (file.path ?? file.name); - - if (file.type === "directory" && file.children) { - paths.push(...flattenFilePaths(file.children, prefix)); - } else { - paths.push(fullPath); - } - } - - return paths; -} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b3adf36d8..9ddf52cc6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,20 +5,18 @@ export * from "./debugger"; export { customFetch } from "./fetch/fetch"; export type { FetchOptions, FetchResponse, SafeFetchResponse } from "./fetch/types"; +export { findFileByPath, flattenFilePaths } from "./files"; + export { createPathFilter, DEFAULT_EXCLUDED_EXTENSIONS, filterTreeStructure, PRECONFIGURED_FILTERS, } from "./filter"; - export type { PathFilter, PathFilterOptions, - TreeEntry, } from "./filter"; - -export { flattenFilePaths } from "./flatten"; export { createGlobMatcher, DEFAULT_PICOMATCH_OPTIONS, diff --git a/packages/shared/test/files.test.ts b/packages/shared/test/files.test.ts new file mode 100644 index 000000000..580dd8257 --- /dev/null +++ b/packages/shared/test/files.test.ts @@ -0,0 +1,437 @@ +import type { UnicodeFileTreeNode } from "@ucdjs/schemas"; +import { describe, expect, it } from "vitest"; +import { findFileByPath, flattenFilePaths } from "../src/files"; + +describe("findFileByPath", () => { + it("should return undefined for empty input", () => { + const result = findFileByPath([], "file.txt"); + expect(result).toBeUndefined(); + }); + + it("should find a file at the root level", () => { + const files: UnicodeFileTreeNode[] = [ + { type: "file", name: "file1.txt", path: "/file1.txt", lastModified: null }, + { type: "file", name: "file2.txt", path: "/file2.txt", lastModified: null }, + ]; + + const result = findFileByPath(files, "/file1.txt"); + expect(result).toEqual({ type: "file", name: "file1.txt", path: "/file1.txt", lastModified: null }); + }); + + it("should return undefined when file is not found", () => { + const files: UnicodeFileTreeNode[] = [ + { type: "file", name: "file1.txt", path: "/file1.txt", lastModified: null }, + ]; + + const result = findFileByPath(files, "/nonexistent.txt"); + expect(result).toBeUndefined(); + }); + + it("should find a file in a nested directory", () => { + const files: UnicodeFileTreeNode[] = [ + { + type: "directory", + name: "folder", + path: "/folder/", + children: [ + { type: "file", name: "nested.txt", path: "/folder/nested.txt", lastModified: null }, + ], + lastModified: null, + }, + ]; + + const result = findFileByPath(files, "/folder/nested.txt"); + expect(result).toEqual({ type: "file", name: "nested.txt", path: "/folder/nested.txt", lastModified: null }); + }); + + it("should find a file in deeply nested directories", () => { + const files: UnicodeFileTreeNode[] = [ + { + type: "directory", + name: "level1", + path: "/level1/", + children: [ + { + type: "directory", + name: "level2", + path: "/level1/level2/", + children: [ + { + type: "directory", + name: "level3", + path: "/level1/level2/level3/", + children: [ + { type: "file", name: "deep.txt", path: "/level1/level2/level3/deep.txt", lastModified: null }, + ], + lastModified: null, + }, + ], + lastModified: null, + }, + ], + lastModified: null, + }, + ]; + + const result = findFileByPath(files, "/level1/level2/level3/deep.txt"); + expect(result).toEqual({ type: "file", name: "deep.txt", path: "/level1/level2/level3/deep.txt", lastModified: null }); + }); + + it("should use path property when available", () => { + const files: UnicodeFileTreeNode[] = [ + { type: "file", name: "file.txt", path: "/custom/path.txt", lastModified: null }, + ]; + + const result = findFileByPath(files, "/custom/path.txt"); + expect(result).toEqual({ type: "file", name: "file.txt", path: "/custom/path.txt", lastModified: null }); + }); + + it("should not match when search path lacks leading slash", () => { + const files: UnicodeFileTreeNode[] = [ + { type: "file", name: "file.txt", path: "/file.txt", lastModified: null }, + ]; + + const result = findFileByPath(files, "file.txt"); + expect(result).toBeUndefined(); + }); + + it("should not match nested path when search path lacks leading slash", () => { + const files: UnicodeFileTreeNode[] = [ + { + type: "directory", + name: "folder", + path: "/folder/", + lastModified: null, + children: [ + { type: "file", name: "nested.txt", path: "/folder/nested.txt", lastModified: null }, + ], + }, + ]; + + const result = findFileByPath(files, "folder/nested.txt"); + expect(result).not.toEqual({ type: "file", name: "nested.txt", path: "/folder/nested.txt", lastModified: null }); + expect(result).toBeUndefined(); + }); + + it("should preserve custom properties on the returned node", () => { + type CustomNode = UnicodeFileTreeNode & { + _content?: string; + }; + + const files: CustomNode[] = [ + { type: "file", name: "file.txt", path: "/file.txt", lastModified: null, _content: "Hello, World!" }, + ]; + + const result = findFileByPath(files, "/file.txt"); + expect(result).toEqual({ type: "file", name: "file.txt", path: "/file.txt", lastModified: null, _content: "Hello, World!" }); + expect(result?._content).toBe("Hello, World!"); + }); + + it("should match directories with trailing slash", () => { + const files: UnicodeFileTreeNode[] = [ + { + type: "directory", + name: "folder", + path: "/folder/", + lastModified: null, + children: [ + { type: "file", name: "file.txt", path: "/folder/file.txt", lastModified: null }, + ], + }, + ]; + + // Searching for just the directory name should match the directory + const result = findFileByPath(files, "/folder/"); + expect(result).toEqual({ + type: "directory", + name: "folder", + path: "/folder/", + lastModified: null, + children: [{ + type: "file", + name: "file.txt", + path: "/folder/file.txt", + lastModified: null, + }], + }); + }); + + it("should handle mixed files and directories", () => { + const files: UnicodeFileTreeNode[] = [ + { type: "file", name: "root.txt", path: "/root.txt", lastModified: null }, + { + type: "directory", + name: "docs", + path: "/docs/", + lastModified: null, + children: [ + { type: "file", name: "readme.md", path: "/docs/readme.md", lastModified: null }, + { + type: "directory", + name: "api", + path: "/docs/api/", + lastModified: null, + children: [ + { type: "file", name: "index.html", path: "/docs/api/index.html", lastModified: null }, + ], + }, + ], + }, + { type: "file", name: "package.json", path: "/package.json", lastModified: null }, + ]; + + expect(findFileByPath(files, "/root.txt")).toEqual({ type: "file", name: "root.txt", path: "/root.txt", lastModified: null }); + expect(findFileByPath(files, "/docs/readme.md")).toEqual({ type: "file", name: "readme.md", path: "/docs/readme.md", lastModified: null }); + expect(findFileByPath(files, "/docs/api/index.html")).toEqual({ type: "file", name: "index.html", path: "/docs/api/index.html", lastModified: null }); + expect(findFileByPath(files, "/package.json")).toEqual({ type: "file", name: "package.json", path: "/package.json", lastModified: null }); + }); + + it("should handle empty directories", () => { + const files: UnicodeFileTreeNode[] = [ + { + type: "directory", + name: "empty", + path: "/empty/", + lastModified: null, + children: [], + }, + { type: "file", name: "file.txt", path: "/file.txt", lastModified: null }, + ]; + + const result = findFileByPath(files, "/file.txt"); + expect(result).toEqual({ type: "file", name: "file.txt", path: "/file.txt", lastModified: null }); + }); +}); + +describe("flattenFilePaths", () => { + it("should return empty array for empty input", () => { + const result = flattenFilePaths([]); + expect(result).toEqual([]); + }); + + it("should handle files without children", () => { + const result = flattenFilePaths([ + { + type: "file", + name: "file1.txt", + path: "/file1.txt", + }, + { + type: "file", + name: "file2.txt", + path: "/file2.txt", + }, + ]); + + expect(result).toEqual(["/file1.txt", "/file2.txt"]); + }); + + it("should handle folders with children", () => { + const result = flattenFilePaths([ + { + name: "folder1", + path: "/folder1/", + type: "directory", + lastModified: null, + children: [ + { type: "file", name: "file1.txt", path: "/folder1/file1.txt", lastModified: null }, + { type: "file", name: "file2.txt", path: "/folder1/file2.txt", lastModified: null }, + ], + }, + ]); + + expect(result).toEqual(["/folder1/file1.txt", "/folder1/file2.txt"]); + }); + + it("should handle mixed files and folders", () => { + const result = flattenFilePaths([ + { + type: "file", + name: "root-file.txt", + path: "/root-file.txt", + lastModified: null, + }, + { + type: "directory", + name: "folder1", + path: "/folder1/", + lastModified: null, + children: [ + { type: "file", name: "nested-file.txt", path: "/folder1/nested-file.txt", lastModified: null }, + ], + }, + { type: "file", name: "another-root-file.txt", path: "/another-root-file.txt", lastModified: null }, + ]); + + expect(result).toEqual([ + "/root-file.txt", + "/folder1/nested-file.txt", + "/another-root-file.txt", + ]); + }); + + it("should handle deeply nested structures", () => { + const result = flattenFilePaths([ + { + type: "directory", + name: "level1", + path: "/level1/", + lastModified: null, + children: [ + { + type: "directory", + name: "level2", + path: "/level1/level2/", + lastModified: null, + children: [ + { + type: "directory", + name: "level3", + path: "/level1/level2/level3/", + lastModified: null, + children: [ + { + type: "file", + name: "deep-file.txt", + path: "/level1/level2/level3/deep-file.txt", + lastModified: null, + }, + ], + }, + ], + }, + ], + }, + ]); + + expect(result).toEqual(["/level1/level2/level3/deep-file.txt"]); + }); + + it("should handle prefix parameter", () => { + const result = flattenFilePaths([ + { + type: "file", + name: "file.txt", + path: "/file.txt", + lastModified: null, + }, + { + type: "directory", + name: "folder", + path: "/folder/", + lastModified: null, + children: [ + { type: "file", name: "nested.txt", path: "/folder/nested.txt", lastModified: null }, + ], + }, + ], ""); + + expect(result).toEqual(["/file.txt", "/folder/nested.txt"]); + }); + + it("should handle empty prefix", () => { + const result = flattenFilePaths([ + { + type: "file", + name: "file.txt", + path: "/file.txt", + lastModified: null, + }, + ], ""); + + expect(result).toEqual(["/file.txt"]); + }); + + it("should handle folders with empty children arrays", () => { + const result = flattenFilePaths([ + { + type: "directory", + name: "empty-folder", + path: "/empty-folder/", + lastModified: null, + children: [], + }, + { + type: "file", + name: "file.txt", + path: "/file.txt", + lastModified: null, + }, + ]); + + expect(result).toEqual(["/file.txt"]); + }); + + it("should handle complex nested structure with multiple levels", () => { + const result = flattenFilePaths([ + { + type: "directory", + name: "docs", + path: "/docs/", + lastModified: null, + children: [ + { type: "file", name: "readme.md", path: "/docs/readme.md", lastModified: null }, + { + type: "directory", + name: "api", + path: "/docs/api/", + lastModified: null, + children: [ + { type: "file", name: "index.html", path: "/docs/api/index.html", lastModified: null }, + { type: "file", name: "methods.html", path: "/docs/api/methods.html", lastModified: null }, + ], + }, + ], + }, + { + type: "directory", + name: "src", + path: "/src/", + lastModified: null, + children: [ + { type: "file", name: "index.ts", path: "/src/index.ts", lastModified: null }, + { + type: "directory", + name: "utils", + path: "/src/utils/", + lastModified: null, + children: [ + { type: "file", name: "helpers.ts", path: "/src/utils/helpers.ts", lastModified: null }, + ], + }, + ], + }, + { type: "file", name: "package.json", path: "/package.json", lastModified: null }, + ]); + + expect(result).toEqual([ + "/docs/readme.md", + "/docs/api/index.html", + "/docs/api/methods.html", + "/src/index.ts", + "/src/utils/helpers.ts", + "/package.json", + ]); + }); + + it("should handle paths with leading slashes", () => { + const result = flattenFilePaths([ + { + type: "file", + name: "file1.txt", + path: "/file1.txt", + }, + { + type: "directory", + name: "folder", + path: "/folder/", + lastModified: null, + children: [ + { type: "file", name: "nested.txt", path: "/folder/nested.txt", lastModified: null }, + ], + }, + ]); + + expect(result).toEqual(["/file1.txt", "/folder/nested.txt"]); + }); +}); From 188d0996928af82f99cfde195eeba1eeed537498 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 19:42:59 +0100 Subject: [PATCH 09/12] refactor(shared): improve type handling in filter functions --- packages/shared/src/filter.ts | 27 ++-- packages/shared/test/flatten.test.ts | 198 --------------------------- 2 files changed, 8 insertions(+), 217 deletions(-) delete mode 100644 packages/shared/test/flatten.test.ts diff --git a/packages/shared/src/filter.ts b/packages/shared/src/filter.ts index d468f99d7..1ff402d17 100644 --- a/packages/shared/src/filter.ts +++ b/packages/shared/src/filter.ts @@ -1,3 +1,4 @@ +import type { UnicodeFileTreeNodeWithoutLastModified } from "@ucdjs/schemas"; import type { PicomatchOptions } from "picomatch"; import picomatch from "picomatch"; import { DEFAULT_PICOMATCH_OPTIONS } from "./glob"; @@ -196,33 +197,21 @@ function isDirectoryOnlyPattern(pattern: string): boolean { && (pattern.includes("/") || !pattern.includes("*")); } -// TODO: Combine all "tree" related entries -export type TreeEntry = { - type: "file"; - name: string; - path: string; -} | { - type: "directory"; - name: string; - path: string; - children: TreeEntry[]; -}; - -export function filterTreeStructure( +export function filterTreeStructure( pathFilter: PathFilter, - entries: TreeEntry[], + entries: T[], extraOptions: Pick = {}, -): TreeEntry[] { +): T[] { return internal__filterTreeStructure(pathFilter, entries, "", extraOptions); } -function internal__filterTreeStructure( +function internal__filterTreeStructure( pathFilter: PathFilter, - entries: TreeEntry[], + entries: T[], parentPath: string, extraOptions: Pick, -): TreeEntry[] { - const filteredEntries: TreeEntry[] = []; +): T[] { + const filteredEntries: T[] = []; for (const entry of entries) { // Since entry.path now contains the full path, use it directly diff --git a/packages/shared/test/flatten.test.ts b/packages/shared/test/flatten.test.ts deleted file mode 100644 index 0ad05a84f..000000000 --- a/packages/shared/test/flatten.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { flattenFilePaths } from "../src/flatten"; - -describe("flattenFilePaths", () => { - it("should return empty array for empty input", () => { - const result = flattenFilePaths([]); - expect(result).toEqual([]); - }); - - it("should handle files without children", () => { - const result = flattenFilePaths([ - { - type: "file", - name: "file1.txt", - path: "file1.txt", - }, - { - type: "file", - name: "file2.txt", - path: "file2.txt", - }, - ]); - - expect(result).toEqual(["file1.txt", "file2.txt"]); - }); - - it("should handle folders with children", () => { - const result = flattenFilePaths([ - { - name: "folder1", - path: "folder1", - type: "directory", - children: [ - { type: "file", name: "file1.txt", path: "folder1/file1.txt" }, - { type: "file", name: "file2.txt", path: "folder1/file2.txt" }, - ], - }, - ]); - - expect(result).toEqual(["folder1/file1.txt", "folder1/file2.txt"]); - }); - - it("should handle mixed files and folders", () => { - const result = flattenFilePaths([ - { - type: "file", - name: "root-file.txt", - path: "root-file.txt", - }, - { - type: "directory", - name: "folder1", - path: "folder1", - children: [ - { type: "file", name: "nested-file.txt", path: "folder1/nested-file.txt" }, - ], - }, - { type: "file", name: "another-root-file.txt", path: "another-root-file.txt" }, - ]); - - expect(result).toEqual([ - "root-file.txt", - "folder1/nested-file.txt", - "another-root-file.txt", - ]); - }); - - it("should handle deeply nested structures", () => { - const result = flattenFilePaths([ - { - type: "directory", - name: "level1", - path: "level1", - children: [ - { - type: "directory", - name: "level2", - path: "level1/level2", - children: [ - { - type: "directory", - name: "level3", - path: "level1/level2/level3", - children: [ - { - type: "file", - name: "deep-file.txt", - path: "level1/level2/level3/deep-file.txt", - }, - ], - }, - ], - }, - ], - }, - ]); - - expect(result).toEqual(["level1/level2/level3/deep-file.txt"]); - }); - - it("should handle prefix parameter", () => { - const result = flattenFilePaths([ - { - type: "file", - name: "file.txt", - path: "file.txt", - }, - { - type: "directory", - name: "folder", - path: "folder", - children: [ - { type: "file", name: "nested.txt", path: "folder/nested.txt" }, - ], - }, - ], "prefix"); - - expect(result).toEqual(["prefix/file.txt", "prefix/folder/nested.txt"]); - }); - - it("should handle empty prefix", () => { - const result = flattenFilePaths([ - { - type: "file", - name: "file.txt", - path: "file.txt", - }, - ], ""); - - expect(result).toEqual(["file.txt"]); - }); - - it("should handle folders with empty children arrays", () => { - const result = flattenFilePaths([ - { - type: "directory", - name: "empty-folder", - path: "empty-folder", - children: [], - }, - { - type: "file", - name: "file.txt", - path: "file.txt", - }, - ]); - - expect(result).toEqual(["file.txt"]); - }); - - it("should handle complex nested structure with multiple levels", () => { - const result = flattenFilePaths([ - { - type: "directory", - name: "docs", - path: "docs", - children: [ - { type: "file", name: "readme.md", path: "docs/readme.md" }, - { - type: "directory", - name: "api", - path: "docs/api", - children: [ - { type: "file", name: "index.html", path: "docs/api/index.html" }, - { type: "file", name: "methods.html", path: "docs/api/methods.html" }, - ], - }, - ], - }, - { - type: "directory", - name: "src", - path: "src", - children: [ - { type: "file", name: "index.ts", path: "src/index.ts" }, - { - type: "directory", - name: "utils", - path: "utils", - children: [ - { type: "file", name: "helpers.ts", path: "src/utils/helpers.ts" }, - ], - }, - ], - }, - { type: "file", name: "package.json", path: "package.json" }, - ]); - - expect(result).toEqual([ - "docs/readme.md", - "docs/api/index.html", - "docs/api/methods.html", - "src/index.ts", - "src/utils/helpers.ts", - "package.json", - ]); - }); -}); From e57f2d33dd6f2c7ad0d7c3cbcf1dd8cdf6d8067d Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 19:43:09 +0100 Subject: [PATCH 10/12] test(api): enhance file-tree tests with structured data --- .../test/routes/v1_versions/$version.test.ts | 217 ++++++++++++++---- 1 file changed, 169 insertions(+), 48 deletions(-) diff --git a/apps/api/test/routes/v1_versions/$version.test.ts b/apps/api/test/routes/v1_versions/$version.test.ts index fae58684f..7e830b8f8 100644 --- a/apps/api/test/routes/v1_versions/$version.test.ts +++ b/apps/api/test/routes/v1_versions/$version.test.ts @@ -1,12 +1,13 @@ +/// + +import type { UnicodeFileTree, UnicodeFileTreeNode } from "@ucdjs/schemas"; import type { Entry } from "apache-autoindex-parse"; -import type { TraverseEntry } from "apache-autoindex-parse/traverse"; import { HttpResponse, mockFetch } from "#test-utils/msw"; - +import { flattenFilePaths } from "@ucdjs-internal/shared"; import { generateAutoIndexHtml } from "apache-autoindex-parse/test-utils"; import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { executeRequest } from "../../helpers/request"; -import { expectApiError, expectCacheHeaders, expectJsonResponse, expectSuccess } from "../../helpers/response"; vi.mock("@unicode-utils/core", async (importOriginal) => { const original = await importOriginal(); @@ -25,18 +26,42 @@ beforeEach(() => { describe("v1_versions", () => { // eslint-disable-next-line test/prefer-lowercase-title describe("GET /api/v1/versions/{version}/file-tree", () => { - const files: TraverseEntry[] = [ - { type: "file", name: "file1.txt", path: "Public/15.1.0/ucd/file1.txt" }, - { type: "file", name: "file2.txt", path: "Public/15.1.0/ucd/file2.txt" }, - { type: "directory", name: "subdir", path: "Public/15.1.0/ucd/subdir", children: [] }, - { type: "file", name: "file3.txt", path: "Public/15.1.0/ucd/subdir/file3.txt" }, - { type: "file", name: "emoji-data.txt", path: "Public/15.1.0/ucd/emoji/emoji-data.txt" }, - ]; + const expectedFiles = [ + { type: "file", name: "file1.txt", path: "file1.txt", lastModified: 1755287100000 }, + { type: "file", name: "file2.txt", path: "file2.txt", lastModified: 1755287100000 }, + { + type: "directory", + name: "subdir", + path: "subdir/", + lastModified: 1755287100000, + }, + { + type: "directory", + name: "emoji", + path: "emoji/", + lastModified: 1755287100000, + }, + ] satisfies Entry[]; it("should return files for a valid Unicode version", async () => { mockFetch([ ["GET", "https://unicode.org/Public/15.1.0/ucd", () => { - return HttpResponse.text(generateAutoIndexHtml(files, "F2")); + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file1.txt", path: "file1.txt", lastModified: 1755287100000 }, + { type: "file", name: "file2.txt", path: "file2.txt", lastModified: 1755287100000 }, + { type: "directory", name: "subdir", path: "subdir/", lastModified: 1755287100000 }, + { type: "directory", name: "emoji", path: "emoji/", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/15.1.0/ucd/emoji", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "emoji-data.txt", path: "emoji-data.txt", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/15.1.0/ucd/subdir", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file3.txt", path: "file3.txt", lastModified: 1755287100000 }, + ], "F2")); }], ]); @@ -45,39 +70,88 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - expectJsonResponse(response); - const data = await json() as unknown[]; + expect(response).toMatchResponse({ + json: true, + status: 200, + }); + + const data = await json(); expect(Array.isArray(data)).toBe(true); - const expectedFiles = files.map((file) => { - return expect.objectContaining({ - name: file.name, - path: file.path, - type: file.type, - ...(file.type === "directory" ? { children: file.children } : {}), - }); - }); + const flattenedFilePaths = flattenFilePaths(data); - expect(data).toEqual(expect.arrayContaining(expectedFiles)); + expect(flattenedFilePaths).toEqual([ + "/15.1.0/ucd/file1.txt", + "/15.1.0/ucd/file2.txt", + "/15.1.0/ucd/subdir/file3.txt", + "/15.1.0/ucd/emoji/emoji-data.txt", + ]); }); it("should return files for latest version", async () => { + mockFetch([ + ["GET", "https://unicode.org/Public/17.0.0/ucd", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file1.txt", path: "file1.txt", lastModified: 1755287100000 }, + { type: "file", name: "file2.txt", path: "file2.txt", lastModified: 1755287100000 }, + { type: "directory", name: "subdir", path: "subdir/", lastModified: 1755287100000 }, + { type: "directory", name: "emoji", path: "emoji/", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/17.0.0/ucd/emoji", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "emoji-data.txt", path: "emoji-data.txt", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/17.0.0/ucd/subdir", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file3.txt", path: "file3.txt", lastModified: 1755287100000 }, + ], "F2")); + }], + ]); + const { response, json } = await executeRequest( new Request("https://api.ucdjs.dev/api/v1/versions/latest/file-tree"), env, ); - expectSuccess(response); - expectJsonResponse(response); - const data = await json(); + expect(response).toMatchResponse({ + json: true, + status: 200, + }); + + const data = await json(); expect(Array.isArray(data)).toBe(true); + + const flattenedFilePaths = flattenFilePaths(data); + + expect(flattenedFilePaths).toEqual([ + "/17.0.0/ucd/file1.txt", + "/17.0.0/ucd/file2.txt", + "/17.0.0/ucd/subdir/file3.txt", + "/17.0.0/ucd/emoji/emoji-data.txt", + ]); }); it("should return structured file data with proper schema", async () => { mockFetch([ ["GET", "https://unicode.org/Public/15.1.0/ucd", () => { - return HttpResponse.text(generateAutoIndexHtml(files, "F2")); + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file1.txt", path: "file1.txt", lastModified: 1755287100000 }, + { type: "file", name: "file2.txt", path: "file2.txt", lastModified: 1755287100000 }, + { type: "directory", name: "subdir", path: "subdir/", lastModified: 1755287100000 }, + { type: "directory", name: "emoji", path: "emoji/", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/15.1.0/ucd/emoji", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "emoji-data.txt", path: "emoji-data.txt", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/15.1.0/ucd/subdir", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file3.txt", path: "file3.txt", lastModified: 1755287100000 }, + ], "F2")); }], ]); @@ -86,10 +160,12 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - expectJsonResponse(response); + expect(response).toMatchResponse({ + json: true, + status: 200, + }); - const data = await json() as TraverseEntry[]; + const data = await json(); // validate the response structure expect(Array.isArray(data)).toBe(true); @@ -105,7 +181,7 @@ describe("v1_versions", () => { return [files, directories]; }, - [[], []] as [Entry[], TraverseEntry[]], + [[], []] as [Exclude[], Exclude[]], ); expect(filesEntries.length).toBeGreaterThan(0); @@ -134,7 +210,22 @@ describe("v1_versions", () => { it("should handle older Unicode versions", async () => { mockFetch([ ["GET", "https://unicode.org/Public/3.1-Update1", () => { - return HttpResponse.text(generateAutoIndexHtml(files, "F2")); + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file1.txt", path: "file1.txt", lastModified: 1755287100000 }, + { type: "file", name: "file2.txt", path: "file2.txt", lastModified: 1755287100000 }, + { type: "directory", name: "subdir", path: "subdir/", lastModified: 1755287100000 }, + { type: "directory", name: "emoji", path: "emoji/", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/3.1-Update1/emoji", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "emoji-data.txt", path: "emoji-data.txt", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/3.1-Update1/subdir", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file3.txt", path: "file3.txt", lastModified: 1755287100000 }, + ], "F2")); }], ]); @@ -143,10 +234,21 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - expectJsonResponse(response); - const data = await json(); + expect(response).toMatchResponse({ + json: true, + status: 200, + }); + const data = await json(); expect(Array.isArray(data)).toBe(true); + + const flattenedFilePaths = flattenFilePaths(data); + + expect(flattenedFilePaths).toEqual([ + "/3.1-Update1/file1.txt", + "/3.1-Update1/file2.txt", + "/3.1-Update1/subdir/file3.txt", + "/3.1-Update1/emoji/emoji-data.txt", + ]); }); describe("error handling", () => { @@ -156,9 +258,11 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { + expect(response).toMatchResponse({ status: 400, - message: "Invalid Unicode version", + error: { + message: "Invalid Unicode version", + }, }); }); @@ -168,9 +272,11 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { + expect(response).toMatchResponse({ status: 400, - message: "Invalid Unicode version", + error: { + message: "Invalid Unicode version", + }, }); }); @@ -180,7 +286,9 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { status: 400 }); + expect(response).toMatchResponse({ + status: 400, + }); }); }); @@ -194,8 +302,10 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - expectCacheHeaders(response); + expect(response).toMatchResponse({ + status: 200, + cache: true, + }); }); it("should cache the response for subsequent requests", async () => { @@ -203,7 +313,7 @@ describe("v1_versions", () => { mockFetch([ ["GET", "https://unicode.org/Public/16.0.0/ucd", () => { callCounter++; - return HttpResponse.text(generateAutoIndexHtml(files, "F2")); + return HttpResponse.text(generateAutoIndexHtml(expectedFiles, "F2")); }], ]); @@ -211,18 +321,26 @@ describe("v1_versions", () => { new Request("https://api.ucdjs.dev/api/v1/versions/16.0.0/file-tree"), env, ); - expectSuccess(firstResponse); + expect(firstResponse).toMatchResponse({ + status: 200, + headers: { + "cf-cache-status": "", + }, + }); expect(callCounter).toBe(1); // First call should hit the network - expect(firstResponse.headers.get("cf-cache-status")).toBeNull(); const { response: secondResponse } = await executeRequest( new Request("https://api.ucdjs.dev/api/v1/versions/16.0.0/file-tree"), env, ); - expectSuccess(secondResponse); + expect(secondResponse).toMatchResponse({ + status: 200, + headers: { + "cf-cache-status": "HIT", + }, + }); expect(callCounter).toBe(1); // Second call should hit the cache - expect(secondResponse.headers.get("cf-cache-status")).toBe("HIT"); }); it("should not cache responses for invalid versions", async () => { @@ -231,7 +349,10 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { status: 400 }); + expect(response).toMatchResponse({ + status: 400, + }); + expect(response.headers.get("cf-cache-status")).toBeNull(); }); }); From dbb50a2ca3b7afa04742b45079afbd51d8a44124 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 19:45:20 +0100 Subject: [PATCH 11/12] test(client): update base URL handling in version tests Removed the hardcoded `baseUrl` and replaced it with `UCDJS_API_BASE_URL` for consistency across the version tests. Added `lastModified` timestamps to file entries in the mock file tree for better accuracy in testing. --- .../client/test/resources/versions.test.ts | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/packages/client/test/resources/versions.test.ts b/packages/client/test/resources/versions.test.ts index 296096170..dad12bef9 100644 --- a/packages/client/test/resources/versions.test.ts +++ b/packages/client/test/resources/versions.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from "vitest"; import { createVersionsResource } from "../../src/resources/versions"; describe("createVersionsResource", () => { - const baseUrl = UCDJS_API_BASE_URL; const endpoints = { files: "/api/v1/files", manifest: "/.well-known/ucd-store/{version}.json", @@ -36,9 +35,10 @@ describe("createVersionsResource", () => { name: "ucd", type: "directory", path: "/16.0.0/ucd", + lastModified: 1755287100000, children: [ - { name: "UnicodeData.txt", type: "file", path: "/16.0.0/ucd/UnicodeData.txt" }, - { name: "PropList.txt", type: "file", path: "/16.0.0/ucd/PropList.txt" }, + { name: "UnicodeData.txt", type: "file", path: "/16.0.0/ucd/UnicodeData.txt", lastModified: 1755287100000 }, + { name: "PropList.txt", type: "file", path: "/16.0.0/ucd/PropList.txt", lastModified: 1755287100000 }, ], }, ]; @@ -46,12 +46,12 @@ describe("createVersionsResource", () => { describe("list()", () => { it("should fetch all Unicode versions successfully", async () => { mockFetch([ - ["GET", `${baseUrl}${endpoints.versions}`, () => { + ["GET", `${UCDJS_API_BASE_URL}${endpoints.versions}`, () => { return HttpResponse.json(mockVersionsList); }], ]); - const versionsResource = createVersionsResource({ baseUrl, endpoints }); + const versionsResource = createVersionsResource({ baseUrl: UCDJS_API_BASE_URL, endpoints }); const { data, error } = await versionsResource.list(); expect(error).toBeNull(); @@ -62,12 +62,12 @@ describe("createVersionsResource", () => { it("should return versions with correct structure", async () => { mockFetch([ - ["GET", `${baseUrl}${endpoints.versions}`, () => { + ["GET", `${UCDJS_API_BASE_URL}${endpoints.versions}`, () => { return HttpResponse.json(mockVersionsList); }], ]); - const versionsResource = createVersionsResource({ baseUrl, endpoints }); + const versionsResource = createVersionsResource({ baseUrl: UCDJS_API_BASE_URL, endpoints }); const { data, error } = await versionsResource.list(); expect(error).toBeNull(); @@ -80,12 +80,12 @@ describe("createVersionsResource", () => { it("should handle errors gracefully", async () => { mockFetch([ - ["GET", `${baseUrl}${endpoints.versions}`, () => { + ["GET", `${UCDJS_API_BASE_URL}${endpoints.versions}`, () => { return new HttpResponse(null, { status: 500 }); }], ]); - const versionsResource = createVersionsResource({ baseUrl, endpoints }); + const versionsResource = createVersionsResource({ baseUrl: UCDJS_API_BASE_URL, endpoints }); const { data, error } = await versionsResource.list(); expect(data).toBeNull(); @@ -95,12 +95,12 @@ describe("createVersionsResource", () => { it("should handle network errors", async () => { mockFetch([ - ["GET", `${baseUrl}${endpoints.versions}`, () => { + ["GET", `${UCDJS_API_BASE_URL}${endpoints.versions}`, () => { return HttpResponse.error(); }], ]); - const versionsResource = createVersionsResource({ baseUrl, endpoints }); + const versionsResource = createVersionsResource({ baseUrl: UCDJS_API_BASE_URL, endpoints }); const { data, error } = await versionsResource.list(); expect(data).toBeNull(); @@ -111,12 +111,12 @@ describe("createVersionsResource", () => { describe("getFileTree()", () => { it("should fetch file tree for a version successfully", async () => { mockFetch([ - ["GET", `${baseUrl}${endpoints.versions}/16.0.0/file-tree`, () => { + ["GET", `${UCDJS_API_BASE_URL}${endpoints.versions}/16.0.0/file-tree`, () => { return HttpResponse.json(mockFileTree); }], ]); - const versionsResource = createVersionsResource({ baseUrl, endpoints }); + const versionsResource = createVersionsResource({ baseUrl: UCDJS_API_BASE_URL, endpoints }); const { data, error } = await versionsResource.getFileTree("16.0.0"); expect(error).toBeNull(); @@ -131,21 +131,18 @@ describe("createVersionsResource", () => { name: "ucd", type: "directory", path: `/${version}/ucd`, + lastModified: 1700000000000, children: [ - { name: "UnicodeData.txt", type: "file", path: `/${version}/ucd/UnicodeData.txt` }, + { name: "UnicodeData.txt", type: "file", path: `/${version}/ucd/UnicodeData.txt`, lastModified: 1700000000000 }, ], }, ]; - mockFetch([ - [ - "GET", - `${baseUrl}${endpoints.versions}/${version}/file-tree`, - () => HttpResponse.json(versionFileTree), - ], - ]); + mockFetch("GET", `${UCDJS_API_BASE_URL}${endpoints.versions}/${version}/file-tree`, () => { + return HttpResponse.json(versionFileTree); + }); - const versionsResource = createVersionsResource({ baseUrl, endpoints }); + const versionsResource = createVersionsResource({ baseUrl: UCDJS_API_BASE_URL, endpoints }); const { data, error } = await versionsResource.getFileTree(version); expect(error).toBeNull(); @@ -154,12 +151,12 @@ describe("createVersionsResource", () => { it("should handle 404 errors for non-existent versions", async () => { mockFetch([ - ["GET", `${baseUrl}${endpoints.versions}/99.0.0/file-tree`, () => { + ["GET", `${UCDJS_API_BASE_URL}${endpoints.versions}/99.0.0/file-tree`, () => { return new HttpResponse(null, { status: 404 }); }], ]); - const versionsResource = createVersionsResource({ baseUrl, endpoints }); + const versionsResource = createVersionsResource({ baseUrl: UCDJS_API_BASE_URL, endpoints }); const { data, error } = await versionsResource.getFileTree("99.0.0"); expect(data).toBeNull(); @@ -169,12 +166,12 @@ describe("createVersionsResource", () => { it("should handle server errors", async () => { mockFetch([ - ["GET", `${baseUrl}${endpoints.versions}/16.0.0/file-tree`, () => { + ["GET", `${UCDJS_API_BASE_URL}${endpoints.versions}/16.0.0/file-tree`, () => { return new HttpResponse(null, { status: 500 }); }], ]); - const versionsResource = createVersionsResource({ baseUrl, endpoints }); + const versionsResource = createVersionsResource({ baseUrl: UCDJS_API_BASE_URL, endpoints }); const { data, error } = await versionsResource.getFileTree("16.0.0"); expect(data).toBeNull(); @@ -207,13 +204,13 @@ describe("createVersionsResource", () => { const customVersionsPath = "/v2/versions"; mockFetch([ - ["GET", `${baseUrl}${customVersionsPath}`, () => { + ["GET", `${UCDJS_API_BASE_URL}${customVersionsPath}`, () => { return HttpResponse.json(mockVersionsList); }], ]); const versionsResource = createVersionsResource({ - baseUrl, + baseUrl: UCDJS_API_BASE_URL, endpoints: { ...endpoints, versions: customVersionsPath, From f1e82e6cbe71932122a8dfe705d52e360cac4b7d Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 5 Jan 2026 20:28:51 +0100 Subject: [PATCH 12/12] refactor(utils): remove unused type `TreeEntry` from exports --- .../test/routes/v1_versions/$version.test.ts | 36 ++++++++----- packages/shared/test/filter.test.ts | 51 +++++++++---------- .../test/mock-store/mock-store.test.ts | 6 ++- .../ucd-store-v2/src/operations/files/tree.ts | 6 +-- packages/ucd-store-v2/src/types.ts | 4 +- packages/ucd-store/src/store.ts | 4 +- packages/utils/src/index.ts | 2 +- vscode/src/lib/files.ts | 4 +- 8 files changed, 63 insertions(+), 50 deletions(-) diff --git a/apps/api/test/routes/v1_versions/$version.test.ts b/apps/api/test/routes/v1_versions/$version.test.ts index 7e830b8f8..8f7e78371 100644 --- a/apps/api/test/routes/v1_versions/$version.test.ts +++ b/apps/api/test/routes/v1_versions/$version.test.ts @@ -81,10 +81,14 @@ describe("v1_versions", () => { const flattenedFilePaths = flattenFilePaths(data); expect(flattenedFilePaths).toEqual([ - "/15.1.0/ucd/file1.txt", - "/15.1.0/ucd/file2.txt", - "/15.1.0/ucd/subdir/file3.txt", - "/15.1.0/ucd/emoji/emoji-data.txt", + // "/15.1.0/ucd/file1.txt", + // "/15.1.0/ucd/file2.txt", + // "/15.1.0/ucd/subdir/file3.txt", + // "/15.1.0/ucd/emoji/emoji-data.txt", + "file1.txt", + "file2.txt", + "subdir/file3.txt", + "emoji/emoji-data.txt", ]); }); @@ -126,10 +130,14 @@ describe("v1_versions", () => { const flattenedFilePaths = flattenFilePaths(data); expect(flattenedFilePaths).toEqual([ - "/17.0.0/ucd/file1.txt", - "/17.0.0/ucd/file2.txt", - "/17.0.0/ucd/subdir/file3.txt", - "/17.0.0/ucd/emoji/emoji-data.txt", + // "/17.0.0/ucd/file1.txt", + // "/17.0.0/ucd/file2.txt", + // "/17.0.0/ucd/subdir/file3.txt", + // "/17.0.0/ucd/emoji/emoji-data.txt", + "file1.txt", + "file2.txt", + "subdir/file3.txt", + "emoji/emoji-data.txt", ]); }); @@ -244,10 +252,14 @@ describe("v1_versions", () => { const flattenedFilePaths = flattenFilePaths(data); expect(flattenedFilePaths).toEqual([ - "/3.1-Update1/file1.txt", - "/3.1-Update1/file2.txt", - "/3.1-Update1/subdir/file3.txt", - "/3.1-Update1/emoji/emoji-data.txt", + // "/3.1-Update1/file1.txt", + // "/3.1-Update1/file2.txt", + // "/3.1-Update1/subdir/file3.txt", + // "/3.1-Update1/emoji/emoji-data.txt", + "file1.txt", + "file2.txt", + "subdir/file3.txt", + "emoji/emoji-data.txt", ]); }); diff --git a/packages/shared/test/filter.test.ts b/packages/shared/test/filter.test.ts index fd2f0601d..a838e4972 100644 --- a/packages/shared/test/filter.test.ts +++ b/packages/shared/test/filter.test.ts @@ -1,4 +1,3 @@ -import type { TreeEntry } from "../src/filter"; import { describe, expect, it } from "vitest"; import { createPathFilter, filterTreeStructure } from "../src/filter"; @@ -672,44 +671,44 @@ describe("createPathFilter", () => { }); describe("filterTreeStructure", () => { - const tree: TreeEntry[] = [ + const tree = [ { - type: "file", + type: "file" as const, name: "root-file.txt", path: "root-file.txt", }, { - type: "file", + type: "file" as const, name: "root-config.json", path: "root-config.json", }, { - type: "directory", + type: "directory" as const, name: "extracted", path: "extracted", children: [ { - type: "file", + type: "file" as const, name: "DerivedBidiClass.txt", path: "extracted/DerivedBidiClass.txt", }, { - type: "file", + type: "file" as const, name: "config.json", path: "extracted/config.json", }, { - type: "directory", + type: "directory" as const, name: "nested", path: "extracted/nested", children: [ { - type: "file", + type: "file" as const, name: "DeepFile.txt", path: "extracted/nested/DeepFile.txt", }, { - type: "file", + type: "file" as const, name: "debug.log", path: "extracted/nested/debug.log", }, @@ -879,10 +878,10 @@ describe("filterTreeStructure", () => { }); it("should handle tree with only files", () => { - const tree: TreeEntry[] = [ - { type: "file", name: "file1.txt", path: "file1.txt" }, - { type: "file", name: "file2.pdf", path: "file2.pdf" }, - { type: "file", name: "file3.md", path: "file3.md" }, + const tree = [ + { type: "file" as const, name: "file1.txt", path: "file1.txt" }, + { type: "file" as const, name: "file2.pdf", path: "file2.pdf" }, + { type: "file" as const, name: "file3.md", path: "file3.md" }, ]; const filter = createPathFilter({ include: ["*.txt"] }); @@ -894,23 +893,23 @@ describe("filterTreeStructure", () => { }); it("should handle deeply nested structure", () => { - const nestedTree: TreeEntry[] = [ + const nestedTree = [ { - type: "directory", + type: "directory" as const, name: "a", path: "a", children: [ { - type: "directory", + type: "directory" as const, name: "b", path: "b", children: [ { - type: "directory", + type: "directory" as const, name: "c", path: "c", children: [ - { type: "file", name: "deep.txt", path: "deep.txt" }, + { type: "file" as const, name: "deep.txt", path: "deep.txt" }, ], }, ], @@ -1036,36 +1035,36 @@ describe("filterTreeStructure", () => { }); it("should handle directory pattern matching file without extension", () => { - const treeWithFileNoExt: TreeEntry[] = [ + const treeWithFileNoExt = [ { - type: "file", + type: "file" as const, name: "entry", path: "entry", }, { - type: "file", + type: "file" as const, name: "entry.txt", path: "entry.txt", }, { - type: "directory", + type: "directory" as const, name: "entryDir", path: "entryDir", children: [ { - type: "file", + type: "file" as const, name: "content.txt", path: "content.txt", }, ], }, { - type: "directory", + type: "directory" as const, name: "other", path: "other", children: [ { - type: "file", + type: "file" as const, name: "entry", path: "entry", }, diff --git a/packages/test-utils/test/mock-store/mock-store.test.ts b/packages/test-utils/test/mock-store/mock-store.test.ts index 18bddeb93..a065ba4f9 100644 --- a/packages/test-utils/test/mock-store/mock-store.test.ts +++ b/packages/test-utils/test/mock-store/mock-store.test.ts @@ -202,9 +202,10 @@ describe("mockStoreApi", () => { "/api/v1/versions/{version}/file-tree": vi.fn(async ({ params }) => { const tree: UnicodeFileTree = [ { - type: "file", + type: "file" as const, name: `test-${params.version}.txt`, path: `test-${params.version}.txt`, + lastModified: 0, }, ]; @@ -408,9 +409,10 @@ describe("mockStoreApi", () => { it("should handle mix of enabled, disabled, and custom endpoints", async () => { const customTree: UnicodeFileTree = [ { - type: "file", + type: "file" as const, name: "custom.txt", path: "custom.txt", + lastModified: 0, }, ]; diff --git a/packages/ucd-store-v2/src/operations/files/tree.ts b/packages/ucd-store-v2/src/operations/files/tree.ts index ebad46e91..9247f1934 100644 --- a/packages/ucd-store-v2/src/operations/files/tree.ts +++ b/packages/ucd-store-v2/src/operations/files/tree.ts @@ -1,5 +1,5 @@ import type { OperationResult } from "@ucdjs-internal/shared"; -import type { UnicodeFileTreeNode } from "@ucdjs/schemas"; +import type { UnicodeFileTreeNodeWithoutLastModified } from "@ucdjs/schemas"; import type { StoreError } from "../../errors"; import type { InternalUCDStoreContext, SharedOperationOptions } from "../../types"; import { @@ -28,13 +28,13 @@ export interface GetFileTreeOptions extends SharedOperationOptions { * @param {InternalUCDStoreContext} context - Internal store context with client, filters, and configuration * @param {string} version - The Unicode version to fetch the file tree for * @param {GetFileTreeOptions} [options] - Optional filters and API fallback behavior - * @returns {Promise>} Operation result with filtered file tree or error + * @returns {Promise>} Operation result with filtered file tree or error */ export async function getFileTree( context: InternalUCDStoreContext, version: string, options?: GetFileTreeOptions, -): Promise> { +): Promise> { return wrapTry(async () => { // Validate version exists in store if (!context.versions.includes(version)) { diff --git a/packages/ucd-store-v2/src/types.ts b/packages/ucd-store-v2/src/types.ts index 1c370526e..14c9640a2 100644 --- a/packages/ucd-store-v2/src/types.ts +++ b/packages/ucd-store-v2/src/types.ts @@ -1,7 +1,7 @@ import type { OperationResult, PathFilter, PathFilterOptions } from "@ucdjs-internal/shared"; import type { UCDClient } from "@ucdjs/client"; import type { FileSystemBridge } from "@ucdjs/fs-bridge"; -import type { UCDWellKnownConfig, UnicodeFileTreeNode } from "@ucdjs/schemas"; +import type { UCDWellKnownConfig, UnicodeFileTreeNodeWithoutLastModified } from "@ucdjs/schemas"; import type { StoreError } from "./errors"; import type { AnalysisReport, AnalyzeOptions } from "./operations/analyze"; import type { GetFileOptions } from "./operations/files/get"; @@ -170,7 +170,7 @@ export interface UCDStoreFileOperations { * Get the file tree structure for a Unicode version. * Returns a hierarchical tree of files and directories. */ - tree: (version: string, options?: GetFileTreeOptions) => Promise>; + tree: (version: string, options?: GetFileTreeOptions) => Promise>; } export interface UCDStoreOperations { diff --git a/packages/ucd-store/src/store.ts b/packages/ucd-store/src/store.ts index 2c97ccf30..15e69238a 100644 --- a/packages/ucd-store/src/store.ts +++ b/packages/ucd-store/src/store.ts @@ -1,7 +1,7 @@ import type { OperationResult, PathFilter, PathFilterOptions } from "@ucdjs-internal/shared"; import type { UCDClient } from "@ucdjs/client"; import type { FileSystemBridge } from "@ucdjs/fs-bridge"; -import type { UCDStoreManifest, UnicodeFileTreeNode } from "@ucdjs/schemas"; +import type { UCDStoreManifest, UnicodeFileTreeNodeWithoutLastModified } from "@ucdjs/schemas"; import type { StoreError } from "./errors"; import type { AnalyzeOptions, AnalyzeResult } from "./internal/analyze"; import type { CleanOptions, CleanResult } from "./internal/clean"; @@ -139,7 +139,7 @@ export class UCDStore { return this.#manifestPath; } - async getFileTree(version: string, extraFilters?: Pick): Promise> { + async getFileTree(version: string, extraFilters?: Pick): Promise> { return wrapTry(async () => { await this.#ensureClient(); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 669b7a30c..c88a28600 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,5 +1,5 @@ export { createPathFilter, filterTreeStructure, PRECONFIGURED_FILTERS } from "@ucdjs-internal/shared"; -export type { PathFilter, PathFilterOptions, TreeEntry } from "@ucdjs-internal/shared"; +export type { PathFilter, PathFilterOptions } from "@ucdjs-internal/shared"; // eslint-disable-next-line ts/explicit-function-return-type export function internal_bingbong() { diff --git a/vscode/src/lib/files.ts b/vscode/src/lib/files.ts index 8e219fa59..acca77063 100644 --- a/vscode/src/lib/files.ts +++ b/vscode/src/lib/files.ts @@ -1,4 +1,4 @@ -import type { UnicodeFileTreeNode } from "@ucdjs/schemas"; +import type { UnicodeFileTreeNodeWithoutLastModified } from "@ucdjs/schemas"; import type { UCDStore } from "@ucdjs/ucd-store"; import type { TreeViewNode } from "reactive-vscode"; import type { UCDTreeItem } from "../composables/useUCDExplorer"; @@ -7,7 +7,7 @@ import { ThemeIcon, TreeItemCollapsibleState } from "vscode"; import * as Meta from "../generated/meta"; import { logger } from "../logger"; -function mapEntryToTreeNode(version: string, entry: UnicodeFileTreeNode, parentPath?: string): TreeViewNode { +function mapEntryToTreeNode(version: string, entry: UnicodeFileTreeNodeWithoutLastModified, parentPath?: string): TreeViewNode { if (entry == null) { throw new Error("Entry is null or undefined"); }