diff --git a/apps/api/src/routes/v1_versions/$version.ts b/apps/api/src/routes/v1_versions/$version.ts index 66982daaf..f78209c4e 100644 --- a/apps/api/src/routes/v1_versions/$version.ts +++ b/apps/api/src/routes/v1_versions/$version.ts @@ -1,8 +1,9 @@ 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"; -import { UnicodeTreeSchema, UnicodeVersionDetailsSchema } from "@ucdjs/schemas"; +import { UnicodeFileTreeNodeSchema, UnicodeFileTreeSchema, UnicodeVersionDetailsSchema } from "@ucdjs/schemas"; import { hasUCDFolderPath, resolveUCDVersion, @@ -108,7 +109,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", @@ -208,19 +209,32 @@ 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); }); } 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"); @@ -243,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, { diff --git a/apps/api/test/routes/v1_versions/$version.test.ts b/apps/api/test/routes/v1_versions/$version.test.ts index fae58684f..8f7e78371 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,96 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - expectJsonResponse(response); - const data = await json() as unknown[]; - 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 } : {}), - }); + expect(response).toMatchResponse({ + json: true, + status: 200, }); - expect(data).toEqual(expect.arrayContaining(expectedFiles)); + const data = await json(); + expect(Array.isArray(data)).toBe(true); + + 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", + "file1.txt", + "file2.txt", + "subdir/file3.txt", + "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", + "file1.txt", + "file2.txt", + "subdir/file3.txt", + "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 +168,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 +189,7 @@ describe("v1_versions", () => { return [files, directories]; }, - [[], []] as [Entry[], TraverseEntry[]], + [[], []] as [Exclude[], Exclude[]], ); expect(filesEntries.length).toBeGreaterThan(0); @@ -134,7 +218,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 +242,25 @@ 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", + "file1.txt", + "file2.txt", + "subdir/file3.txt", + "emoji/emoji-data.txt", + ]); }); describe("error handling", () => { @@ -156,9 +270,11 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { + expect(response).toMatchResponse({ status: 400, - message: "Invalid Unicode version", + error: { + message: "Invalid Unicode version", + }, }); }); @@ -168,9 +284,11 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { + expect(response).toMatchResponse({ status: 400, - message: "Invalid Unicode version", + error: { + message: "Invalid Unicode version", + }, }); }); @@ -180,7 +298,9 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { status: 400 }); + expect(response).toMatchResponse({ + status: 400, + }); }); }); @@ -194,8 +314,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 +325,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 +333,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 +361,10 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { status: 400 }); + expect(response).toMatchResponse({ + status: 400, + }); + expect(response.headers.get("cf-cache-status")).toBeNull(); }); }); 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/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, 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/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; 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/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, + }); + }); }); 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, + }); + }); +}); 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/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/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"]); + }); +}); 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/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", - ]); - }); -}); 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..a065ba4f9 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,15 +200,16 @@ describe("mockStoreApi", () => { mockStoreApi({ responses: { "/api/v1/versions/{version}/file-tree": vi.fn(async ({ params }) => { - const tree: UnicodeTree = [ + const tree: UnicodeFileTree = [ { - type: "file", + type: "file" as const, name: `test-${params.version}.txt`, path: `test-${params.version}.txt`, + lastModified: 0, }, ]; - return HttpResponse.json(tree); + return HttpResponse.json(tree); }), }, }); @@ -406,11 +407,12 @@ describe("mockStoreApi", () => { }); it("should handle mix of enabled, disabled, and custom endpoints", async () => { - const customTree: UnicodeTree = [ + const customTree: UnicodeFileTree = [ { - type: "file", + type: "file" as const, name: "custom.txt", path: "custom.txt", + lastModified: 0, }, ]; @@ -974,7 +976,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..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 { UnicodeTreeNode } 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 a5b169834..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, UnicodeTreeNode } 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 e71e6bd5f..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, UnicodeTreeNode } 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/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/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/pnpm-lock.yaml b/pnpm-lock.yaml index 790d0584d..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 @@ -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 @@ -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 @@ -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: @@ -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,8 +9240,8 @@ 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==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -9251,25 +9251,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 +9308,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: @@ -10185,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 @@ -10289,9 +10289,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 +10312,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 +11428,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 +12910,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: @@ -12975,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: {} @@ -14006,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 @@ -14510,7 +14510,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 +14536,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 +14559,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 +14567,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 +14581,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) @@ -18316,15 +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: {} + zod@4.3.5: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b615d27fe..f576b0aed 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -53,12 +53,12 @@ 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 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 diff --git a/vscode/src/lib/files.ts b/vscode/src/lib/files.ts index 9c7831ce9..acca77063 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 { 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: UnicodeTreeNode, parentPath?: string): TreeViewNode { +function mapEntryToTreeNode(version: string, entry: UnicodeFileTreeNodeWithoutLastModified, parentPath?: string): TreeViewNode { if (entry == null) { throw new Error("Entry is null or undefined"); }