diff --git a/.vscode/settings.json b/.vscode/settings.json index 732f86398..4366f7358 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -45,5 +45,7 @@ }, "files.readonlyInclude": { "**/routeTree.gen.ts": true - } + }, + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.experimental.useTsgo": true } diff --git a/AGENTS.md b/AGENTS.md index eba6e1de9..b1bd3165a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,13 +18,10 @@ The repository is organized into three main workspace categories: ### Key Packages - **@ucdjs/ucd-store**: Store for managing Unicode Character Database files. Supports multiple file system bridges (Node.js, HTTP, in-memory). Core operations include mirror, analyze, and clean. -- **@ucdjs/ucd-store-v2**: Next-generation store with lockfile and snapshot support. - > NOTE: This is a temporary package name while the new store is being implemented. Once stable, it will replace `@ucdjs/ucd-store`. - > Core operations include mirror, analyze, and sync (different from v1). - **@ucdjs/lockfile**: Lockfile and snapshot management utilities for UCD stores. Provides file hashing, lockfile/snapshot validation, and test utilities. - **@ucdjs/schema-gen**: Uses AI (OpenAI) to generate TypeScript schemas from Unicode data files - **@ucdjs/cli**: Command-line interface for UCD operations (binary: `ucd`) - > Currently uses `@ucdjs/ucd-store-v2` and `@ucdjs/lockfile` under the hood. + > Currently uses `@ucdjs/ucd-store` and `@ucdjs/lockfile` under the hood. - **@ucdjs/client**: OpenAPI-based API client for the UCD API - **@ucdjs/fs-bridge**: File system abstraction layer that allows different storage backends - **@ucdjs/schemas**: Zod schemas for Unicode data files (includes lockfile and snapshot schemas) @@ -183,21 +180,6 @@ The `@ucdjs/schema-gen` package uses OpenAI to generate TypeScript type definiti 2. Uses AI to infer field types and descriptions 3. Generates TypeScript interfaces using knitwork -### UCD Store Migration -The project is currently migrating from `@ucdjs/ucd-store` to `@ucdjs/ucd-store-v2`. Key differences: - -**Old Store (@ucdjs/ucd-store)**: -- Operations: mirror, analyze, clean -- No lockfile/snapshot support - -**New Store (@ucdjs/ucd-store-v2)**: -- Operations: mirror, analyze, sync -- Integrated lockfile and snapshot support via `@ucdjs/lockfile` -- Currently used by CLI -- Will replace old store once stable - -Both stores coexist during the migration period. When working with the CLI or testing new features, use ucd-store-v2. - ### Internal Development Tools #### Moonbeam (@ucdjs/moonbeam) diff --git a/apps/api/README.md b/apps/api/README.md index 714605d62..d3e17111b 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -2,18 +2,12 @@ A RESTful API for accessing Unicode Character Database (UCD) data. -## 📦 Installation +## � Usage -```sh -git clone https://github.com/ucdjs/api.ucdjs.dev.git -cd api.ucdjs.dev -pnpm install -``` - -## 🚀 Usage +From the root directory: ```sh -pnpm run dev +pnpm run dev:apps ``` ## 📖 API Documentation diff --git a/apps/api/package.json b/apps/api/package.json index 9bfa235c7..02c705d1c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -2,7 +2,7 @@ "name": "@ucdjs/api", "type": "module", "private": true, - "packageManager": "pnpm@10.26.1", + "packageManager": "pnpm@10.26.2", "scripts": { "dev": "wrangler dev --port 8787 --inspector-port 9229", "build": "wrangler deploy --dry-run --outdir=dist --tsconfig=./tsconfig.build.json --env=production", diff --git a/apps/api/src/constants.ts b/apps/api/src/constants.ts index 184bdb89a..cbbb85ca4 100644 --- a/apps/api/src/constants.ts +++ b/apps/api/src/constants.ts @@ -1,6 +1,10 @@ export const V1_FILES_ROUTER_BASE_PATH = "/api/v1/files"; export const V1_VERSIONS_ROUTER_BASE_PATH = "/api/v1/versions"; export const V1_SCHEMAS_ROUTER_BASE_PATH = "/api/v1/schemas"; +export const V1_CHARACTERS_ROUTER_BASE_PATH = "/api/v1/characters"; +export const V1_PROPERTIES_ROUTER_BASE_PATH = "/api/v1/properties"; +export const V1_BLOCKS_ROUTER_BASE_PATH = "/api/v1/blocks"; + export const WELL_KNOWN_ROUTER_BASE_PATH = "/.well-known"; export const HTML_EXTENSIONS = [ diff --git a/apps/api/src/lib/files.ts b/apps/api/src/lib/files.ts index 0621b4b6d..4f6b972e3 100644 --- a/apps/api/src/lib/files.ts +++ b/apps/api/src/lib/files.ts @@ -1,5 +1,5 @@ import type { Entry } from "apache-autoindex-parse"; -import { trimTrailingSlash } from "@luxass/utils"; +import { createGlobMatcher } from "@ucdjs-internal/shared"; import { parse } from "apache-autoindex-parse"; /** @@ -20,13 +20,102 @@ import { parse } from "apache-autoindex-parse"; * console.log(entries); // [{ type: 'directory', name: 'UNIDATA', path: '/UNIDATA', ... }] * ``` */ -export async function parseUnicodeDirectory(html: string): Promise { - const files = parse(html, "F2"); - - return files.map(({ type, name, path, lastModified }) => ({ - type, - name: trimTrailingSlash(name), - path: trimTrailingSlash(path), - lastModified, - })); +export async function parseUnicodeDirectory(html: string, basePath = ""): Promise { + const files = parse(html, { + format: "F2", + basePath, + }); + + return files; +} + +export interface DirectoryFilterOptions { + /** + * A string to filter file/directory names that start with this query (case-insensitive). + */ + query?: string; + + /** + * A glob pattern to filter file/directory names. + */ + pattern?: string; + + /** + * Type of entries to include: "all" (default), "files", or "directories". + */ + type?: string; + + /** + * Field to sort by: "name" (default) or "lastModified". + */ + sort?: string; + + /** + * Sort order: "asc" (default) or "desc". + */ + order?: string; +} + +/** + * Applies filtering and sorting to directory entries based on query parameters. + * + * @param {Entry[]} files - Array of directory entries to filter and sort + * @param {DirectoryFilterOptions} options - Filter and sort options + * @returns {Entry[]} Filtered and sorted array of entries + */ +export function applyDirectoryFiltersAndSort( + files: Entry[], + options: DirectoryFilterOptions, +): Entry[] { + let filtered = [...files]; + + // Apply query filter (prefix search, case-insensitive) + if (options.query) { + // eslint-disable-next-line no-console + console.info(`[v1_files]: applying query filter: ${options.query}`); + const queryLower = options.query.toLowerCase(); + filtered = filtered.filter((entry) => entry.name.toLowerCase().startsWith(queryLower)); + } + + // Apply pattern filter if provided + if (options.pattern) { + // eslint-disable-next-line no-console + console.info(`[v1_files]: applying glob pattern filter: ${options.pattern}`); + const matcher = createGlobMatcher(options.pattern); + filtered = filtered.filter((entry) => matcher(entry.name)); + } + + // Apply type filter + const type = options.type || "all"; + if (type === "files") { + filtered = filtered.filter((entry) => entry.type === "file"); + } else if (type === "directories") { + filtered = filtered.filter((entry) => entry.type === "directory"); + } + + // Apply sorting (directories always first, like Windows File Explorer) + const sort = options.sort || "name"; + const order = options.order || "asc"; + + filtered = filtered.toSorted((a, b) => { + // Directories always come first + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1; + } + + // Within same type, apply the requested sort + let comparison: number; + + if (sort === "lastModified") { + // lastModified is always available from parseUnicodeDirectory + comparison = (a.lastModified ?? 0) - (b.lastModified ?? 0); + } else { + // Natural name sorting (numeric aware) so 2.0.0 < 10.0.0 + comparison = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); + } + + return order === "desc" ? -comparison : comparison; + }); + + return filtered; } diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index b5b21ed37..c3ef3e8d1 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -7,6 +7,9 @@ export type OpenAPIObjectConfig = Parameters; diff --git a/apps/api/src/routes/.well-known/ucd-config.json.ts b/apps/api/src/routes/.well-known/ucd-config.json.ts index 072f9127b..eb2483e81 100644 --- a/apps/api/src/routes/.well-known/ucd-config.json.ts +++ b/apps/api/src/routes/.well-known/ucd-config.json.ts @@ -74,7 +74,7 @@ export function registerUcdConfigRoute(router: OpenAPIHono) { version: "0.1", endpoints: { files: V1_FILES_ROUTER_BASE_PATH, - manifest: `${WELL_KNOWN_ROUTER_BASE_PATH}/ucd-store.json`, + manifest: `${WELL_KNOWN_ROUTER_BASE_PATH}/ucd-store/{version}.json`, versions: V1_VERSIONS_ROUTER_BASE_PATH, }, versions: versionStrings, diff --git a/apps/api/src/routes/v1_blocks/$block.ts b/apps/api/src/routes/v1_blocks/$block.ts new file mode 100644 index 000000000..df26b3ccc --- /dev/null +++ b/apps/api/src/routes/v1_blocks/$block.ts @@ -0,0 +1,115 @@ +import type { OpenAPIHono } from "@hono/zod-openapi"; +import type { HonoEnv } from "../../types"; +import { createRoute } from "@hono/zod-openapi"; +import { dedent } from "@luxass/utils"; +import { UnicodeBlockSchema } from "@ucdjs/schemas"; +import { cache } from "hono/cache"; +import { MAX_AGE_ONE_DAY_SECONDS } from "../../constants"; +import { badRequest, notFound } from "../../lib/errors"; +import { createLogger } from "../../lib/logger"; +import { VERSION_ROUTE_PARAM } from "../../lib/shared-parameters"; +import { generateReferences, OPENAPI_TAGS } from "../../openapi"; + +const log = createLogger("ucd:api:v1_blocks"); + +const GET_BLOCK_ROUTE = createRoute({ + method: "get", + path: "/{version}/{block}", + tags: [OPENAPI_TAGS.BLOCKS], + middleware: [ + cache({ + cacheName: "ucdjs:v1_blocks:block", + cacheControl: `max-age=${MAX_AGE_ONE_DAY_SECONDS}`, + }), + ], + parameters: [ + VERSION_ROUTE_PARAM, + { + name: "block", + in: "path", + schema: { type: "string" }, + required: true, + description: "Unicode block name or ID (e.g., Basic_Latin, CJK_Unified_Ideographs)", + }, + ], + + description: dedent` + ## Get Unicode Block Details + + Retrieve detailed information about a specific Unicode block. + + - Supports **block name or ID** (e.g., \`Basic_Latin\`, \`CJK_Unified_Ideographs\`) + - Returns **block information** including codepoint range and character count + - Optionally **includes character list** with minimal or detailed format + - Supports **pagination** via limit parameter + - Supports **caching** for performance optimization + `, + responses: { + 200: { + content: { + "application/json": { + schema: UnicodeBlockSchema, + examples: { + default: { + summary: "Basic Latin block", + value: { + name: "Basic Latin", + aliases: ["ASCII"], + range: { start: "U+0000", end: "U+007F" }, + count: 128, + description: "ASCII characters and basic Latin", + relatedScripts: ["Latin"], + }, + }, + withCharacters: { + summary: "Block with characters", + value: { + name: "Basic Latin", + aliases: ["ASCII"], + range: { start: "U+0000", end: "U+007F" }, + count: 128, + description: "ASCII characters and basic Latin", + relatedScripts: ["Latin"], + characters: [ + { codepoint: "U+0041", character: "A", name: "LATIN CAPITAL LETTER A" }, + { codepoint: "U+0061", character: "a", name: "LATIN SMALL LETTER A" }, + ], + }, + }, + }, + }, + }, + description: "Block details", + }, + ...(generateReferences([ + 400, + 404, + 429, + 500, + ])), + }, +}); + +export function registerBlockRoute(router: OpenAPIHono) { + router.openapi(GET_BLOCK_ROUTE, async (c) => { + const { block, version } = c.req.param(); + + if (!block || block.length === 0) { + log.warn("Empty block name"); + return badRequest(c, { + message: "Block name cannot be empty", + }); + } + + // TODO: Fetch block data from Unicode database + // Include characters if requested, apply format and limit + log.info("Fetching block data", { + block, + version, + }); + + return notFound(c, { + message: `Block "${block}" not found. API implementation pending.`, + }); + }); +} diff --git a/apps/api/src/routes/v1_blocks/list.ts b/apps/api/src/routes/v1_blocks/list.ts new file mode 100644 index 000000000..769833d4b --- /dev/null +++ b/apps/api/src/routes/v1_blocks/list.ts @@ -0,0 +1,85 @@ +import type { OpenAPIHono } from "@hono/zod-openapi"; +import type { HonoEnv } from "../../types"; +import { createRoute } from "@hono/zod-openapi"; +import { dedent } from "@luxass/utils"; +import { UnicodeBlockListSchema } from "@ucdjs/schemas"; +import { cache } from "hono/cache"; +import { MAX_AGE_ONE_DAY_SECONDS } from "../../constants"; +import { internalServerError } from "../../lib/errors"; +import { createLogger } from "../../lib/logger"; +import { VERSION_ROUTE_PARAM } from "../../lib/shared-parameters"; +import { generateReferences, OPENAPI_TAGS } from "../../openapi"; + +const log = createLogger("ucd:api:v1_blocks"); + +const GET_BLOCKS_LIST_ROUTE = createRoute({ + method: "get", + path: "/{version}", + tags: [OPENAPI_TAGS.BLOCKS], + middleware: [ + cache({ + cacheName: "ucdjs:v1_blocks:list", + cacheControl: `max-age=${MAX_AGE_ONE_DAY_SECONDS * 7}`, // 7 days + }), + ], + parameters: [ + VERSION_ROUTE_PARAM, + ], + description: dedent` + ## List All Unicode Blocks + + Retrieve a complete list of all Unicode blocks with their basic information. + + - Returns **all Unicode blocks** with names and codepoint ranges + - Includes **character counts** for each block + - Supports **caching** with longer TTL for performance + `, + responses: { + 200: { + content: { + "application/json": { + schema: UnicodeBlockListSchema, + examples: { + default: { + summary: "List of Unicode blocks", + value: [ + { + name: "Basic Latin", + range: { start: "U+0000", end: "U+007F" }, + count: 128, + relatedScripts: ["Latin"], + }, + { + name: "Latin-1 Supplement", + range: { start: "U+0080", end: "U+00FF" }, + count: 128, + relatedScripts: ["Latin"], + }, + ], + }, + }, + }, + }, + description: "List of all Unicode blocks", + }, + ...(generateReferences([ + 429, + 500, + ])), + }, +}); + +export function registerBlocksListRoute(router: OpenAPIHono) { + router.openapi(GET_BLOCKS_LIST_ROUTE, async (c) => { + const { version } = c.req.param(); + + log.info("Fetching blocks list"); + + // TODO: Fetch blocks list from Unicode database + log.info("Blocks list request", { version }); + + return internalServerError(c, { + message: "Blocks list not yet available. API implementation pending.", + }); + }); +} diff --git a/apps/api/src/routes/v1_blocks/router.ts b/apps/api/src/routes/v1_blocks/router.ts new file mode 100644 index 000000000..d3a280b91 --- /dev/null +++ b/apps/api/src/routes/v1_blocks/router.ts @@ -0,0 +1,10 @@ +import type { HonoEnv } from "../../types"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { V1_BLOCKS_ROUTER_BASE_PATH } from "../../constants"; +import { registerBlockRoute } from "./$block"; +import { registerBlocksListRoute } from "./list"; + +export const V1_BLOCKS_ROUTER = new OpenAPIHono().basePath(V1_BLOCKS_ROUTER_BASE_PATH); + +registerBlocksListRoute(V1_BLOCKS_ROUTER); +registerBlockRoute(V1_BLOCKS_ROUTER); diff --git a/apps/api/src/routes/v1_characters/$codepoint.ts b/apps/api/src/routes/v1_characters/$codepoint.ts new file mode 100644 index 000000000..64c7e42cc --- /dev/null +++ b/apps/api/src/routes/v1_characters/$codepoint.ts @@ -0,0 +1,134 @@ +import type { OpenAPIHono } from "@hono/zod-openapi"; +import type { HonoEnv } from "../../types"; +import { createRoute } from "@hono/zod-openapi"; +import { dedent } from "@luxass/utils"; +import { UnicodeCharacterSchema } from "@ucdjs/schemas"; +import { cache } from "hono/cache"; +import { MAX_AGE_ONE_DAY_SECONDS } from "../../constants"; +import { badRequest, notFound } from "../../lib/errors"; +import { createLogger } from "../../lib/logger"; +import { VERSION_ROUTE_PARAM } from "../../lib/shared-parameters"; +import { generateReferences, OPENAPI_TAGS } from "../../openapi"; + +const log = createLogger("ucd:api:v1_characters"); + +const GET_CHARACTER_ROUTE = createRoute({ + method: "get", + path: "/{version}/{codepoint}", + tags: [OPENAPI_TAGS.CHARACTERS], + middleware: [ + cache({ + cacheName: "ucdjs:v1_characters:codepoint", + cacheControl: `max-age=${MAX_AGE_ONE_DAY_SECONDS}`, + }), + ], + parameters: [ + VERSION_ROUTE_PARAM, + { + name: "codepoint", + in: "path", + schema: { type: "string" }, + required: true, + description: "Unicode codepoint in U+XXXX, 0xXXXX, decimal, or character format (e.g., U+0041, 0x41, 65, A)", + }, + ], + description: dedent` + ## Get Unicode Character Details + + Retrieve detailed information about a specific Unicode character by its codepoint. + + - Supports multiple **codepoint formats** (hexadecimal, decimal, character) + - Returns **comprehensive character data** including name, category, block, script + - Includes **case mappings** when applicable + - Supports **caching** for performance optimization + `, + responses: { + 200: { + content: { + "application/json": { + schema: UnicodeCharacterSchema, + examples: { + default: { + summary: "Latin capital letter A", + value: { + codepoint: "U+0041", + character: "A", + name: "LATIN CAPITAL LETTER A", + category: "Lu", + bidiCategory: "L", + block: "Basic Latin", + script: "Latin", + caseMapping: { + lowercase: "U+0061", + }, + }, + }, + }, + }, + }, + description: "Character information", + }, + ...(generateReferences([ + 400, + 404, + 429, + 500, + ])), + }, +}); + +/** + * Parse codepoint in various formats to a standard U+XXXX format + */ +function parseCodepoint(input: string): string | null { + // Already in U+XXXX format + if (/^U\+[0-9A-Fa-f]{4,6}$/.test(input)) { + return input.toUpperCase(); + } + + // 0xXXXX format + if (/^0x[0-9A-Fa-f]{1,6}$/.test(input)) { + const hex = input.slice(2); + return `U+${hex.toUpperCase().padStart(4, "0")}`; + } + + // Decimal format + if (/^\d+$/.test(input)) { + const num = Number.parseInt(input, 10); + if (num < 0 || num > 0x10FFFF) { + return null; + } + return `U+${num.toString(16).toUpperCase().padStart(4, "0")}`; + } + + // Single character + if (input.length === 1) { + const code = input.charCodeAt(0); + return `U+${code.toString(16).toUpperCase().padStart(4, "0")}`; + } + + return null; +} + +export function registerCharacterRoute(router: OpenAPIHono) { + router.openapi(GET_CHARACTER_ROUTE, async (c) => { + const { codepoint: codepointInput, version } = c.req.param(); + + const codepoint = parseCodepoint(codepointInput); + + if (!codepoint) { + log.warn("Invalid codepoint format", { input: codepointInput }); + return badRequest(c, { + message: `Invalid codepoint format: "${codepointInput}". Use formats like U+0041, 0x41, 65, or A`, + }); + } + + // TODO: Fetch character data from Unicode database + // For now, return a placeholder response + log.info("Fetching character data", { codepoint, version }); + + return notFound(c, { + message: `Character data for ${codepoint} not yet available. API implementation pending.`, + }); + }); +} diff --git a/apps/api/src/routes/v1_characters/router.ts b/apps/api/src/routes/v1_characters/router.ts new file mode 100644 index 000000000..a4262c28c --- /dev/null +++ b/apps/api/src/routes/v1_characters/router.ts @@ -0,0 +1,8 @@ +import type { HonoEnv } from "../../types"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { V1_CHARACTERS_ROUTER_BASE_PATH } from "../../constants"; +import { registerCharacterRoute } from "./$codepoint"; + +export const V1_CHARACTERS_ROUTER = new OpenAPIHono().basePath(V1_CHARACTERS_ROUTER_BASE_PATH); + +registerCharacterRoute(V1_CHARACTERS_ROUTER); diff --git a/apps/api/src/routes/v1_files/$wildcard.ts b/apps/api/src/routes/v1_files/$wildcard.ts index a1dfb96dd..086f1ef27 100644 --- a/apps/api/src/routes/v1_files/$wildcard.ts +++ b/apps/api/src/routes/v1_files/$wildcard.ts @@ -1,129 +1,76 @@ +/* eslint-disable no-console */ import type { OpenAPIHono } from "@hono/zod-openapi"; +import type { Entry } from "apache-autoindex-parse"; import type { HonoEnv } from "../../types"; import { createRoute, z } from "@hono/zod-openapi"; import { dedent } from "@luxass/utils"; -import { createGlobMatcher, isValidGlobPattern } from "@ucdjs-internal/shared"; -import { DEFAULT_USER_AGENT, UCD_FILE_STAT_TYPE_HEADER } from "@ucdjs/env"; +import { isValidGlobPattern } from "@ucdjs-internal/shared"; +import { + DEFAULT_USER_AGENT, + UCD_STAT_CHILDREN_DIRS_HEADER, + UCD_STAT_CHILDREN_FILES_HEADER, + UCD_STAT_CHILDREN_HEADER, + UCD_STAT_SIZE_HEADER, + UCD_STAT_TYPE_HEADER, +} from "@ucdjs/env"; import { FileEntryListSchema } from "@ucdjs/schemas"; import { cache } from "hono/cache"; import { HTML_EXTENSIONS, MAX_AGE_ONE_WEEK_SECONDS } from "../../constants"; import { badGateway, badRequest, notFound } from "../../lib/errors"; -import { parseUnicodeDirectory } from "../../lib/files"; +import { applyDirectoryFiltersAndSort, parseUnicodeDirectory } from "../../lib/files"; import { generateReferences, OPENAPI_TAGS } from "../../openapi"; +import { + ORDER_QUERY_PARAM, + PATTERN_QUERY_PARAM, + QUERY_PARAM, + SORT_QUERY_PARAM, + TYPE_QUERY_PARAM, + WILDCARD_PARAM, +} from "./openapi-params"; import { determineContentTypeFromExtension, isInvalidPath } from "./utils"; -const WILDCARD_PARAM = { - in: "path", - name: "wildcard", - description: dedent` - The path to the Unicode data resource you want to access. This can be any valid path from the official Unicode Public directory structure. - - ## Path Format Options - - | Pattern | Description | Example | - |--------------------------------|--------------------------------|-------------------------------------| - | \`{version}/ucd/{filename}\` | UCD files for specific version | \`15.1.0/ucd/UnicodeData.txt\` | - | \`{version}/ucd/{sub}/{file}\` | Files in subdirectories | \`15.1.0/ucd/emoji/emoji-data.txt\` | - | \`{version}\` | List files for version | \`15.1.0\` | - | \`latest/ucd/{filename}\` | Latest version of file | \`latest/ucd/PropList.txt\` | - `, - required: true, - schema: { - type: "string", - pattern: ".*", - }, - examples: { - "UnicodeData.txt": { - summary: "UnicodeData.txt for Unicode 15.0.0", - value: "15.0.0/ucd/UnicodeData.txt", - }, - "emoji-data.txt": { - summary: "Emoji data file", - value: "15.1.0/ucd/emoji/emoji-data.txt", - }, - "root": { - summary: "Root path", - value: "", - }, - "list-version-dir": { - summary: "Versioned path", - value: "15.1.0", - }, - }, -} as const; - -const PATTERN_QUERY_PARAM = { - in: "query", - name: "pattern", - description: dedent` - A glob pattern to filter directory listing results by filename. Only applies when the response is a directory listing. - The matching is **case-insensitive**. - - ## Supported Glob Syntax - - | Pattern | Description | Example | - |-----------|-----------------------------------------------|------------------------------------------------------| - | \`*\` | Match any characters (except path separators) | \`*.txt\` matches \`file.txt\` | - | \`?\` | Match a single character | \`file?.txt\` matches \`file1.txt\` | - | \`{a,b}\` | Match any of the patterns | \`*.{txt,xml}\` matches \`file.txt\` or \`file.xml\` | - | \`[abc]\` | Match any character in the set | \`file[123].txt\` matches \`file1.txt\` | - - ## Examples - - - \`*.txt\` - Match all text files - - \`Uni*\` - Match files starting with "Uni" (e.g., UnicodeData.txt) - - \`*Data*\` - Match files containing "Data" - - \`*.{txt,xml}\` - Match text or XML files - `, - required: false, - schema: { - type: "string", - }, - examples: { - "txt-files": { - summary: "Match all .txt files", - value: "*.txt", - }, - "prefix-match": { - summary: "Match files starting with 'Uni'", - value: "Uni*", - }, - "contains-match": { - summary: "Match files containing 'Data'", - value: "*Data*", - }, - "multi-extension": { - summary: "Match .txt or .xml files", - value: "*.{txt,xml}", - }, - }, -} as const; - export const WILDCARD_ROUTE = createRoute({ method: "get", path: "/{wildcard}", tags: [OPENAPI_TAGS.FILES], - parameters: [WILDCARD_PARAM, PATTERN_QUERY_PARAM], + parameters: [ + WILDCARD_PARAM, + PATTERN_QUERY_PARAM, + QUERY_PARAM, + TYPE_QUERY_PARAM, + SORT_QUERY_PARAM, + ORDER_QUERY_PARAM, + ], description: dedent` - This endpoint proxies your request directly to Unicode.org, allowing you to access any file or directory under the Unicode Public directory structure with only slight [modifications](#tag/files/get/api/v1/files/{wildcard}/description/modifications). + This endpoint proxies requests to Unicode.org's Public directory, streaming files directly while transforming directory listings into structured JSON. + + All paths are relative to \`/api/v1/files\` — for example, requesting \`/api/v1/files/15.1.0/ucd/emoji/emoji-data.txt\` fetches the emoji data file from Unicode version 15.1.0. > [!IMPORTANT] - > The \`{wildcard}\` parameter can be any valid path, you are even allowed to use nested paths like \`15.1.0/ucd/emoji/emoji-data.txt\`. + > The \`{wildcard}\` parameter accepts any valid path, including deeply nested ones like \`15.1.0/ucd/emoji/emoji-data.txt\`. In directory listing responses, paths for directories include a trailing slash (e.g., \`/15.1.0/ucd/charts/\`), while file paths do not. > [!NOTE] - > If you wanna access only some metadata about the path, you can use a \`HEAD\` request instead. See [here](#tag/files/head/api/v1/files/{wildcard}) + > To retrieve only metadata without downloading content, use a \`HEAD\` request instead. See [here](#tag/files/head/api/v1/files/{wildcard}) + ### Directory Listing Features + + When accessing a directory, you can filter and sort entries using these query parameters: + + - \`query\` - Prefix-based search (case-insensitive) on entry names + - \`pattern\` - Glob pattern matching for filtering + - \`type\` - Filter by entry type: \`all\` (default), \`files\`, or \`directories\` + - \`sort\` - Sort by \`name\` (default) or \`lastModified\` + - \`order\` - Sort order: \`asc\` (default) or \`desc\` ### Modifications - We are doing a slight modification to the response, only if the response is for a directory. - If you request a directory, we will return a JSON listing of the files and subdirectories in that directory. + Directory responses are automatically transformed into JSON arrays containing file and directory entries. Files are streamed directly from Unicode.org with appropriate content types. `, responses: { 200: { description: "Response from Unicode.org", headers: { - [UCD_FILE_STAT_TYPE_HEADER]: { + [UCD_STAT_TYPE_HEADER]: { description: "The type of the file or directory", schema: { type: "string", @@ -131,6 +78,34 @@ export const WILDCARD_ROUTE = createRoute({ }, required: true, }, + [UCD_STAT_SIZE_HEADER]: { + description: "The size of the file in bytes (only for files)", + schema: { + type: "string", + }, + required: false, + }, + [UCD_STAT_CHILDREN_HEADER]: { + description: "Number of children (only for directories)", + schema: { + type: "string", + }, + required: false, + }, + [UCD_STAT_CHILDREN_FILES_HEADER]: { + description: "Number of child files (only for directories)", + schema: { + type: "string", + }, + required: false, + }, + [UCD_STAT_CHILDREN_DIRS_HEADER]: { + description: "Number of child directories (only for directories)", + schema: { + type: "string", + }, + required: false, + }, }, content: { "application/json": { @@ -142,13 +117,13 @@ export const WILDCARD_ROUTE = createRoute({ { type: "file", name: "ReadMe.txt", - path: "ReadMe.txt", + path: "/15.1.0/ucd/ReadMe.txt", lastModified: 1693213740000, }, { type: "directory", name: "charts", - path: "charts", + path: "/15.1.0/ucd/charts/", lastModified: 1697495340000, }, ], @@ -171,7 +146,7 @@ export const WILDCARD_ROUTE = createRoute({ 0004;;Cc;0;BN;;;;;N;END OF TRANSMISSION;;;; 0005;;Cc;0;BN;;;;;N;ENQUIRY;;;; 0006;;Cc;0;BN;;;;;N;ACKNOWLEDGE;;;; - `, + `.trim(), }, "15.1.0/ucd/emoji/emoji-data.txt": { summary: "Emoji data file for Unicode 15.1.0", @@ -180,7 +155,7 @@ export const WILDCARD_ROUTE = createRoute({ 2660 ; Emoji # E0.6 [1] (♠️) spade suit 2663 ; Emoji # E0.6 [1] (♣️) club suit 2665..2666 ; Emoji # E0.6 [2] (♥️..♦️) heart suit..diamond suit - `, + `.trim(), }, }, }, @@ -214,21 +189,27 @@ export const METADATA_WILDCARD_ROUTE = createRoute({ method: "head", path: "/{wildcard}", tags: [OPENAPI_TAGS.FILES], - parameters: [WILDCARD_PARAM], + parameters: [ + WILDCARD_PARAM, + PATTERN_QUERY_PARAM, + QUERY_PARAM, + TYPE_QUERY_PARAM, + SORT_QUERY_PARAM, + ORDER_QUERY_PARAM, + ], description: dedent` - This endpoint returns metadata about the requested file or directory without fetching the entire content. - It is useful for checking the existence of a file or directory and retrieving its metadata without downloading - the content. + Retrieve metadata about a file or directory without downloading the content. Useful for checking existence, file size, and other metadata. + + All paths are relative to \`/api/v1/files\`. Directory paths always include a trailing slash (e.g., \`/15.1.0/ucd/charts/\`), while file paths do not. > [!NOTE] - > The \`HEAD\` request will return the same headers as a \`GET\` request, but without the body. - > This means you can use it to check if a file exists or to get metadata like the last modified date, size, etc. + > This endpoint returns the same headers as the \`GET\` request (file size, directory entry counts, last modified timestamps, content type) without the response body. `, responses: { 200: { description: "Response from Unicode.org", headers: { - [UCD_FILE_STAT_TYPE_HEADER]: { + [UCD_STAT_TYPE_HEADER]: { description: "The type of the file or directory", schema: { type: "string", @@ -236,6 +217,34 @@ export const METADATA_WILDCARD_ROUTE = createRoute({ }, required: true, }, + [UCD_STAT_SIZE_HEADER]: { + description: "The size of the file in bytes (only for files)", + schema: { + type: "string", + }, + required: true, + }, + [UCD_STAT_CHILDREN_HEADER]: { + description: "Number of children (only for directories)", + schema: { + type: "string", + }, + required: true, + }, + [UCD_STAT_CHILDREN_FILES_HEADER]: { + description: "Number of child files (only for directories)", + schema: { + type: "string", + }, + required: true, + }, + [UCD_STAT_CHILDREN_DIRS_HEADER]: { + description: "Number of child directories (only for directories)", + schema: { + type: "string", + }, + required: true, + }, "Content-Type": { description: "The content type of the file", schema: { @@ -251,80 +260,109 @@ export const METADATA_WILDCARD_ROUTE = createRoute({ "Content-Length": { description: "Byte length when applicable", schema: { type: "string" }, - required: false, + required: true, }, }, }, }, }); +function buildDirectoryHeaders(files: Entry[], baseHeaders: Record): Record { + return { + ...baseHeaders, + [UCD_STAT_TYPE_HEADER]: "directory", + [UCD_STAT_CHILDREN_HEADER]: `${files.length}`, + [UCD_STAT_CHILDREN_FILES_HEADER]: `${files.filter((f) => f.type === "file").length}`, + [UCD_STAT_CHILDREN_DIRS_HEADER]: `${files.filter((f) => f.type === "directory").length}`, + }; +} + +function buildFileHeaders( + contentType: string, + baseHeaders: Record, + response: Response, + actualContentLength: number, +): Record { + const headers: Record = { + "Content-Type": contentType, + ...baseHeaders, + [UCD_STAT_TYPE_HEADER]: "file", + [UCD_STAT_SIZE_HEADER]: `${actualContentLength}`, + "Content-Length": `${actualContentLength}`, + }; + + const cd = response.headers.get("Content-Disposition"); + if (cd) headers["Content-Disposition"] = cd; + + return headers; +} + export function registerWildcardRoute(router: OpenAPIHono) { router.openAPIRegistry.registerPath(WILDCARD_ROUTE); router.openAPIRegistry.registerPath(METADATA_WILDCARD_ROUTE); - router.get("/:wildcard{.*}?", cache({ - cacheName: "ucdjs:v1_files:files", - cacheControl: `max-age=${MAX_AGE_ONE_WEEK_SECONDS}`, // 7 days - }), async (c) => { - const path = c.req.param("wildcard")?.trim() || ""; + router.get( + "/:wildcard{.*}?", + cache({ + cacheName: "ucdjs:v1_files:files", + cacheControl: `max-age=${MAX_AGE_ONE_WEEK_SECONDS}`, // 7 days + }), + async (c) => { + const path = c.req.param("wildcard")?.trim() || ""; + + // Validate path for path traversal attacks + if (isInvalidPath(path)) { + return badRequest({ + message: "Invalid path", + }); + } - // Validate path for path traversal attacks - if (isInvalidPath(path)) { - return badRequest({ - message: "Invalid path", - }); - } + const normalizedPath = path.replace(/^\/+|\/+$/g, ""); + const url = normalizedPath + ? `https://unicode.org/Public/${normalizedPath}?F=2` + : "https://unicode.org/Public?F=2"; - const normalizedPath = path.replace(/^\/+|\/+$/g, ""); - const url = normalizedPath - ? `https://unicode.org/Public/${normalizedPath}?F=2` - : "https://unicode.org/Public?F=2"; + console.info(`[v1_files]: fetching file at ${url}`); - // eslint-disable-next-line no-console - console.info(`[v1_files]: fetching file at ${url}`); + const response = await fetch(url, { + method: "GET", + headers: { + "User-Agent": DEFAULT_USER_AGENT, + }, + }); - const response = await fetch(url, { - method: "GET", - headers: { - "User-Agent": DEFAULT_USER_AGENT, - }, - }); + if (!response.ok) { + if (response.status === 404) { + return notFound(c, { + message: "Resource not found", + }); + } - if (!response.ok) { - if (response.status === 404) { - return notFound(c, { - message: "Resource not found", - }); + return badGateway(c); } - return badGateway(c); - } - - let contentType = response.headers.get("content-type") || ""; - const lastModified = response.headers.get("Last-Modified") || undefined; - const upstreamContentLength = response.headers.get("Content-Length") || undefined; - const baseHeaders: Record = {}; - if (lastModified) baseHeaders["Last-Modified"] = lastModified; - - const leaf = normalizedPath.split("/").pop() ?? ""; - const extName = leaf.includes(".") ? leaf.split(".").pop()!.toLowerCase() : ""; - const isHtmlFile = HTML_EXTENSIONS.includes(`.${extName}`); - - // check if this is a directory listing (HTML response for non-HTML files) - const isDirectoryListing = contentType.includes("text/html") && !isHtmlFile; - - // eslint-disable-next-line no-console - console.info(`[v1_files]: fetched content type: ${contentType} for .${extName} file`); - if (isDirectoryListing) { - const html = await response.text(); - let files = await parseUnicodeDirectory(html); - - // Apply pattern filter if provided - const pattern = c.req.query("pattern"); - if (pattern) { - // eslint-disable-next-line no-console - console.info(`[v1_files]: applying glob pattern filter: ${pattern}`); - if (!isValidGlobPattern(pattern, { + let contentType = response.headers.get("content-type") || ""; + const lastModified = response.headers.get("Last-Modified") || undefined; + const baseHeaders: Record = {}; + if (lastModified) baseHeaders["Last-Modified"] = lastModified; + + const leaf = normalizedPath.split("/").pop() ?? ""; + const extName = leaf.includes(".") ? leaf.split(".").pop()!.toLowerCase() : ""; + const isHtmlFile = HTML_EXTENSIONS.includes(`.${extName}`); + + // check if this is a directory listing (HTML response for non-HTML files) + const isDirectoryListing = contentType.includes("text/html") && !isHtmlFile; + + console.info(`[v1_files]: fetched content type: ${contentType} for .${extName} file`); + if (isDirectoryListing) { + const html = await response.text(); + const parsedFiles = await parseUnicodeDirectory(html, normalizedPath || "/"); + + // Get query parameters for filtering and sorting + const pattern = c.req.query("pattern"); + + // Validate glob pattern before applying + if (pattern && !isValidGlobPattern(pattern, { maxLength: 128, maxSegments: 8, maxBraceExpansions: 8, @@ -336,35 +374,46 @@ export function registerWildcardRoute(router: OpenAPIHono) { }); } - const matcher = createGlobMatcher(pattern); - files = files.filter((entry) => matcher(entry.name)); + const files = applyDirectoryFiltersAndSort(parsedFiles, { + query: c.req.query("query"), + pattern, + type: c.req.query("type"), + sort: c.req.query("sort"), + order: c.req.query("order"), + }); + + const headers = buildDirectoryHeaders(files, baseHeaders); + return c.json(files, 200, headers); + } + + // Handle file response + console.log(`[v1_files]: pre content type check: ${contentType} for .${extName} file`); + contentType ||= determineContentTypeFromExtension(extName); + console.log(`[v1_files]: inferred content type as ${contentType} for .${extName} file`); + + const isHeadRequest = c.req.method === "HEAD"; + + // For HEAD requests, buffer to calculate accurate size + if (isHeadRequest) { + const blob = await response.blob(); + const actualSize = blob.size; + const headers = buildFileHeaders(contentType, baseHeaders, response, actualSize); + console.log(`[v1_files]: HEAD request, calculated size: ${actualSize}`); + return c.newResponse(null, 200, headers); } - return c.json(files, 200, { + const headers: Record = { + "Content-Type": contentType, ...baseHeaders, + [UCD_STAT_TYPE_HEADER]: "file", + }; - // Custom STAT Headers - [UCD_FILE_STAT_TYPE_HEADER]: "directory", - }); - } - - // eslint-disable-next-line no-console - console.log(`[v1_files]: pre content type check: ${contentType} for .${extName} file`); - contentType ||= determineContentTypeFromExtension(extName); - // eslint-disable-next-line no-console - console.log(`[v1_files]: inferred content type as ${contentType} for .${extName} file`); - - const headers: Record = { - "Content-Type": contentType, - ...baseHeaders, - - // Custom STAT Headers - [UCD_FILE_STAT_TYPE_HEADER]: "file", - }; - - const cd = response.headers.get("Content-Disposition"); - if (cd) headers["Content-Disposition"] = cd; - if (upstreamContentLength) headers["Content-Length"] = upstreamContentLength; - return c.newResponse(response.body!, 200, headers); - }); + const cd = response.headers.get("Content-Disposition"); + if (cd) headers["Content-Disposition"] = cd; + + console.log(`[v1_files]: binary file, streaming without buffering`); + + return c.newResponse(response.body, 200, headers); + }, + ); } diff --git a/apps/api/src/routes/v1_files/openapi-params.ts b/apps/api/src/routes/v1_files/openapi-params.ts new file mode 100644 index 000000000..8f5a4a028 --- /dev/null +++ b/apps/api/src/routes/v1_files/openapi-params.ts @@ -0,0 +1,202 @@ +import { dedent } from "@luxass/utils"; + +export const WILDCARD_PARAM = { + in: "path", + name: "wildcard", + description: dedent` + The path to the Unicode data resource you want to access. This can be any valid path from the official Unicode Public directory structure. + + ## Path Format Options + + | Pattern | Description | Example | + |--------------------------------|--------------------------------|-------------------------------------| + | \`{version}/ucd/{filename}\` | UCD files for specific version | \`15.1.0/ucd/UnicodeData.txt\` | + | \`{version}/ucd/{sub}/{file}\` | Files in subdirectories | \`15.1.0/ucd/emoji/emoji-data.txt\` | + | \`{version}\` | List files for version | \`15.1.0\` | + | \`latest/ucd/{filename}\` | Latest version of file | \`latest/ucd/PropList.txt\` | + `, + required: true, + schema: { + type: "string", + pattern: ".*", + }, + examples: { + "UnicodeData.txt": { + summary: "UnicodeData.txt for Unicode 15.0.0", + value: "15.0.0/ucd/UnicodeData.txt", + }, + "emoji-data.txt": { + summary: "Emoji data file", + value: "15.1.0/ucd/emoji/emoji-data.txt", + }, + "root": { + summary: "Root path", + value: "", + }, + "list-version-dir": { + summary: "Versioned path", + value: "15.1.0", + }, + }, +} as const; + +export const PATTERN_QUERY_PARAM = { + in: "query", + name: "pattern", + description: dedent` + A glob pattern to filter directory listing results by filename. Only applies when the response is a directory listing. + The matching is **case-insensitive**. + + ## Supported Glob Syntax + + | Pattern | Description | Example | + |-----------|-----------------------------------------------|------------------------------------------------------| + | \`*\` | Match any characters (except path separators) | \`*.txt\` matches \`file.txt\` | + | \`?\` | Match a single character | \`file?.txt\` matches \`file1.txt\` | + | \`{a,b}\` | Match any of the patterns | \`*.{txt,xml}\` matches \`file.txt\` or \`file.xml\` | + | \`[abc]\` | Match any character in the set | \`file[123].txt\` matches \`file1.txt\` | + + ## Examples + + - \`*.txt\` - Match all text files + - \`Uni*\` - Match files starting with "Uni" (e.g., UnicodeData.txt) + - \`*Data*\` - Match files containing "Data" + - \`*.{txt,xml}\` - Match text or XML files + `, + required: false, + schema: { + type: "string", + }, + examples: { + "txt-files": { + summary: "Match all .txt files", + value: "*.txt", + }, + "prefix-match": { + summary: "Match files starting with 'Uni'", + value: "Uni*", + }, + "contains-match": { + summary: "Match files containing 'Data'", + value: "*Data*", + }, + "multi-extension": { + summary: "Match .txt or .xml files", + value: "*.{txt,xml}", + }, + }, +} as const; + +export const QUERY_PARAM = { + in: "query", + name: "query", + description: dedent` + A search query to filter directory listing results. Entries are matched if their name **starts with** this value (case-insensitive). + This is useful for quick prefix-based searching within a directory. + + ## Examples + + - \`Uni\` - Match entries starting with "Uni" (e.g., UnicodeData.txt) + - \`15\` - Match version directories starting with "15" + `, + required: false, + schema: { + type: "string", + }, + examples: { + "unicode-prefix": { + summary: "Search for entries starting with 'Uni'", + value: "Uni", + }, + "version-prefix": { + summary: "Search for version directories", + value: "15", + }, + }, +} as const; + +export const TYPE_QUERY_PARAM = { + in: "query", + name: "type", + description: dedent` + Filter directory listing results by entry type. + + - \`all\` (default) - Return both files and directories + - \`files\` - Return only files + - \`directories\` - Return only directories + `, + required: false, + schema: { + type: "string", + enum: ["all", "files", "directories"] as string[], + default: "all", + }, + examples: { + "all": { + summary: "Show all entries (default)", + value: "all", + }, + "files-only": { + summary: "Show only files", + value: "files", + }, + "directories-only": { + summary: "Show only directories", + value: "directories", + }, + }, +} as const; + +export const SORT_QUERY_PARAM = { + in: "query", + name: "sort", + description: dedent` + The field to sort directory listing results by. + + - \`name\` (default) - Sort alphabetically by entry name + - \`lastModified\` - Sort by last modification timestamp + `, + required: false, + schema: { + type: "string", + enum: ["name", "lastModified"] as string[], + default: "name", + }, + examples: { + "by-name": { + summary: "Sort by name (default)", + value: "name", + }, + "by-date": { + summary: "Sort by last modified date", + value: "lastModified", + }, + }, +} as const; + +export const ORDER_QUERY_PARAM = { + in: "query", + name: "order", + description: dedent` + The sort order for directory listing results. + + - \`asc\` (default) - Ascending order (A-Z, oldest first) + - \`desc\` - Descending order (Z-A, newest first) + `, + required: false, + schema: { + type: "string", + enum: ["asc", "desc"] as string[], + default: "asc", + }, + examples: { + ascending: { + summary: "Ascending order (default)", + value: "asc", + }, + descending: { + summary: "Descending order", + value: "desc", + }, + }, +} as const; diff --git a/apps/api/src/routes/v1_files/router.ts b/apps/api/src/routes/v1_files/router.ts index 00958a3c7..414dcaea3 100644 --- a/apps/api/src/routes/v1_files/router.ts +++ b/apps/api/src/routes/v1_files/router.ts @@ -2,10 +2,7 @@ import type { HonoEnv } from "../../types"; import { OpenAPIHono } from "@hono/zod-openapi"; import { V1_FILES_ROUTER_BASE_PATH } from "../../constants"; import { registerWildcardRoute } from "./$wildcard"; -import { registerSearchRoute } from "./search"; export const V1_FILES_ROUTER = new OpenAPIHono().basePath(V1_FILES_ROUTER_BASE_PATH); -// Search endpoint - must be registered BEFORE the wildcard route -registerSearchRoute(V1_FILES_ROUTER); registerWildcardRoute(V1_FILES_ROUTER); diff --git a/apps/api/src/routes/v1_files/search.ts b/apps/api/src/routes/v1_files/search.ts deleted file mode 100644 index 3299e1e1b..000000000 --- a/apps/api/src/routes/v1_files/search.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { OpenAPIHono } from "@hono/zod-openapi"; -import type { HonoEnv } from "../../types"; -import { createRoute } from "@hono/zod-openapi"; -import { dedent } from "@luxass/utils"; -import { DEFAULT_USER_AGENT } from "@ucdjs/env"; -import { FileEntryListSchema } from "@ucdjs/schemas"; -import { cache } from "hono/cache"; -import { MAX_AGE_ONE_WEEK_SECONDS } from "../../constants"; -import { badGateway, badRequest } from "../../lib/errors"; -import { parseUnicodeDirectory } from "../../lib/files"; -import { generateReferences, OPENAPI_TAGS } from "../../openapi"; -import { isInvalidPath } from "./utils"; - -const SEARCH_ROUTE_DOCS = dedent` - Search for files and directories within a path. This endpoint performs a **prefix-based search** on entry names. - - ## Search Behavior - - The search is **case-insensitive** and matches entries where the name **starts with** the query string. - - Results are sorted with **files first**, followed by **directories**. This prioritization means: - - If your query matches both files and directories, files appear first - - Within each group (files/directories), results maintain their original order - - ## Example - - Given a directory with: - - \`come/\` (directory) - - \`computer.txt\` (file) - - | Query | Result | - |----------|------------------------------------------------| - | \`com\` | \`computer.txt\` (file), \`come/\` (directory) | - | \`come\` | \`come/\` (exact directory match) | - | \`comp\` | \`computer.txt\` | - - > [!NOTE] - > If no entries match the query, an empty array is returned with a 200 status. -`; - -const SEARCH_QUERY_PARAM_DOCS = dedent` - The search query string. Entries are matched if their name **starts with** this value (case-insensitive). -`; - -const SEARCH_PATH_PARAM_DOCS = dedent` - The base path to search within. If not provided, searches from the root of the Unicode Public directory. -`; - -export const SEARCH_ROUTE = createRoute({ - method: "get", - path: "/search", - tags: [OPENAPI_TAGS.FILES], - middleware: [ - cache({ - cacheName: "ucdjs:v1_files:search", - cacheControl: `max-age=${MAX_AGE_ONE_WEEK_SECONDS}`, // 7 days - }), - ], - parameters: [ - { - in: "query", - name: "q", - description: SEARCH_QUERY_PARAM_DOCS, - required: true, - schema: { - type: "string", - minLength: 1, - }, - examples: { - "unicode-prefix": { - summary: "Search for entries starting with 'uni'", - value: "uni", - }, - "version-prefix": { - summary: "Search for version directories", - value: "15", - }, - }, - }, - { - in: "query", - name: "path", - description: SEARCH_PATH_PARAM_DOCS, - required: false, - schema: { - type: "string", - }, - examples: { - "root": { - summary: "Search from root", - value: "", - }, - "ucd-dir": { - summary: "Search within UCD directory", - value: "15.1.0/ucd", - }, - }, - }, - ], - description: SEARCH_ROUTE_DOCS, - responses: { - 200: { - description: "Search results sorted with files first, then directories", - content: { - "application/json": { - schema: FileEntryListSchema, - examples: { - "files-first": { - summary: "Files appear before directories", - value: [ - { - type: "file", - name: "computer.txt", - path: "computer.txt", - lastModified: 1693213740000, - }, - { - type: "directory", - name: "come", - path: "come", - lastModified: 1697495340000, - }, - ], - }, - "empty-results": { - summary: "No matching entries", - value: [], - }, - }, - }, - }, - }, - ...(generateReferences([ - 400, - 500, - 502, - ])), - }, -}); - -export function registerSearchRoute(router: OpenAPIHono) { - router.openapi(SEARCH_ROUTE, async (c) => { - const query = c.req.query("q"); - const basePath = c.req.query("path") || ""; - - if (!query) { - return badRequest({ - message: "Missing required query parameter: q", - }); - } - - // Validate basePath for path traversal attacks - if (isInvalidPath(basePath)) { - return badRequest({ - message: "Invalid path", - }); - } - - const normalizedPath = basePath.replace(/^\/+|\/+$/g, ""); - const url = normalizedPath - ? `https://unicode.org/Public/${normalizedPath}?F=2` - : "https://unicode.org/Public?F=2"; - - // eslint-disable-next-line no-console - console.info(`[v1_files:search]: fetching directory at ${url}`); - - const response = await fetch(url, { - method: "GET", - headers: { - "User-Agent": DEFAULT_USER_AGENT, - }, - }); - - if (!response.ok) { - if (response.status === 404) { - // Return empty array if the base path doesn't exist - return c.json([], 200); - } - return badGateway(c); - } - - const contentType = response.headers.get("content-type") || ""; - - // If not a directory listing, return empty results - if (!contentType.includes("text/html")) { - return c.json([], 200); - } - - const html = await response.text(); - const entries = await parseUnicodeDirectory(html); - - // Filter entries where name starts with query (case-insensitive) - const queryLower = query.toLowerCase(); - const matchingEntries = entries.filter((entry) => - entry.name.toLowerCase().startsWith(queryLower), - ); - - // Sort: files first, then directories - const sortedEntries = matchingEntries.toSorted((a, b) => { - // Files before directories - if (a.type === "file" && b.type === "directory") return -1; - if (a.type === "directory" && b.type === "file") return 1; - // Maintain original order within same type - return 0; - }); - - return c.json(sortedEntries, 200); - }); -} diff --git a/apps/api/src/routes/v1_properties/$property.ts b/apps/api/src/routes/v1_properties/$property.ts new file mode 100644 index 000000000..f6000b7cb --- /dev/null +++ b/apps/api/src/routes/v1_properties/$property.ts @@ -0,0 +1,111 @@ +import type { OpenAPIHono } from "@hono/zod-openapi"; +import type { HonoEnv } from "../../types"; +import { createRoute } from "@hono/zod-openapi"; +import { dedent } from "@luxass/utils"; +import { UnicodePropertyResponseSchema } from "@ucdjs/schemas"; +import { cache } from "hono/cache"; +import { MAX_AGE_ONE_DAY_SECONDS } from "../../constants"; +import { badRequest, notFound } from "../../lib/errors"; +import { createLogger } from "../../lib/logger"; +import { VERSION_ROUTE_PARAM } from "../../lib/shared-parameters"; +import { generateReferences, OPENAPI_TAGS } from "../../openapi"; + +const log = createLogger("ucd:api:v1_properties"); + +const GET_PROPERTY_ROUTE = createRoute({ + method: "get", + path: "/{version}/{property}", + tags: [OPENAPI_TAGS.PROPERTIES], + middleware: [ + cache({ + cacheName: "ucdjs:v1_properties:property", + cacheControl: `max-age=${MAX_AGE_ONE_DAY_SECONDS}`, + }), + ], + parameters: [ + VERSION_ROUTE_PARAM, + { + name: "property", + in: "path", + schema: { type: "string" }, + required: true, + description: "Unicode property name (e.g., Alphabetic, Uppercase, Emoji)", + }, + ], + description: dedent` + ## Get Characters by Unicode Property + + Retrieve all characters that have a specific Unicode property. + + - Supports **multiple property names** (e.g., \`Alphabetic\`, \`Uppercase\`, \`Emoji\`) + - Returns characters in **multiple formats** (JSON details, codepoint ranges, simple list) + - Supports **property value filtering** (e.g., \`Numeric_Value=5\`) + - Includes **pagination** via limit and offset + - Supports **caching** for performance optimization + `, + responses: { + 200: { + content: { + "application/json": { + schema: UnicodePropertyResponseSchema, + examples: { + ranges: { + summary: "Emoji characters in ranges format", + value: { + property: "Emoji", + total: 1234, + ranges: [ + { start: "U+1F600", end: "U+1F64F", count: 80 }, + { start: "U+1F300", end: "U+1F5FF", count: 512 }, + ], + }, + }, + list: { + summary: "Alphabetic characters in list format", + value: { + property: "Alphabetic", + format: "list", + total: 5000, + characters: [ + { codepoint: "U+0041", character: "A", name: "LATIN CAPITAL LETTER A" }, + { codepoint: "U+0061", character: "a", name: "LATIN SMALL LETTER A" }, + ], + }, + }, + }, + }, + }, + description: "Characters matching the property", + }, + ...(generateReferences([ + 400, + 404, + 429, + 500, + ])), + }, +}); + +export function registerPropertyRoute(router: OpenAPIHono) { + router.openapi(GET_PROPERTY_ROUTE, async (c) => { + const { property, version } = c.req.param(); + + if (!property || property.length === 0) { + log.warn("Empty property name"); + return badRequest(c, { + message: "Property name cannot be empty", + }); + } + + // TODO: Fetch property data from Unicode database + // Filter by property and value, apply format, pagination + log.info("Fetching property data", { + property, + version, + }); + + return notFound(c, { + message: `Property "${property}" data not yet available. API implementation pending.`, + }); + }); +} diff --git a/apps/api/src/routes/v1_properties/router.ts b/apps/api/src/routes/v1_properties/router.ts new file mode 100644 index 000000000..c0abc594d --- /dev/null +++ b/apps/api/src/routes/v1_properties/router.ts @@ -0,0 +1,8 @@ +import type { HonoEnv } from "../../types"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { V1_PROPERTIES_ROUTER_BASE_PATH } from "../../constants"; +import { registerPropertyRoute } from "./$property"; + +export const V1_PROPERTIES_ROUTER = new OpenAPIHono().basePath(V1_PROPERTIES_ROUTER_BASE_PATH); + +registerPropertyRoute(V1_PROPERTIES_ROUTER); diff --git a/apps/api/src/routes/v1_schemas/router.ts b/apps/api/src/routes/v1_schemas/router.ts index 51b2ccb4d..80d07802e 100644 --- a/apps/api/src/routes/v1_schemas/router.ts +++ b/apps/api/src/routes/v1_schemas/router.ts @@ -1,9 +1,13 @@ import type { HonoEnv } from "../../types"; +import { tryOr } from "@ucdjs-internal/shared"; import { LockfileSchema, SnapshotSchema } from "@ucdjs/schemas"; import { Hono } from "hono"; import { cache } from "hono/cache"; import { z } from "zod"; -import { MAX_AGE_ONE_DAY_SECONDS, V1_SCHEMAS_ROUTER_BASE_PATH } from "../../constants"; +import { + MAX_AGE_ONE_DAY_SECONDS, + V1_SCHEMAS_ROUTER_BASE_PATH, +} from "../../constants"; export const V1_SCHEMAS_ROUTER = new Hono().basePath(V1_SCHEMAS_ROUTER_BASE_PATH); @@ -20,7 +24,23 @@ for (const { name, schema } of schemas) { cacheControl: `max-age=${MAX_AGE_ONE_DAY_SECONDS * 4}`, // 4 days }), async (c) => { - const jsonSchema = z.toJSONSchema(schema); + const jsonSchema = await tryOr({ + try: () => z.toJSONSchema(schema, { + unrepresentable: "any", + override: (ctx) => { + const def = ctx.zodSchema._zod.def; + if (def.type === "date") { + ctx.jsonSchema.type = "string"; + ctx.jsonSchema.format = "date-time"; + } + }, + }), + err: (err) => { + console.error(`Failed to generate JSON schema for ${name}:`, err); + throw err; + }, + }); + return c.json(jsonSchema, 200); }, ); diff --git a/apps/api/src/routes/v1_versions/$version.ts b/apps/api/src/routes/v1_versions/$version.ts deleted file mode 100644 index 66982daaf..000000000 --- a/apps/api/src/routes/v1_versions/$version.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type { OpenAPIHono } from "@hono/zod-openapi"; -import type { HonoEnv } from "../../types"; -import { createRoute } from "@hono/zod-openapi"; -import { dedent } from "@luxass/utils"; -import { UnicodeTreeSchema, UnicodeVersionDetailsSchema } from "@ucdjs/schemas"; -import { - hasUCDFolderPath, - resolveUCDVersion, - UNICODE_STABLE_VERSION, - UNICODE_VERSION_METADATA, -} from "@unicode-utils/core"; -import { traverse } from "apache-autoindex-parse/traverse"; -import { cache } from "hono/cache"; -import { MAX_AGE_ONE_DAY_SECONDS, MAX_AGE_ONE_WEEK_SECONDS } from "../../constants"; -import { badGateway, badRequest, internalServerError, notFound } from "../../lib/errors"; -import { createLogger } from "../../lib/logger"; -import { captureUpstreamError, COMPONENTS } from "../../lib/sentry"; -import { VERSION_ROUTE_PARAM } from "../../lib/shared-parameters"; -import { generateReferences, OPENAPI_TAGS } from "../../openapi"; -import { calculateStatistics, getVersionFromList } from "./utils"; - -const log = createLogger("ucd:api:v1_versions"); - -const GET_VERSION_FILE_TREE_ROUTE_DOCS = dedent` - This endpoint provides a **structured list of all files** inside the [\`ucd folder\`](https://unicode.org/Public/UCD/latest/ucd) associated with a specific Unicode version. - - For older versions, the files are retrieved without the \`/ucd\` prefix, while for the latest version, the \`/ucd\` prefix is included. -`; - -const GET_VERSION_ROUTE = createRoute({ - method: "get", - path: "/{version}", - tags: [OPENAPI_TAGS.VERSIONS], - middleware: [ - cache({ - cacheName: "ucdjs:v1_versions:version", - cacheControl: `max-age=${MAX_AGE_ONE_DAY_SECONDS * 4}`, // 4 days - }), - ], - parameters: [ - VERSION_ROUTE_PARAM, - ], - description: dedent` - ## Get Unicode Version Details - - This endpoint retrieves detailed information about a specific Unicode version. - - - Provides **version metadata** such as version name, documentation URL, release date, and type (stable/draft) - - Includes **location information** (UCD URL and mapped version) - - Returns **statistics** about characters, blocks, and scripts (if available) - - Supports **caching** for performance optimization - `, - responses: { - 200: { - content: { - "application/json": { - schema: UnicodeVersionDetailsSchema, - examples: { - default: { - summary: "Unicode version details", - value: { - 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: 331, - newBlocks: 4, - totalScripts: 165, - newScripts: 2, - }, - }, - }, - }, - }, - }, - description: "Detailed information about a Unicode version", - }, - ...(generateReferences([ - 400, - 404, - 429, - 502, - 500, - ])), - }, -}); - -export const GET_VERSION_FILE_TREE_ROUTE = createRoute({ - method: "get", - path: "/{version}/file-tree", - tags: [OPENAPI_TAGS.VERSIONS], - middleware: [ - cache({ - cacheName: "ucdjs:v1_versions:file-tree", - cacheControl: `max-age=${MAX_AGE_ONE_WEEK_SECONDS}`, // 1 week - }), - ], - parameters: [ - VERSION_ROUTE_PARAM, - ], - description: GET_VERSION_FILE_TREE_ROUTE_DOCS, - responses: { - 200: { - content: { - "application/json": { - schema: UnicodeTreeSchema, - examples: { - default: { - summary: "File tree for a Unicode version", - value: [ - { - type: "file", - name: "ArabicShaping.txt", - path: "ArabicShaping.txt", - lastModified: 1724601900000, - }, - { - type: "file", - name: "BidiBrackets.txt", - path: "BidiBrackets.txt", - lastModified: 1724601900000, - }, - { - type: "directory", - name: "emoji", - path: "emoji", - lastModified: 1724669760000, - children: [ - { - type: "file", - name: "ReadMe.txt", - path: "ReadMe.txt", - lastModified: 1724601900000, - }, - { - type: "file", - name: "emoji-data.txt", - path: "emoji-data.txt", - lastModified: 1724601900000, - }, - ], - }, - ], - }, - }, - }, - }, - description: "Structured list of files for a Unicode version", - }, - ...(generateReferences([ - 400, - 429, - 500, - 502, - ])), - }, -}); - -export function registerGetVersionRoute(router: OpenAPIHono) { - router.openapi(GET_VERSION_ROUTE, async (c) => { - let version = c.req.param("version"); - - if (version === "latest") { - version = UNICODE_STABLE_VERSION; - } - - if ( - !UNICODE_VERSION_METADATA.map((v) => v.version) - .includes(version as typeof UNICODE_VERSION_METADATA[number]["version"])) { - return badRequest(c, { - message: "Invalid Unicode version", - }); - } - - const [versionInfo, error] = await getVersionFromList(version); - - // If there's an error (upstream service failure), return 502 - if (error) { - log.error("Error fetching version from upstream service", { error }); - captureUpstreamError(error, { - component: COMPONENTS.V1_VERSIONS, - operation: "getVersionFromList", - upstreamService: "unicode.org", - context: c, - tags: { - requested_version: version, - }, - extra: { - version, - }, - }); - return badGateway(c, { - message: "Failed to fetch Unicode version from upstream service", - }); - } - - // If versionInfo is null but no error, it means version not found - if (!versionInfo) { - return notFound(c, { - message: "Unicode version not found", - }); - } - - // Try to get statistics from bucket if available - const bucket = c.env.UCD_BUCKET; - let statistics = null; - if (bucket) { - statistics = await calculateStatistics(bucket, version); - } - - return c.json({ - ...versionInfo, - statistics: statistics ?? undefined, - }, 200); - }); -} - -export function registerVersionFileTreeRoute(router: OpenAPIHono) { - router.openapi(GET_VERSION_FILE_TREE_ROUTE, async (c) => { - try { - let version = c.req.param("version"); - - if (version === "latest") { - version = UNICODE_STABLE_VERSION; - } - - const mappedVersion = resolveUCDVersion(version); - - if ( - !UNICODE_VERSION_METADATA.map((v) => v.version) - .includes(version as typeof UNICODE_VERSION_METADATA[number]["version"])) { - return badRequest(c, { - message: "Invalid Unicode version", - }); - } - - const result = await traverse(`https://unicode.org/Public/${mappedVersion}${hasUCDFolderPath(mappedVersion) ? "/ucd" : ""}`, { - format: "F2", - }); - - return c.json(result, 200); - } catch (error) { - console.error("Error processing directory:", error); - return internalServerError(c, { - message: "Failed to fetch file mappings", - }); - } - }); -} diff --git a/apps/api/src/routes/v1_versions/$version/file-tree.ts b/apps/api/src/routes/v1_versions/$version/file-tree.ts new file mode 100644 index 000000000..888012ae3 --- /dev/null +++ b/apps/api/src/routes/v1_versions/$version/file-tree.ts @@ -0,0 +1,141 @@ +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 { createDebugger } from "@ucdjs-internal/shared"; +import { + UnicodeFileTreeNodeSchema, + UnicodeFileTreeSchema, +} from "@ucdjs/schemas"; +import { + hasUCDFolderPath, + resolveUCDVersion, + UNICODE_STABLE_VERSION, + UNICODE_VERSION_METADATA, +} from "@unicode-utils/core"; +import { traverse } from "apache-autoindex-parse/traverse"; +import { cache } from "hono/cache"; +import { MAX_AGE_ONE_WEEK_SECONDS } from "../../../constants"; +import { badRequest, internalServerError } from "../../../lib/errors"; +import { VERSION_ROUTE_PARAM } from "../../../lib/shared-parameters"; +import { generateReferences, OPENAPI_TAGS } from "../../../openapi"; + +const debug = createDebugger("ucdjs:api:v1_versions:file-tree"); + +const GET_VERSION_FILE_TREE_ROUTE = createRoute({ + method: "get", + path: "/{version}/file-tree", + tags: [OPENAPI_TAGS.VERSIONS], + middleware: [ + cache({ + cacheName: "ucdjs:v1_versions:file-tree", + cacheControl: `max-age=${MAX_AGE_ONE_WEEK_SECONDS}`, // 1 week + }), + ], + parameters: [ + VERSION_ROUTE_PARAM, + ], + description: dedent` + This endpoint provides a **structured list of all files** inside the [\`ucd folder\`](https://unicode.org/Public/UCD/latest/ucd) associated with a specific Unicode version. + + For older versions, the files are retrieved without the \`/ucd\` prefix, while for the latest version, the \`/ucd\` prefix is included. +`, + responses: { + 200: { + content: { + "application/json": { + schema: UnicodeFileTreeSchema, + examples: { + default: { + summary: "File tree for a Unicode version", + value: [ + { + type: "file", + name: "ArabicShaping.txt", + path: "ArabicShaping.txt", + lastModified: 1724601900000, + }, + { + type: "file", + name: "BidiBrackets.txt", + path: "BidiBrackets.txt", + lastModified: 1724601900000, + }, + { + type: "directory", + name: "emoji", + path: "emoji", + lastModified: 1724669760000, + children: [ + { + type: "file", + name: "ReadMe.txt", + path: "ReadMe.txt", + lastModified: 1724601900000, + }, + { + type: "file", + name: "emoji-data.txt", + path: "emoji-data.txt", + lastModified: 1724601900000, + }, + ], + }, + ], + }, + }, + }, + }, + description: "Structured list of files for a Unicode version", + }, + ...(generateReferences([ + 400, + 429, + 500, + 502, + ])), + }, +}); + +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"); + + if (version === "latest") { + debug?.(`Resolving 'latest' to stable version ${UNICODE_STABLE_VERSION}`); + version = UNICODE_STABLE_VERSION; + } + + const mappedVersion = resolveUCDVersion(version); + + if ( + !UNICODE_VERSION_METADATA.map((v) => v.version) + .includes(version as typeof UNICODE_VERSION_METADATA[number]["version"])) { + return badRequest(c, { + message: "Invalid Unicode version", + }); + } + + const normalizedPath = `${mappedVersion}${hasUCDFolderPath(mappedVersion) ? "/ucd" : ""}`; + + const result = await traverse(`https://unicode.org/Public/${normalizedPath}`, { + format: "F2", + basePath: normalizedPath, + }); + + // 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, { + message: "Failed to fetch file mappings", + }); + } + }); +} diff --git a/apps/api/src/routes/v1_versions/$version/get.ts b/apps/api/src/routes/v1_versions/$version/get.ts new file mode 100644 index 000000000..94e017abf --- /dev/null +++ b/apps/api/src/routes/v1_versions/$version/get.ts @@ -0,0 +1,153 @@ +import type { OpenAPIHono } from "@hono/zod-openapi"; +import type { HonoEnv } from "../../../types"; +import { createRoute } from "@hono/zod-openapi"; +import { dedent } from "@luxass/utils"; +import { UnicodeVersionDetailsSchema } from "@ucdjs/schemas"; +import { + UNICODE_STABLE_VERSION, + UNICODE_VERSION_METADATA, +} from "@unicode-utils/core"; +import { cache } from "hono/cache"; +import { MAX_AGE_ONE_DAY_SECONDS } from "../../../constants"; +import { badGateway, badRequest, notFound } from "../../../lib/errors"; +import { createLogger } from "../../../lib/logger"; +import { captureUpstreamError, COMPONENTS } from "../../../lib/sentry"; +import { VERSION_ROUTE_PARAM } from "../../../lib/shared-parameters"; +import { generateReferences, OPENAPI_TAGS } from "../../../openapi"; +import { calculateStatistics, getVersionFromList } from "../utils"; + +const log = createLogger("ucdjs:api:v1_versions"); + +const GET_VERSION_ROUTE = createRoute({ + method: "get", + path: "/{version}", + tags: [OPENAPI_TAGS.VERSIONS], + middleware: [ + cache({ + cacheName: "ucdjs:v1_versions:version", + cacheControl: `max-age=${MAX_AGE_ONE_DAY_SECONDS * 4}`, // 4 days + }), + ], + parameters: [ + VERSION_ROUTE_PARAM, + ], + description: dedent` + ## Get Unicode Version Details + + This endpoint retrieves detailed information about a specific Unicode version. + + - Provides **version metadata** such as version name, documentation URL, release date, and type (stable/draft) + - Includes **location information** (UCD URL and mapped version) + - Returns **statistics** about characters, blocks, and scripts (if available) + - Supports **caching** for performance optimization + `, + responses: { + 200: { + content: { + "application/json": { + schema: UnicodeVersionDetailsSchema, + examples: { + default: { + summary: "Unicode version details", + value: { + 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: 331, + newBlocks: 4, + totalScripts: 165, + newScripts: 2, + }, + }, + }, + }, + }, + }, + description: "Detailed information about a Unicode version", + }, + ...(generateReferences([ + 400, + 404, + 429, + 502, + 500, + ])), + }, +}); + +export function registerGetVersionRoute(router: OpenAPIHono) { + router.openapi(GET_VERSION_ROUTE, async (c) => { + let version = c.req.param("version"); + + if (version === "latest") { + version = UNICODE_STABLE_VERSION; + } + + if ( + !UNICODE_VERSION_METADATA.map((v) => v.version) + .includes(version as typeof UNICODE_VERSION_METADATA[number]["version"])) { + return badRequest(c, { + message: "Invalid Unicode version", + }); + } + + const [versionInfo, error] = await getVersionFromList(version); + + // If there's an error (upstream service failure), return 502 + if (error) { + log.error("Error fetching version from upstream service", { error }); + captureUpstreamError(error, { + component: COMPONENTS.V1_VERSIONS, + operation: "getVersionFromList", + upstreamService: "unicode.org", + context: c, + tags: { + requested_version: version, + }, + extra: { + version, + }, + }); + return badGateway(c, { + message: "Failed to fetch Unicode version from upstream service", + }); + } + + // If versionInfo is null but no error, it means version not found + if (!versionInfo) { + return notFound(c, { + message: "Unicode version not found", + }); + } + + // Try to get statistics from bucket if available + const bucket = c.env.UCD_BUCKET; + 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) { + const tmp = await calculateStatistics(bucket, version); + if (tmp) { + statistics = tmp; + } + } + + return c.json({ + ...versionInfo, + statistics, + }, 200); + }); +} diff --git a/apps/api/src/routes/v1_versions/router.ts b/apps/api/src/routes/v1_versions/router.ts index 5eca47918..ca21d0715 100644 --- a/apps/api/src/routes/v1_versions/router.ts +++ b/apps/api/src/routes/v1_versions/router.ts @@ -1,7 +1,8 @@ import type { HonoEnv } from "../../types"; import { OpenAPIHono } from "@hono/zod-openapi"; import { V1_VERSIONS_ROUTER_BASE_PATH } from "../../constants"; -import { registerGetVersionRoute, registerVersionFileTreeRoute } from "./$version"; +import { registerVersionFileTreeRoute } from "./$version/file-tree"; +import { registerGetVersionRoute } from "./$version/get"; import { registerListVersionsRoute } from "./list"; export const V1_VERSIONS_ROUTER = new OpenAPIHono().basePath(V1_VERSIONS_ROUTER_BASE_PATH); diff --git a/apps/api/src/routes/v1_versions/utils.ts b/apps/api/src/routes/v1_versions/utils.ts index cd99c3b2f..b23137edc 100644 --- a/apps/api/src/routes/v1_versions/utils.ts +++ b/apps/api/src/routes/v1_versions/utils.ts @@ -138,6 +138,7 @@ export async function getVersionFromList(version: string): Promise< return [versionInfo, null] as const; } +// TODO: fix this in the future. export async function calculateStatistics( bucket: NonNullable, version: string, diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index b42faca00..16002be49 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -8,7 +8,10 @@ import { setupCors, setupRatelimit } from "./lib/setups"; import { buildOpenApiConfig, registerApp } from "./openapi"; import { WELL_KNOWN_ROUTER } from "./routes/.well-known/router"; import { TASKS_ROUTER } from "./routes/tasks/routes"; +import { V1_BLOCKS_ROUTER } from "./routes/v1_blocks/router"; +import { V1_CHARACTERS_ROUTER } from "./routes/v1_characters/router"; import { V1_FILES_ROUTER } from "./routes/v1_files/router"; +import { V1_PROPERTIES_ROUTER } from "./routes/v1_properties/router"; import { V1_SCHEMAS_ROUTER } from "./routes/v1_schemas/router"; import { V1_VERSIONS_ROUTER } from "./routes/v1_versions/router"; @@ -21,6 +24,9 @@ setupRatelimit(app); app.route("/", V1_VERSIONS_ROUTER); app.route("/", V1_FILES_ROUTER); app.route("/", V1_SCHEMAS_ROUTER); +app.route("/", V1_CHARACTERS_ROUTER); +app.route("/", V1_PROPERTIES_ROUTER); +app.route("/", V1_BLOCKS_ROUTER); app.route("/", WELL_KNOWN_ROUTER); app.route("/", TASKS_ROUTER); diff --git a/apps/api/test/msw-contract/file-tree.contract.test.ts b/apps/api/test/msw-contract/file-tree.contract.test.ts new file mode 100644 index 000000000..0c99eb017 --- /dev/null +++ b/apps/api/test/msw-contract/file-tree.contract.test.ts @@ -0,0 +1,405 @@ +/// + +import type { UnicodeFileTree } from "@ucdjs/schemas"; +import { mockStoreApi } from "#test-utils/mock-store"; +import { HttpResponse, passthrough } from "#test-utils/msw"; +import { UnicodeFileTreeSchema } from "@ucdjs/schemas"; +import { UNICODE_STABLE_VERSION } from "@unicode-utils/core"; +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; +import { executeRequest } from "../helpers/request"; + +describe("msw file-tree handler contract", () => { + it("unwraps top-level ucd directory and keeps /{version}/ucd paths", async () => { + const customTree = [ + { + type: "directory" as const, + name: "ucd", + lastModified: 0, + children: [ + { + type: "file" as const, + name: "UnicodeData.txt", + lastModified: 0, + _content: "data", + }, + ], + }, + ]; + + mockStoreApi({ + files: { + "16.0.0": customTree, + }, + responses: { + "/api/v1/versions/{version}/file-tree": true, + }, + onRequest({ path }) { + if (path !== "/api/v1/versions/16.0.0/file-tree") { + expect.fail(`The requested path should be /api/v1/versions/16.0.0/file-tree`); + } + }, + customResponses: [ + ["GET", "https://unicode.org/Public/16.0.0/ucd", () => { + return passthrough(); + }], + ["GET", "https://unicode.org/Public/16.0.0/ucd/auxiliary", () => { + return passthrough(); + }], + ["GET", "https://unicode.org/Public/16.0.0/ucd/emoji", () => { + return passthrough(); + }], + ["GET", "https://unicode.org/Public/16.0.0/ucd/extracted", () => { + return passthrough(); + }], + ], + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/versions/16.0.0/file-tree", + ); + expect(mswResponse.ok).toBe(true); + const mswData = await mswResponse.json() as any[]; + + expect(Array.isArray(mswData)).toBe(true); + expect(mswData).toHaveLength(1); + expect(mswData).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "UnicodeData.txt", + path: "/16.0.0/ucd/UnicodeData.txt", + }), + ])); + expect(mswData.find((entry: any) => entry.name === "ucd")).toBeUndefined(); + + const { response: apiResponse, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/versions/16.0.0/file-tree"), + env, + ); + expect(apiResponse.ok).toBe(true); + const apiData = await json(); + + expect(Array.isArray(apiData)).toBe(true); + expect(apiData.length).toBeGreaterThan(0); + expect(apiData).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "UnicodeData.txt", + path: "/16.0.0/ucd/UnicodeData.txt", + }), + ])); + + expect(mswData).toMatchSchema({ + schema: UnicodeFileTreeSchema, + success: true, + }); + + expect(apiData).toMatchSchema({ + schema: UnicodeFileTreeSchema, + success: true, + }); + }); + + it("falls back to wildcard files but rewrites paths with the requested version", async () => { + const wildcardTree = [ + { + type: "directory" as const, + name: "ucd", + lastModified: 0, + children: [ + { + type: "file" as const, + name: "Blocks.txt", + lastModified: 0, + _content: "blocks", + }, + ], + }, + ]; + + mockStoreApi({ + files: { + "*": wildcardTree, + }, + responses: { + "/api/v1/versions/{version}/file-tree": true, + }, + onRequest({ path }) { + if (path !== "/api/v1/versions/17.0.0/file-tree") { + expect.fail(`The requested path should be /api/v1/versions/17.0.0/file-tree`); + } + }, + customResponses: [ + ["GET", "https://unicode.org/Public/17.0.0/ucd", () => { + return passthrough(); + }], + ["GET", "https://unicode.org/Public/17.0.0/ucd/auxiliary", () => { + return passthrough(); + }], + ["GET", "https://unicode.org/Public/17.0.0/ucd/emoji", () => { + return passthrough(); + }], + ["GET", "https://unicode.org/Public/17.0.0/ucd/extracted", () => { + return passthrough(); + }], + ], + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/versions/17.0.0/file-tree", + ); + expect(mswResponse.ok).toBe(true); + const mswData = await mswResponse.json(); + + expect(Array.isArray(mswData)).toBe(true); + expect(mswData).toHaveLength(1); + expect(mswData).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "Blocks.txt", + path: "/17.0.0/ucd/Blocks.txt", + }), + ])); + + const { response: apiResponse, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/versions/17.0.0/file-tree"), + env, + ); + expect(apiResponse.ok).toBe(true); + const apiData = await json(); + + expect(Array.isArray(apiData)).toBe(true); + expect(apiData.length).toBeGreaterThan(0); + expect(apiData).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "Blocks.txt", + path: "/17.0.0/ucd/Blocks.txt", + }), + ])); + + expect(mswData).toMatchSchema({ + schema: UnicodeFileTreeSchema, + success: true, + }); + + expect(apiData).toMatchSchema({ + schema: UnicodeFileTreeSchema, + success: true, + }); + }); + + it("resolves latest alias to the stable version and keeps prefixed paths", async () => { + mockStoreApi({ + responses: { + "/api/v1/versions/{version}/file-tree": ({ params }) => { + const requestedVersion = params.version as string; + const resolvedVersion = requestedVersion === "latest" ? UNICODE_STABLE_VERSION : requestedVersion; + + return HttpResponse.json([ + { + type: "file", + name: "ArabicShaping.txt", + path: `/${resolvedVersion}/ucd/ArabicShaping.txt`, + lastModified: 0, + }, + ]); + }, + }, + onRequest({ path }) { + if (path !== "/api/v1/versions/latest/file-tree") { + expect.fail(`The requested path should be /api/v1/versions/latest/file-tree`); + } + }, + customResponses: [ + ["GET", "https://unicode.org/Public/17.0.0/ucd", () => { + return passthrough(); + }], + ["GET", "https://unicode.org/Public/17.0.0/ucd/auxiliary", () => { + return passthrough(); + }], + ["GET", "https://unicode.org/Public/17.0.0/ucd/emoji", () => { + return passthrough(); + }], + ["GET", "https://unicode.org/Public/17.0.0/ucd/extracted", () => { + return passthrough(); + }], + ], + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/versions/latest/file-tree", + ); + expect(mswResponse.ok).toBe(true); + const mswData = await mswResponse.json(); + + expect(mswData).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "ArabicShaping.txt", + path: `/${UNICODE_STABLE_VERSION}/ucd/ArabicShaping.txt`, + }), + ])); + + const { response: apiResponse, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/versions/latest/file-tree"), + env, + ); + expect(apiResponse.ok).toBe(true); + const apiData = await json(); + + expect(apiData).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "ArabicShaping.txt", + path: `/${UNICODE_STABLE_VERSION}/ucd/ArabicShaping.txt`, + }), + ])); + + expect(mswData).toMatchSchema({ + schema: UnicodeFileTreeSchema, + success: true, + }); + + expect(apiData).toMatchSchema({ + schema: UnicodeFileTreeSchema, + success: true, + }); + }); + + it("returns 400 for invalid Unicode version", async () => { + mockStoreApi({ + responses: { + "/api/v1/versions/{version}/file-tree": () => HttpResponse.json({ + message: "Invalid Unicode version", + status: 400, + timestamp: new Date().toISOString(), + }, { status: 400 }), + }, + onRequest({ path }) { + if (path !== "/api/v1/versions/99.0.0/file-tree") { + expect.fail(`The requested path should be /api/v1/versions/99.0.0/file-tree`); + } + }, + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/versions/99.0.0/file-tree", + ); + expect(mswResponse.ok).toBe(false); + expect(mswResponse.status).toBe(400); + const mswError = await mswResponse.json(); + expect(mswError).toEqual(expect.objectContaining({ + message: expect.any(String), + status: 400, + })); + + const { response: apiResponse } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/versions/99.0.0/file-tree"), + env, + ); + expect(apiResponse.ok).toBe(false); + expect(apiResponse.status).toBe(400); + const apiError = await apiResponse.json(); + expect(apiError).toEqual(expect.objectContaining({ + message: expect.any(String), + status: 400, + })); + }); + + it("preserves nested directory paths under /{version}/ucd", async () => { + const nestedTree = [ + { + type: "directory" as const, + name: "ucd", + lastModified: 0, + children: [ + { + type: "directory" as const, + name: "emoji", + lastModified: 0, + children: [ + { + type: "file" as const, + name: "emoji-data.txt", + lastModified: 0, + _content: "emoji", + }, + ], + }, + ], + }, + ]; + + mockStoreApi({ + files: { + "16.0.0": nestedTree, + }, + responses: { + "/api/v1/versions/{version}/file-tree": true, + }, + onRequest({ path }) { + if (path !== "/api/v1/versions/16.0.0/file-tree") { + expect.fail(`The requested path should be /api/v1/versions/16.0.0/file-tree`); + } + }, + customResponses: [ + ["GET", "https://unicode.org/Public/16.0.0/ucd", () => passthrough()], + ["GET", "https://unicode.org/Public/16.0.0/ucd/emoji", () => passthrough()], + ["GET", "https://unicode.org/Public/16.0.0/ucd/auxiliary", () => passthrough()], + ["GET", "https://unicode.org/Public/16.0.0/ucd/extracted", () => passthrough()], + ], + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/versions/16.0.0/file-tree", + ); + expect(mswResponse.ok).toBe(true); + const mswData = await mswResponse.json(); + + expect(mswData).toEqual(expect.arrayContaining([ + { + type: "directory", + name: "emoji", + path: "/16.0.0/ucd/emoji/", + children: expect.arrayContaining([ + { + type: "file", + name: "emoji-data.txt", + path: "/16.0.0/ucd/emoji/emoji-data.txt", + lastModified: expect.any(Number), + }, + ]), + lastModified: expect.any(Number), + }, + ])); + + const { response: apiResponse, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/versions/16.0.0/file-tree"), + env, + ); + expect(apiResponse.ok).toBe(true); + const apiData = await json(); + + expect(apiData).toEqual(expect.arrayContaining([ + { + type: "directory", + name: "emoji", + path: "/16.0.0/ucd/emoji/", + children: expect.arrayContaining([ + { + type: "file", + name: "emoji-data.txt", + path: "/16.0.0/ucd/emoji/emoji-data.txt", + lastModified: expect.any(Number), + }, + ]), + lastModified: expect.any(Number), + }, + ])); + + expect(mswData).toMatchSchema({ + schema: UnicodeFileTreeSchema, + success: true, + }); + + expect(apiData).toMatchSchema({ + schema: UnicodeFileTreeSchema, + success: true, + }); + }); +}); diff --git a/apps/api/test/msw-contract/files.contract.test.ts b/apps/api/test/msw-contract/files.contract.test.ts new file mode 100644 index 000000000..5b560a87b --- /dev/null +++ b/apps/api/test/msw-contract/files.contract.test.ts @@ -0,0 +1,437 @@ +/// + +import type { FileEntryList } from "@ucdjs/schemas"; +import { mockStoreApi } from "#test-utils/mock-store"; +import { passthrough } from "#test-utils/msw"; +import { + UCD_STAT_CHILDREN_DIRS_HEADER, + UCD_STAT_CHILDREN_FILES_HEADER, + UCD_STAT_CHILDREN_HEADER, + UCD_STAT_SIZE_HEADER, + UCD_STAT_TYPE_HEADER, +} from "@ucdjs/env"; +import { FileEntryListSchema } from "@ucdjs/schemas"; +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; +import { executeRequest } from "../helpers/request"; + +describe("msw files handler contract", () => { + it("lists versioned /{version}/ucd directory entries", async () => { + const files = [ + { + type: "file" as const, + name: "UnicodeData.txt", + lastModified: 0, + _content: "data", + }, + { + type: "file" as const, + name: "Blocks.txt", + lastModified: 0, + _content: "blocks", + }, + ]; + + mockStoreApi({ + files: { + "16.0.0": files, + }, + responses: { + "/api/v1/files/{wildcard}": true, + }, + onRequest({ path }) { + if (path !== "/api/v1/files/16.0.0/ucd") { + expect.fail(`The requested path should not have been requested, only /api/v1/files/16.0.0/ucd was expected.`); + } + }, + customResponses: [ + ["GET", "https://unicode.org/Public/16.0.0/ucd", () => { + return passthrough(); + }], + ], + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/files/16.0.0/ucd", + ); + const mswData = await mswResponse.json(); + + expect(Array.isArray(mswData)).toBe(true); + expect(mswData).toHaveLength(2); + expect(mswData).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "UnicodeData.txt", path: "/16.0.0/ucd/UnicodeData.txt" }), + expect.objectContaining({ name: "Blocks.txt", path: "/16.0.0/ucd/Blocks.txt" }), + ])); + + const { response: apiResponse, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files/16.0.0/ucd"), + env, + ); + expect(apiResponse.ok).toBe(true); + const apiData = await json(); + + expect(Array.isArray(apiData)).toBe(true); + expect(apiData.length).toBeGreaterThan(2); + expect(apiData).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "UnicodeData.txt", path: "/16.0.0/ucd/UnicodeData.txt" }), + expect.objectContaining({ name: "Blocks.txt", path: "/16.0.0/ucd/Blocks.txt" }), + ])); + + expect(mswData).toMatchSchema({ + schema: FileEntryListSchema, + success: true, + }); + + expect(apiData).toMatchSchema({ + schema: FileEntryListSchema, + success: true, + }); + }); + + it("falls back to wildcard files and rewrites paths with requested version", async () => { + const files = [ + { + type: "file" as const, + name: "Scripts.txt", + lastModified: 0, + _content: "scripts", + }, + ]; + + mockStoreApi({ + files: { + "*": files, + }, + responses: { + "/api/v1/files/{wildcard}": true, + }, + onRequest({ path }) { + if (path !== "/api/v1/files/17.0.0/ucd") { + expect.fail(`The requested path should not have been requested, only /api/v1/files/17.0.0/ucd was expected.`); + } + }, + customResponses: [ + ["GET", "https://unicode.org/Public/17.0.0/ucd", () => { + return passthrough(); + }], + ], + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/files/17.0.0/ucd", + ); + const mswData = await mswResponse.json(); + + expect(Array.isArray(mswData)).toBe(true); + expect(mswData).toHaveLength(1); + expect(mswData).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "Scripts.txt", path: "/17.0.0/ucd/Scripts.txt" }), + ])); + + const { response: apiResponse, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files/17.0.0/ucd"), + env, + ); + expect(apiResponse.ok).toBe(true); + const apiData = await json(); + + expect(Array.isArray(apiData)).toBe(true); + expect(apiData.length).toBeGreaterThan(0); + expect(apiData).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "Scripts.txt", path: "/17.0.0/ucd/Scripts.txt" }), + ])); + + expect(mswData).toMatchSchema({ + schema: FileEntryListSchema, + success: true, + }); + + expect(apiData).toMatchSchema({ + schema: FileEntryListSchema, + success: true, + }); + }); + + it("lists root /api/v1/files directory entries", async () => { + const rootFiles = [ + { + type: "file" as const, + name: "ReadMe.txt", + lastModified: 0, + _content: "ReadMe!", + }, + { + type: "directory" as const, + name: "17.0.0", + lastModified: 0, + children: [], + }, + ]; + + mockStoreApi({ + files: { + root: rootFiles, + }, + responses: { + "/api/v1/files/{wildcard}": true, + }, + onRequest({ path }) { + if (path !== "/api/v1/files") { + expect.fail(`The requested path should not have been requested, only /api/v1/files was expected.`); + } + }, + customResponses: [ + ["GET", "https://unicode.org/Public", () => { + return passthrough(); + }], + ], + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/files", + ); + const mswData = await mswResponse.json(); + + expect(Array.isArray(mswData)).toBe(true); + expect(mswData).toHaveLength(2); + expect(mswData).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "ReadMe.txt", path: "/ReadMe.txt" }), + expect.objectContaining({ name: "17.0.0", path: "/17.0.0/" }), + ])); + + const { response: apiResponse, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files"), + env, + ); + expect(apiResponse.ok).toBe(true); + const apiData = await json(); + + expect(Array.isArray(apiData)).toBe(true); + expect(apiData.length).toBeGreaterThan(2); + expect(apiData).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "ReadMe.txt", path: "/ReadMe.txt" }), + expect.objectContaining({ name: "17.0.0", path: "/17.0.0/" }), + ])); + + expect(mswData).toMatchSchema({ + schema: FileEntryListSchema, + success: true, + }); + + expect(apiData).toMatchSchema({ + schema: FileEntryListSchema, + success: true, + }); + }); + + it("returns 404 for non-existent path", async () => { + mockStoreApi({ + files: { + root: [], + }, + responses: { + "/api/v1/files/{wildcard}": true, + }, + onRequest({ path }) { + if (path !== "/api/v1/files/nonexistent.txt") { + expect.fail(`The requested path should not have been requested, only /api/v1/files/nonexistent.txt was expected.`); + } + }, + customResponses: [ + ["GET", "https://unicode.org/Public/nonexistent.txt", () => { + return passthrough(); + }], + ], + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/files/nonexistent.txt", + ); + + expect(mswResponse).toMatchResponse({ + status: 404, + json: true, + error: { + message: expect.any(String), + }, + }); + + expect(mswResponse.ok).toBe(false); + + const { response: apiResponse } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files/nonexistent.txt"), + env, + ); + + expect(apiResponse.ok).toBe(false); + expect(apiResponse).toMatchResponse({ + status: 404, + json: true, + error: { + message: expect.any(String), + }, + }); + }); + + it("serves file content with correct headers", async () => { + mockStoreApi({ + responses: { + "/api/v1/files/{wildcard}": true, + }, + onRequest({ path }) { + if (path !== "/api/v1/files/17.0.0/ucd/ArabicShaping.txt") { + expect.fail(`The requested path should be /api/v1/files/17.0.0/ucd/ArabicShaping.txt`); + } + }, + customResponses: [ + ["GET", "https://unicode.org/Public/17.0.0/ucd/ArabicShaping.txt", () => { + return passthrough(); + }], + ], + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/files/17.0.0/ucd/ArabicShaping.txt", + ); + + expect(mswResponse.ok).toBe(true); + const mswContent = await mswResponse.text(); + expect(mswResponse.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(mswResponse.headers.get(UCD_STAT_TYPE_HEADER)).toBe("file"); + expect(mswResponse.headers.get(UCD_STAT_SIZE_HEADER)).toBeDefined(); + + const { response: apiResponse, text } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files/17.0.0/ucd/ArabicShaping.txt"), + env, + ); + + expect(apiResponse.ok).toBe(true); + const apiContent = await text(); + expect(apiContent).toBe(mswContent); + expect(apiResponse.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(apiResponse.headers.get(UCD_STAT_TYPE_HEADER)).toBe("file"); + expect(apiResponse.headers.get(UCD_STAT_SIZE_HEADER)).toBeDefined(); + }); + + it("mirrors headers on HEAD requests for files", async () => { + const content = "abcd"; + + mockStoreApi({ + files: { + "17.0.0": [ + { + type: "file" as const, + name: "ArabicShaping.txt", + lastModified: 0, + _content: content, + }, + ], + }, + responses: { + "/api/v1/files/{wildcard}": true, + }, + onRequest({ path }) { + if (path !== "/api/v1/files/17.0.0/ucd/ArabicShaping.txt") { + expect.fail(`The requested path should be /api/v1/files/17.0.0/ucd/ArabicShaping.txt`); + } + }, + customResponses: [ + ["GET", "https://unicode.org/Public/17.0.0/ucd/ArabicShaping.txt", () => { + return passthrough(); + }], + ], + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/files/17.0.0/ucd/ArabicShaping.txt", + { method: "HEAD" }, + ); + + expect(mswResponse.ok).toBe(true); + expect(await mswResponse.text()).toBe(""); + + const { response: apiResponse } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files/17.0.0/ucd/ArabicShaping.txt", { method: "HEAD" }), + env, + ); + + expect(apiResponse.ok).toBe(true); + expect(await apiResponse.text()).toBe(""); + + const mswSize = mswResponse.headers.get(UCD_STAT_SIZE_HEADER); + const apiSize = apiResponse.headers.get(UCD_STAT_SIZE_HEADER); + + expect(mswResponse.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(apiResponse.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + expect(mswResponse.headers.get(UCD_STAT_TYPE_HEADER)).toBe("file"); + expect(apiResponse.headers.get(UCD_STAT_TYPE_HEADER)).toBe("file"); + + const computed = new TextEncoder().encode(content); + expect(+mswSize!).toBe(computed.length); + // The API size header is larger than the MSW size. + expect(+apiSize!).toBeGreaterThan(computed.length); + }); + + it("sets correct headers for directory listing", async () => { + const rootFiles = [ + { + type: "file" as const, + name: "ReadMe.txt", + lastModified: 0, + _content: "ReadMe!", + }, + { + type: "directory" as const, + name: "Files", + lastModified: 0, + children: [], + }, + ]; + + mockStoreApi({ + files: { + root: rootFiles, + }, + responses: { + "/api/v1/files/{wildcard}": true, + }, + onRequest({ path }) { + if (path !== "/api/v1/files") { + expect.fail(`The requested path should not have been requested, only /api/v1/files was expected.`); + } + }, + customResponses: [ + ["GET", "https://unicode.org/Public", () => { + return passthrough(); + }], + ], + }); + + const mswResponse = await fetch( + "https://api.ucdjs.dev/api/v1/files", + ); + + const mswChildrenHeader = mswResponse.headers.get(UCD_STAT_CHILDREN_HEADER); + const mswChildrenFilesCountHeader = mswResponse.headers.get(UCD_STAT_CHILDREN_FILES_HEADER); + const mswChildrenDirsCountHeader = mswResponse.headers.get(UCD_STAT_CHILDREN_DIRS_HEADER); + + expect(mswResponse.ok).toBe(true); + expect(mswResponse.headers.get(UCD_STAT_TYPE_HEADER)).toBe("directory"); + expect(mswChildrenHeader).toBe("2"); + expect(mswChildrenFilesCountHeader).toBe("1"); + expect(mswChildrenDirsCountHeader).toBe("1"); + + const { response: apiResponse } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files"), + env, + ); + + expect(apiResponse.ok).toBe(true); + expect(apiResponse.headers.get(UCD_STAT_TYPE_HEADER)).toBe("directory"); + + // We use the "greater than" check here because the real API may have more files than the mock + expect(+apiResponse.headers.get(UCD_STAT_CHILDREN_HEADER)!).toBeGreaterThan(+mswChildrenHeader!); + expect(+apiResponse.headers.get(UCD_STAT_CHILDREN_FILES_HEADER)!).toBeGreaterThanOrEqual(+mswChildrenFilesCountHeader!); + expect(+apiResponse.headers.get(UCD_STAT_CHILDREN_DIRS_HEADER)!).toBeGreaterThan(+mswChildrenDirsCountHeader!); + }); +}); diff --git a/apps/api/test/routes/v1_blocks/$block.test.ts b/apps/api/test/routes/v1_blocks/$block.test.ts new file mode 100644 index 000000000..fa3577f12 --- /dev/null +++ b/apps/api/test/routes/v1_blocks/$block.test.ts @@ -0,0 +1,157 @@ +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; +import { executeRequest } from "../../helpers/request"; +import { + expectApiError, + expectJsonResponse, +} from "../../helpers/response"; + +describe("v1_blocks", () => { + // eslint-disable-next-line test/prefer-lowercase-title + describe("GET /api/v1/blocks/{version}/{block}", () => { + it("should return 404 for a valid block name (pending implementation)", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0/Basic_Latin"), + env, + ); + + await expectApiError(response, { + status: 404, + message: "Block \"Basic_Latin\" not found. API implementation pending.", + }); + }); + + it("should return 400 for empty block name", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0/"), + env, + ); + + // This will likely hit a different route or 404 + // But we're testing the path normalization + expect(response.status).toBeGreaterThanOrEqual(400); + }); + + it("should handle query parameters - include_characters", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0/Basic_Latin?include_characters=true"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Block \"Basic_Latin\" not found. API implementation pending.", + }); + }); + + it("should handle query parameters - format", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0/Basic_Latin?format=detailed"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Block \"Basic_Latin\" not found. API implementation pending.", + }); + }); + + it("should handle query parameters - limit", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0/Basic_Latin?limit=10"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Block \"Basic_Latin\" not found. API implementation pending.", + }); + }); + + it("should handle combined query parameters", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0/CJK_Unified_Ideographs?include_characters=true&format=detailed&limit=50"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Block \"CJK_Unified_Ideographs\" not found. API implementation pending.", + }); + }); + + it("should handle different block name formats", async () => { + const blockNames = [ + "Basic_Latin", + "Latin-1_Supplement", + "CJK_Unified_Ideographs", + "Emoji", + ]; + + for (const blockName of blockNames) { + const { response } = await executeRequest( + new Request(`https://api.ucdjs.dev/api/v1/blocks/16.0.0/${blockName}`), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: `Block "${blockName}" not found. API implementation pending.`, + }); + } + }); + + it("should validate include_characters parameter", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0/Basic_Latin?include_characters=invalid"), + env, + ); + + // Should return validation error + await expectApiError(response, { + status: 400, + }); + }); + + it("should validate format parameter", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0/Basic_Latin?format=invalid"), + env, + ); + + // Should return validation error + await expectApiError(response, { + status: 400, + }); + }); + + it("should validate limit parameter as positive integer", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0/Basic_Latin?limit=-5"), + env, + ); + + // Should return validation error for negative limit + await expectApiError(response, { + status: 400, + }); + }); + + it("should validate limit parameter as integer not string", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0/Basic_Latin?limit=abc"), + env, + ); + + // Should return validation error for non-numeric limit + await expectApiError(response, { + status: 400, + }); + }); + }); +}); diff --git a/apps/api/test/routes/v1_blocks/list.test.ts b/apps/api/test/routes/v1_blocks/list.test.ts new file mode 100644 index 000000000..2bb44928a --- /dev/null +++ b/apps/api/test/routes/v1_blocks/list.test.ts @@ -0,0 +1,44 @@ +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; +import { executeRequest } from "../../helpers/request"; +import { + expectApiError, + expectJsonResponse, +} from "../../helpers/response"; + +describe("v1_blocks", () => { + // eslint-disable-next-line test/prefer-lowercase-title + describe("GET /api/v1/blocks/{version}", () => { + it("should return 500 with pending implementation message", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0"), + env, + ); + + await expectApiError(response, { + status: 500, + message: "Blocks list not yet available. API implementation pending.", + }); + }); + + it("should have proper cache headers configured", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0"), + env, + ); + + // Despite being an error response, cache headers should still be configured + // from the middleware + expect(response.status).toBe(500); + }); + + it("should respond with JSON content type", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/blocks/16.0.0"), + env, + ); + + expectJsonResponse(response); + }); + }); +}); diff --git a/apps/api/test/routes/v1_characters/$codepoint.test.ts b/apps/api/test/routes/v1_characters/$codepoint.test.ts new file mode 100644 index 000000000..e5c301a4a --- /dev/null +++ b/apps/api/test/routes/v1_characters/$codepoint.test.ts @@ -0,0 +1,278 @@ +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; +import { executeRequest } from "../../helpers/request"; +import { + expectApiError, + expectJsonResponse, +} from "../../helpers/response"; + +describe("v1_characters", () => { + // eslint-disable-next-line test/prefer-lowercase-title + describe("GET /api/v1/characters/{version}/{codepoint}", () => { + describe("valid codepoint formats", () => { + it("should accept U+XXXX format", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/U+0041"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Character data for U+0041 not yet available. API implementation pending.", + }); + }); + + it("should accept u+xxxx lowercase format", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/u+0041"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Character data for U+0041 not yet available. API implementation pending.", + }); + }); + + it("should accept 0xXX hexadecimal format", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/0x41"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Character data for U+0041 not yet available. API implementation pending.", + }); + }); + + it("should accept decimal format", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/65"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Character data for U+0041 not yet available. API implementation pending.", + }); + }); + + it("should accept single character", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/A"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Character data for U+0041 not yet available. API implementation pending.", + }); + }); + + it("should handle 6-digit Unicode codepoints", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/U+1F600"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Character data for U+1F600 not yet available. API implementation pending.", + }); + }); + + it("should pad 4-digit codepoints correctly", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/0x41"), + env, + ); + + expectJsonResponse(response); + // Should pad to U+0041 + await expectApiError(response, { + status: 404, + message: "Character data for U+0041 not yet available. API implementation pending.", + }); + }); + }); + + describe("invalid codepoint formats", () => { + it("should reject invalid U+ format", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/U+ZZZZ"), + env, + ); + + await expectApiError(response, { + status: 400, + message: /Invalid codepoint format/, + }); + }); + + it("should reject codepoint out of range (> 0x10FFFF)", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/1114112"), // 0x110000 + env, + ); + + await expectApiError(response, { + status: 400, + message: /Invalid codepoint format/, + }); + }); + + it("should reject negative decimal values", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/-1"), + env, + ); + + await expectApiError(response, { + status: 400, + message: /Invalid codepoint format/, + }); + }); + + it("should reject multi-character strings", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/ABC"), + env, + ); + + await expectApiError(response, { + status: 400, + message: /Invalid codepoint format/, + }); + }); + + it("should reject malformed hex format", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/0xGHIJ"), + env, + ); + + await expectApiError(response, { + status: 400, + message: /Invalid codepoint format/, + }); + }); + + it("should reject random invalid string", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/invalid"), + env, + ); + + await expectApiError(response, { + status: 400, + message: /Invalid codepoint format/, + }); + }); + + it("should provide helpful error message with examples", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/xyz"), + env, + ); + + const error = await expectApiError(response, { + status: 400, + message: /Invalid codepoint format.*Use formats like U\+0041, 0x41, 65, or A/, + }); + + expect(error.message).toContain("xyz"); + }); + }); + + describe("common characters", () => { + it("should handle ASCII characters", async () => { + const characters = ["A", "z", "0", "9", " ", "!"]; + + for (const char of characters) { + const { response } = await executeRequest( + new Request(`https://api.ucdjs.dev/api/v1/characters/16.0.0/${char}`), + env, + ); + + expectJsonResponse(response); + expect(response.status).toBe(404); + } + }); + + it("should handle emoji codepoints", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/U+1F603"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Character data for U+1F603 not yet available. API implementation pending.", + }); + }); + + it("should handle CJK characters", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/U+4E00"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Character data for U+4E00 not yet available. API implementation pending.", + }); + }); + }); + + describe("edge cases", () => { + it("should handle null character (U+0000)", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/U+0000"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Character data for U+0000 not yet available. API implementation pending.", + }); + }); + + it("should handle last valid Unicode codepoint (U+10FFFF)", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/U+10FFFF"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Character data for U+10FFFF not yet available. API implementation pending.", + }); + }); + + it("should normalize codepoint case", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/characters/16.0.0/u+00e9"), + env, + ); + + expectJsonResponse(response); + // Should normalize to uppercase + await expectApiError(response, { + status: 404, + message: "Character data for U+00E9 not yet available. API implementation pending.", + }); + }); + }); + }); +}); diff --git a/apps/api/test/routes/v1_files/$wildcard.test.ts b/apps/api/test/routes/v1_files/$wildcard.test.ts index 746363c23..8d5e2a726 100644 --- a/apps/api/test/routes/v1_files/$wildcard.test.ts +++ b/apps/api/test/routes/v1_files/$wildcard.test.ts @@ -1,16 +1,12 @@ +/// + +import type { FileEntryList } from "@ucdjs/schemas"; import { HttpResponse, mockFetch, RawResponse } from "#test-utils/msw"; -import { UCD_FILE_STAT_TYPE_HEADER } from "@ucdjs/env"; +import { UCD_STAT_SIZE_HEADER, UCD_STAT_TYPE_HEADER } from "@ucdjs/env"; import { generateAutoIndexHtml } from "apache-autoindex-parse/test-utils"; import { env } from "cloudflare:workers"; import { describe, expect, it } from "vitest"; import { executeRequest } from "../../helpers/request"; -import { - expectApiError, - expectCacheHeaders, - expectContentType, - expectHeadError, - expectSuccess, -} from "../../helpers/response"; describe("v1_files", () => { // eslint-disable-next-line test/prefer-lowercase-title @@ -35,9 +31,47 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - expectContentType(response, "text/plain; charset=utf-8"); - expectCacheHeaders(response); + expect(response).toMatchResponse({ + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + cache: true, + }); + + const content = await text(); + expect(content).toBe(mockFileContent); + }); + + it("should not forward content-length for streamed GET responses", async () => { + const mockFileContent = "Plain text content"; + + mockFetch([ + ["GET", "https://unicode.org/Public/15.1.0/ucd/ReadMe.txt", () => { + return HttpResponse.text(mockFileContent, { + headers: { + "content-type": "text/plain; charset=utf-8", + "content-length": mockFileContent.length.toString(), + }, + }); + }], + ]); + + const { response, text } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files/15.1.0/ucd/ReadMe.txt"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + cache: true, + }); + + expect(response.headers.has("Content-Length")).toBe(false); + expect(response.headers.has(UCD_STAT_SIZE_HEADER)).toBe(false); const content = await text(); expect(content).toBe(mockFileContent); @@ -51,7 +85,10 @@ describe("v1_files", () => { env, ); - await expectApiError(response, { status: 400, message: "Invalid path" }); + expect(response).toBeApiError({ + status: 400, + message: "Invalid path", + }); }); it("should reject paths with '//' segments", async () => { @@ -60,7 +97,10 @@ describe("v1_files", () => { env, ); - await expectApiError(response, { status: 400, message: "Invalid path" }); + expect(response).toBeApiError({ + status: 400, + message: "Invalid path", + }); }); }); @@ -77,7 +117,7 @@ describe("v1_files", () => { env, ); - await expectApiError(response, { status: 404, message: "Resource not found" }); + expect(response).toBeApiError({ status: 404, message: "Resource not found" }); }); it("should handle 502 from unicode.org", async () => { @@ -92,7 +132,7 @@ describe("v1_files", () => { env, ); - await expectApiError(response, { status: 502, message: "Bad Gateway" }); + expect(response).toBeApiError({ status: 502, message: "Bad Gateway" }); }); }); @@ -123,9 +163,13 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - expectContentType(response, "application/octet-stream"); - expectCacheHeaders(response); + expect(response).toMatchResponse({ + status: 200, + headers: { + "Content-Type": "application/octet-stream", + }, + cache: true, + }); }); it("should infer content-type from .txt when upstream omits it", async () => { @@ -147,9 +191,13 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - expectContentType(response, "text/plain"); - expectCacheHeaders(response); + expect(response).toMatchResponse({ + status: 200, + headers: { + "Content-Type": "text/plain", + }, + cache: true, + }); const content = await text(); expect(content).toBe(mockContent); @@ -173,9 +221,13 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - expectContentType(response, "application/xml"); - expectCacheHeaders(response); + expect(response).toMatchResponse({ + status: 200, + headers: { + "Content-Type": "application/xml", + }, + cache: true, + }); const content = await text(); expect(content).toBe(mockContent); @@ -199,9 +251,13 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - expectContentType(response, "application/octet-stream"); - expectCacheHeaders(response); + expect(response).toMatchResponse({ + status: 200, + headers: { + "Content-Type": "application/octet-stream", + }, + cache: true, + }); const content = await text(); expect(content).toBe(mockContent); @@ -211,10 +267,10 @@ describe("v1_files", () => { describe("pattern filter", () => { it("should filter directory listing by glob pattern *.txt", async () => { const html = generateAutoIndexHtml([ - { name: "UnicodeData.txt", path: "/Public/15.1.0/ucd/UnicodeData.txt", type: "file", lastModified: Date.now() }, - { name: "Blocks.txt", path: "/Public/15.1.0/ucd/Blocks.txt", type: "file", lastModified: Date.now() }, - { name: "emoji", path: "/Public/15.1.0/ucd/emoji", type: "directory", lastModified: Date.now() }, - { name: "data.xml", path: "/Public/15.1.0/ucd/data.xml", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, + { name: "emoji", path: "emoji/", type: "directory", lastModified: Date.now() }, + { name: "data.xml", path: "data.xml", type: "file", lastModified: Date.now() }, ], "F2"); mockFetch([ @@ -230,17 +286,20 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - const files = await json() as { name: string }[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const files = await json(); expect(files).toHaveLength(2); - expect(files.map((f) => f.name)).toEqual(["UnicodeData.txt", "Blocks.txt"]); + expect(files.map((f) => f.name)).toEqual(["Blocks.txt", "UnicodeData.txt"]); }); it("should filter directory listing by prefix pattern Uni*", async () => { const html = generateAutoIndexHtml([ - { name: "UnicodeData.txt", path: "/Public/15.1.0/ucd/UnicodeData.txt", type: "file", lastModified: Date.now() }, - { name: "Unihan.zip", path: "/Public/15.1.0/ucd/Unihan.zip", type: "file", lastModified: Date.now() }, - { name: "Blocks.txt", path: "/Public/15.1.0/ucd/Blocks.txt", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "Unihan.zip", path: "Unihan.zip", type: "file", lastModified: Date.now() }, + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, ], "F2"); mockFetch([ @@ -256,16 +315,19 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - const files = await json() as { name: string }[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const files = await json(); expect(files).toHaveLength(2); expect(files.map((f) => f.name)).toEqual(["UnicodeData.txt", "Unihan.zip"]); }); it("should filter case-insensitively", async () => { const html = generateAutoIndexHtml([ - { name: "UnicodeData.txt", path: "/Public/15.1.0/ucd/UnicodeData.txt", type: "file", lastModified: Date.now() }, - { name: "Blocks.txt", path: "/Public/15.1.0/ucd/Blocks.txt", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, ], "F2"); mockFetch([ @@ -281,16 +343,20 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - const files = await json() as { name: string }[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + + const files = await json(); expect(files).toHaveLength(1); expect(files[0]!.name).toBe("UnicodeData.txt"); }); it("should return empty array when no matches", async () => { const html = generateAutoIndexHtml([ - { name: "UnicodeData.txt", path: "/Public/15.1.0/ucd/UnicodeData.txt", type: "file", lastModified: Date.now() }, - { name: "Blocks.txt", path: "/Public/15.1.0/ucd/Blocks.txt", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, ], "F2"); mockFetch([ @@ -306,16 +372,19 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - const files = await json() as { name: string }[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const files = await json(); expect(files).toEqual([]); }); it("should support multi-extension pattern *.{txt,xml}", async () => { const html = generateAutoIndexHtml([ - { name: "UnicodeData.txt", path: "/Public/15.1.0/ucd/UnicodeData.txt", type: "file", lastModified: Date.now() }, - { name: "ucd.all.flat.xml", path: "/Public/15.1.0/ucd/ucd.all.flat.xml", type: "file", lastModified: Date.now() }, - { name: "Unihan.zip", path: "/Public/15.1.0/ucd/Unihan.zip", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "ucd.all.flat.xml", path: "ucd.all.flat.xml", type: "file", lastModified: Date.now() }, + { name: "Unihan.zip", path: "Unihan.zip", type: "file", lastModified: Date.now() }, ], "F2"); mockFetch([ @@ -331,17 +400,20 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - const files = await json() as { name: string }[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const files = await json(); expect(files).toHaveLength(2); - expect(files.map((f) => f.name)).toEqual(["UnicodeData.txt", "ucd.all.flat.xml"]); + expect(files.map((f) => f.name)).toEqual(["ucd.all.flat.xml", "UnicodeData.txt"]); }); - it("should support substring pattern *Data*", async () => { + it("should support substring pattern *Data* (case-insensitive)", async () => { const html = generateAutoIndexHtml([ - { name: "UnicodeData.txt", path: "/Public/15.1.0/ucd/UnicodeData.txt", type: "file", lastModified: Date.now() }, - { name: "emoji-data.txt", path: "/Public/15.1.0/ucd/emoji-data.txt", type: "file", lastModified: Date.now() }, - { name: "Blocks.txt", path: "/Public/15.1.0/ucd/Blocks.txt", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "emoji-data.txt", path: "emoji-data.txt", type: "file", lastModified: Date.now() }, + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, ], "F2"); mockFetch([ @@ -357,10 +429,13 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - const files = await json() as { name: string }[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const files = await json(); expect(files).toHaveLength(2); - expect(files.map((f) => f.name)).toEqual(["UnicodeData.txt", "emoji-data.txt"]); + expect(files.map((f) => f.name)).toEqual(["emoji-data.txt", "UnicodeData.txt"]); }); it("should not apply pattern filter for file requests", async () => { @@ -380,15 +455,20 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - expectContentType(response, "text/plain; charset=utf-8"); + expect(response).toMatchResponse({ + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + }); + const content = await text(); expect(content).toBe(mockFileContent); }); it("should return 200 for empty pattern", async () => { const html = generateAutoIndexHtml([ - { name: "UnicodeData.txt", path: "/Public/15.1.0/ucd/UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, ], "F2"); mockFetch([ @@ -405,18 +485,496 @@ describe("v1_files", () => { env, ); - expectSuccess(response); + expect(response).toMatchResponse({ + status: 200, + json: true, + }); const result = await json(); expect(result).toEqual([ { lastModified: expect.any(Number), name: "UnicodeData.txt", - path: "/Public/15.1.0/ucd/UnicodeData.txt", + path: "/15.1.0/ucd/UnicodeData.txt", type: "file", }, ]); }); }); + + describe("query filter (prefix search)", () => { + it("should filter entries by prefix", async () => { + const html = generateAutoIndexHtml([ + { name: "come", path: "come/", type: "directory", lastModified: Date.now() }, + { name: "computer.txt", path: "computer.txt", type: "file", lastModified: Date.now() }, + { name: "other.txt", path: "other.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?query=com"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.name)).toContain("come"); + expect(results.map((r) => r.name)).toContain("computer.txt"); + }); + + it("should search case-insensitively", async () => { + const html = generateAutoIndexHtml([ + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?query=unicode"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + expect(results).toHaveLength(1); + expect(results[0]!.name).toBe("UnicodeData.txt"); + }); + + it("should search within a specific path", async () => { + const html = generateAutoIndexHtml([ + { name: "emoji-data.txt", path: "emoji-data.txt", type: "file", lastModified: Date.now() }, + { name: "emoji-sequences.txt", path: "emoji-sequences.txt", type: "file", lastModified: Date.now() }, + { name: "other.txt", path: "other.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public/15.1.0/ucd/emoji", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files/15.1.0/ucd/emoji?query=emoji"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.name)).toEqual(["emoji-data.txt", "emoji-sequences.txt"]); + }); + + it("should return empty array when no matches found", async () => { + const html = generateAutoIndexHtml([ + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?query=nonexistent"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + expect(results).toEqual([]); + }); + + it("should match exact entry name when query matches exactly", async () => { + const html = generateAutoIndexHtml([ + { name: "come", path: "come/", type: "directory", lastModified: Date.now() }, + { name: "computer.txt", path: "computer.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?query=come"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + // Only the directory matches exactly + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ name: "come", type: "directory" }); + }); + + it("should combine query with pattern filter", async () => { + const html = generateAutoIndexHtml([ + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "Unicode.zip", path: "Unicode.zip", type: "file", lastModified: Date.now() }, + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?query=Uni&pattern=*.txt"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + expect(results).toHaveLength(1); + expect(results[0]!.name).toBe("UnicodeData.txt"); + }); + }); + + describe("type filter", () => { + it("should return only files when type=files", async () => { + const html = generateAutoIndexHtml([ + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "emoji", path: "emoji/", type: "directory", lastModified: Date.now() }, + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?type=files"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + expect(results).toHaveLength(2); + expect(results.every((r) => r.type === "file")).toBe(true); + }); + + it("should return only directories when type=directories", async () => { + const html = generateAutoIndexHtml([ + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "emoji", path: "emoji/", type: "directory", lastModified: Date.now() }, + { name: "charts", path: "charts/", type: "directory", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?type=directories"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + expect(results).toHaveLength(2); + expect(results.every((r) => r.type === "directory")).toBe(true); + }); + + it("should return all entries when type=all", async () => { + const html = generateAutoIndexHtml([ + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "emoji", path: "emoji/", type: "directory", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?type=all"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + expect(results).toHaveLength(2); + }); + + it("should combine type with query filter", async () => { + const html = generateAutoIndexHtml([ + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "Unicode", path: "Unicode/", type: "directory", lastModified: Date.now() }, + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?query=Uni&type=files"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ name: "UnicodeData.txt", type: "file" }); + }); + }); + + describe("sort and order", () => { + it("should sort by name ascending by default", async () => { + const html = generateAutoIndexHtml([ + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "ArabicShaping.txt", path: "ArabicShaping.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + expect(results.map((r) => r.name)).toEqual([ + "ArabicShaping.txt", + "Blocks.txt", + "UnicodeData.txt", + ]); + }); + + it("should sort by name descending when order=desc", async () => { + const html = generateAutoIndexHtml([ + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "ArabicShaping.txt", path: "ArabicShaping.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?sort=name&order=desc"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + expect(results.map((r) => r.name)).toEqual([ + "UnicodeData.txt", + "Blocks.txt", + "ArabicShaping.txt", + ]); + }); + + it("should sort by lastModified ascending", async () => { + const html = generateAutoIndexHtml([ + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "ArabicShaping.txt", path: "ArabicShaping.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?sort=lastModified&order=asc"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + // Check all entries returned and have lastModified + expect(results).toHaveLength(3); + expect(results.every((r) => typeof r.lastModified === "number")).toBe(true); + + // Verify sorted by lastModified descending + let prev: number | null = results.at(-1)!.lastModified; + for (let i = 1; i < results.length; i++) { + expect(results[i]!.lastModified).toBeLessThanOrEqual(prev!); + prev = results[i]!.lastModified; + } + }); + + it("should sort by lastModified descending", async () => { + const html = generateAutoIndexHtml([ + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "ArabicShaping.txt", path: "ArabicShaping.txt", type: "file", lastModified: Date.now() }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?sort=lastModified&order=desc"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + + // Check all entries returned and have lastModified + expect(results).toHaveLength(3); + expect(results.every((r) => typeof r.lastModified === "number")).toBe(true); + + // Verify sorted by lastModified descending + let prev: number | null = results.at(-1)!.lastModified; + for (let i = 1; i < results.length; i++) { + expect(results[i]!.lastModified).toBeLessThanOrEqual(prev!); + prev = results[i]!.lastModified; + } + }); + + it("should combine sort with filters", async () => { + const now = Date.now(); + const html = generateAutoIndexHtml([ + { name: "UnicodeData.txt", path: "UnicodeData.txt", type: "file", lastModified: now - 1000 }, + { name: "Unihan.zip", path: "Unihan.zip", type: "file", lastModified: now - 3000 }, + { name: "Blocks.txt", path: "Blocks.txt", type: "file", lastModified: now - 2000 }, + ], "F2"); + + mockFetch([ + ["GET", "https://unicode.org/Public", () => { + return HttpResponse.text(html, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }], + ]); + + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files?query=Uni&sort=lastModified&order=desc"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const results = await json(); + expect(results).toHaveLength(2); + expect(results.map((r) => r.name)).toEqual([ + "UnicodeData.txt", + "Unihan.zip", + ]); + }); + }); }); // eslint-disable-next-line test/prefer-lowercase-title @@ -443,15 +1001,53 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - expectContentType(response, "text/plain; charset=utf-8"); - expectCacheHeaders(response); + expect(response).toMatchResponse({ + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + cache: true, + }); + }); + + it("should include size headers for HEAD file requests", async () => { + const mockFileContent = "Head response content"; + + mockFetch([ + ["GET", "https://unicode.org/Public/sample/file.txt", () => { + return HttpResponse.text(mockFileContent, { + headers: { + "content-type": "text/plain; charset=utf-8", + "content-length": mockFileContent.length.toString(), + }, + }); + }], + ]); + + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/files/sample/file.txt", { + method: "HEAD", + }), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + headers: { + "Content-Type": "text/plain; charset=utf-8", + }, + cache: true, + }); + + expect(response.headers.get("Content-Length")).toBe(`${mockFileContent.length}`); + expect(response.headers.get(UCD_STAT_SIZE_HEADER)).toBe(`${mockFileContent.length}`); + expect(response.headers.get(UCD_STAT_TYPE_HEADER)).toBe("file"); }); it("should handle HEAD requests for directories", async () => { const html = generateAutoIndexHtml([ - { name: "UnicodeData.txt", path: "/Public/15.1.0/ucd/UnicodeData.txt", type: "file", lastModified: Date.now() }, - { name: "Blocks.txt", path: "/Public/15.1.0/ucd/Blocks.txt", type: "file", lastModified: Date.now() }, + { name: "UnicodeData.txt", path: "15.1.0/ucd/UnicodeData.txt", type: "file", lastModified: Date.now() }, + { name: "Blocks.txt", path: "15.1.0/ucd/Blocks.txt", type: "file", lastModified: Date.now() }, ], "F2"); mockFetch([ @@ -472,10 +1068,12 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - expectContentType(response, "application/json"); - expectCacheHeaders(response); - expect(response.headers.get(UCD_FILE_STAT_TYPE_HEADER)).toBe("directory"); + expect(response).toMatchResponse({ + status: 200, + json: true, + cache: true, + }); + expect(response.headers.get(UCD_STAT_TYPE_HEADER)).toBe("directory"); expect(response.headers.get("content-length")).toBeDefined(); expect(response.headers.get("last-modified")).toBeDefined(); }); @@ -490,7 +1088,7 @@ describe("v1_files", () => { env, ); - expectHeadError(response, 400); + expect(response).toBeHeadError(400); }); it("should handle HEAD requests with '//' segments", async () => { @@ -501,7 +1099,7 @@ describe("v1_files", () => { env, ); - expectHeadError(response, 400); + expect(response).toBeHeadError(400); }); }); @@ -520,7 +1118,7 @@ describe("v1_files", () => { env, ); - expectHeadError(response, 404); + expect(response).toBeHeadError(404); }); it("should handle HEAD requests with 502 from unicode.org", async () => { @@ -539,7 +1137,7 @@ describe("v1_files", () => { env, ); - expectHeadError(response, 502); + expect(response).toBeHeadError(502); }); }); @@ -571,8 +1169,12 @@ describe("v1_files", () => { env, ); - expectSuccess(response); - expectContentType(response, "application/octet-stream"); + expect(response).toMatchResponse({ + status: 200, + headers: { + "Content-Type": "application/octet-stream", + }, + }); }); }); }); diff --git a/apps/api/test/routes/v1_files/search.test.ts b/apps/api/test/routes/v1_files/search.test.ts deleted file mode 100644 index 8bb3359aa..000000000 --- a/apps/api/test/routes/v1_files/search.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { HttpResponse, mockFetch } from "#test-utils/msw"; - -import { generateAutoIndexHtml } from "apache-autoindex-parse/test-utils"; -import { env } from "cloudflare:workers"; -import { describe, expect, it } from "vitest"; -import { executeRequest } from "../../helpers/request"; -import { expectApiError, expectSuccess } from "../../helpers/response"; - -describe("v1_files", () => { - // eslint-disable-next-line test/prefer-lowercase-title - describe("GET /api/v1/files/search", () => { - it("should search files by prefix and return files first", async () => { - const html = generateAutoIndexHtml([ - { name: "come", path: "/Public/come", type: "directory", lastModified: Date.now() }, - { name: "computer.txt", path: "/Public/computer.txt", type: "file", lastModified: Date.now() }, - { name: "other.txt", path: "/Public/other.txt", type: "file", lastModified: Date.now() }, - ], "F2"); - - mockFetch([ - ["GET", "https://unicode.org/Public", () => { - return HttpResponse.text(html, { - headers: { "content-type": "text/html; charset=utf-8" }, - }); - }], - ]); - - const { response, json } = await executeRequest( - new Request("https://api.ucdjs.dev/api/v1/files/search?q=com"), - env, - ); - - expectSuccess(response); - const results = await json() as { name: string; type: string }[]; - - expect(results).toHaveLength(2); - // Files should come before directories - expect(results[0]).toMatchObject({ name: "computer.txt", type: "file" }); - expect(results[1]).toMatchObject({ name: "come", type: "directory" }); - }); - - it("should search case-insensitively", async () => { - const html = generateAutoIndexHtml([ - { name: "UnicodeData.txt", path: "/Public/UnicodeData.txt", type: "file", lastModified: Date.now() }, - { name: "Blocks.txt", path: "/Public/Blocks.txt", type: "file", lastModified: Date.now() }, - ], "F2"); - - mockFetch([ - ["GET", "https://unicode.org/Public", () => { - return HttpResponse.text(html, { - headers: { "content-type": "text/html; charset=utf-8" }, - }); - }], - ]); - - const { response, json } = await executeRequest( - new Request("https://api.ucdjs.dev/api/v1/files/search?q=unicode"), - env, - ); - - expectSuccess(response); - const results = await json() as { name: string; type: string }[]; - - expect(results).toHaveLength(1); - expect(results[0]!.name).toBe("UnicodeData.txt"); - }); - - it("should search within a specific path", async () => { - const html = generateAutoIndexHtml([ - { name: "emoji-data.txt", path: "/Public/15.1.0/ucd/emoji/emoji-data.txt", type: "file", lastModified: Date.now() }, - { name: "emoji-sequences.txt", path: "/Public/15.1.0/ucd/emoji/emoji-sequences.txt", type: "file", lastModified: Date.now() }, - { name: "other.txt", path: "/Public/15.1.0/ucd/emoji/other.txt", type: "file", lastModified: Date.now() }, - ], "F2"); - - mockFetch([ - ["GET", "https://unicode.org/Public/15.1.0/ucd/emoji", () => { - return HttpResponse.text(html, { - headers: { "content-type": "text/html; charset=utf-8" }, - }); - }], - ]); - - const { response, json } = await executeRequest( - new Request("https://api.ucdjs.dev/api/v1/files/search?q=emoji&path=15.1.0/ucd/emoji"), - env, - ); - - expectSuccess(response); - const results = await json() as { name: string; type: string }[]; - - expect(results).toHaveLength(2); - expect(results.map((r) => r.name)).toEqual(["emoji-data.txt", "emoji-sequences.txt"]); - }); - - it("should return empty array when no matches found", async () => { - const html = generateAutoIndexHtml([ - { name: "UnicodeData.txt", path: "/Public/UnicodeData.txt", type: "file", lastModified: Date.now() }, - { name: "Blocks.txt", path: "/Public/Blocks.txt", type: "file", lastModified: Date.now() }, - ], "F2"); - - mockFetch([ - ["GET", "https://unicode.org/Public", () => { - return HttpResponse.text(html, { - headers: { "content-type": "text/html; charset=utf-8" }, - }); - }], - ]); - - const { response, json } = await executeRequest( - new Request("https://api.ucdjs.dev/api/v1/files/search?q=nonexistent"), - env, - ); - - expectSuccess(response); - const results = await json(); - expect(results).toEqual([]); - }); - - it("should return empty array when path does not exist", async () => { - mockFetch([ - ["GET", "https://unicode.org/Public/nonexistent/path", () => { - return HttpResponse.text("Not Found", { status: 404 }); - }], - ]); - - const { response, json } = await executeRequest( - new Request("https://api.ucdjs.dev/api/v1/files/search?q=test&path=nonexistent/path"), - env, - ); - - expectSuccess(response); - const results = await json(); - expect(results).toEqual([]); - }); - - it("should reject invalid path with '..'", async () => { - const { response } = await executeRequest( - new Request("https://api.ucdjs.dev/api/v1/files/search?q=test&path=../etc"), - env, - ); - - await expectApiError(response, { status: 400, message: "Invalid path" }); - }); - - it("should reject invalid path with '//'", async () => { - const { response } = await executeRequest( - new Request("https://api.ucdjs.dev/api/v1/files/search?q=test&path=path//double"), - env, - ); - - await expectApiError(response, { status: 400, message: "Invalid path" }); - }); - - it("should return 400 when q parameter is missing", async () => { - const { response } = await executeRequest( - new Request("https://api.ucdjs.dev/api/v1/files/search"), - env, - ); - - await expectApiError(response, { status: 400 }); - }); - - it("should match exact directory name when query matches exactly", async () => { - const html = generateAutoIndexHtml([ - { name: "come", path: "/Public/come", type: "directory", lastModified: Date.now() }, - { name: "computer.txt", path: "/Public/computer.txt", type: "file", lastModified: Date.now() }, - ], "F2"); - - mockFetch([ - ["GET", "https://unicode.org/Public", () => { - return HttpResponse.text(html, { - headers: { "content-type": "text/html; charset=utf-8" }, - }); - }], - ]); - - const { response, json } = await executeRequest( - new Request("https://api.ucdjs.dev/api/v1/files/search?q=come"), - env, - ); - - expectSuccess(response); - const results = await json() as { name: string; type: string }[]; - - // Only the directory matches exactly - expect(results).toHaveLength(1); - expect(results[0]).toMatchObject({ name: "come", type: "directory" }); - }); - }); -}); diff --git a/apps/api/test/routes/v1_properties/$property.test.ts b/apps/api/test/routes/v1_properties/$property.test.ts new file mode 100644 index 000000000..f06759f1d --- /dev/null +++ b/apps/api/test/routes/v1_properties/$property.test.ts @@ -0,0 +1,357 @@ +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; +import { executeRequest } from "../../helpers/request"; +import { + expectApiError, + expectJsonResponse, +} from "../../helpers/response"; + +describe("v1_properties", () => { + // eslint-disable-next-line test/prefer-lowercase-title + describe("GET /api/v1/properties/{version}/{property}", () => { + describe("basic property requests", () => { + it("should return 404 for valid property name (pending implementation)", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Alphabetic"), + env, + ); + + await expectApiError(response, { + status: 404, + message: "Property \"Alphabetic\" data not yet available. API implementation pending.", + }); + }); + + it("should return 400 for empty property name", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/"), + env, + ); + + // This will likely hit a different route or 404 + expect(response.status).toBeGreaterThanOrEqual(400); + }); + + it("should respond with JSON content type", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji"), + env, + ); + + expectJsonResponse(response); + }); + }); + + describe("common Unicode properties", () => { + const commonProperties = [ + "Alphabetic", + "Uppercase", + "Lowercase", + "Emoji", + "White_Space", + "Numeric_Type", + "Script", + "Block", + "General_Category", + ]; + + it("should handle various property names", async () => { + for (const property of commonProperties) { + const { response } = await executeRequest( + new Request(`https://api.ucdjs.dev/api/v1/properties/16.0.0/${property}`), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: `Property "${property}" data not yet available. API implementation pending.`, + }); + } + }); + }); + + describe("query parameters - format", () => { + it("should accept format=ranges", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji?format=ranges"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Emoji\" data not yet available. API implementation pending.", + }); + }); + + it("should accept format=list", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Uppercase?format=list"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Uppercase\" data not yet available. API implementation pending.", + }); + }); + + it("should accept format=json", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Alphabetic?format=json"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Alphabetic\" data not yet available. API implementation pending.", + }); + }); + + it("should default to ranges format when not specified", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + }); + }); + + it("should reject invalid format value", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji?format=invalid"), + env, + ); + + await expectApiError(response, { + status: 400, + }); + }); + }); + + describe("query parameters - pagination", () => { + it("should accept limit parameter", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Alphabetic?limit=100"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Alphabetic\" data not yet available. API implementation pending.", + }); + }); + + it("should accept offset parameter", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Alphabetic?offset=50"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Alphabetic\" data not yet available. API implementation pending.", + }); + }); + + it("should accept both limit and offset", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji?limit=50&offset=100"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Emoji\" data not yet available. API implementation pending.", + }); + }); + + it("should default offset to 0 when not specified", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji?limit=10"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + }); + }); + + it("should reject negative limit", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji?limit=-10"), + env, + ); + + await expectApiError(response, { + status: 400, + }); + }); + + it("should reject zero limit", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji?limit=0"), + env, + ); + + await expectApiError(response, { + status: 400, + }); + }); + + it("should reject negative offset", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji?offset=-5"), + env, + ); + + await expectApiError(response, { + status: 400, + }); + }); + + it("should reject non-integer limit", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji?limit=10.5"), + env, + ); + + await expectApiError(response, { + status: 400, + }); + }); + + it("should reject non-numeric limit", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji?limit=abc"), + env, + ); + + await expectApiError(response, { + status: 400, + }); + }); + }); + + describe("query parameters - value filter", () => { + it("should accept value parameter", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Numeric_Type?value=Decimal"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Numeric_Type\" data not yet available. API implementation pending.", + }); + }); + + it("should handle value with equals sign (e.g., Numeric_Value=5)", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Numeric_Value?value=5"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Numeric_Value\" data not yet available. API implementation pending.", + }); + }); + + it("should handle URL-encoded value", async () => { + const value = encodeURIComponent("Latin Extended-A"); + const { response } = await executeRequest( + new Request(`https://api.ucdjs.dev/api/v1/properties/16.0.0/Block?value=${value}`), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Block\" data not yet available. API implementation pending.", + }); + }); + }); + + describe("combined query parameters", () => { + it("should handle all parameters together", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Alphabetic?format=list&limit=50&offset=100&value=true"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Alphabetic\" data not yet available. API implementation pending.", + }); + }); + + it("should handle format and pagination", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Emoji?format=json&limit=25&offset=0"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Emoji\" data not yet available. API implementation pending.", + }); + }); + }); + + describe("property name variations", () => { + it("should handle property with underscores", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/White_Space"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"White_Space\" data not yet available. API implementation pending.", + }); + }); + + it("should handle mixed case property names", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/General_Category"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"General_Category\" data not yet available. API implementation pending.", + }); + }); + + it("should handle single word property", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/properties/16.0.0/Script"), + env, + ); + + expectJsonResponse(response); + await expectApiError(response, { + status: 404, + message: "Property \"Script\" data not yet available. API implementation pending.", + }); + }); + }); + }); +}); diff --git a/apps/api/test/routes/v1_schemas/schemas.test.ts b/apps/api/test/routes/v1_schemas/schemas.test.ts new file mode 100644 index 000000000..597b15dee --- /dev/null +++ b/apps/api/test/routes/v1_schemas/schemas.test.ts @@ -0,0 +1,218 @@ +/// + +import type { JSONSchema } from "zod/v4/core"; +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; +import { executeRequest } from "../../helpers/request"; + +describe("v1_schemas", () => { + // eslint-disable-next-line test/prefer-lowercase-title + describe("GET /api/v1/schemas/lockfile.json", () => { + it("should return lockfile JSON schema", async () => { + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/lockfile.json"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + cache: true, + }); + + const schema = await json(); + + // Verify it's a valid JSON Schema + expect(schema).toHaveProperty("$schema"); + expect(schema).toHaveProperty("type"); + + // Should be an object schema + expect(schema.type).toBe("object"); + }); + + it("should have proper cache control headers", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/lockfile.json"), + env, + ); + + expect(response).toMatchResponse({ + cache: true, + }); + const cacheControl = response.headers.get("cache-control"); + + // Should have 4 days cache (from router: MAX_AGE_ONE_DAY_SECONDS * 4) + expect(cacheControl).toMatch(/max-age=345600/); // 86400 * 4 + }); + + it("should return consistent schema on multiple requests", async () => { + const { json: json1 } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/lockfile.json"), + env, + ); + + const { json: json2 } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/lockfile.json"), + env, + ); + + const schema1 = await json1(); + const schema2 = await json2(); + + expect(schema1).toEqual(schema2); + }); + + it("should have expected lockfile schema properties", async () => { + const { json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/lockfile.json"), + env, + ); + + const schema = await json(); + + // Lockfile should have properties like version, files, etc. + expect(schema).toHaveProperty("properties"); + expect(schema.properties).toBeDefined(); + }); + }); + + // eslint-disable-next-line test/prefer-lowercase-title + describe("GET /api/v1/schemas/snapshot.json", () => { + it("should return snapshot JSON schema", async () => { + const { response, json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/snapshot.json"), + env, + ); + + expect(response).toMatchResponse({ + status: 200, + json: true, + cache: true, + }); + + const schema = await json(); + + // Verify it's a valid JSON Schema + expect(schema).toHaveProperty("$schema"); + expect(schema).toHaveProperty("type"); + + // Should be an object schema + expect(schema.type).toBe("object"); + }); + + it("should have proper cache control headers", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/snapshot.json"), + env, + ); + + expect(response).toMatchResponse({ + cache: true, + }); + const cacheControl = response.headers.get("cache-control"); + + // Should have 4 days cache (from router: MAX_AGE_ONE_DAY_SECONDS * 4) + expect(cacheControl).toMatch(/max-age=345600/); // 86400 * 4 + }); + + it("should return consistent schema on multiple requests", async () => { + const { json: json1 } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/snapshot.json"), + env, + ); + + const { json: json2 } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/snapshot.json"), + env, + ); + + const schema1 = await json1(); + const schema2 = await json2(); + + expect(schema1).toEqual(schema2); + }); + + it("should have expected snapshot schema properties", async () => { + const { json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/snapshot.json"), + env, + ); + + const schema = await json(); + + // Snapshot should have properties + expect(schema).toHaveProperty("properties"); + expect(schema.properties).toBeDefined(); + }); + }); + + describe("schema comparison", () => { + it("should return different schemas for lockfile and snapshot", async () => { + const { json: lockfileJson } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/lockfile.json"), + env, + ); + + const { json: snapshotJson } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/snapshot.json"), + env, + ); + + const lockfileSchema = await lockfileJson(); + const snapshotSchema = await snapshotJson(); + + // Schemas should be different + expect(lockfileSchema).not.toEqual(snapshotSchema); + }); + }); + + describe("404 for non-existent schemas", () => { + it("should return 404 for unknown schema", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/nonexistent.json"), + env, + ); + + expect(response.status).toBe(404); + }); + + it("should return 404 for schema without .json extension", async () => { + const { response } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/lockfile"), + env, + ); + + expect(response.status).toBe(404); + }); + }); + + // eslint-disable-next-line test/prefer-lowercase-title + describe("JSON Schema structure validation", () => { + it("should have valid JSON Schema $schema property", async () => { + const { json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/lockfile.json"), + env, + ); + + const schema = await json(); + + expect(schema.$schema).toBeDefined(); + expect(typeof schema.$schema).toBe("string"); + // Should be a JSON Schema draft URL + expect(schema.$schema).toMatch(/json-schema\.org/); + }); + + it("should convert Zod schema to valid JSON Schema format", async () => { + const { json } = await executeRequest( + new Request("https://api.ucdjs.dev/api/v1/schemas/snapshot.json"), + env, + ); + + const schema = await json(); + + // Basic JSON Schema validation + expect(schema).toHaveProperty("type"); + expect(["object", "string", "number", "boolean", "array", "null"]).toContain(schema.type); + }); + }); +}); diff --git a/apps/api/test/routes/v1_versions/$version.test.ts b/apps/api/test/routes/v1_versions/$version.test.ts index fae58684f..7e830b8f8 100644 --- a/apps/api/test/routes/v1_versions/$version.test.ts +++ b/apps/api/test/routes/v1_versions/$version.test.ts @@ -1,12 +1,13 @@ +/// + +import type { UnicodeFileTree, UnicodeFileTreeNode } from "@ucdjs/schemas"; import type { Entry } from "apache-autoindex-parse"; -import type { TraverseEntry } from "apache-autoindex-parse/traverse"; import { HttpResponse, mockFetch } from "#test-utils/msw"; - +import { flattenFilePaths } from "@ucdjs-internal/shared"; import { generateAutoIndexHtml } from "apache-autoindex-parse/test-utils"; import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { executeRequest } from "../../helpers/request"; -import { expectApiError, expectCacheHeaders, expectJsonResponse, expectSuccess } from "../../helpers/response"; vi.mock("@unicode-utils/core", async (importOriginal) => { const original = await importOriginal(); @@ -25,18 +26,42 @@ beforeEach(() => { describe("v1_versions", () => { // eslint-disable-next-line test/prefer-lowercase-title describe("GET /api/v1/versions/{version}/file-tree", () => { - const files: TraverseEntry[] = [ - { type: "file", name: "file1.txt", path: "Public/15.1.0/ucd/file1.txt" }, - { type: "file", name: "file2.txt", path: "Public/15.1.0/ucd/file2.txt" }, - { type: "directory", name: "subdir", path: "Public/15.1.0/ucd/subdir", children: [] }, - { type: "file", name: "file3.txt", path: "Public/15.1.0/ucd/subdir/file3.txt" }, - { type: "file", name: "emoji-data.txt", path: "Public/15.1.0/ucd/emoji/emoji-data.txt" }, - ]; + const expectedFiles = [ + { type: "file", name: "file1.txt", path: "file1.txt", lastModified: 1755287100000 }, + { type: "file", name: "file2.txt", path: "file2.txt", lastModified: 1755287100000 }, + { + type: "directory", + name: "subdir", + path: "subdir/", + lastModified: 1755287100000, + }, + { + type: "directory", + name: "emoji", + path: "emoji/", + lastModified: 1755287100000, + }, + ] satisfies Entry[]; it("should return files for a valid Unicode version", async () => { mockFetch([ ["GET", "https://unicode.org/Public/15.1.0/ucd", () => { - return HttpResponse.text(generateAutoIndexHtml(files, "F2")); + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file1.txt", path: "file1.txt", lastModified: 1755287100000 }, + { type: "file", name: "file2.txt", path: "file2.txt", lastModified: 1755287100000 }, + { type: "directory", name: "subdir", path: "subdir/", lastModified: 1755287100000 }, + { type: "directory", name: "emoji", path: "emoji/", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/15.1.0/ucd/emoji", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "emoji-data.txt", path: "emoji-data.txt", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/15.1.0/ucd/subdir", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file3.txt", path: "file3.txt", lastModified: 1755287100000 }, + ], "F2")); }], ]); @@ -45,39 +70,88 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - expectJsonResponse(response); - const data = await json() as unknown[]; + expect(response).toMatchResponse({ + json: true, + status: 200, + }); + + const data = await json(); expect(Array.isArray(data)).toBe(true); - const expectedFiles = files.map((file) => { - return expect.objectContaining({ - name: file.name, - path: file.path, - type: file.type, - ...(file.type === "directory" ? { children: file.children } : {}), - }); - }); + const flattenedFilePaths = flattenFilePaths(data); - expect(data).toEqual(expect.arrayContaining(expectedFiles)); + expect(flattenedFilePaths).toEqual([ + "/15.1.0/ucd/file1.txt", + "/15.1.0/ucd/file2.txt", + "/15.1.0/ucd/subdir/file3.txt", + "/15.1.0/ucd/emoji/emoji-data.txt", + ]); }); it("should return files for latest version", async () => { + mockFetch([ + ["GET", "https://unicode.org/Public/17.0.0/ucd", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file1.txt", path: "file1.txt", lastModified: 1755287100000 }, + { type: "file", name: "file2.txt", path: "file2.txt", lastModified: 1755287100000 }, + { type: "directory", name: "subdir", path: "subdir/", lastModified: 1755287100000 }, + { type: "directory", name: "emoji", path: "emoji/", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/17.0.0/ucd/emoji", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "emoji-data.txt", path: "emoji-data.txt", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/17.0.0/ucd/subdir", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file3.txt", path: "file3.txt", lastModified: 1755287100000 }, + ], "F2")); + }], + ]); + const { response, json } = await executeRequest( new Request("https://api.ucdjs.dev/api/v1/versions/latest/file-tree"), env, ); - expectSuccess(response); - expectJsonResponse(response); - const data = await json(); + expect(response).toMatchResponse({ + json: true, + status: 200, + }); + + const data = await json(); expect(Array.isArray(data)).toBe(true); + + const flattenedFilePaths = flattenFilePaths(data); + + expect(flattenedFilePaths).toEqual([ + "/17.0.0/ucd/file1.txt", + "/17.0.0/ucd/file2.txt", + "/17.0.0/ucd/subdir/file3.txt", + "/17.0.0/ucd/emoji/emoji-data.txt", + ]); }); it("should return structured file data with proper schema", async () => { mockFetch([ ["GET", "https://unicode.org/Public/15.1.0/ucd", () => { - return HttpResponse.text(generateAutoIndexHtml(files, "F2")); + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file1.txt", path: "file1.txt", lastModified: 1755287100000 }, + { type: "file", name: "file2.txt", path: "file2.txt", lastModified: 1755287100000 }, + { type: "directory", name: "subdir", path: "subdir/", lastModified: 1755287100000 }, + { type: "directory", name: "emoji", path: "emoji/", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/15.1.0/ucd/emoji", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "emoji-data.txt", path: "emoji-data.txt", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/15.1.0/ucd/subdir", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file3.txt", path: "file3.txt", lastModified: 1755287100000 }, + ], "F2")); }], ]); @@ -86,10 +160,12 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - expectJsonResponse(response); + expect(response).toMatchResponse({ + json: true, + status: 200, + }); - const data = await json() as TraverseEntry[]; + const data = await json(); // validate the response structure expect(Array.isArray(data)).toBe(true); @@ -105,7 +181,7 @@ describe("v1_versions", () => { return [files, directories]; }, - [[], []] as [Entry[], TraverseEntry[]], + [[], []] as [Exclude[], Exclude[]], ); expect(filesEntries.length).toBeGreaterThan(0); @@ -134,7 +210,22 @@ describe("v1_versions", () => { it("should handle older Unicode versions", async () => { mockFetch([ ["GET", "https://unicode.org/Public/3.1-Update1", () => { - return HttpResponse.text(generateAutoIndexHtml(files, "F2")); + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file1.txt", path: "file1.txt", lastModified: 1755287100000 }, + { type: "file", name: "file2.txt", path: "file2.txt", lastModified: 1755287100000 }, + { type: "directory", name: "subdir", path: "subdir/", lastModified: 1755287100000 }, + { type: "directory", name: "emoji", path: "emoji/", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/3.1-Update1/emoji", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "emoji-data.txt", path: "emoji-data.txt", lastModified: 1755287100000 }, + ], "F2")); + }], + ["GET", "https://unicode.org/Public/3.1-Update1/subdir", () => { + return HttpResponse.text(generateAutoIndexHtml([ + { type: "file", name: "file3.txt", path: "file3.txt", lastModified: 1755287100000 }, + ], "F2")); }], ]); @@ -143,10 +234,21 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - expectJsonResponse(response); - const data = await json(); + expect(response).toMatchResponse({ + json: true, + status: 200, + }); + const data = await json(); expect(Array.isArray(data)).toBe(true); + + const flattenedFilePaths = flattenFilePaths(data); + + expect(flattenedFilePaths).toEqual([ + "/3.1-Update1/file1.txt", + "/3.1-Update1/file2.txt", + "/3.1-Update1/subdir/file3.txt", + "/3.1-Update1/emoji/emoji-data.txt", + ]); }); describe("error handling", () => { @@ -156,9 +258,11 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { + expect(response).toMatchResponse({ status: 400, - message: "Invalid Unicode version", + error: { + message: "Invalid Unicode version", + }, }); }); @@ -168,9 +272,11 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { + expect(response).toMatchResponse({ status: 400, - message: "Invalid Unicode version", + error: { + message: "Invalid Unicode version", + }, }); }); @@ -180,7 +286,9 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { status: 400 }); + expect(response).toMatchResponse({ + status: 400, + }); }); }); @@ -194,8 +302,10 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - expectCacheHeaders(response); + expect(response).toMatchResponse({ + status: 200, + cache: true, + }); }); it("should cache the response for subsequent requests", async () => { @@ -203,7 +313,7 @@ describe("v1_versions", () => { mockFetch([ ["GET", "https://unicode.org/Public/16.0.0/ucd", () => { callCounter++; - return HttpResponse.text(generateAutoIndexHtml(files, "F2")); + return HttpResponse.text(generateAutoIndexHtml(expectedFiles, "F2")); }], ]); @@ -211,18 +321,26 @@ describe("v1_versions", () => { new Request("https://api.ucdjs.dev/api/v1/versions/16.0.0/file-tree"), env, ); - expectSuccess(firstResponse); + expect(firstResponse).toMatchResponse({ + status: 200, + headers: { + "cf-cache-status": "", + }, + }); expect(callCounter).toBe(1); // First call should hit the network - expect(firstResponse.headers.get("cf-cache-status")).toBeNull(); const { response: secondResponse } = await executeRequest( new Request("https://api.ucdjs.dev/api/v1/versions/16.0.0/file-tree"), env, ); - expectSuccess(secondResponse); + expect(secondResponse).toMatchResponse({ + status: 200, + headers: { + "cf-cache-status": "HIT", + }, + }); expect(callCounter).toBe(1); // Second call should hit the cache - expect(secondResponse.headers.get("cf-cache-status")).toBe("HIT"); }); it("should not cache responses for invalid versions", async () => { @@ -231,7 +349,10 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { status: 400 }); + expect(response).toMatchResponse({ + status: 400, + }); + expect(response.headers.get("cf-cache-status")).toBeNull(); }); }); diff --git a/apps/api/test/routes/v1_versions/list.test.ts b/apps/api/test/routes/v1_versions/list.test.ts index 785693a07..84d26beb8 100644 --- a/apps/api/test/routes/v1_versions/list.test.ts +++ b/apps/api/test/routes/v1_versions/list.test.ts @@ -1,15 +1,11 @@ -import type { UnicodeVersion } from "@ucdjs/schemas"; +/// + +import type { UnicodeVersionList } from "@ucdjs/schemas"; import { HttpResponse, mockFetch } from "#test-utils/msw"; import { getCurrentDraftVersion, resolveUCDVersion } from "@unicode-utils/core"; 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(); @@ -64,9 +60,11 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - expectJsonResponse(response); - const data = await json() as UnicodeVersion[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const data = await json(); expect(Array.isArray(data)).toBe(true); expect(data).toHaveLength(3); @@ -97,8 +95,12 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - const data = await json() as UnicodeVersion[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + + const data = await json(); // Should include the draft version const draftVersion = data.find((v) => v.type === "draft"); @@ -129,8 +131,11 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - const data = await json() as UnicodeVersion[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const data = await json(); // Find the version with different mapping const versionWithDifferentMapping = data.find((v) => v.version === "15.0.0"); @@ -153,9 +158,11 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { + expect(response).toMatchResponse({ status: 502, - message: "Failed to fetch Unicode versions from upstream service", + error: { + message: "Failed to fetch Unicode versions from upstream service", + }, }); }); @@ -174,9 +181,11 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { + expect(response).toMatchResponse({ status: 502, - message: "Failed to fetch Unicode versions from upstream service", + error: { + message: "Failed to fetch Unicode versions from upstream service", + }, }); }); @@ -206,9 +215,11 @@ describe("v1_versions", () => { env, ); - await expectApiError(response, { + expect(response).toMatchResponse({ status: 502, - message: "No Unicode versions found", + error: { + message: "No Unicode versions found", + }, }); }); @@ -227,8 +238,11 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - const data = await json() as UnicodeVersion[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const data = await json(); // Should still work without draft version expect(data.every((v) => v.type === "stable")).toBe(true); @@ -249,8 +263,11 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - expectCacheHeaders(response); + expect(response).toMatchResponse({ + status: 200, + json: true, + cache: true, + }); }); it("should sort versions correctly (newest first)", async () => { @@ -268,8 +285,11 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - const data = await json() as UnicodeVersion[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const data = await json(); expect(data[0]!.version).toBe("16.0.0"); expect(data[1]!.version).toBe("15.1.0"); @@ -306,8 +326,11 @@ describe("v1_versions", () => { env, ); - expectSuccess(response); - const data = await json() as UnicodeVersion[]; + expect(response).toMatchResponse({ + status: 200, + json: true, + }); + const data = await json(); // Should only include versions with valid year patterns expect(data).toHaveLength(1); diff --git a/apps/web/README.md b/apps/web/README.md index 4a6711bb3..d8a23b0e7 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,293 +1,33 @@ -Welcome to your new TanStack app! +# ucdjs.dev -# Getting Started +Web application for UCD.js - Unicode Character Database in a more readable way. -To run this application: +## 🚀 Usage -```bash -pnpm install -pnpm start -``` - -# Building For Production - -To build this application for production: - -```bash -pnpm build -``` - -## Testing - -This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with: - -```bash -pnpm test -``` - -## Styling - -This project uses [Tailwind CSS](https://tailwindcss.com/) for styling. - -## Routing - -This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`. - -### Adding A Route - -To add a new route to your application just add another a new file in the `./src/routes` directory. - -TanStack will automatically generate the content of the route file for you. - -Now that you have two routes you can use a `Link` component to navigate between them. - -### Adding Links - -To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`. - -```tsx -import { Link } from "@tanstack/react-router"; -``` - -Then anywhere in your JSX you can use it like so: - -```tsx -About; -``` - -This will create a link that will navigate to the `/about` route. - -More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent). - -### Using A Layout - -In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `` component. - -Here is an example layout that includes a header: - -```tsx -import { createRootRoute, Link, Outlet } from "@tanstack/react-router"; - -import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; - -export const Route = createRootRoute({ - component: () => ( - <> -
- -
- - - - ), -}); -``` - -The `` component is not required so you can remove it if you don't want it in your layout. - -More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts). - -## Data Fetching - -There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered. - -For example: - -```tsx -const peopleRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/people", - loader: async () => { - const response = await fetch("https://swapi.dev/api/people"); - return response.json() as Promise<{ - results: { - name: string; - }[]; - }>; - }, - component: () => { - const data = peopleRoute.useLoaderData(); - return ( -
    - {data.results.map((person) => ( -
  • {person.name}
  • - ))} -
- ); - }, -}); -``` - -Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters). +From the root directory: -### React-Query - -React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze. - -First add your dependencies: - -```bash -pnpm add @tanstack/react-query @tanstack/react-query-devtools -``` - -Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`. - -```tsx -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -// ... - -const queryClient = new QueryClient(); - -// ... - -if (!rootElement.innerHTML) { - const root = ReactDOM.createRoot(rootElement); - - root.render( - - - - ); -} +```sh +pnpm run dev:apps ``` -You can also add TanStack Query Devtools to the root route (optional). +## 🏗️ Building For Production -```tsx -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +From the root directory: -const rootRoute = createRootRoute({ - component: () => ( - <> - - - - - ), -}); +```sh +pnpm build:apps ``` -Now you can use `useQuery` to fetch your data. - -```tsx -import { useQuery } from "@tanstack/react-query"; - -import "./App.css"; - -function App() { - const { data } = useQuery({ - queryKey: ["people"], - queryFn: () => - fetch("https://swapi.dev/api/people") - .then((res) => res.json()) - .then((data) => data.results as { name: string }[]), - initialData: [], - }); - - return ( -
-
    - {data.map((person) => ( -
  • {person.name}
  • - ))} -
-
- ); -} - -export default App; -``` - -You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview). - -## State Management - -Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project. - -First you need to add TanStack Store as a dependency: - -```bash -pnpm add @tanstack/store -``` - -Now let's create a simple counter in the `src/App.tsx` file as a demonstration. - -```tsx -import { useStore } from "@tanstack/react-store"; -import { Store } from "@tanstack/store"; -import "./App.css"; - -const countStore = new Store(0); - -function App() { - const count = useStore(countStore); - return ( -
- -
- ); -} - -export default App; -``` - -One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates. - -Let's check this out by doubling the count using derived state. - -```tsx -import { useStore } from "@tanstack/react-store"; -import { Derived, Store } from "@tanstack/store"; -import "./App.css"; - -const countStore = new Store(0); - -const doubledStore = new Derived({ - fn: () => countStore.state * 2, - deps: [countStore], -}); -doubledStore.mount(); - -function App() { - const count = useStore(countStore); - const doubledCount = useStore(doubledStore); - - return ( -
- -
- Doubled - - {doubledCount} -
-
- ); -} - -export default App; -``` - -We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating. - -Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook. - -You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest). +## 🔧 Development -# Demo files +The project uses: -Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed. +- [React](https://react.dev/) for the UI framework +- [TanStack Router](https://tanstack.com/router) for routing +- [Vite](https://vitejs.dev/) for build tooling +- [Tailwind CSS](https://tailwindcss.com/) for styling +- [Vitest](https://vitest.dev/) for testing -# Learn More +## 📄 License -You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com). +Published under [MIT License](./LICENSE). diff --git a/apps/web/components.json b/apps/web/components.json index fb8c93972..b1c0a7a03 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/styles.css", + "css": "src/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" diff --git a/apps/web/package.json b/apps/web/package.json index 1319a9ef7..068e3103c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -2,7 +2,7 @@ "name": "@ucdjs/web", "type": "module", "private": true, - "packageManager": "pnpm@10.26.1", + "packageManager": "pnpm@10.26.2", "scripts": { "dev": "vite dev --port 3000", "build": "vite build", @@ -21,7 +21,11 @@ "@tanstack/react-router-devtools": "catalog:web", "@tanstack/react-router-ssr-query": "catalog:web", "@tanstack/react-start": "catalog:web", + "@tanstack/zod-adapter": "catalog:web", + "@ucdjs-internal/shared": "workspace:*", + "@ucdjs/env": "workspace:*", "@ucdjs/schemas": "workspace:*", + "@ucdjs/ucd-store": "workspace:*", "@unicode-utils/core": "catalog:prod", "class-variance-authority": "catalog:web", "clsx": "catalog:web", @@ -31,6 +35,7 @@ "lucide-react": "catalog:web", "react": "catalog:web", "react-dom": "catalog:web", + "recharts": "catalog:web", "shadcn": "catalog:web", "tailwind-merge": "catalog:web", "tw-animate-css": "catalog:web", @@ -45,6 +50,7 @@ "@types/node": "catalog:types", "@types/react": "catalog:types", "@types/react-dom": "catalog:types", + "@ucdjs-tooling/tsconfig": "workspace:*", "@vitejs/plugin-react": "catalog:web", "babel-plugin-react-compiler": "catalog:web", "eslint": "catalog:linting", diff --git a/apps/web/src/apis/characters.ts b/apps/web/src/apis/characters.ts index ae54097ce..d65241094 100644 --- a/apps/web/src/apis/characters.ts +++ b/apps/web/src/apis/characters.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { tryOr } from "@ucdjs-internal/shared"; export interface UnicodeCharacter { codepoint: string; @@ -19,9 +20,15 @@ export async function fetchCharacter(hex: string, version: string) { // TODO: Replace with actual API call when endpoint exists // const res = await fetch(`${API_BASE}/u/${hex}?version=${version}`) - // Mock data for now - const codepoint = Number.parseInt(hex, 16); - const char = String.fromCodePoint(codepoint); + const char = await tryOr({ + try: () => { + const codepoint = Number.parseInt(hex, 16); + return String.fromCodePoint(codepoint); + }, + err: (err) => { + throw new Error(`Invalid hex codepoint: ${hex}. ${err}`); + }, + }); return { codepoint: `U+${hex.toUpperCase().padStart(4, "0")}`, diff --git a/apps/web/src/apis/files.ts b/apps/web/src/apis/files.ts deleted file mode 100644 index 15c522932..000000000 --- a/apps/web/src/apis/files.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { FileEntry } from "@ucdjs/schemas"; -import { queryOptions } from "@tanstack/react-query"; -import { createServerFn } from "@tanstack/react-start"; - -const UCD_FILE_STAT_TYPE_HEADER = "UCD-File-Stat-Type"; - -export type FilesResponse - = | { type: "directory"; files: FileEntry[] } - | { type: "file"; content: string; contentType: string }; - -export const fetchFiles = createServerFn({ method: "GET" }) - .inputValidator((data: { path: string }) => data) - .handler(async ({ data }): Promise => { - const baseFilesUrl = `${import.meta.env.VITE_UCDJS_API_BASE_URL}/api/v1/files`; - const url = data.path ? `${baseFilesUrl}/${data.path}` : baseFilesUrl; - - const res = await fetch(url); - - if (!res.ok) { - throw new Error(`Failed to fetch files: ${res.statusText}`); - } - - const fileStatType = res.headers.get(UCD_FILE_STAT_TYPE_HEADER); - - if (fileStatType === "file") { - const content = await res.text(); - const contentType = res.headers.get("Content-Type") || "text/plain"; - return { type: "file", content, contentType }; - } - - // Default to directory listing (JSON response) - const files = (await res.json()) as FileEntry[]; - return { type: "directory", files }; - }); - -export function filesQueryOptions(path: string = "") { - return queryOptions({ - queryKey: ["files", path], - queryFn: () => fetchFiles({ data: { path } }), - staleTime: 1000 * 60 * 60, // 1 hour - }); -} diff --git a/apps/web/src/apis/versions.ts b/apps/web/src/apis/versions.ts deleted file mode 100644 index d6d134ffb..000000000 --- a/apps/web/src/apis/versions.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { UnicodeVersion } from "@ucdjs/schemas"; -import { queryOptions } from "@tanstack/react-query"; -import { createServerFn } from "@tanstack/react-start"; - -const API_BASE = "https://api.ucdjs.dev/api/v1"; - -export interface UnicodeVersionDetails { - version: string; - totalCharacters: number; - newCharacters: number; - totalBlocks: number; - newBlocks: number; - totalScripts: number; - newScripts: number; -} - -// Mock data for version details until API endpoint is available -const VERSION_DETAILS_MOCK: Record = { - "17.0.0": { version: "17.0.0", totalCharacters: 154998, newCharacters: 5185, totalBlocks: 338, newBlocks: 7, totalScripts: 168, newScripts: 3 }, - "16.0.0": { version: "16.0.0", totalCharacters: 149813, newCharacters: 5185, totalBlocks: 331, newBlocks: 4, totalScripts: 165, newScripts: 2 }, - "15.1.0": { version: "15.1.0", totalCharacters: 149628, newCharacters: 627, totalBlocks: 327, newBlocks: 1, totalScripts: 163, newScripts: 1 }, - "15.0.0": { version: "15.0.0", totalCharacters: 149001, newCharacters: 4489, totalBlocks: 326, newBlocks: 6, totalScripts: 162, newScripts: 2 }, - "14.0.0": { version: "14.0.0", totalCharacters: 144512, newCharacters: 838, totalBlocks: 320, newBlocks: 5, totalScripts: 160, newScripts: 1 }, - "13.0.0": { version: "13.0.0", totalCharacters: 143674, newCharacters: 5930, totalBlocks: 315, newBlocks: 5, totalScripts: 159, newScripts: 4 }, - "12.1.0": { version: "12.1.0", totalCharacters: 137744, newCharacters: 1, totalBlocks: 310, newBlocks: 0, totalScripts: 155, newScripts: 0 }, - "12.0.0": { version: "12.0.0", totalCharacters: 137743, newCharacters: 553, totalBlocks: 310, newBlocks: 4, totalScripts: 155, newScripts: 1 }, -}; - -export const fetchVersions = createServerFn({ method: "GET" }).handler(async () => { - const res = await fetch(`${API_BASE}/versions`, { - headers: { accept: "application/json" }, - }); - if (!res.ok) { - throw new Error("Failed to fetch versions"); - } - return res.json() as Promise; -}); - -export async function fetchVersionDetails(version: string): Promise { - // TODO: Replace with actual API call when endpoint is available - // const res = await fetch(`${API_BASE}/versions/${version}/details`) - // return res.json() as Promise - - // Return mock data for now - const details = VERSION_DETAILS_MOCK[version]; - if (details) { - return details; - } - - // Fallback for unknown versions - return { - version, - totalCharacters: 0, - newCharacters: 0, - totalBlocks: 0, - newBlocks: 0, - totalScripts: 0, - newScripts: 0, - }; -} - -export function versionsQueryOptions() { - return queryOptions({ - queryKey: ["versions"], - queryFn: () => fetchVersions(), - staleTime: 1000 * 60 * 60, - }); -} - -export function versionDetailsQueryOptions(version: string) { - return queryOptions({ - queryKey: ["version-details", version], - queryFn: () => fetchVersionDetails(version), - staleTime: 1000 * 60 * 60, - }); -} diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx deleted file mode 100644 index 0f976a3af..000000000 --- a/apps/web/src/components/app-sidebar.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; -import { BookOpen, ExternalLink, Layers, Search } from "lucide-react"; -import * as React from "react"; -import { versionsQueryOptions } from "@/apis/versions"; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupLabel, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarRail, -} from "@/components/ui/sidebar"; -import { NavItem } from "./nav"; - -function UcdLogo({ className }: { className?: string }) { - return ( - - - - - - ); -} - -export function AppSidebar({ ...props }: React.ComponentProps) { - const { data: versions = [], isLoading } = useQuery(versionsQueryOptions()); - - // Build navigation items from versions - const navItems = React.useMemo(() => { - if (isLoading || versions.length === 0) { - return [ - { - title: "Versions", - url: "#", - icon: Layers, - isActive: true, - items: [], - }, - ]; - } - - return [ - { - title: "Versions", - url: "#", - icon: Layers, - isActive: false, - items: versions.map((v) => ({ - title: `Unicode ${v.version}`, - url: `/v/${v.version}`, - })), - }, - { - title: "Explorer", - url: "/explorer", - icon: Search, - }, - ]; - }, [versions, isLoading]); - - return ( - - - - -
-

UCD.js

- Unicode Database -
- -
- - - - {navItems.map((item) => )} - - - - - - Documentation - - - - - Getting Started - - )} - /> - - - - - API Reference - - )} - /> - - - - - -
- ); -} diff --git a/apps/web/src/components/file-explorer/entry-list.tsx b/apps/web/src/components/file-explorer/entry-list.tsx new file mode 100644 index 000000000..630cd355b --- /dev/null +++ b/apps/web/src/components/file-explorer/entry-list.tsx @@ -0,0 +1,48 @@ +import type { FileEntry } from "@ucdjs/schemas"; +import type { ViewMode } from "@/types/file-explorer"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useSearch } from "@tanstack/react-router"; +import { filesQueryOptions } from "@/functions/files"; +import { ExplorerEntry } from "./explorer-entry"; + +export interface EntryListProps { + currentPath: string; + viewMode: ViewMode; +} + +export function EntryList({ currentPath, viewMode }: EntryListProps) { + const search = useSearch({ from: "/file-explorer/$" }); + const { data } = useSuspenseQuery(filesQueryOptions({ + path: currentPath, + order: search.order, + pattern: search.pattern, + sort: search.sort, + query: search.query, + type: search.type, + })); + + if (data.files.length === 0) { + return ( +
+

+ {search.query + ? `No files matching "${search.query}"` + : "This directory is empty"} +

+
+ ); + } + + return ( + <> + {data.files.map((entry: FileEntry) => ( + + ))} + + ); +} diff --git a/apps/web/src/components/file-explorer/directory.tsx b/apps/web/src/components/file-explorer/explorer-entry.tsx similarity index 56% rename from apps/web/src/components/file-explorer/directory.tsx rename to apps/web/src/components/file-explorer/explorer-entry.tsx index 001440068..8675e9f8f 100644 --- a/apps/web/src/components/file-explorer/directory.tsx +++ b/apps/web/src/components/file-explorer/explorer-entry.tsx @@ -1,16 +1,10 @@ import type { FileEntry } from "@ucdjs/schemas"; +import type { ViewMode } from "@/types/file-explorer"; import { Link } from "@tanstack/react-router"; -import { FolderIcon, FolderOpen } from "lucide-react"; - +import { FileIcon, FolderIcon, FolderOpen } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; -export interface DirectoryItemProps { - directory: FileEntry; - viewMode: "list" | "cards"; - currentPath: string; -} - function formatRelativeTime(timestamp: number): string { const now = Date.now(); const diff = now - timestamp; @@ -30,26 +24,44 @@ function formatRelativeTime(timestamp: number): string { return "just now"; } -export function DirectoryItem({ directory, viewMode, currentPath }: DirectoryItemProps) { - const dirPath = currentPath ? `${currentPath}/${directory.name}` : directory.name; - const lastModified = directory.lastModified - ? formatRelativeTime(directory.lastModified) +export interface ExplorerEntryProps { + entry: FileEntry; + viewMode: ViewMode; + currentPath: string; +} + +export function ExplorerEntry({ entry, viewMode, currentPath }: ExplorerEntryProps) { + const entryPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; + const lastModified = entry.lastModified + ? formatRelativeTime(entry.lastModified) : null; + const isDirectory = entry.type === "directory"; + const linkProps = isDirectory + ? { to: "/file-explorer/$" as const, params: { _splat: entryPath } } + : { to: "/file-explorer/v/$" as const, params: { _splat: entryPath } }; + if (viewMode === "cards") { return (
- - + {isDirectory + ? ( + <> + + + + ) + : ( + + )} - {directory.name} + {entry.name}
{lastModified && ( @@ -62,21 +74,28 @@ export function DirectoryItem({ directory, viewMode, currentPath }: DirectoryIte return ( - - + {isDirectory + ? ( + <> + + + + ) + : ( + + )} - {directory.name} + {entry.name} {lastModified && ( diff --git a/apps/web/src/components/file-explorer/explorer-toolbar.tsx b/apps/web/src/components/file-explorer/explorer-toolbar.tsx index 9b63e93f7..6926b0860 100644 --- a/apps/web/src/components/file-explorer/explorer-toolbar.tsx +++ b/apps/web/src/components/file-explorer/explorer-toolbar.tsx @@ -1,119 +1,444 @@ -import { Filter, Grid3X3, List, Search, X } from "lucide-react"; - +import type { SearchQueryParams } from "@/routes/file-explorer/$"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { + Archive, + ArrowUpDown, + File, + FileText, + Filter, + Folder, + Grid3X3, + List, + Search, + TrendingDown, + TrendingUp, + X, +} from "lucide-react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, - DropdownMenuItem, DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; export type ViewMode = "list" | "cards"; -export interface FileFilter { - type: "all" | "files" | "directories"; - // Future filter options (mock for now) - // extension?: string; - // dateRange?: { from: Date; to: Date }; -} +// Isolated search input - only re-renders when query changes +const SearchInput = memo(() => { + const navigate = useNavigate({ from: "/file-explorer/$" }); + const query = useSearch({ from: "/file-explorer/$", select: (s) => s.query }); -export interface ExplorerToolbarProps { - searchTerm: string; - onSearchChange: (value: string) => void; - viewMode: ViewMode; - onViewModeChange: (mode: ViewMode) => void; - filter: FileFilter; - onFilterChange: (filter: FileFilter) => void; -} + const [localValue, setLocalValue] = useState(query || ""); + const timerRef = useRef | null>(null); + + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + setLocalValue(value); + + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + const trimmed = value.trim(); + navigate({ + search: (prev) => ({ + ...prev, + query: trimmed || undefined, + }), + }); + }, 300); + }, [navigate]); + + useEffect(() => { + return () => { + // If there is a pending timer, we will clear it. + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + const handleClear = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + setLocalValue(""); + navigate({ + search: (prev) => ({ + ...prev, + query: undefined, + }), + }); + }, [navigate]); -export function ExplorerToolbar({ - searchTerm, - onSearchChange, - viewMode, - onViewModeChange, - filter, - onFilterChange, -}: ExplorerToolbarProps) { return ( -
- {/* Search Input */} -
- - onSearchChange(e.target.value)} - className="pl-8 pr-8" - /> - {searchTerm && ( - +
+ + + /> + {localValue && ( + + )} +
+ ); +}); +SearchInput.displayName = "SearchInput"; - - { + const navigate = useNavigate({ from: "/file-explorer/$" }); + const filterType = useSearch({ from: "/file-explorer/$", select: (s) => s.type }) || "all"; + const isActive = filterType !== "all"; + + const setType = useCallback((type: "all" | "files" | "directories" | undefined) => { + navigate({ + search: (prev) => ({ + ...prev, + type: type === "all" ? undefined : type, + }), + }); + }, [navigate]); + + const getIcon = () => { + switch (filterType) { + case "files": + return ; + case "directories": + return ; + default: + return ; + } + }; + + return ( + + ( + + )} + > + + + + Type + + setType(v as "all" | "files" | "directories")}> + + All files + + Files only - - onFilterChange({ ...filter, type: "directories" })} - > + + Directories only - - - - + + + + + + ); +}); +TypeFilter.displayName = "TypeFilter"; + +// Pattern/extension filter dropdown +const PatternFilter = memo(() => { + const navigate = useNavigate({ from: "/file-explorer/$" }); + const pattern = useSearch({ from: "/file-explorer/$", select: (s) => s.pattern }); + const isActive = !!pattern; -
+ const setPattern = useCallback((newPattern: string | undefined) => { + navigate({ + search: (prev) => ({ + ...prev, + pattern: newPattern, + }), + }); + }, [navigate]); + + const commonPatterns = [ + { label: "All files", value: undefined, icon: }, + { label: "Text files", value: "*.txt", icon: }, + { label: "XML files", value: "*.xml", icon: }, + { label: "Zip archives", value: "*.zip", icon: }, + ]; + + const currentPattern = commonPatterns.find((p) => p.value === pattern); + const displayIcon = currentPattern?.icon || ; + + return ( + + ( - + )} > - - Card view - + + + + Sort By + + setSort(v)}> + + Name + + + Last Modified + + + + + + +
+ ); +}); +SortControl.displayName = "SortControl"; + +// View mode toggle +const ViewModeToggle = memo(() => { + const navigate = useNavigate({ from: "/file-explorer/$" }); + const viewMode = useSearch({ from: "/file-explorer/$", select: (s) => s.viewMode }) || "list"; + + const setViewMode = useCallback((mode: ViewMode) => { + navigate({ + search: (prev) => ({ + ...prev, + viewMode: mode, + }), + }); + }, [navigate]); + + return ( +
+ + +
+ ); +}); +ViewModeToggle.displayName = "ViewModeToggle"; + +// Active filters summary with clear all +const ActiveFilters = memo(() => { + const navigate = useNavigate({ from: "/file-explorer/$" }); + const query = useSearch({ from: "/file-explorer/$", select: (s) => s.query }); + const type = useSearch({ from: "/file-explorer/$", select: (s) => s.type }); + const pattern = useSearch({ from: "/file-explorer/$", select: (s) => s.pattern }); + + const activeCount = [query, type, pattern].filter(Boolean).length; + + const clearAll = useCallback(() => { + navigate({ + search: (prev) => ({ + ...prev, + query: undefined, + type: undefined, + pattern: undefined, + }), + }); + }, [navigate]); + + if (activeCount === 0) return null; + + return ( + + + {activeCount} + {" "} + filter + {activeCount > 1 ? "s" : ""} + + + + ); +}); +ActiveFilters.displayName = "ActiveFilters"; + +export function ExplorerToolbar() { + return ( +
+ + +
+ + +
+ +
+ + +
); diff --git a/apps/web/src/components/file-explorer/file-explorer.tsx b/apps/web/src/components/file-explorer/file-explorer.tsx deleted file mode 100644 index 642e5a93d..000000000 --- a/apps/web/src/components/file-explorer/file-explorer.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import type { FileEntry } from "@ucdjs/schemas"; -import type { FileFilter, ViewMode } from "./explorer-toolbar"; -import { useMemo, useState } from "react"; - -import { Skeleton } from "@/components/ui/skeleton"; -import { cn } from "@/lib/utils"; - -import { DirectoryItem } from "./directory"; -import { ExplorerToolbar } from "./explorer-toolbar"; -import { FileItem } from "./file"; -import { ParentDirectory } from "./parent-directory"; - -export interface FileExplorerProps { - files: FileEntry[]; - currentPath: string; - isLoading?: boolean; -} - -export function FileExplorer({ files, currentPath, isLoading }: FileExplorerProps) { - const [searchTerm, setSearchTerm] = useState(""); - const [viewMode, setViewMode] = useState("list"); - const [filter, setFilter] = useState({ type: "all" }); - - // Filter and sort files - const filteredFiles = useMemo(() => { - let result = [...files]; - - // Apply search filter - if (searchTerm) { - const lowerSearch = searchTerm.toLowerCase(); - result = result.filter((file) => - file.name.toLowerCase().includes(lowerSearch), - ); - } - - // Apply type filter - if (filter.type === "files") { - result = result.filter((file) => file.type === "file"); - } else if (filter.type === "directories") { - result = result.filter((file) => file.type === "directory"); - } - - // Sort: directories first, then alphabetically - result.sort((a, b) => { - if (a.type === "directory" && b.type !== "directory") return -1; - if (a.type !== "directory" && b.type === "directory") return 1; - return a.name.localeCompare(b.name); - }); - - return result; - }, [files, searchTerm, filter]); - - const directories = filteredFiles.filter((f) => f.type === "directory"); - const fileItems = filteredFiles.filter((f) => f.type === "file"); - - if (isLoading) { - return ( -
-
- - - -
-
- - - - - - - - -
-
- ); - } - - return ( -
- - - {filteredFiles.length === 0 - ? ( -
-

- {searchTerm - ? `No files matching "${searchTerm}"` - : "This directory is empty"} -

-
- ) - : ( -
- - {directories.map((dir) => ( - - ))} - {fileItems.map((file) => ( - - ))} -
- )} - - {/* Stats footer */} -
- {directories.length} - {" "} - {directories.length === 1 ? "directory" : "directories"} - , - {" "} - {fileItems.length} - {" "} - {fileItems.length === 1 ? "file" : "files"} - {searchTerm && ` (filtered from ${files.length} total)`} -
-
- ); -} diff --git a/apps/web/src/components/file-explorer/file-viewer.tsx b/apps/web/src/components/file-explorer/file-viewer.tsx index 03b3956f3..9ccfe5c77 100644 --- a/apps/web/src/components/file-explorer/file-viewer.tsx +++ b/apps/web/src/components/file-explorer/file-viewer.tsx @@ -1,9 +1,9 @@ -import { Link } from "@tanstack/react-router"; -import { ArrowLeft, Check, Download, ExternalLink, FileText, Link2 } from "lucide-react"; +import { Check, Download, ExternalLink, FileText, Link2, Loader2 } from "lucide-react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; export interface FileViewerProps { @@ -29,7 +29,7 @@ function parseLineHash(hash: string): LineSelection | null { const match = hash.match(/^#L(\d+)(?:-L?(\d+))?$/); if (!match) return null; - const start = Number.parseInt(match[1], 10); + const start = Number.parseInt(match[1]!, 10); const end = match[2] ? Number.parseInt(match[2], 10) : start; if (Number.isNaN(start) || Number.isNaN(end)) return null; @@ -133,17 +133,10 @@ function LineContentComponent({ line, selected }: LineContentProps) { const LineContent = memo(LineContentComponent); -function getParentPath(path: string): string { - const parts = path.split("/").filter(Boolean); - parts.pop(); - return parts.join("/"); -} - export function FileViewer({ content, contentType, fileName, filePath }: FileViewerProps) { const language = getLanguageFromContentType(contentType, fileName); const lines = useMemo(() => content.split("\n"), [content]); const lineCount = lines.length; - const parentPath = getParentPath(filePath); // Parse initial selection from URL hash (only on mount) const initialSelection = useMemo((): LineSelection | null => { @@ -229,22 +222,10 @@ export function FileViewer({ content, contentType, fileName, filePath }: FileVie return ( -
-
+ + + {fileName} +
{lineCount} @@ -291,7 +272,7 @@ export function FileViewer({ content, contentType, fileName, filePath }: FileVie
{/* Line numbers */} -
+
{lines.map((_, idx) => { const lineNum = idx + 1; const selected = lineNum >= selectionStart && lineNum <= selectionEnd; @@ -335,3 +316,57 @@ export function FileViewer({ content, contentType, fileName, filePath }: FileVie ); } + +export interface FileViewerSkeletonProps { + fileName: string; +} + +/** + * Skeleton loading state for FileViewer + * Shows the card shell with loading placeholders for content + */ +export function FileViewerSkeleton({ fileName }: FileViewerSkeletonProps) { + return ( + + + + + {fileName} + +
+ + + +
+
+ +
+
+ {/* Line numbers skeleton */} +
+ {Array.from({ length: 15 }).map((_, idx) => ( + // eslint-disable-next-line react/no-array-index-key +
+ +
+ ))} +
+ {/* Content skeleton */} +
+
+ + Loading file content... +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/file-explorer/file.tsx b/apps/web/src/components/file-explorer/file.tsx deleted file mode 100644 index 9acc7a936..000000000 --- a/apps/web/src/components/file-explorer/file.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { FileEntry } from "@ucdjs/schemas"; -import { Link } from "@tanstack/react-router"; -import { FileIcon, FileText } from "lucide-react"; - -import { Card, CardContent } from "@/components/ui/card"; -import { cn } from "@/lib/utils"; - -export interface FileItemProps { - file: FileEntry; - viewMode: "list" | "cards"; - currentPath: string; -} - -function getFileIcon(fileName: string) { - const ext = fileName.split(".").pop()?.toLowerCase(); - - switch (ext) { - case "txt": - case "md": - return ; - default: - return ; - } -} - -function formatRelativeTime(timestamp: number): string { - const now = Date.now(); - const diff = now - timestamp; - - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - const months = Math.floor(days / 30); - const years = Math.floor(days / 365); - - if (years > 0) return `${years} year${years > 1 ? "s" : ""} ago`; - if (months > 0) return `${months} month${months > 1 ? "s" : ""} ago`; - if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`; - if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`; - if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`; - return "just now"; -} - -export function FileItem({ file, viewMode, currentPath }: FileItemProps) { - const filePath = currentPath ? `${currentPath}/${file.name}` : file.name; - const lastModified = file.lastModified - ? formatRelativeTime(file.lastModified) - : null; - - if (viewMode === "cards") { - return ( - - -
- {getFileIcon(file.name)} - - {file.name} - -
- {lastModified && ( -

{lastModified}

- )} -
-
- ); - } - - return ( -
- {getFileIcon(file.name)} - - {file.name} - - {lastModified && ( - - {lastModified} - - )} -
- ); -} diff --git a/apps/web/src/components/file-explorer/index.ts b/apps/web/src/components/file-explorer/index.ts deleted file mode 100644 index e92ac5692..000000000 --- a/apps/web/src/components/file-explorer/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { DirectoryItem } from "./directory"; -export type { DirectoryItemProps } from "./directory"; -export { ExplorerToolbar } from "./explorer-toolbar"; -export type { ExplorerToolbarProps, FileFilter, ViewMode } from "./explorer-toolbar"; -export { FileItem } from "./file"; -export type { FileItemProps } from "./file"; -export { FileExplorer } from "./file-explorer"; -export type { FileExplorerProps } from "./file-explorer"; -export { FileViewer } from "./file-viewer"; -export type { FileViewerProps } from "./file-viewer"; -export { NON_RENDERABLE_EXTENSIONS, NonRenderableFile } from "./non-renderable-file"; -export type { NonRenderableFileProps } from "./non-renderable-file"; -export { ParentDirectory } from "./parent-directory"; -export type { ParentDirectoryProps } from "./parent-directory"; diff --git a/apps/web/src/components/file-explorer/large-file-warning.tsx b/apps/web/src/components/file-explorer/large-file-warning.tsx new file mode 100644 index 000000000..9f12acac4 --- /dev/null +++ b/apps/web/src/components/file-explorer/large-file-warning.tsx @@ -0,0 +1,70 @@ +import { Download } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +export interface LargeFileWarningProps { + fileName: string; + size: number; + downloadUrl: string; + contentType: string; +} + +/** + * Warning component shown for files too large to render inline + * Displays file metadata and provides a download button + */ +export function LargeFileWarning({ fileName, size, downloadUrl, contentType }: LargeFileWarningProps) { + const sizeInMB = (size / (1024 * 1024)).toFixed(2); + + return ( +
+
+ +
+ +
+

+ File Too Large to Preview +

+

+ {fileName} + {" "} + is + {" "} + {sizeInMB} + {" "} + MB +

+

+ Files larger than 1 MB cannot be previewed to prevent performance issues +

+
+ +
+
+ Size: + + {sizeInMB} + {" "} + MB + +
+
+ Type: + {contentType} +
+
+ +
+ ); +} diff --git a/apps/web/src/components/file-explorer/non-renderable-file.tsx b/apps/web/src/components/file-explorer/non-renderable-file.tsx index a9dadff6b..ff6b908b6 100644 --- a/apps/web/src/components/file-explorer/non-renderable-file.tsx +++ b/apps/web/src/components/file-explorer/non-renderable-file.tsx @@ -1,13 +1,12 @@ -import { Link } from "@tanstack/react-router"; -import { ArrowLeft, Download, FileWarning } from "lucide-react"; +import { Download, FileWarning } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; export interface NonRenderableFileProps { fileName: string; - filePath: string; contentType: string; + fileUrl: string; } /** @@ -127,37 +126,21 @@ function getFileTypeDescription(fileName: string): string { return descriptions[ext] || "Binary File"; } -function getParentPath(path: string): string { - const parts = path.split("/").filter(Boolean); - parts.pop(); - return parts.join("/"); -} - -export function NonRenderableFile({ fileName, filePath, contentType }: NonRenderableFileProps) { +export function NonRenderableFile({ + fileName, + contentType, + fileUrl, +}: NonRenderableFileProps) { const fileType = getFileTypeDescription(fileName); const ext = fileName.split(".").pop()?.toLowerCase() || ""; - const parentPath = getParentPath(filePath); return ( -
- - - {fileName} - -
+ + + {fileName} +
@@ -186,30 +169,18 @@ export function NonRenderableFile({ fileName, filePath, contentType }: NonRender

)}
-
-
+
diff --git a/apps/web/src/components/file-explorer/parent-directory.tsx b/apps/web/src/components/file-explorer/parent-directory.tsx index 4bee9adfb..8e4ba9df8 100644 --- a/apps/web/src/components/file-explorer/parent-directory.tsx +++ b/apps/web/src/components/file-explorer/parent-directory.tsx @@ -1,6 +1,5 @@ import { Link } from "@tanstack/react-router"; import { ArrowUp, FolderUp } from "lucide-react"; - import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; @@ -30,8 +29,8 @@ export function ParentDirectory({ currentPath, viewMode }: ParentDirectoryProps)
.. @@ -45,8 +44,8 @@ export function ParentDirectory({ currentPath, viewMode }: ParentDirectoryProps) return ( + {versions.map((version) => ( + +
+ {version.version} + +
+ + {/* Badges */} +
+ {version.version === latestVersion?.version && ( + + + Latest + + )} + + {version.type} + + {version.date && ( + + {version.date} + + )} +
+ + {/* Documentation link */} + + + ))} +
+ ); +} + +export function VersionsCardListSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/apps/web/src/components/layout/sidebar/app-sidebar.tsx b/apps/web/src/components/layout/sidebar/app-sidebar.tsx new file mode 100644 index 000000000..947c0bef3 --- /dev/null +++ b/apps/web/src/components/layout/sidebar/app-sidebar.tsx @@ -0,0 +1,152 @@ +import type { ComponentProps } from "react"; +import { Link, useLoaderData, useMatches } from "@tanstack/react-router"; +import { BookOpen, ExternalLink, Grid3X3, Lightbulb, Search, Type } from "lucide-react"; +import { Suspense } from "react"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, +} from "@/components/ui/sidebar"; +import { UcdLogo } from "../../ucd-logo"; +import { VersionsList, VersionsListSkeleton } from "./versions-list"; + +const MAIN_ITEMS = [ + { to: "/", icon: BookOpen, label: "Home" }, + { to: "/search", icon: Search, label: "Search" }, +] as const; + +const VERSION_ITEMS = [ + { to: "/v/$version", icon: BookOpen, label: "Overview" }, + { to: "/v/$version/blocks", icon: Grid3X3, label: "Blocks" }, + { to: "/v/$version/grapheme-visualizer", icon: Lightbulb, label: "Grapheme Visualizer" }, + { to: "/v/$version/normalization-preview", icon: Lightbulb, label: "Normalization Preview" }, + { to: "/v/$version/bidi-linebreak", icon: Lightbulb, label: "BIDI & Line Break" }, + { to: "/v/$version/font-glyph-view", icon: Lightbulb, label: "Font & Glyph View" }, + { to: "/v/$version/u/$hex", params: { hex: "0041" }, icon: Type, label: "Codepoint Visualizer" }, +] as const; + +const TOOLS_ITEMS = [ + { to: "/file-explorer/$", params: { _splat: "" }, icon: BookOpen, label: "File Explorer" }, + { to: "/compare", icon: Grid3X3, label: "Compare" }, +] as const; + +export function AppSidebar({ ...props }: ComponentProps) { + const { ucdjsApiBaseUrl } = useLoaderData({ from: "__root__" }); + + const matches = useMatches(); + const currentVersionMatch = matches.find((m) => (m.params as any)?.version !== undefined); + const currentVersion = currentVersionMatch ? (currentVersionMatch.params as any).version : undefined; + + return ( + + + + +
+

UCD.js

+ Unicode Database +
+ +
+ + + + {MAIN_ITEMS.map((item) => ( + + + + {item.label} + + )} + /> + + ))} + + + + {currentVersion + ? ( + + + Version: + {" "} + {currentVersion} + + + {VERSION_ITEMS.map((item) => ( + + + + {item.label} + + )} + /> + + ))} + + + ) + : null} + + }> + + + + + Tools + + {TOOLS_ITEMS.map((item) => ( + + + + {item.label} + + )} + /> + + ))} + + + + + + Documentation + + + + + Getting Started + + )} + /> + + + + + API Reference + + )} + /> + + + + + +
+ ); +} diff --git a/apps/web/src/components/layout/sidebar/versions-list.tsx b/apps/web/src/components/layout/sidebar/versions-list.tsx new file mode 100644 index 000000000..b48bb99b9 --- /dev/null +++ b/apps/web/src/components/layout/sidebar/versions-list.tsx @@ -0,0 +1,105 @@ +import type { UnicodeVersion } from "@ucdjs/schemas"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; +import { versionsQueryOptions } from "@/functions/versions"; +import { UVersion } from "../../u-version"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "../../ui/sidebar"; + +const DEFAULT_VISIBLE_VERSIONS = 5; + +function getBadgeLabel(v: UnicodeVersion): { label: string; cls: string } { + const year = v.date ? Number.parseInt(String(v.date), 10) : undefined; + const age = year ? new Date().getFullYear() - year : undefined; + + if (age === undefined) return { label: "Unknown", cls: "bg-muted text-muted-foreground" }; + if (age <= 1) return { label: "Recent", cls: "bg-green-100 text-green-700" }; + if (age <= 3) return { label: "Mature", cls: "bg-blue-100 text-blue-700" }; + if (age <= 5) return { label: "Old", cls: "bg-muted text-muted-foreground" }; + return { label: "Legacy", cls: "bg-muted/60 text-muted-foreground/80" }; +} + +export function VersionsList() { + const { data: versions } = useSuspenseQuery(versionsQueryOptions()); + const [showAll, setShowAll] = useState(false); + const visibleVersions = showAll ? versions : versions.slice(0, DEFAULT_VISIBLE_VERSIONS); + const hiddenCount = versions.length - DEFAULT_VISIBLE_VERSIONS; + + return ( + + Versions + +
+ {visibleVersions.map((v) => { + const badge = getBadgeLabel(v); + + return ( + + +
+
+ + + + + Unicode + {" "} + {v.version} + +
+ {badge.label} +
+ + )} + /> +
+ ); + })} +
+ + {hiddenCount > 0 && ( + + setShowAll(!showAll)} + className="text-muted-foreground hover:text-foreground" + > + + {showAll ? "Show less" : `Show ${hiddenCount} more`} + + + )} +
+
+ ); +} + +export function VersionsListSkeleton() { + return ( + +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( + +
+
+
+
+
+
+
+ + ))} +
+ + + ); +} diff --git a/apps/web/src/components/layout/version/header.tsx b/apps/web/src/components/layout/version/header.tsx new file mode 100644 index 000000000..b451ff1f3 --- /dev/null +++ b/apps/web/src/components/layout/version/header.tsx @@ -0,0 +1,43 @@ +import { Link } from "@tanstack/react-router"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +interface VersionHeaderProps { + version: string; + title: string; +} + +export function VersionHeader({ version, title }: VersionHeaderProps) { + return ( +
+ + + + Home} /> + + + + + Unicode + {" "} + {version} + + )} + /> + + + + {title} + + + +
+ ); +} diff --git a/apps/web/src/components/nav.tsx b/apps/web/src/components/nav.tsx deleted file mode 100644 index c75738bd8..000000000 --- a/apps/web/src/components/nav.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type { LucideIcon } from "lucide-react"; -import { Link } from "@tanstack/react-router"; -import { - ChevronRight, -} from "lucide-react"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "./ui/collapsible"; -import { - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, -} from "./ui/sidebar"; - -export interface NavItemProps { - title: string; - url: string; - icon?: LucideIcon; - isActive?: boolean; - items?: { - title: string; - url: string; - }[]; -} - -export function NavItem({ item }: { item: NavItemProps }) { - return (item.items == null || !item.items.length) - ? ( - - { - return ( - - {item.icon && } - {item.title} - - ); - }} - /> - - ) - : ( - - - - {item.icon && } - {item.title} - - - )} - /> - - - {item.items?.map((subItem) => ( - - - {subItem.title} - - )} - /> - - ))} - - - - - ); -} diff --git a/apps/web/src/components/not-found.tsx b/apps/web/src/components/not-found.tsx new file mode 100644 index 000000000..2ab52d46c --- /dev/null +++ b/apps/web/src/components/not-found.tsx @@ -0,0 +1,108 @@ +import { Link } from "@tanstack/react-router"; +import { AlertCircle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +interface NotFoundLayoutProps { + title: string; + description: string; + hint?: string; + actions?: React.ReactNode; +} + +export function NotFoundLayout({ title, description, hint, actions }: NotFoundLayoutProps) { + return ( +
+
+
+ + Page not found +
+
+

{title}

+

{description}

+
+ {hint + ? ( +
+ {hint} +
+ ) + : null} + {actions + ? ( +
{actions}
+ ) + : null} +
+
+ ); +} + +export function AppNotFound() { + return ( + +