diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a952b56b..0c186744 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -25,6 +25,18 @@ jobs: require-lockfile: true - name: Build Packages run: pnpm build + # Any changes after regenerating each package from the schema package mean the generated files weren't + # regenerated and committed alongside a schema change + - name: Generate Packages + run: pnpm generate + - name: Check for Uncommitted Changes + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "::error::Generated files are out of date; run \`pnpm generate\` and commit the changes." + git status --short + git --no-pager diff + exit 1 + fi - name: Lint Code run: pnpm lint - name: Run Unit Tests diff --git a/package.json b/package.json index 9b81ef89..8f71b5c1 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "check": "turbo run check", "format": "oxfmt --config oxfmt.config.ts --write .", "format:check": "oxfmt --config oxfmt.config.ts --check .", + "generate": "turbo run generate", "lint": "oxlint --config oxlint.config.ts .", "lint:fix": "oxlint --config oxlint.config.ts --fix .", "test": "vitest run" @@ -17,6 +18,7 @@ "devDependencies": { "@arethetypeswrong/core": "0.18.5", "@bachman-dev/oxc-config": "0.3.0", + "@types/node": "24.19.2", "@vitest/coverage-v8": "5.0.3", "oxfmt": "0.72.0", "oxlint": "1.87.0", diff --git a/packages/requests/vitest.config.ts b/packages/requests/vitest.config.ts index 098c8cae..5f47a95a 100644 --- a/packages/requests/vitest.config.ts +++ b/packages/requests/vitest.config.ts @@ -8,5 +8,6 @@ export default defineConfig({ enabled: true, }, pool: "threads", + isolate: false, }, }); diff --git a/packages/schema/README.md b/packages/schema/README.md new file mode 100644 index 00000000..715b1ee2 --- /dev/null +++ b/packages/schema/README.md @@ -0,0 +1,25 @@ +# @bachman-dev/wanikani-api-schema + +The internal, single source of truth for the WaniKani API schema. The type definitions in `@bachman-dev/wanikani-api-types` and the runtime schema in `@bachman-dev/wanikani-api-valibot` and `@bachman-dev/wanikani-api-zod` are generated from it; their `src/` files (other than `lang/` folders) shouldn't be edited by hand. + +## Layout + +- `src/core/` (`@bachman-dev/wanikani-api-schema/core`): the building blocks for defining schema (`define`, `object`, `property`, `union`, ...), along with helpers for writing generators (docs rendering, import tracking, writing and formatting files). +- `src/v20170710/` (`@bachman-dev/wanikani-api-schema/v20170710`): the definitions of the `20170710` API revision, one file per generated module, plus the revision's custom error `messages`. +- `src/index.ts`: the latest revision, along with `revisions`, every revision a generator should generate. + +## Changing the Schema + +1. Edit the definitions in `src//`. Docs given to a definition or property are printed above it in every generated package. +2. Run `pnpm generate` from the repository root; this builds the schema package, then runs each package's `scripts/generate.ts`. +3. Commit the regenerated files along with the schema change. + +CI runs `pnpm generate` and fails if it changes any files, so generated files can't fall out of date. + +## Adding a Schema Library + +1. Create the package, following the layout of the existing ones (`src/index.ts` exporting the latest revision from `src/v20170710/`). +2. Add `@bachman-dev/wanikani-api-schema` as a dev dependency, and a `generate` script running `node scripts/generate.ts`. +3. Write `scripts/generate.ts` (see `packages/valibot/scripts/generate.ts`), rendering every kind of `Schema` and passing the output to `generatePackageFiles()` and `writeGeneratedFiles()`. +4. Implement the revision's `messages` in `src//lang/`, exported under the same names. +5. Write tests similar to those of the existing schema libraries. diff --git a/packages/schema/package.json b/packages/schema/package.json new file mode 100644 index 00000000..4826694e --- /dev/null +++ b/packages/schema/package.json @@ -0,0 +1,28 @@ +{ + "name": "@bachman-dev/wanikani-api-schema", + "version": "0.0.0", + "private": true, + "description": "The base WaniKani API schema used to generate types and runtime schema definitions", + "homepage": "https://wanikani-api-types.bachman.dev", + "license": "MIT", + "author": "Collin Bachman ", + "repository": { + "type": "git", + "url": "https://github.com/bachman-dev/wanikani-api-types.git", + "directory": "packages/schema" + }, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": "./dist/index.mjs", + "./core": "./dist/core/index.mjs", + "./v20170710": "./dist/v20170710/index.mjs", + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsdown --config tsdown.config.ts", + "check": "tsc --noEmit" + } +} diff --git a/packages/schema/src/core/analysis.ts b/packages/schema/src/core/analysis.ts new file mode 100644 index 00000000..7730c3cc --- /dev/null +++ b/packages/schema/src/core/analysis.ts @@ -0,0 +1,314 @@ +import type { Module } from "./revision.js"; +import type { + AliasDefinition, + ConstantDefinition, + Definition, + NumberBound, + NumberSchema, + ObjectDefinition, + Property, + Schema, + SchemaDefinition, +} from "./schema.js"; + +/** The checks a number schema applies. */ +interface NumberConstraints { + integer: boolean; + max?: NumberBound; + min?: NumberBound; +} + +function inheritedConstraints(schema: NumberSchema): NumberConstraints { + const inherited = schema.base === undefined ? { integer: false } : inheritedConstraints(schema.base.schema); + const constraints: NumberConstraints = { integer: inherited.integer || schema.integer === true }; + const min = schema.min ?? inherited.min; + const max = schema.max ?? inherited.max; + if (min !== undefined) { + constraints.min = min; + } + if (max !== undefined) { + constraints.max = max; + } + return constraints; +} + +/** A definition referenced by a schema. */ +export type Reference = ConstantDefinition | Definition; + +/** + * Calls the visitor for each schema nested in the given schema, including itself, without following references. + * + * @param schema The schema to walk + * @param visit Called for each schema + */ +export function walkSchema(schema: Schema, visit: (schema: Schema) => void): void { + visit(schema); + switch (schema.kind) { + case "array": + walkSchema(schema.item, visit); + break; + case "nullable": + walkSchema(schema.wrapped, visit); + break; + case "tuple": + for (const item of schema.items) { + walkSchema(item.schema, visit); + } + if (schema.rest !== undefined) { + walkSchema(schema.rest.schema, visit); + } + break; + case "object": + for (const property of Object.values(schema.entries)) { + walkSchema(property.schema, visit); + } + break; + case "intersect": + case "union": + case "variant": + for (const option of schema.options) { + walkSchema(option, visit); + } + break; + default: + break; + } +} + +/** + * Lists the definitions directly referenced by a definition, in the order they're encountered. + * + * @param definition The definition + * @returns The referenced definitions + */ +export function referencesOf(definition: Definition): Reference[] { + const references = new Set(); + if (definition.kind === "alias") { + references.add(definition.target); + } else if (definition.kind === "constant") { + if (definition.type !== undefined) { + references.add(definition.type); + } + } else { + walkSchema(definition.schema, (schema) => { + switch (schema.kind) { + case "alias": + case "definition": + references.add(schema); + break; + case "number": + for (const reference of [schema.base, schema.min, schema.max]) { + if (typeof reference === "object") { + references.add(reference); + } + } + break; + case "object": + for (const parent of schema.extends) { + references.add(parent); + } + break; + case "partial": + references.add(schema.source); + break; + default: + break; + } + }); + } + return [...references]; +} + +/** + * Lists the message keys used by a definition. + * + * @param definition The definition + * @returns The message keys + */ +export function messagesOf(definition: Definition): string[] { + const messages: string[] = []; + if (definition.kind === "definition") { + walkSchema(definition.schema, (schema) => { + if ((schema.kind === "union" || schema.kind === "intersect") && schema.message !== undefined) { + messages.push(schema.message); + } else if (schema.kind === "tuple" && schema.unique?.message !== undefined) { + messages.push(schema.unique.message); + } + }); + } + return messages; +} + +/** + * Follows an alias to the definition it names. + * + * @param definition A schema or alias definition + * @returns The schema definition, or the aliased definition + */ +export function resolveAlias(definition: AliasDefinition | SchemaDefinition): SchemaDefinition { + return definition.kind === "alias" ? definition.target : definition; +} + +/** + * Lists all entries of an object-like definition, including inherited ones, in the order they're printed. + * + * @param definition An object or partial object definition + * @returns The entries; entries of a partial definition are all optional + */ +export function objectEntries(definition: ObjectDefinition): [key: string, property: Property][] { + const { schema } = definition; + if (schema.kind === "partial") { + return objectEntries(schema.source).map(([key, property]) => [key, { ...property, optional: true }]); + } + const entries = new Map(); + for (const parent of schema.extends) { + for (const [key, property] of objectEntries(parent)) { + entries.set(key, property); + } + } + for (const [key, property] of Object.entries(schema.entries)) { + entries.set(key, property); + } + return [...entries]; +} + +/** + * Gets the numeric value of a number bound. + * + * @param bound A number or constant definition + * @returns The bound's value + */ +export function boundValue(bound: NumberBound): number { + return typeof bound === "number" ? bound : bound.value; +} + +/** + * Determines which checks a number schema needs on top of its base; e.g. a minimum of `0` isn't repeated when the base + * already requires it, which would otherwise report the same issue twice. + * + * @param schema A number schema + * @returns The checks to apply, in addition to those of the schema's base + */ +export function numberChecks(schema: NumberSchema): NumberConstraints { + const base = schema.base === undefined ? { integer: false } : inheritedConstraints(schema.base.schema); + const checks: NumberConstraints = { integer: schema.integer === true && !base.integer }; + if (schema.min !== undefined && (base.min === undefined || boundValue(schema.min) > boundValue(base.min))) { + checks.min = schema.min; + } + if (schema.max !== undefined && (base.max === undefined || boundValue(schema.max) < boundValue(base.max))) { + checks.max = schema.max; + } + return checks; +} + +/** + * Collects the categories of a definition: its module's category, along with any of its own, sorted alphabetically. + * + * @param definition The definition + * @param module The module containing the definition + * @returns The categories + */ +export function categoriesOf(definition: Definition, module: Module): string[] { + return [...new Set([module.category, ...(definition.docs.categories ?? [])])].toSorted(); +} + +/** Lookup helpers for a revision, shared by generators. */ +export interface RevisionIndex { + /** Finds the module containing a definition, if it's part of the revision. */ + findModule: (definition: Reference) => Module | undefined; + + /** + * Finds the module containing a definition. + * + * @throws {Error} If the definition isn't part of the revision + */ + moduleOf: (definition: Reference) => Module; +} + +/** + * Indexes a revision's definitions. + * + * @param revision The revision + * @returns Lookup helpers + */ +export function indexRevision(revision: { readonly modules: readonly Module[] }): RevisionIndex { + const modules = new Map(); + for (const module of revision.modules) { + for (const definition of module.definitions) { + modules.set(definition, module); + } + } + return { + findModule: (definition) => modules.get(definition), + moduleOf: (definition) => { + const module = modules.get(definition); + if (module === undefined) { + throw new Error(`${definition.name} isn't part of any module in the revision`); + } + return module; + }, + }; +} + +/** + * Checks whether any object entry in a module is documented as deprecated; referencing such entries in generated code + * needs the `typescript/no-deprecated` lint rule disabled. + * + * @param module The module + * @returns Whether the module has deprecated entries + */ +export function hasDeprecatedEntries(module: Module): boolean { + const deprecated: string[] = []; + const visit = (schema: Schema): void => { + if (schema.kind === "object") { + for (const [key, property] of Object.entries(schema.entries)) { + if (property.docs?.deprecated !== undefined) { + deprecated.push(key); + } + } + } + }; + for (const definition of module.definitions) { + if (definition.kind === "definition") { + walkSchema(definition.schema, visit); + } + } + return deprecated.length > 0; +} + +/** + * Finds a cycle in a dependency graph. + * + * @param graph Each node's dependencies; dependencies without an entry of their own have none + * @returns The nodes of the first cycle found, starting and ending with the same node, or `undefined` if there's none + */ +export function findCycle(graph: ReadonlyMap>): string[] | undefined { + const visiting: string[] = []; + const visited = new Set(); + const visit = (node: string): string[] | undefined => { + const index = visiting.indexOf(node); + if (index !== -1) { + return [...visiting.slice(index), node]; + } + if (visited.has(node)) { + return undefined; + } + visiting.push(node); + for (const next of graph.get(node) ?? []) { + const cycle = visit(next); + if (cycle !== undefined) { + return cycle; + } + } + visiting.pop(); + visited.add(node); + return undefined; + }; + for (const node of graph.keys()) { + const cycle = visit(node); + if (cycle !== undefined) { + return cycle; + } + } + return undefined; +} diff --git a/packages/schema/src/core/codegen.ts b/packages/schema/src/core/codegen.ts new file mode 100644 index 00000000..515048c6 --- /dev/null +++ b/packages/schema/src/core/codegen.ts @@ -0,0 +1,232 @@ +import * as fs from "node:fs/promises"; +import path from "node:path"; + +import type { RevisionIndex } from "./analysis.js"; +import type { Module, Revision } from "./revision.js"; +import type { ConstantDefinition, Definition } from "./schema.js"; + +/** Formats a file's contents. */ +type Formatter = (filePath: string, contents: string) => Promise; + +/** The most times a file is formatted while waiting for the output to settle. */ +const MAX_FORMAT_PASSES = 5; + +/* +N.b. Generated code isn't indented, so the first formatting pass wraps nested comments as if they started in the first +column; formatting again wraps them at their final indentation. +*/ +async function formatUntilStable(filePath: string, contents: string, format: Formatter): Promise { + let current = contents; + for (let pass = 0; pass < MAX_FORMAT_PASSES; pass += 1) { + // oxlint-disable-next-line no-await-in-loop -- Each pass formats the output of the previous one + const formatted = await format(filePath, current); + if (formatted === current) { + return formatted; + } + current = formatted; + } + throw new Error(`Formatting ${filePath} didn't settle after ${MAX_FORMAT_PASSES} passes`); +} + +/** The first line of every generated file; files starting with it may be overwritten or removed by generators. */ +export const GENERATED_HEADER = + "// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand."; + +/** A file to be generated. */ +export interface GeneratedFile { + /** The file path, relative to the package root, e.g. `src/v20170710/assignments.ts`. */ + readonly path: string; + + /** The file's contents, without the generated header. */ + readonly contents: string; +} + +export interface WriteOptions { + /** The absolute path of the package root. */ + readonly root: string; + + /** + * Formats a file's contents before it's written. + * + * The formatter receives the absolute path of the file and its contents, and returns the formatted contents. + */ + readonly format: Formatter; +} + +/** + * Writes generated files, then removes previously generated files from the same folders that weren't generated this + * time. Hand-written files, i.e. those without the generated header, are never removed. + * + * @param files The files to write + * @param options Where and how to write the files + * @returns The paths of the written and removed files, relative to the package root + */ +export async function writeGeneratedFiles( + files: readonly GeneratedFile[], + options: WriteOptions, +): Promise<{ removed: string[]; written: string[] }> { + const written = new Set(); + const folders = new Set(); + + for (const file of files) { + const filePath = path.join(options.root, file.path); + // oxlint-disable-next-line no-await-in-loop -- Writing files one at a time keeps the output deterministic + const contents = await formatUntilStable(filePath, `${GENERATED_HEADER}\n\n${file.contents}`, options.format); + // oxlint-disable-next-line no-await-in-loop -- See above + await fs.mkdir(path.dirname(filePath), { recursive: true }); + // oxlint-disable-next-line no-await-in-loop -- See above + await fs.writeFile(filePath, contents); + written.add(filePath); + folders.add(path.dirname(filePath)); + } + + const removed: string[] = []; + for (const folder of folders) { + // oxlint-disable-next-line no-await-in-loop -- See above + const entries = await fs.readdir(folder, { withFileTypes: true }); + for (const entry of entries) { + const filePath = path.join(folder, entry.name); + if (entry.isFile() && !written.has(filePath)) { + // oxlint-disable-next-line no-await-in-loop -- See above + const contents = await fs.readFile(filePath, "utf8"); + if (contents.startsWith(GENERATED_HEADER)) { + // oxlint-disable-next-line no-await-in-loop -- See above + await fs.rm(filePath); + removed.push(path.relative(options.root, filePath)); + } + } + } + } + + return { removed, written: [...written].map((filePath) => path.relative(options.root, filePath)) }; +} + +/** Tracks the definitions a generated module uses, so only those from other modules are imported. */ +export interface ModuleScope { + /** The module being generated. */ + readonly module: Module; + + /** + * Records that a definition is used by the module being generated. + * + * @param definition The used definition + * @returns The definition's name + */ + use: (definition: ConstantDefinition | Definition) => string; + + /** + * Lists the used definitions from other modules. + * + * @returns The names of the used definitions, grouped by their module + */ + imports: () => Map; +} + +/** + * Creates a scope for generating a module. + * + * @param module The module being generated + * @param index Lookup helpers for the module's revision + * @returns The module's scope + */ +export function createModuleScope(module: Module, index: RevisionIndex): ModuleScope { + const used = new Map>(); + return { + module, + use: (definition) => { + const definitionModule = index.moduleOf(definition); + if (definitionModule !== module) { + const names = used.get(definitionModule) ?? new Set(); + names.add(definition.name); + used.set(definitionModule, names); + } + return definition.name; + }, + imports: () => new Map([...used].map(([key, names]) => [key, [...names].toSorted()])), + }; +} + +/** + * Renders a named import statement; the formatter takes care of sorting. + * + * @param names The imported names + * @param from The module specifier + * @param options Whether to render a type-only import + * @returns The import statement, or an empty string if there are no names + */ +export function renderImport(names: Iterable, from: string, options: { type?: boolean } = {}): string { + const sorted = [...new Set(names)].toSorted(); + if (sorted.length === 0) { + return ""; + } + return `import ${options.type === true ? "type " : ""}{ ${sorted.join(", ")} } from "${from}";`; +} + +/** + * Joins blocks of code with an empty line between them, skipping empty blocks. + * + * @param blocks The blocks of code + * @returns The joined code, ending with a newline + */ +export function joinBlocks(blocks: Iterable): string { + return `${[...blocks].filter((block) => block !== "").join("\n\n")}\n`; +} + +/** + * Renders a value as a JavaScript string or number literal. + * + * @param value The value + * @returns The literal + */ +export function renderLiteral(value: boolean | number | string): string { + return JSON.stringify(value); +} + +/** + * Renders an object key, quoting it if it isn't a valid identifier. + * + * @param key The key + * @returns The key as it should appear in an object literal or type + */ +export function renderKey(key: string): string { + return /^[A-Za-z_$][\w$]*$/v.test(key) ? key : renderLiteral(key); +} + +/** Generates the source files of a package from the schema. */ +export interface PackageGenerator { + /** + * Generates the file for a module of a revision, e.g. `src/v20170710/assignments.ts`. + * + * The generator receives the module, and the revision it's part of. + */ + readonly module: (module: Module, revision: Revision) => string; + + /** Generates the entrypoint of a revision, e.g. `src/v20170710/index.ts`. */ + readonly revisionIndex: (revision: Revision) => string; + + /** Generates the package entrypoint, `src/index.ts`, which should export the given (latest) revision. */ + readonly rootIndex: (latestRevision: Revision) => string; +} + +/** + * Generates the source files of a package for every revision. + * + * @param revisions Every supported revision, oldest first + * @param generator Generates each file's contents + * @returns The files to write + */ +export function generatePackageFiles(revisions: readonly Revision[], generator: PackageGenerator): GeneratedFile[] { + const files: GeneratedFile[] = []; + for (const revision of revisions) { + for (const module of revision.modules) { + files.push({ path: `src/${revision.folder}/${module.name}.ts`, contents: generator.module(module, revision) }); + } + files.push({ path: `src/${revision.folder}/index.ts`, contents: generator.revisionIndex(revision) }); + } + const latestRevision = revisions.at(-1); + if (latestRevision === undefined) { + throw new Error("There are no revisions to generate"); + } + files.push({ path: "src/index.ts", contents: generator.rootIndex(latestRevision) }); + return files; +} diff --git a/packages/schema/src/core/docs.ts b/packages/schema/src/core/docs.ts new file mode 100644 index 00000000..a7e5a429 --- /dev/null +++ b/packages/schema/src/core/docs.ts @@ -0,0 +1,56 @@ +import type { Docs } from "./schema.js"; +import { textLines } from "./text.js"; + +function prefixFirstLine(prefix: string, lines: string[]): string[] { + return lines.map((line, index) => (index === 0 ? `${prefix}${line}` : line)); +} + +export interface JsDocOptions { + /** `@category` tags to print; for definitions, see {@link categoriesOf}. */ + readonly categories?: readonly string[]; + + /** Extra tags to print after all others, e.g. `@param value An unknown value`. */ + readonly tags?: readonly string[]; +} + +/** + * Renders docs as a JSDoc comment, without wrapping lines or indentation; the generated code is expected to be run + * through the formatter. + * + * @param docs The docs to render + * @param options Additional tags to render + * @returns The JSDoc comment, or an empty string if there's nothing to render + */ +export function renderJsDoc(docs: Docs | undefined, options: JsDocOptions = {}): string { + const lines: string[] = docs?.description === undefined ? [] : textLines(docs.description); + const tags: string[] = []; + + if (docs?.deprecated !== undefined) { + tags.push(...prefixFirstLine("@deprecated ", textLines(docs.deprecated))); + } + if (docs?.remarks !== undefined) { + tags.push(...prefixFirstLine("@remarks ", textLines(docs.remarks))); + } + for (const category of options.categories ?? []) { + tags.push(`@category ${category}`); + } + for (const url of docs?.see ?? []) { + tags.push(`@see {@link ${url}}`); + } + tags.push(...(options.tags ?? [])); + + if (tags.length > 0) { + if (lines.length > 0) { + lines.push(""); + } + lines.push(...tags); + } + + if (lines.length === 0) { + return ""; + } + if (lines.length === 1) { + return `/** ${lines[0]} */`; + } + return ["/**", ...lines.map((line) => (line === "" ? " *" : ` * ${line}`)), " */"].join("\n"); +} diff --git a/packages/schema/src/core/index.ts b/packages/schema/src/core/index.ts new file mode 100644 index 00000000..02b199da --- /dev/null +++ b/packages/schema/src/core/index.ts @@ -0,0 +1,13 @@ +// oxlint-disable oxc/no-barrel-file -- This is the entrypoint for building schema and generators +/** + * The building blocks for defining the WaniKani API schema, and helpers for writing generators that turn it into type + * definitions or runtime schema. + */ + +export * from "./analysis.js"; +export * from "./codegen.js"; +export * from "./docs.js"; +export * from "./revision.js"; +export * from "./schema.js"; +export * from "./text.js"; +export { default as validateRevision } from "./validate.js"; diff --git a/packages/schema/src/core/revision.ts b/packages/schema/src/core/revision.ts new file mode 100644 index 00000000..59eebf09 --- /dev/null +++ b/packages/schema/src/core/revision.ts @@ -0,0 +1,60 @@ +import type { Definition } from "./schema.js"; +import validateRevision from "./validate.js"; + +/** A group of definitions generated into one file, e.g. `assignments.ts`. */ +export interface Module { + /** The file name, without an extension. */ + readonly name: string; + + /** The documentation category of every definition in the module. */ + readonly category: string; + + /** The definitions, in the order they're generated; a definition must come after any it references. */ + readonly definitions: readonly Definition[]; +} + +/** A WaniKani API revision, generated into its own folder, e.g. `v20170710`. */ +export interface Revision { + /** The WaniKani API revision, e.g. `20170710`. */ + readonly revision: string; + + /** The folder name of the revision, e.g. `v20170710`. */ + readonly folder: string; + + readonly modules: readonly Module[]; + + /** + * Custom error messages used by schemas, keyed by name, with their default (English) text. Runtime schema libraries + * implement each message in their `lang` folder, exported under the same name. + */ + readonly messages: Readonly>; +} + +/** + * Defines a module of an API revision. + * + * @param name The file name, without an extension + * @param category The documentation category of every definition in the module + * @param definitions The definitions, in the order they're generated + * @returns The module + */ +export function defineModule(name: string, category: string, definitions: readonly Definition[]): Module { + return { name, category, definitions }; +} + +/** + * Defines an API revision, validating that its definitions can be generated. + * + * @param revision The revision's modules and messages + * @returns The revision + * @throws {Error} If the revision's definitions are invalid + */ +export function defineRevision(revision: Omit): Revision { + const defined: Revision = { ...revision, folder: `v${revision.revision}` }; + const problems = validateRevision(defined); + if (problems.length > 0) { + const list = problems.map((problem) => `- ${problem}`).join("\n"); + throw new Error(`Invalid schema for revision ${revision.revision}:\n${list}`); + } + return defined; +} diff --git a/packages/schema/src/core/schema.ts b/packages/schema/src/core/schema.ts new file mode 100644 index 00000000..e49547ce --- /dev/null +++ b/packages/schema/src/core/schema.ts @@ -0,0 +1,356 @@ +import type { Text } from "./text.js"; + +function isText(docs: DocsInput): docs is Text { + return typeof docs === "string" || Array.isArray(docs); +} + +function toDocs(docs: DocsInput): Docs { + return isText(docs) ? { description: docs } : docs; +} + +/** Documentation attached to a definition or an object property. */ +export interface Docs { + /** The main description, printed first. */ + readonly description?: Text; + + /** Printed as a `@deprecated` tag. */ + readonly deprecated?: Text; + + /** Printed as a `@remarks` tag. */ + readonly remarks?: Text; + + /** + * Printed as `@category` tags, alongside the category of the module containing the definition. Only used for + * definitions. + */ + readonly categories?: readonly string[]; + + /** URLs printed as `@see` tags. */ + readonly see?: readonly string[]; +} + +/** Docs, or the text for a description-only set of docs. */ +export type DocsInput = Docs | Text; + +/** A literal value, e.g. the `object` key of a resource. */ +export interface LiteralSchema { + readonly kind: "literal"; + readonly value: boolean | number | string; +} + +/** A string, optionally validated against a format. */ +export interface StringSchema { + readonly kind: "string"; + + /** + * When set, the string is branded with the name of its definition so that a plain `string` can't be mistaken for it; + * only allowed for a definition's top-level schema, and should be paired with a `format`. + */ + readonly brand?: boolean; + + /** A format the string is validated against. */ + readonly format?: "iso-timestamp"; + + /** Whether surrounding whitespace is trimmed before validating. */ + readonly trim?: boolean; +} + +/** A bound for a number; a constant definition is printed by name when the target supports it. */ +export type NumberBound = ConstantDefinition | number; + +/** A number, optionally validated as a safe integer within bounds. */ +export interface NumberSchema { + readonly kind: "number"; + + /** A number definition this one extends, inheriting its validation. */ + readonly base?: SchemaDefinition; + + /** Whether the number must be a safe integer. */ + readonly integer?: boolean; + + /** The inclusive maximum value. */ + readonly max?: NumberBound; + + /** The inclusive minimum value. */ + readonly min?: NumberBound; +} + +export interface BooleanSchema { + readonly kind: "boolean"; +} + +/** A JavaScript `Date` object. */ +export interface DateSchema { + readonly kind: "date"; +} + +/** A value that may never be present; used for mutually exclusive optional properties. */ +export interface NeverSchema { + readonly kind: "never"; +} + +/** One of a list of string values. */ +export interface PicklistSchema { + readonly kind: "picklist"; + readonly options: readonly [string, ...string[]]; +} + +export interface ArraySchema { + readonly kind: "array"; + readonly item: Schema; +} + +/** A labelled tuple element. */ +export interface TupleItem { + readonly name: string; + readonly schema: Schema; +} + +/** A tuple of fixed items, optionally followed by any number of rest items. */ +export interface TupleSchema { + readonly kind: "tuple"; + readonly items: readonly TupleItem[]; + readonly rest?: TupleItem; + + /** When set, all items of the tuple must be unique. */ + readonly unique?: { readonly message?: string }; +} + +/** An entry of an object schema. */ +export interface Property { + readonly docs?: Docs; + readonly optional: boolean; + readonly schema: Schema; +} + +/** An object with known entries, optionally extending other object definitions. */ +export interface ObjectSchema { + readonly kind: "object"; + readonly entries: Readonly>; + readonly extends: readonly ObjectDefinition[]; +} + +/** An object with every entry of another object definition made optional. */ +export interface PartialSchema { + readonly kind: "partial"; + readonly source: ObjectDefinition; +} + +export interface NullableSchema { + readonly kind: "nullable"; + readonly wrapped: Schema; +} + +export interface UnionSchema { + readonly kind: "union"; + readonly options: readonly [Schema, Schema, ...Schema[]]; + + /** The key of a custom message used when no option matches. */ + readonly message?: string; +} + +/** A discriminated union of objects, keyed by a property with a literal value in each option. */ +export interface VariantSchema { + readonly kind: "variant"; + readonly key: string; + readonly options: readonly [ObjectSchema, ObjectSchema, ...ObjectSchema[]]; +} + +export interface IntersectSchema { + readonly kind: "intersect"; + readonly options: readonly [Schema, Schema, ...Schema[]]; + + /** The key of a custom message used when the options can't be merged, if the target supports it. */ + readonly message?: string; +} + +/** A schema that can be printed inline. */ +export type InlineSchema = + | ArraySchema + | BooleanSchema + | DateSchema + | IntersectSchema + | LiteralSchema + | NeverSchema + | NullableSchema + | NumberSchema + | ObjectSchema + | PartialSchema + | PicklistSchema + | StringSchema + | TupleSchema + | UnionSchema + | VariantSchema; + +/** Any schema; definitions are referenced by name. */ +export type Schema = AliasDefinition | InlineSchema | SchemaDefinition; + +/** A named, exported schema. */ +export interface SchemaDefinition { + readonly kind: "definition"; + readonly name: string; + readonly schema: S; + readonly docs: Docs; + + /** Whether runtime schema libraries should export an `is` type guard for this definition. */ + readonly typeGuard: boolean; +} + +/** A definition whose schema is object-like, i.e. can be extended or made partial. */ +export type ObjectDefinition = SchemaDefinition; + +/** A named, exported constant value. */ +export interface ConstantDefinition { + readonly kind: "constant"; + readonly name: string; + readonly value: T; + readonly docs: Docs; + + /** A definition used to annotate the constant's type. */ + readonly type?: SchemaDefinition; +} + +/** Another name for an existing definition. */ +export interface AliasDefinition { + readonly kind: "alias"; + readonly name: string; + readonly target: SchemaDefinition; + readonly docs: Docs; +} + +export type Definition = AliasDefinition | ConstantDefinition | SchemaDefinition; + +export function literal(value: LiteralSchema["value"]): LiteralSchema { + return { kind: "literal", value }; +} + +export function string(options: Omit = {}): StringSchema { + return { kind: "string", ...options }; +} + +export function number(options: Omit = {}): NumberSchema { + return { kind: "number", ...options }; +} + +export function boolean(): BooleanSchema { + return { kind: "boolean" }; +} + +export function date(): DateSchema { + return { kind: "date" }; +} + +export function never(): NeverSchema { + return { kind: "never" }; +} + +export function picklist(options: PicklistSchema["options"]): PicklistSchema { + return { kind: "picklist", options }; +} + +export function array(item: Schema): ArraySchema { + return { kind: "array", item }; +} + +export function tuple(items: readonly TupleItem[], options: Omit = {}): TupleSchema { + return { kind: "tuple", items, ...options }; +} + +export function nullable(wrapped: Schema): NullableSchema { + return { kind: "nullable", wrapped }; +} + +export function union(options: UnionSchema["options"], settings: { message?: string } = {}): UnionSchema { + return { kind: "union", options, ...settings }; +} + +export function variant(key: string, options: VariantSchema["options"]): VariantSchema { + return { kind: "variant", key, options }; +} + +export function intersect(options: IntersectSchema["options"], settings: { message?: string } = {}): IntersectSchema { + return { kind: "intersect", options, ...settings }; +} + +export function object( + entries: Readonly>, + options: { extends?: readonly ObjectDefinition[] } = {}, +): ObjectSchema { + return { kind: "object", entries, extends: options.extends ?? [] }; +} + +export function partial(source: ObjectDefinition): PartialSchema { + return { kind: "partial", source }; +} + +/** + * A required object entry. + * + * @param schema The entry's schema + * @param docs Documentation printed above the entry + * @returns An object entry + */ +export function property(schema: Schema, docs?: DocsInput): Property { + return docs === undefined ? { schema, optional: false } : { schema, optional: false, docs: toDocs(docs) }; +} + +/** + * An optional object entry; when present, it may not be `undefined` unless its schema allows it. + * + * @param schema The entry's schema + * @param docs Documentation printed above the entry + * @returns An object entry + */ +export function optionalProperty(schema: Schema, docs?: DocsInput): Property { + return docs === undefined ? { schema, optional: true } : { schema, optional: true, docs: toDocs(docs) }; +} + +/** + * Defines a named, exported schema. + * + * @param name The exported name + * @param schema The schema + * @param docs Documentation printed above the export + * @param options Generation options + * @returns A schema definition, which can be referenced by other schemas + */ +export function define( + name: string, + schema: S, + docs: DocsInput, + options: { typeGuard?: boolean } = {}, +): SchemaDefinition { + return { kind: "definition", name, schema, docs: toDocs(docs), typeGuard: options.typeGuard ?? false }; +} + +/** + * Defines a named, exported constant. + * + * @param name The exported name + * @param value The constant's value + * @param docs Documentation printed above the export + * @param options Generation options + * @returns A constant definition + */ +export function constant( + name: string, + value: T, + docs: DocsInput, + options: { type?: SchemaDefinition } = {}, +): ConstantDefinition { + return options.type === undefined + ? { kind: "constant", name, value, docs: toDocs(docs) } + : { kind: "constant", name, value, docs: toDocs(docs), type: options.type }; +} + +/** + * Defines another exported name for an existing schema definition. + * + * @param name The exported name + * @param target The aliased definition + * @param docs Documentation printed above the export + * @returns An alias definition + */ +export function alias(name: string, target: SchemaDefinition, docs: DocsInput): AliasDefinition { + return { kind: "alias", name, target, docs: toDocs(docs) }; +} diff --git a/packages/schema/src/core/text.ts b/packages/schema/src/core/text.ts new file mode 100644 index 00000000..7f99609f --- /dev/null +++ b/packages/schema/src/core/text.ts @@ -0,0 +1,30 @@ +function isListItem(line: string): boolean { + return line.startsWith("- "); +} + +/** + * Documentation text. A plain string is a single paragraph; an array holds several paragraphs, where consecutive list + * items (lines starting with `- `) are kept together as one list. + * + * Text isn't wrapped here; the generated code is run through the formatter, which wraps comments to the print width. + */ +export type Text = string | readonly string[]; + +/** + * Joins documentation text into lines, separating paragraphs with an empty line. + * + * @param text The text to join + * @returns The lines of text + */ +export function textLines(text: Text): string[] { + const paragraphs = typeof text === "string" ? [text] : text; + const lines: string[] = []; + for (const [index, paragraph] of paragraphs.entries()) { + const previous = paragraphs[index - 1]; + if (previous !== undefined && !(isListItem(previous) && isListItem(paragraph))) { + lines.push(""); + } + lines.push(paragraph); + } + return lines; +} diff --git a/packages/schema/src/core/validate.ts b/packages/schema/src/core/validate.ts new file mode 100644 index 00000000..b484c8ee --- /dev/null +++ b/packages/schema/src/core/validate.ts @@ -0,0 +1,71 @@ +import { findCycle, indexRevision, messagesOf, referencesOf, walkSchema } from "./analysis.js"; +import type { Module, Revision } from "./revision.js"; +import type { Definition } from "./schema.js"; + +function validateSchema(definition: Definition, problems: string[]): void { + if (definition.kind !== "definition") { + return; + } + walkSchema(definition.schema, (schema) => { + if (schema.kind === "string" && schema.brand === true && schema !== definition.schema) { + problems.push(`${definition.name} has a branded string that isn't its top-level schema`); + } + if (schema.kind === "variant") { + for (const option of schema.options) { + if (option.entries[schema.key]?.schema.kind !== "literal") { + problems.push(`${definition.name} has a variant option without a literal "${schema.key}" entry`); + } + } + } + }); +} + +/** + * Checks that a revision's definitions can be generated: names are unique, references are part of the revision and + * defined before use, modules don't depend on each other in a cycle, and messages exist. + * + * @param revision The revision + * @returns A list of problems; empty if the revision is valid + */ +export default function validateRevision(revision: Pick): string[] { + const problems: string[] = []; + const index = indexRevision(revision); + const names = new Set(); + const moduleGraph = new Map>(); + + for (const module of revision.modules) { + const dependencies = new Set(); + moduleGraph.set(module.name, dependencies); + for (const [position, definition] of module.definitions.entries()) { + if (names.has(definition.name)) { + problems.push(`${definition.name} is defined more than once`); + } + names.add(definition.name); + validateSchema(definition, problems); + + for (const message of messagesOf(definition)) { + if (!Object.hasOwn(revision.messages, message)) { + problems.push(`${definition.name} uses the message "${message}", which isn't in the revision's messages`); + } + } + + for (const reference of referencesOf(definition)) { + const referencedModule: Module | undefined = index.findModule(reference); + if (referencedModule === undefined) { + problems.push(`${definition.name} references ${reference.name}, which isn't part of any module`); + } else if (referencedModule !== module) { + dependencies.add(referencedModule.name); + } else if (module.definitions.indexOf(reference) >= position) { + problems.push(`${definition.name} references ${reference.name} before it's defined in ${module.name}`); + } + } + } + } + + const cycle = findCycle(moduleGraph); + if (cycle !== undefined) { + problems.push(`Modules depend on each other in a cycle: ${cycle.join(" -> ")}`); + } + + return problems; +} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts new file mode 100644 index 00000000..242ed847 --- /dev/null +++ b/packages/schema/src/index.ts @@ -0,0 +1,12 @@ +// oxlint-disable oxc/no-barrel-file -- This is the entrypoint for the package +import { revision as v20170710 } from "./v20170710/index.js"; + +/* +This file exports the schema of the recommended (latest) API revision, as `revision`, along with its definitions. +Generators should use `revisions` to generate every supported revision. +*/ + +export * from "./v20170710/index.js"; + +/** Every supported WaniKani API revision, oldest first; the last one is the latest. */ +export const revisions = [v20170710] as const; diff --git a/packages/schema/src/v20170710/assignments.ts b/packages/schema/src/v20170710/assignments.ts new file mode 100644 index 00000000..21fdfbe0 --- /dev/null +++ b/packages/schema/src/v20170710/assignments.ts @@ -0,0 +1,170 @@ +import { + array, + boolean, + define, + defineModule, + literal, + nullable, + object, + optionalProperty, + property, +} from "../core/index.js"; +import { + BaseCollection, + BaseResource, + CollectionParameters, + DatableInput, + DatableString, + Level, + SafeInteger, +} from "./base.js"; +import { SpacedRepetitionSystemStageNumber } from "./spaced-repetition-systems.js"; +import { SubjectTuple, SubjectType } from "./subjects.js"; + +const collectionSee = ["https://docs.api.wanikani.com/20170710/#get-all-assignments"]; + +export const Assignment = define( + "Assignment", + object( + { + data: property( + object({ + available_at: property( + nullable(DatableString), + "When the related subject will be available in the user's review queue.", + ), + burned_at: property(nullable(DatableString), "When the user reaches SRS stage 9 the first time."), + created_at: property(DatableString, "When the assignment was created."), + hidden: property( + boolean(), + "Indicates if the associated subject has been hidden, preventing it from appearing in lessons or reviews.", + ), + passed_at: property(nullable(DatableString), "When the user reaches SRS stage `5` for the first time."), + resurrected_at: property( + nullable(DatableString), + "When the subject is resurrected and placed back in the user's review queue.", + ), + srs_stage: property( + SpacedRepetitionSystemStageNumber, + "The current SRS stage interval. The interval range is determined by the related subject's Spaced Repetition System.", + ), + started_at: property(nullable(DatableString), "When the user completes the lesson for the related subject."), + subject_id: property(SafeInteger, "Unique identifier of the associated subject."), + subject_type: property(SubjectType, "The type of the associated subject."), + unlocked_at: property(nullable(DatableString), [ + "When the related subjects has its prerequisites satisfied and is made available in lessons.", + "Prerequisites are:", + "- The subject components have reached SRS stage 5 once (they have been “passed”).", + "- The user's level is equal to or greater than the level of the assignment’s subject.", + ]), + }), + "Data for the returned assignment.", + ), + id: property(SafeInteger, "A unique number identifying the assignment."), + object: property(literal("assignment"), "The kind of object returned."), + }, + { extends: [BaseResource] }, + ), + { + description: + "Assignments contain information about a user's progress on a particular subject, including their current state and timestamps for various progress milestones. Assignments are created when a user has passed all the components of the given subject and the assignment is at or below their current level for the first time.", + categories: ["Resources"], + see: ["https://docs.api.wanikani.com/20170710/#assignments"], + }, + { typeGuard: true }, +); + +export const AssignmentCollection = define( + "AssignmentCollection", + object({ data: property(array(Assignment), "An array of returned assignments.") }, { extends: [BaseCollection] }), + { + description: "A collection of assignments returned from the WaniKani API.", + categories: ["Collections"], + see: collectionSee, + }, + { typeGuard: true }, +); + +export const AssignmentParameters = define( + "AssignmentParameters", + object( + { + available_after: optionalProperty(DatableInput, "Only assignments available at or after this time are returned."), + available_before: optionalProperty( + DatableInput, + "Only assignments available at or before this time are returned.", + ), + burned: optionalProperty( + boolean(), + "When set to `true`, returns assignments that have a value in `data.burned_at`. Returns assignments with a `null` `data.burned_at` if `false`.", + ), + hidden: optionalProperty(boolean(), "Return assignments with a matching value in the `hidden` attribute."), + immediately_available_for_lessons: optionalProperty( + boolean(), + "When set to `true`, returns assignments which are immediately available for lessons.", + ), + immediately_available_for_review: optionalProperty( + boolean(), + "When set to `true`, returns assignments which are immediately available for review.", + ), + in_review: optionalProperty(boolean(), "When set to `true`, returns assignments which are in the review state."), + levels: optionalProperty( + array(Level), + "Only assignments where the associated subject level matches one of the array values are returned. Valid values range from `1` to `60`.", + ), + srs_stages: optionalProperty( + array(SpacedRepetitionSystemStageNumber), + "Only assignments where `data.srs_stage` matches one of the array values are returned. Valid values range from `0` to `9`.", + ), + started: optionalProperty( + boolean(), + "When set to `true`, returns assignments that have a value in `data.started_at`. Returns assignments with a `null` `data.started_at` if `false`.", + ), + subject_ids: optionalProperty( + array(SafeInteger), + "Only assignments where `data.subject_id` matches one of the array values are returned.", + ), + subject_types: optionalProperty( + SubjectTuple, + "Only assignments where `data.subject_type` matches one of the array values are returned.", + ), + unlocked: optionalProperty( + boolean(), + "When set to true, returns assignments that have a value in `data.unlocked_at`. Returns assignments with a `null` `data.unlocked_at` if `false`.", + ), + }, + { extends: [CollectionParameters] }, + ), + { + description: "Parameters that can be passed to the WaniKani API to filter a request for an Assignment Collection.", + categories: ["Parameters"], + see: collectionSee, + }, +); + +export const AssignmentPayload = define( + "AssignmentPayload", + object({ + assignment: property( + object({ + started_at: optionalProperty( + DatableInput, + "When the assignment was started. Must be greater than or equal to the assignment's `unlocked_at` date.", + ), + }), + "Specify properties of the Assignment; currently only `started_at` is supported.", + ), + }), + { + description: "The optional payload used in the request to start a new assignment via the WaniKani API.", + categories: ["Payloads"], + see: ["https://docs.api.wanikani.com/20170710/#start-an-assignment"], + }, +); + +export default defineModule("assignments", "Assignments", [ + Assignment, + AssignmentCollection, + AssignmentParameters, + AssignmentPayload, +]); diff --git a/packages/schema/src/v20170710/base.ts b/packages/schema/src/v20170710/base.ts new file mode 100644 index 00000000..fb8bbc8b --- /dev/null +++ b/packages/schema/src/v20170710/base.ts @@ -0,0 +1,193 @@ +import { + array, + constant, + date, + define, + defineModule, + literal, + nullable, + number, + object, + optionalProperty, + property, + string, + union, +} from "../core/index.js"; + +export const ApiRevision = define( + "ApiRevision", + literal("20170710"), + { + description: "All known WaniKani API revisions, created when breaking changes are introduced to the WaniKani API.", + see: ["https://docs.api.wanikani.com/20170710/#revisions-aka-versioning"], + }, + { typeGuard: true }, +); + +export const API_REVISION = constant( + "API_REVISION", + "20170710", + { + description: + "A constant representing the WaniKani API revision. This will match the revision module being imported from, or the latest revision when importing from the root module.", + see: ["https://docs.api.wanikani.com/20170710/#revisions-aka-versioning"], + }, + { type: ApiRevision }, +); + +export const SafeInteger = define( + "SafeInteger", + number({ integer: true, min: 0 }), + "Items with this type will have their number validated as a safe integer >= 0.", +); + +export const DatableString = define( + "DatableString", + string({ brand: true, format: "iso-timestamp", trim: true }), + { + description: + "A `string` sent to/returned from the WaniKani API that can be converted into a JavaScript `Date` object.", + remarks: + "This is a branded type. A `string` can be narrowed to it by validating it with one of the schema library packages (e.g. `@bachman-dev/wanikani-api-valibot`), or by using a type assertion if it's known to be valid.", + }, + { typeGuard: true }, +); + +export const MIN_LEVEL = constant( + "MIN_LEVEL", + 1, + "The minimum level provided by WaniKani; exported for use in lieu of a Magic Number.", +); + +export const MAX_LEVEL = constant( + "MAX_LEVEL", + // oxlint-disable-next-line no-magic-numbers -- This names the number + 60, + "The maximum level provided by WaniKani; exported for use in lieu of a Magic Number.", +); + +export const Level = define( + "Level", + number({ base: SafeInteger, min: MIN_LEVEL, max: MAX_LEVEL }), + "A number representing a level in WaniKani, from `1` to `60`.", + { typeGuard: true }, +); + +/** A timestamp sent to the WaniKani API, either as a string or a JavaScript `Date`. */ +export const DatableInput = union([DatableString, date()], { message: "dateUnion" }); + +export const BaseResource = define( + "BaseResource", + object({ + data_updated_at: property( + DatableString, + "For a resource, this is the last time that particular resource was updated.", + ), + url: property(string(), "The URL of the requested resource."), + }), + { + description: "The common properties across all Resources from the WaniKani API.", + remarks: "This is a partial interface; most use cases involve using a reource that extends it.", + categories: ["Resources"], + see: ["https://docs.api.wanikani.com/20170710/#response-structure"], + }, +); + +export const BaseCollection = define( + "BaseCollection", + object({ + data_updated_at: property( + nullable(DatableString), + "For collections, this is the timestamp of the most recently updated resource in the specified scope and is not limited by pagination. If no items were returned for the specified scope, then this will be `null`.", + ), + object: property(literal("collection"), "The kind of object returned."), + pages: property( + object({ + next_url: property( + nullable(string()), + "The URL of the next page of results. If there are no more results, the value is `null`.", + ), + per_page: property(SafeInteger, "Maximum number of items delivered per page for this collection."), + previous_url: property( + nullable(string()), + "The URL of the previous page of results. If there are no results at all or no previous page to go to, the value is `null`.", + ), + }), + "Pagination Info for the collection.", + ), + total_count: property(SafeInteger, "The total number of items in the collection."), + url: property( + string(), + "The URL of the request. For collections, that will contain all the filters and options you've passed to the API.", + ), + }), + { + description: "The common properties across all Collection items from the WaniKani API.", + remarks: "This is a partial interface; most use cases involve using a reource that extends it.", + categories: ["Collections"], + see: ["https://docs.api.wanikani.com/20170710/#response-structure"], + }, +); + +export const CollectionParameters = define( + "CollectionParameters", + object({ + ids: optionalProperty( + array(SafeInteger), + "Only resources where `data.id` matches one of the array values are returned.", + ), + page_after_id: optionalProperty(SafeInteger, [ + "Get a collection's next page containing `pages.per_page` resources after the given ID.", + "This will take precedence over `page_before_id` if both are specified.", + ]), + page_before_id: optionalProperty(SafeInteger, [ + "Get a collection's previous page containing `pages.per_page` resources before the given ID.", + "The `page_after_id` parameter takes precedence over this if it is specified alongside this parameter.", + ]), + updated_after: optionalProperty(DatableInput, "Only resources updated after this time are returned."), + }), + { + description: "Query string parameters that can be sent to any WaniKani API collection endpoint.", + categories: ["Parameters"], + }, +); + +export const BaseReport = define( + "BaseReport", + object({ + data_updated_at: property(DatableString, "The last time the report was updated."), + object: property(literal("report"), "The kind of object returned."), + url: property(string(), "The URL of the requested report."), + }), + { + description: "The common properties across all Reports from the WaniKani API", + remarks: "This is a partial interface; most use cases involve using a reource that extends it.", + categories: ["Reports"], + see: ["https://docs.api.wanikani.com/20170710/#response-structure"], + }, +); + +export const ApiError = define( + "ApiError", + object({ + code: property(SafeInteger, "An HTTP status code indicating the type of error."), + error: property(string(), "A message string that describes the error."), + }), + "An error response returned by the WaniKani API.", + { typeGuard: true }, +); + +export default defineModule("base", "Base", [ + ApiRevision, + API_REVISION, + SafeInteger, + DatableString, + MIN_LEVEL, + MAX_LEVEL, + Level, + BaseResource, + BaseCollection, + CollectionParameters, + BaseReport, + ApiError, +]); diff --git a/packages/schema/src/v20170710/index.ts b/packages/schema/src/v20170710/index.ts new file mode 100644 index 00000000..534bead7 --- /dev/null +++ b/packages/schema/src/v20170710/index.ts @@ -0,0 +1,54 @@ +// oxlint-disable oxc/no-barrel-file -- This is an entrypoint for the v20170710 API revision +import { defineRevision } from "../core/index.js"; +import assignments from "./assignments.js"; +import base from "./base.js"; +import levelProgressions from "./level-progressions.js"; +import resets from "./resets.js"; +import reviewStatistics from "./review-statistics.js"; +import reviews from "./reviews.js"; +import spacedRepetitionSystems from "./spaced-repetition-systems.js"; +import studyMaterials from "./study-materials.js"; +import subjects from "./subjects.js"; +import summary from "./summary.js"; +import user from "./user.js"; +import voiceActors from "./voice-actors.js"; + +export * from "./assignments.js"; +export * from "./base.js"; +export * from "./level-progressions.js"; +export * from "./resets.js"; +export * from "./review-statistics.js"; +export * from "./reviews.js"; +export * from "./spaced-repetition-systems.js"; +export * from "./study-materials.js"; +export * from "./subjects.js"; +export * from "./summary.js"; +export * from "./user.js"; +export * from "./voice-actors.js"; + +/** The schema of the v20170710 WaniKani API revision. */ +export const revision = defineRevision({ + revision: "20170710", + modules: [ + assignments, + base, + levelProgressions, + resets, + reviewStatistics, + reviews, + spacedRepetitionSystems, + studyMaterials, + subjects, + summary, + user, + voiceActors, + ], + messages: { + dateUnion: "Expected either a valid ISO-8601 timestamp string or a JavaScript Date", + reviewPayloadIntersect: + "Failed to intersect Review Payload objects; this is usually caused when trying to merge a base object with key/value type conflicts.", + reviewPayloadUnion: + "Review Payload must have one and only one of either an assignment_id or subject_id positive integer", + subjectTupleDuplicate: "Duplicate Subject Type detected in Subject Tuple", + }, +}); diff --git a/packages/schema/src/v20170710/level-progressions.ts b/packages/schema/src/v20170710/level-progressions.ts new file mode 100644 index 00000000..bbd06bc4 --- /dev/null +++ b/packages/schema/src/v20170710/level-progressions.ts @@ -0,0 +1,80 @@ +import { alias, array, define, defineModule, literal, nullable, object, property } from "../core/index.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; + +const collectionSee = ["https://docs.api.wanikani.com/20170710/#get-all-level-progressions"]; + +export const LevelProgression = define( + "LevelProgression", + object( + { + data: property( + object({ + abandoned_at: property( + nullable(DatableString), + "Timestamp when the user abandons the level. This is primarily used when the user initiates a reset.", + ), + completed_at: property( + nullable(DatableString), + "Timestamp when the user burns 100% of the assignments belonging to the associated subject's level.", + ), + created_at: property(DatableString, "Timestamp when the level progression is created."), + level: property(Level, "The level of the progression, with possible values from `1` to `60`."), + passed_at: property( + nullable(DatableString), + "Timestamp, in ISO-8601 format, when the user passes at least 90% of the assignments with a type of `kanji` belonging to the associated subject's level.", + ), + started_at: property( + nullable(DatableString), + "Timestamp when the user starts their first lesson of a subject belonging to the level.", + ), + unlocked_at: property( + nullable(DatableString), + "Timestamp when the user can access lessons and reviews for the `level`.", + ), + }), + "Data for the returned level progression.", + ), + id: property(SafeInteger, "A unique number identifying the level progression."), + object: property(literal("level_progression"), "The kind of object returned."), + }, + { extends: [BaseResource] }, + ), + { + description: [ + "Level progressions contain information about a user's progress through the WaniKani levels.", + "A level progression is created when a user has met the prerequisites for leveling up, which are:", + "- Reach a 90% passing rate on assignments for a user's current level with a `subject_type` of `kanji`. Passed assignments have `data.passed` equal to `true` and a `data.passed_at` that's in the past.", + "- Have access to the level. Under `/user`, the `data.level` must be less than or equal to `data.subscription.max_level_granted`.", + ], + categories: ["Resources"], + see: ["https://docs.api.wanikani.com/20170710/#level-progressions"], + }, + { typeGuard: true }, +); + +export const LevelProgressionCollection = define( + "LevelProgressionCollection", + object( + { data: property(array(LevelProgression), "An array of returned level progressions.") }, + { extends: [BaseCollection] }, + ), + { + description: "A collection of level progressions returned from the WaniKani API.", + categories: ["Collections"], + see: collectionSee, + }, + { typeGuard: true }, +); + +export const LevelProgressionParameters = alias("LevelProgressionParameters", CollectionParameters, { + description: + "Parameters that can be passed to the WaniKani API to filter a request for a Level Progression Collection.", + categories: ["Parameters"], + see: collectionSee, +}); + +export default defineModule("level-progressions", "Level Progressions", [ + LevelProgression, + LevelProgressionCollection, + LevelProgressionParameters, +]); diff --git a/packages/schema/src/v20170710/resets.ts b/packages/schema/src/v20170710/resets.ts new file mode 100644 index 00000000..5695218f --- /dev/null +++ b/packages/schema/src/v20170710/resets.ts @@ -0,0 +1,55 @@ +import { alias, array, define, defineModule, literal, nullable, object, property } from "../core/index.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; + +const collectionSee = ["https://docs.api.wanikani.com/20170710/#get-all-resets"]; + +export const Reset = define( + "Reset", + object( + { + data: property( + object({ + confirmed_at: property(nullable(DatableString), "Timestamp when the user confirmed the reset."), + created_at: property(DatableString, "Timestamp when the reset was created."), + original_level: property(Level, "The user's level before the reset, from `1` to `60`."), + target_level: property( + Level, + "The user's level after the reset, from `1` to `60`. It must be less than or equal to `original_level`.", + ), + }), + "Data for the returned reset.", + ), + id: property(SafeInteger, "A unique number identifying the reset."), + object: property(literal("reset"), "The kind of object returned."), + }, + { extends: [BaseResource] }, + ), + { + description: [ + "Users can reset their progress back to any level at or below their current level. When they reset to a particular level, all of the assignments and review_statistics at that level or higher are set back to their default state.", + "Resets contain information about when those resets happen, the starting level, and the target level.", + ], + categories: ["Resources"], + see: ["https://docs.api.wanikani.com/20170710/#resets"], + }, + { typeGuard: true }, +); + +export const ResetCollection = define( + "ResetCollection", + object({ data: property(array(Reset), "An array of returned resets.") }, { extends: [BaseCollection] }), + { + description: "A collection of resets returned from the WaniKani API.", + categories: ["Collections"], + see: collectionSee, + }, + { typeGuard: true }, +); + +export const ResetParameters = alias("ResetParameters", CollectionParameters, { + description: "Parameters that can be passed to the WaniKani API to filter a request for a Reset Collection.", + categories: ["Parameters"], + see: collectionSee, +}); + +export default defineModule("resets", "Resets", [Reset, ResetCollection, ResetParameters]); diff --git a/packages/schema/src/v20170710/review-statistics.ts b/packages/schema/src/v20170710/review-statistics.ts new file mode 100644 index 00000000..6fa06f27 --- /dev/null +++ b/packages/schema/src/v20170710/review-statistics.ts @@ -0,0 +1,135 @@ +import { + array, + boolean, + define, + defineModule, + literal, + number, + object, + optionalProperty, + property, +} from "../core/index.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; +import { SubjectTuple, SubjectType } from "./subjects.js"; + +const collectionSee = ["https://docs.api.wanikani.com/20170710/#get-all-review-statistics"]; + +export const ReviewStatistic = define( + "ReviewStatistic", + object( + { + data: property( + object({ + created_at: property(DatableString, "Timestamp when the review statistic was created."), + hidden: property( + boolean(), + "Indicates if the associated subject has been hidden, preventing it from appearing in lessons or reviews.", + ), + meaning_correct: property( + SafeInteger, + "Total number of correct answers submitted for the meaning of the associated subject.", + ), + meaning_current_streak: property( + SafeInteger, + "The current, uninterrupted series of correct answers given for the meaning of the associated subject.", + ), + meaning_incorrect: property( + SafeInteger, + "Total number of incorrect answers submitted for the meaning of the associated subject.", + ), + meaning_max_streak: property( + SafeInteger, + "The longest, uninterrupted series of correct answers ever given for the meaning of the associated subject.", + ), + percentage_correct: property( + number(), + "The overall correct answer rate by the user for the subject, including both meaning and reading.", + ), + reading_correct: property( + SafeInteger, + "Total number of correct answers submitted for the reading of the associated subject.", + ), + reading_current_streak: property( + SafeInteger, + "The current, uninterrupted series of correct answers given for the reading of the associated subject.", + ), + reading_incorrect: property( + SafeInteger, + "Total number of incorrect answers submitted for the reading of the associated subject.", + ), + reading_max_streak: property( + SafeInteger, + "The longest, uninterrupted series of correct answers ever given for the reading of the associated subject.", + ), + subject_id: property(SafeInteger, "Unique identifier of the associated subject."), + subject_type: property(SubjectType, "The type of the associated subject."), + }), + "Data for the returned review statistic.", + ), + id: property(SafeInteger, "A unique number identifying the review statistic."), + object: property(literal("review_statistic"), "The kind of object returned."), + }, + { extends: [BaseResource] }, + ), + { + description: [ + "Review statistics summarize the activity recorded in reviews. They contain sum the number of correct and incorrect answers for both meaning and reading. They track current and maximum streaks of correct answers. They store the overall percentage of correct answers versus total answers.", + "A review statistic is created when the user has done their first review on the related subject.", + ], + categories: ["Resources"], + see: ["https://docs.api.wanikani.com/20170710/#review-statistics"], + }, + { typeGuard: true }, +); + +export const ReviewStatisticCollection = define( + "ReviewStatisticCollection", + object( + { data: property(array(ReviewStatistic), "An array of returned review statistics.") }, + { extends: [BaseCollection] }, + ), + { + description: "A collection of review statistics returned from the WaniKani API.", + categories: ["Collections"], + see: collectionSee, + }, + { typeGuard: true }, +); + +export const ReviewStatisticParameters = define( + "ReviewStatisticParameters", + object( + { + hidden: optionalProperty(boolean(), "Return review statistics with a matching value in the `hidden` attribute."), + percentages_greater_than: optionalProperty( + number(), + "Return review statistics where the `percentage_correct` is greater than the value.", + ), + percentages_less_than: optionalProperty( + number(), + "Return review statistics where the `percentage_correct` is less than the value.", + ), + subject_ids: optionalProperty( + array(SafeInteger), + "Only review statistics where `data.subject_id` matches one of the array values are returned.", + ), + subject_types: optionalProperty( + SubjectTuple, + "Only review statistics where `data.subject_type` matches one of the array values are returned.", + ), + }, + { extends: [CollectionParameters] }, + ), + { + description: + "Parameters that can be passed to the WaniKani API to filter a request for a Review Statistic Collection.", + categories: ["Parameters"], + see: collectionSee, + }, +); + +export default defineModule("review-statistics", "Review Statistics", [ + ReviewStatistic, + ReviewStatisticCollection, + ReviewStatisticParameters, +]); diff --git a/packages/schema/src/v20170710/reviews.ts b/packages/schema/src/v20170710/reviews.ts new file mode 100644 index 00000000..f9b3c46e --- /dev/null +++ b/packages/schema/src/v20170710/reviews.ts @@ -0,0 +1,200 @@ +import { + array, + define, + defineModule, + intersect, + literal, + never, + object, + optionalProperty, + property, + union, +} from "../core/index.js"; +import { Assignment } from "./assignments.js"; +import { + BaseCollection, + BaseResource, + CollectionParameters, + DatableInput, + DatableString, + SafeInteger, +} from "./base.js"; +import { ReviewStatistic } from "./review-statistics.js"; +import { SpacedRepetitionSystemStageNumber } from "./spaced-repetition-systems.js"; + +const createSee = ["https://docs.api.wanikani.com/20170710/#create-a-review"]; + +const collectionSee = ["https://docs.api.wanikani.com/20170710/#get-all-reviews"]; + +export const Review = define( + "Review", + object( + { + data: property( + object({ + assignment_id: property(SafeInteger, "Unique identifier of the associated assignment."), + created_at: property(DatableString, "Timestamp when the review was created."), + ending_srs_stage: property( + SpacedRepetitionSystemStageNumber, + "The SRS stage interval calculated from the number of correct and incorrect answers, with valid values ranging from `1` to `9`.", + ), + incorrect_meaning_answers: property( + SafeInteger, + "The number of times the user has answered the meaning incorrectly.", + ), + incorrect_reading_answers: property( + SafeInteger, + "The number of times the user has answered the reading incorrectly.", + ), + spaced_repetition_system_id: property( + SafeInteger, + "Unique identifier of the associated `spaced_repetition_system`.", + ), + starting_srs_stage: property( + SpacedRepetitionSystemStageNumber, + "The starting SRS stage interval, with valid values ranging from `1` to `8`.", + ), + subject_id: property(SafeInteger, "Unique identifier of the associated subject."), + }), + "Data for the returned review.", + ), + id: property(SafeInteger, "A unique number identifying the review."), + object: property(literal("review"), "The kind of object returned."), + }, + { extends: [BaseResource] }, + ), + { + description: + "Reviews log all the correct and incorrect answers provided through the 'Reviews' section of WaniKani. Review records are created when a user answers all the parts of a subject correctly once; some subjects have both meaning or reading parts, and some only have one or the other. Note that reviews are not created for the quizzes in lessons.", + categories: ["Resources"], + see: ["https://docs.api.wanikani.com/20170710/#reviews"], + }, + { typeGuard: true }, +); + +export const ReviewCollection = define( + "ReviewCollection", + object({ data: property(array(Review), "An array of returned reviews.") }, { extends: [BaseCollection] }), + { + description: "A collection of reviews returned from the WaniKani API.", + categories: ["Collections"], + see: collectionSee, + }, + { typeGuard: true }, +); + +export const ReviewParameters = define( + "ReviewParameters", + object( + { + assignment_ids: optionalProperty( + array(SafeInteger), + "Only reviews where `data.assignment_id` matches one of the array values are returned.", + ), + subject_ids: optionalProperty( + array(SafeInteger), + "Only reviews where `data.subject_id` matches one of the array values are returned.", + ), + }, + { extends: [CollectionParameters] }, + ), + { + description: "Parameters that can be passed to the WaniKani API to filter a request for a Review Collection.", + categories: ["Parameters"], + see: collectionSee, + }, +); + +export const ReviewPayload = define( + "ReviewPayload", + object({ + review: property( + intersect( + [ + object({ + incorrect_meaning_answers: property( + SafeInteger, + "Must be zero or a positive number. This is the number of times the meaning was answered incorrectly.", + ), + incorrect_reading_answers: property( + SafeInteger, + "Must be zero or a positive number. This is the number of times the reading was answered incorrectly. Note that subjects with a type of `radical` do not quiz on readings. Thus, set this value to `0`.", + ), + created_at: optionalProperty( + DatableInput, + "Timestamp when the review was completed. Defaults to the time of the request if omitted from the request body. Must be in the past, but after `assignment.available_at`.", + ), + }), + union( + [ + object({ + assignment_id: property( + SafeInteger, + "Unique identifier of the assignment. This or `subject_id` must be set.", + ), + subject_id: optionalProperty( + never(), + "The `subject_id` should not be set at the same time as `assignment_id`.", + ), + }), + object({ + subject_id: property( + SafeInteger, + "Unique identifier of the subject. This or `assignment_id` must be set.", + ), + assignment_id: optionalProperty( + never(), + "The `assignment_id` should never be set at the same time as `subject_id`.", + ), + }), + ], + { message: "reviewPayloadUnion" }, + ), + ], + { message: "reviewPayloadIntersect" }, + ), + "A review object with either the `assignment_id` or `subject_id` specified.", + ), + }), + { + description: "The payload used in the request to create a new review via the WaniKani API.", + categories: ["Payloads"], + see: createSee, + }, +); + +export const CreatedReview = define( + "CreatedReview", + object( + { + resources_updated: property( + object({ + assignment: property(Assignment, { + description: "The updated assignment upon creating the review.", + see: ["https://docs.api.wanikani.com/20170710/#assignments"], + }), + review_statistic: property(ReviewStatistic, { + description: "The updated review statistic upon creating the review.", + see: ["https://docs.api.wanikani.com/20170710/#review-statistics"], + }), + }), + "The resources updated alongside creating the review.", + ), + }, + { extends: [Review] }, + ), + { + description: "A created review returned from the WaniKani API.", + categories: ["Resources"], + see: createSee, + }, + { typeGuard: true }, +); + +export default defineModule("reviews", "Reviews", [ + Review, + ReviewCollection, + ReviewParameters, + ReviewPayload, + CreatedReview, +]); diff --git a/packages/schema/src/v20170710/spaced-repetition-systems.ts b/packages/schema/src/v20170710/spaced-repetition-systems.ts new file mode 100644 index 00000000..dbb26d67 --- /dev/null +++ b/packages/schema/src/v20170710/spaced-repetition-systems.ts @@ -0,0 +1,125 @@ +import { + alias, + array, + constant, + define, + defineModule, + literal, + nullable, + number, + object, + picklist, + property, + string, +} from "../core/index.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; + +const see = ["https://docs.api.wanikani.com/20170710/#spaced-repetition-systems"]; + +const collectionSee = ["https://docs.api.wanikani.com/20170710/#get-all-spaced-repetition-systems"]; + +export const MIN_SRS_STAGE = constant( + "MIN_SRS_STAGE", + 0, + "The minimum SRS Stage Number used in WaniKani's SRS; exported for use in lieu of a Magic Number.", +); + +export const MAX_SRS_REVIEW_STAGE = constant( + "MAX_SRS_REVIEW_STAGE", + // oxlint-disable-next-line no-magic-numbers -- This names the number + 8, + "The maximum SRS Stage Number used in WaniKani's reviews; exported for use in lieu of a Magic Number.", +); + +export const MAX_SRS_STAGE = constant( + "MAX_SRS_STAGE", + // oxlint-disable-next-line no-magic-numbers -- This names the number + 9, + "The maximum SRS Stage Number used in WaniKani's SRS; exported for use in lieu of a Magic Number.", +); + +export const SpacedRepetitionSystemStageNumber = define( + "SpacedRepetitionSystemStageNumber", + number({ base: SafeInteger, min: MIN_SRS_STAGE, max: MAX_SRS_STAGE }), + "A valid WaniKani Spaced Repetition System (SRS) Stage Number, based on the known SRS' on WaniKani and its API.", + { typeGuard: true }, +); + +export const SpacedRepetitionSystemStage = define( + "SpacedRepetitionSystemStage", + object({ + interval: property( + nullable(SafeInteger), + "The length of time added to the time of review registration, adjusted to the beginning of the hour.", + ), + interval_unit: property( + nullable(picklist(["days", "hours", "milliseconds", "minutes", "seconds", "weeks"])), + "Unit of time. Can be the following: `milliseconds`, `seconds`, `minutes`, `hours`, `days`, and `weeks`.", + ), + position: property(SpacedRepetitionSystemStageNumber, "The position of the stage within the continuous order."), + }), + { description: "An individual Spaced Repetition System (SRS) Stage.", see }, +); + +export const SpacedRepetitionSystem = define( + "SpacedRepetitionSystem", + object( + { + data: property( + object({ + burning_stage_position: property(SpacedRepetitionSystemStageNumber, "`position` of the burning stage."), + created_at: property(DatableString, "Timestamp when the `spaced_repetition_system` was created."), + description: property(string(), "Details about the spaced repetition system."), + name: property(string(), "The name of the spaced repetition system."), + passing_stage_position: property(SpacedRepetitionSystemStageNumber, "`position` of the passing stage."), + stages: property(array(SpacedRepetitionSystemStage), "A collection of stages."), + starting_stage_position: property(SpacedRepetitionSystemStageNumber, "`position` of the starting stage."), + unlocking_stage_position: property(SpacedRepetitionSystemStageNumber, "`position` of the unlocking stage."), + }), + "Data for the return Spaced Repetition System.", + ), + id: property(SafeInteger, "A unique number identifying the Spaced Repetition System."), + object: property(literal("spaced_repetition_system"), "The kind of object returned."), + }, + { extends: [BaseResource] }, + ), + { + description: + "Available spaced repetition systems used for calculating `srs_stage` changes to Assignments and Reviews. Has relationship with Subjects.", + categories: ["Resources"], + see, + }, + { typeGuard: true }, +); + +export const SpacedRepetitionSystemCollection = define( + "SpacedRepetitionSystemCollection", + object( + { data: property(array(SpacedRepetitionSystem), "An array of returned Spaced Repetition Systems.") }, + { extends: [BaseCollection] }, + ), + { + description: "A collection of Spaced Repetition Systems returned from the WaniKani API.", + categories: ["Collections"], + see: collectionSee, + }, + { typeGuard: true }, +); + +export const SpacedRepetitionSystemParameters = alias("SpacedRepetitionSystemParameters", CollectionParameters, { + description: + "Parameters that can be passed to the WaniKani API to filter a request for a Spaced Repetition System Collection.", + categories: ["Parameters"], + see: collectionSee, +}); + +export default defineModule("spaced-repetition-systems", "Spaced Repetition Systems", [ + MIN_SRS_STAGE, + MAX_SRS_REVIEW_STAGE, + MAX_SRS_STAGE, + SpacedRepetitionSystemStageNumber, + SpacedRepetitionSystemStage, + SpacedRepetitionSystem, + SpacedRepetitionSystemCollection, + SpacedRepetitionSystemParameters, +]); diff --git a/packages/schema/src/v20170710/study-materials.ts b/packages/schema/src/v20170710/study-materials.ts new file mode 100644 index 00000000..f6754049 --- /dev/null +++ b/packages/schema/src/v20170710/study-materials.ts @@ -0,0 +1,133 @@ +import { + array, + boolean, + define, + defineModule, + literal, + object, + optionalProperty, + partial, + property, + string, +} from "../core/index.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; +import { SubjectTuple, SubjectType } from "./subjects.js"; + +const collectionSee = ["https://docs.api.wanikani.com/20170710/#get-all-study-materials"]; + +export const StudyMaterialBaseData = define( + "StudyMaterialBaseData", + object({ + meaning_note: property(string(), "Free form note related to the meaning(s) of the associated subject."), + meaning_synonyms: property( + array(string()), + "Synonyms for the meaning of the subject. These are used as additional correct answers during reviews.", + ), + reading_note: property(string(), "Free form note related to the reading(s) of the associated subject."), + }), + { + description: + "Data found in all study materials whether they are being created/updated, or received from the WaniKani API.", + remarks: + "For creating study materials, use {@link StudyMaterialCreatePayload}; for updating study materials, use {@link StudyMaterialUpdatePayload}.", + }, +); + +export const StudyMaterial = define( + "StudyMaterial", + object( + { + data: property( + object( + { + created_at: property(DatableString, "Timestamp when the study material was created."), + hidden: property( + boolean(), + "Indicates if the associated subject has been hidden, preventing it from appearing in lessons or reviews.", + ), + subject_id: property(SafeInteger, "Unique identifier of the associated subject."), + subject_type: property(SubjectType, "The type of the associated subject."), + }, + { extends: [StudyMaterialBaseData] }, + ), + "Data for the returned study material.", + ), + id: property(SafeInteger, "A unique number identifying the study material."), + object: property(literal("study_material"), "The kind of object returned."), + }, + { extends: [BaseResource] }, + ), + { + description: + "Study materials store user-specific notes and synonyms for a given subject. The records are created as soon as the user enters any study information.", + categories: ["Resources"], + see: ["https://docs.api.wanikani.com/20170710/#study-materials"], + }, + { typeGuard: true }, +); + +export const StudyMaterialCollection = define( + "StudyMaterialCollection", + object( + { data: property(array(StudyMaterial), "An array of returned study materials.") }, + { extends: [BaseCollection] }, + ), + { + description: "A collection of study materials returned from the WaniKani API.", + categories: ["Collections"], + see: collectionSee, + }, + { typeGuard: true }, +); + +export const StudyMaterialParameters = define( + "StudyMaterialParameters", + object( + { + hidden: optionalProperty(boolean(), "Return study materials with a matching value in the `hidden` attribute."), + subject_ids: optionalProperty( + array(SafeInteger), + "Only study material records where `data.subject_id` matches one of the array values are returned.", + ), + subject_types: optionalProperty( + SubjectTuple, + "Only study material records where `data.subject_type` matches one of the array values are returned.", + ), + }, + { extends: [CollectionParameters] }, + ), + { + description: + "Parameters that can be passed to the WaniKani API to filter a request for a Study Material Collection.", + categories: ["Parameters"], + see: collectionSee, + }, +); + +export const StudyMaterialUpdatePayload = define("StudyMaterialUpdatePayload", partial(StudyMaterialBaseData), { + description: "The payload sent to the WaniKani API when updating study materials.", + categories: ["Payloads"], + see: ["https://docs.api.wanikani.com/20170710/#update-a-study-material"], +}); + +export const StudyMaterialCreatePayload = define( + "StudyMaterialCreatePayload", + object( + { subject_id: property(SafeInteger, "Unique identifier of the associated subject.") }, + { extends: [StudyMaterialUpdatePayload] }, + ), + { + description: "The payload sent to the WaniKani API when creating new study materials.", + categories: ["Payloads"], + see: ["https://docs.api.wanikani.com/20170710/#create-a-study-material"], + }, +); + +export default defineModule("study-materials", "Study Materials", [ + StudyMaterialBaseData, + StudyMaterial, + StudyMaterialCollection, + StudyMaterialParameters, + StudyMaterialUpdatePayload, + StudyMaterialCreatePayload, +]); diff --git a/packages/schema/src/v20170710/subjects.ts b/packages/schema/src/v20170710/subjects.ts new file mode 100644 index 00000000..58b5859f --- /dev/null +++ b/packages/schema/src/v20170710/subjects.ts @@ -0,0 +1,351 @@ +import { + array, + boolean, + define, + defineModule, + intersect, + literal, + nullable, + object, + optionalProperty, + picklist, + property, + string, + tuple, + variant, +} from "../core/index.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; + +const see = ["https://docs.api.wanikani.com/20170710/#subjects"]; + +const imageMetadataDocs = "Details about the image. Each `content_type` returns a uniquely structured object."; + +export const SubjectType = define( + "SubjectType", + picklist(["kana_vocabulary", "kanji", "radical", "vocabulary"]), + { description: "The types of subjects used on WaniKani and its API.", see }, + { typeGuard: true }, +); + +export const SubjectTuple = define( + "SubjectTuple", + tuple([{ name: "first", schema: SubjectType }], { + rest: { name: "rest", schema: SubjectType }, + unique: { message: "subjectTupleDuplicate" }, + }), + { description: "A non-empty array of WaniKani subject types.", see }, + { typeGuard: true }, +); + +export const SubjectAuxiliaryMeaning = define( + "SubjectAuxiliaryMeaning", + object({ + meaning: property(string(), "A singular subject meaning."), + type: property( + picklist(["blacklist", "whitelist"]), + "Either `whitelist` or `blacklist`. When evaluating user input, whitelisted meanings are used to match for correctness. Blacklisted meanings are used to match for incorrectness.", + ), + }), + { description: "A subject's auxilliary meanings.", see }, +); + +export const SubjectMeaning = define( + "SubjectMeaning", + object({ + accepted_answer: property(boolean(), "Indicates if the meaning is used to evaluate user input for correctness."), + meaning: property(string(), "A singular subject meaning."), + primary: property(boolean(), "Indicates priority in the WaniKani system."), + }), + { description: "Information pertaining to a subject's meaning.", see }, +); + +export const SubjectBaseData = define( + "SubjectBaseData", + object({ + auxiliary_meanings: property(array(SubjectAuxiliaryMeaning), "Collection of auxiliary meanings."), + created_at: property(DatableString, "Timestamp when the subject was created."), + document_url: property( + string(), + "A URL pointing to the page on wanikani.com that provides detailed information about this subject.", + ), + hidden_at: property( + nullable(DatableString), + "Timestamp when the subject was hidden, indicating associated assignments will no longer appear in lessons or reviews and that the subject page is no longer visible on wanikani.com.", + ), + lesson_position: property( + SafeInteger, + "The position that the subject appears in lessons. Note that the value is scoped to the level of the subject, so there are duplicate values across levels.", + ), + level: property(Level, "The level of the subject, from `1` to `60`."), + meaning_mnemonic: property(string(), "The subject's meaning mnemonic."), + meanings: property(array(SubjectMeaning), "The subject meanings."), + slug: property( + string(), + "The string that is used when generating the document URL for the subject. Radicals use their meaning, downcased. Kanji and vocabulary use their characters.", + ), + spaced_repetition_system_id: property(SafeInteger, "Unique identifier of the associated Spaced Repetition System."), + }), + { + description: "The common properties of all subjects on WaniKani.", + remarks: + "This only represents a partial structure of a subject, and it's highly recommended to use one of the child type definitions that extend this type definition.", + see, + }, +); + +export const RadicalCharacterImage = define( + "RadicalCharacterImage", + intersect([ + object({ + url: property(string(), "The location of the image."), + }), + variant("content_type", [ + object({ + content_type: property(literal("image/png"), "The content type of the image."), + metadata: property( + object({ + color: property(string(), "Color of the asset in hexadecimal"), + dimensions: property(string(), "Dimension of the asset in pixels"), + style_name: property(string(), "A name descriptor"), + }), + imageMetadataDocs, + ), + }), + object({ + content_type: property(literal("image/svg+xml"), "The content type of the image."), + metadata: property( + object({ + inline_styles: property(boolean(), "The SVG asset contains built-in CSS styling."), + }), + imageMetadataDocs, + ), + }), + ]), + ]), + { description: "An image representing a radical subject.", see }, +); + +export const RadicalData = define( + "RadicalData", + object( + { + amalgamation_subject_ids: property( + array(SafeInteger), + "An array of numeric identifiers for the kanji that have the radical as a component.", + ), + character_images: property(array(RadicalCharacterImage), "A collection of images of the radical."), + characters: property( + nullable(string()), + "Unlike kanji and vocabulary, radicals can have a null value for characters. Not all radicals have a UTF entry, so the radical must be visually represented with an image instead.", + ), + }, + { extends: [SubjectBaseData] }, + ), + { description: "Data returned only for radical subjects.", see }, +); + +export const KanjiReading = define( + "KanjiReading", + object({ + accepted_answer: property(boolean(), "Indicates if the reading is used to evaluate user input for correctness."), + primary: property(boolean(), "Indicates priority in the WaniKani system."), + reading: property(string(), "A singular subject reading."), + type: property( + picklist(["kunyomi", "nanori", "onyomi"]), + "The kanji reading's classfication: `kunyomi`, `nanori`, or `onyomi`.", + ), + }), + { description: "Information pertaining to a reading of a kanji subject.", see }, +); + +export const KanjiData = define( + "KanjiData", + object( + { + amalgamation_subject_ids: property( + array(SafeInteger), + "An array of numeric identifiers for the vocabulary that have the kanji as a component.", + ), + characters: property(string(), "The UTF-8 characters for the subject, including kanji and hiragana."), + component_subject_ids: property( + array(SafeInteger), + "An array of numeric identifiers for the radicals that make up this kanji. Note that these are the subjects that must have passed assignments in order to unlock this subject's assignment.", + ), + meaning_hint: property(nullable(string()), "Meaning hint for the kanji."), + reading_hint: property(nullable(string()), "Reading hint for the kanji."), + reading_mnemonic: property(string(), "The kanji's reading mnemonic."), + readings: property(array(KanjiReading), "Selected readings for the kanji."), + visually_similar_subject_ids: property( + array(SafeInteger), + "An array of numeric identifiers for kanji which are visually similar to the kanji in question.", + ), + }, + { extends: [SubjectBaseData] }, + ), + { description: "Data returned only for kanji subjects.", see }, +); + +export const VocabularyContextSentence = define( + "VocabularyContextSentence", + object({ + en: property(string(), "English translation of the sentence."), + ja: property(string(), "Japanese context sentence."), + }), + { description: "Japanese context sentences for vocabulary, with a corresponding English translation.", see }, +); + +export const VocabularyPronunciationAudio = define( + "VocabularyPronunciationAudio", + object({ + content_type: property( + picklist(["audio/mpeg", "audio/ogg", "audio/webm"]), + "The content type of the audio. Currently the API delivers `audio/mpeg`, `audio/ogg`, and `audio/webm`.", + ), + metadata: property( + object({ + gender: property(picklist(["female", "male"]), "The gender of the voice actor."), + pronunciation: property(string(), "Vocabulary being pronounced in kana."), + source_id: property(SafeInteger, "A unique ID shared between same source pronunciation audio."), + voice_actor_id: property(SafeInteger, "A unique ID belonging to the voice actor."), + voice_actor_name: property(string(), "Humanized name of the voice actor."), + voice_description: property(string(), "Description of the voice."), + }), + "Details about the pronunciation audio.", + ), + url: property(string(), "The location of the audio."), + }), + { description: "Information pertaining to pronunciation audio for a vocabulary subject.", see }, +); + +export const VocabularyReading = define( + "VocabularyReading", + object({ + accepted_answer: property(boolean(), "Indicates if the reading is used to evaluate user input for correctness."), + primary: property(boolean(), "Indicates priority in the WaniKani system."), + reading: property(string(), "A singular subject reading."), + }), + { description: "Information pertaining to a reading of a vocabulary subject..", see }, +); + +export const VocabularyData = define( + "VocabularyData", + object( + { + characters: property(string(), "The UTF-8 characters for the subject, including kanji and hiragana."), + component_subject_ids: property( + array(SafeInteger), + "An array of numeric identifiers for the kanji that make up this vocabulary. Note that these are the subjects that must be have passed assignments in order to unlock this subject's assignment.", + ), + context_sentences: property(array(VocabularyContextSentence), "A collection of context sentences."), + parts_of_speech: property(array(string()), "Parts of speech."), + pronunciation_audios: property(array(VocabularyPronunciationAudio), "A collection of pronunciation audio."), + reading_mnemonic: property(string(), "The vocabulary's reading mnemonic."), + readings: property(array(VocabularyReading), "Selected readings for the vocabulary."), + }, + { extends: [SubjectBaseData] }, + ), + { description: "Data returned only for vocabulary subjects.", see }, +); + +export const KanaVocabularyData = define( + "KanaVocabularyData", + object( + { + characters: property(string(), "The UTF-8 characters for the subject, including kanji and hiragana."), + context_sentences: property(array(VocabularyContextSentence), "A collection of context sentences."), + parts_of_speech: property(array(string()), "Parts of speech."), + pronunciation_audios: property(array(VocabularyPronunciationAudio), "A collection of pronunciation audio."), + }, + { extends: [SubjectBaseData] }, + ), + { description: "Data returned only for kana-only vocabulary subjects.", see }, +); + +export const Subject = define( + "Subject", + intersect([ + object({ id: property(SafeInteger, "A unique number identifying the subject.") }, { extends: [BaseResource] }), + variant("object", [ + object({ + data: property(KanaVocabularyData, "Data for the returned kana-only vocabulary."), + object: property(literal("kana_vocabulary"), "The kind of object returned."), + }), + object({ + data: property(KanjiData, "Data for the returned kanji."), + object: property(literal("kanji"), "The kind of object returned."), + }), + object({ + data: property(RadicalData, "Data for the returned radical."), + object: property(literal("radical"), "The kind of object returned."), + }), + object({ + data: property(VocabularyData, "Data for the returned vocabulary."), + object: property(literal("vocabulary"), "The kind of object returned."), + }), + ]), + ]), + { + description: [ + "The exact structure of a subject depends on the subject type. The available subject types are `kana_vocabulary`, `kanji`, `radical`, and `vocabulary`. Note that any attributes called out for the specific subject type behaves differently than the common attribute of the same name.", + "This type is for mixed or unknown subject types; it is a discriminated union based on the subject's `object` key.", + ], + categories: ["Resources"], + see, + }, + { typeGuard: true }, +); + +export const SubjectCollection = define( + "SubjectCollection", + object( + { data: property(array(Subject), "An array of returned subjects of mixed or unknown type.") }, + { extends: [BaseCollection] }, + ), + { + description: "A collection of subjects of mixed or unknown types returned from the WaniKani API.", + categories: ["Collections"], + see: ["https://docs.api.wanikani.com/20170710/#get-all-subjects"], + }, + { typeGuard: true }, +); + +export const SubjectParameters = define( + "SubjectParameters", + object( + { + hidden: optionalProperty( + boolean(), + "Return subjects which are or are not hidden from the user-facing application.", + ), + levels: optionalProperty(array(Level), "Return subjects at the specified levels."), + slugs: optionalProperty(array(string()), "Return subjects of the specified slug."), + types: optionalProperty(SubjectTuple, "Return subjects of the specified types."), + }, + { extends: [CollectionParameters] }, + ), + { + description: "Parameters that can be passed to the WaniKani API to filter a request for a Subject Collection.", + categories: ["Parameters"], + see: ["https://docs.api.wanikani.com/20170710/#get-all-subjects"], + }, +); + +export default defineModule("subjects", "Subjects", [ + SubjectType, + SubjectTuple, + SubjectAuxiliaryMeaning, + SubjectMeaning, + SubjectBaseData, + RadicalCharacterImage, + RadicalData, + KanjiReading, + KanjiData, + VocabularyContextSentence, + VocabularyPronunciationAudio, + VocabularyReading, + VocabularyData, + KanaVocabularyData, + Subject, + SubjectCollection, + SubjectParameters, +]); diff --git a/packages/schema/src/v20170710/summary.ts b/packages/schema/src/v20170710/summary.ts new file mode 100644 index 00000000..6dd97c6b --- /dev/null +++ b/packages/schema/src/v20170710/summary.ts @@ -0,0 +1,48 @@ +import { array, define, defineModule, nullable, object, property } from "../core/index.js"; +import { BaseReport, DatableString, SafeInteger } from "./base.js"; + +const see = ["https://docs.api.wanikani.com/20170710/#summary"]; + +export const SummaryInterval = define( + "SummaryInterval", + object({ + available_at: property( + DatableString, + "When the paired `subject_ids` are available for lessons. Always beginning of the current hour when the API endpoint is accessed.", + ), + subject_ids: property(array(SafeInteger), "Collection of unique identifiers for subjects."), + }), + { description: "Details about subjects listed as available for lessons in the Summary report.", see }, +); + +export const Summary = define( + "Summary", + object( + { + data: property( + object({ + lessons: property(array(SummaryInterval), "Details about subjects available for lessons."), + next_reviews_at: property( + nullable(DatableString), + "Earliest date when the reviews are available. Is `null` when the user has no reviews scheduled.", + ), + reviews: property( + array(SummaryInterval), + "Details about subjects available for reviews now and in the next 24 hours by the hour (total of 25 objects).", + ), + }), + "Data for the Summary report.", + ), + }, + { extends: [BaseReport] }, + ), + { + description: + "The summary report contains currently available lessons and reviews and the reviews that will become available in the next 24 hours, grouped by the hour.", + categories: ["Reports"], + see, + }, + { typeGuard: true }, +); + +export default defineModule("summary", "Summary", [SummaryInterval, Summary]); diff --git a/packages/schema/src/v20170710/user.ts b/packages/schema/src/v20170710/user.ts new file mode 100644 index 00000000..03cbe809 --- /dev/null +++ b/packages/schema/src/v20170710/user.ts @@ -0,0 +1,154 @@ +import { + boolean, + constant, + define, + defineModule, + literal, + nullable, + number, + object, + partial, + picklist, + property, + string, +} from "../core/index.js"; +import { BaseResource, DatableString, Level, SafeInteger } from "./base.js"; + +const see = ["https://docs.api.wanikani.com/20170710/#user"]; + +export const MIN_LESSON_BATCH_SIZE = constant( + "MIN_LESSON_BATCH_SIZE", + // oxlint-disable-next-line no-magic-numbers -- This names the number + 3, + "The minimum batch size for lessons in the WaniKani app; exported for use in lieu of a Magic Number.", +); + +export const MAX_LESSON_BATCH_SIZE = constant( + "MAX_LESSON_BATCH_SIZE", + // oxlint-disable-next-line no-magic-numbers -- This names the number + 10, + "The maximum batch size for lessons in the WaniKani app; exported for use in lieu of a Magic Number.", +); + +export const LessonBatchSizeNumber = define( + "LessonBatchSizeNumber", + number({ base: SafeInteger, min: MIN_LESSON_BATCH_SIZE, max: MAX_LESSON_BATCH_SIZE }), + "A number representing a valid lesson batch size in WaniKani, from `3` to `10`.", + { typeGuard: true }, +); + +export const UserPreferences = define( + "UserPreferences", + object({ + default_voice_actor_id: property(SafeInteger, { + deprecated: + "This is a deprecated user preference. It will always return `1` and cannot be set. It exists only to ensure existing consumers of this API don't break.", + }), + extra_study_autoplay_audio: property( + boolean(), + "Automatically play pronunciation audio for vocabulary during extra study.", + ), + lessons_autoplay_audio: property( + boolean(), + "Automatically play pronunciation audio for vocabulary during lessons.", + ), + lessons_batch_size: property( + LessonBatchSizeNumber, + "Number of subjects introduced to the user during lessons before quizzing.", + ), + lessons_presentation_order: property( + picklist(["ascending_level_then_shuffled", "ascending_level_then_subject", "shuffled"]), + { + deprecated: + "This is a deprecated user preference. It always returns `ascending_level_then_subject`. Setting this preference will do nothing. It exists only to ensure existing consumers of this API don't break.", + }, + ), + reviews_autoplay_audio: property( + boolean(), + "Automatically play pronunciation audio for vocabulary during reviews.", + ), + reviews_display_srs_indicator: property( + boolean(), + "Toggle for display SRS change indicator after a subject has been completely answered during review.", + ), + reviews_presentation_order: property( + picklist(["lower_levels_first", "shuffled"]), + "The order in which reviews are presented. The options are `lower_levels_first` and `shuffled`.", + ), + }), + { description: "User settings specific to the WaniKani application.", see }, +); + +export const User = define( + "User", + object( + { + data: property( + object({ + current_vacation_started_at: property( + nullable(DatableString), + "If the user is on vacation, this will be the timestamp of when that vacation started. If the user is not on vacation, this is `null`.", + ), + id: property(string(), "A user's unique ID string."), + level: property(Level, "The current level of the user. This ignores subscription status."), + preferences: property(UserPreferences, "User settings specific to the WaniKani application."), + profile_url: property(string(), "The URL to the user's public facing profile page."), + started_at: property(DatableString, "The signup date for the user."), + subscription: property( + object({ + active: property(boolean(), "Whether or not the user currently has a paid subscription."), + max_level_granted: property(Level, [ + "The maximum level of content accessible to the user for lessons, reviews, and content review. For unsubscribed/free users, the maximum level is `3`. For subscribed users, this is `60`.", + "**Any application that uses data from the WaniKani API must respect these access limits.**", + ]), + period_ends_at: property( + nullable(DatableString), + "The date when the user's subscription period ends. If the user has subscription type `lifetime` or `free` then the value is `null`.", + ), + type: property( + picklist(["free", "lifetime", "recurring", "unknown"]), + "The type of subscription the user has. Options are following: `free`, `recurring`, and `lifetime`. A type of `unknown` means the user subscription state isn't exactly known. This is a weird state on WaniKani, should be treated as `free`, and reported to the WaniKani developers.", + ), + }), + "Details about the user's subscription state.", + ), + username: property(string(), "The user's username."), + }), + "Data for the returned user.", + ), + object: property(literal("user"), "The kind of object returned."), + }, + { extends: [BaseResource] }, + ), + { description: "A user and their status/information on WaniKani.", categories: ["Resources"], see }, + { typeGuard: true }, +); + +export const UserPreferencesPayload = define( + "UserPreferencesPayload", + object({ + user: property( + object({ + preferences: property( + partial(UserPreferences), + "The user preferences to be updated; only those specified in the object will be updated.", + ), + }), + "The user object, as part of the payload.", + ), + }), + { + description: "The payload sent to the WaniKani API to update a user's preferences.", + categories: ["Payloads"], + see: ["https://docs.api.wanikani.com/20170710/#update-user-information"], + }, +); + +export default defineModule("user", "User", [ + MIN_LESSON_BATCH_SIZE, + MAX_LESSON_BATCH_SIZE, + LessonBatchSizeNumber, + UserPreferences, + User, + UserPreferencesPayload, +]); diff --git a/packages/schema/src/v20170710/voice-actors.ts b/packages/schema/src/v20170710/voice-actors.ts new file mode 100644 index 00000000..3d831c7e --- /dev/null +++ b/packages/schema/src/v20170710/voice-actors.ts @@ -0,0 +1,49 @@ +import { alias, array, define, defineModule, literal, object, picklist, property, string } from "../core/index.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; + +const collectionSee = ["https://docs.api.wanikani.com/20170710/#get-all-voice-actors"]; + +export const VoiceActor = define( + "VoiceActor", + object( + { + data: property( + object({ + created_at: property(DatableString, "Timestamp for when the voice actor was added to WaniKani."), + description: property(string(), "Details about the voice actor."), + gender: property(picklist(["female", "male"]), "The voice actor's gender, either `male` or `female`."), + name: property(string(), "The voice actor's name."), + }), + "Data for the returned voice actor.", + ), + id: property(SafeInteger, "A unique number identifying the voice actor."), + object: property(literal("voice_actor"), "The kind of object returned."), + }, + { extends: [BaseResource] }, + ), + { + description: "Available voice actors used for vocabulary reading pronunciation audio.", + categories: ["Resources"], + see: ["https://docs.api.wanikani.com/20170710/#voice-actors"], + }, + { typeGuard: true }, +); + +export const VoiceActorCollection = define( + "VoiceActorCollection", + object({ data: property(array(VoiceActor), "An array of returned voice actors.") }, { extends: [BaseCollection] }), + { + description: "A collection of voice actors returned from the WaniKani API.", + categories: ["Collections"], + see: collectionSee, + }, + { typeGuard: true }, +); + +export const VoiceActorParameters = alias("VoiceActorParameters", CollectionParameters, { + description: "Parameters that can be passed to the WaniKani API to filter a request for a Voice Actor Collection.", + categories: ["Parameters"], + see: collectionSee, +}); + +export default defineModule("voice-actors", "Voice Actors", [VoiceActor, VoiceActorCollection, VoiceActorParameters]); diff --git a/packages/schema/tests/core.test.ts b/packages/schema/tests/core.test.ts new file mode 100644 index 00000000..29fd1312 --- /dev/null +++ b/packages/schema/tests/core.test.ts @@ -0,0 +1,407 @@ +import * as fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { + GENERATED_HEADER, + alias, + array, + categoriesOf, + constant, + createModuleScope, + define, + defineModule, + defineRevision, + findCycle, + generatePackageFiles, + hasDeprecatedEntries, + indexRevision, + joinBlocks, + literal, + number, + numberChecks, + object, + objectEntries, + optionalProperty, + partial, + property, + referencesOf, + renderImport, + renderJsDoc, + renderKey, + resolveAlias, + string, + textLines, + tuple, + union, + validateRevision, + variant, + walkSchema, + writeGeneratedFiles, +} from "../src/core/index.js"; +import { revisions } from "../src/index.js"; + +vi.setConfig({ testTimeout: 5000 }); + +describe("textLines", () => { + it("separates paragraphs with an empty line", () => { + expect(textLines(["One.", "Two."])).toStrictEqual(["One.", "", "Two."]); + }); + it("keeps consecutive list items together", () => { + expect(textLines(["Intro:", "- A", "- B", "Outro."])).toStrictEqual(["Intro:", "", "- A", "- B", "", "Outro."]); + }); +}); + +describe("renderJsDoc", () => { + it("renders a description-only comment on one line", () => { + expect(renderJsDoc({ description: "Hello." })).toBe("/** Hello. */"); + }); + it("renders nothing without docs", () => { + expect(renderJsDoc(undefined)).toBe(""); + }); + it("renders tags after the description", () => { + expect( + renderJsDoc( + { description: "Hello.", remarks: "Note.", see: ["https://example.com"] }, + { categories: ["A", "B"], tags: ["@returns Nothing"] }, + ), + ).toBe( + [ + "/**", + " * Hello.", + " *", + " * @remarks Note.", + " * @category A", + " * @category B", + " * @see {@link https://example.com}", + " * @returns Nothing", + " */", + ].join("\n"), + ); + }); +}); + +describe("renderJsDoc tags", () => { + it("renders multi-paragraph tags without a description", () => { + expect(renderJsDoc({ deprecated: ["Old.", "Use something else."] })).toBe( + ["/**", " * @deprecated Old.", " *", " * Use something else.", " */"].join("\n"), + ); + }); +}); + +describe("numberChecks", () => { + const Base = define("Base", number({ integer: true, min: 0 }), "A base number."); + const LIMIT = constant("LIMIT", 9, "A limit."); + it("only applies checks that tighten the base", () => { + expect(numberChecks(number({ base: Base, integer: true, min: 0, max: LIMIT }))).toStrictEqual({ + integer: false, + max: LIMIT, + }); + expect(numberChecks(number({ base: Base, min: 1 }))).toStrictEqual({ integer: false, min: 1 }); + }); + it("inherits bounds through every base", () => { + const Unbounded = define("Unbounded", number({ integer: true }), "An unbounded integer."); + const Capped = define("Capped", number({ base: Unbounded, max: LIMIT }), "A capped integer."); + const Narrowed = define("Narrowed", number({ base: Capped, min: 1 }), "A narrowed integer."); + expect(numberChecks(number({ base: Narrowed, min: 1, max: LIMIT }))).toStrictEqual({ integer: false }); + expect(numberChecks(number({ base: Narrowed, max: 5 }))).toStrictEqual({ integer: false, max: 5 }); + }); + it("applies every check without a base", () => { + expect(numberChecks(Base.schema)).toStrictEqual({ integer: true, min: 0 }); + }); +}); + +describe("objectEntries", () => { + const Parent = define("Parent", object({ a: property(string()) }), "A parent."); + const Child = define("Child", object({ b: optionalProperty(string()) }, { extends: [Parent] }), "A child."); + it("includes inherited entries", () => { + expect(objectEntries(Child).map(([key, entry]) => [key, entry.optional])).toStrictEqual([ + ["a", false], + ["b", true], + ]); + }); + it("makes entries of partial definitions optional", () => { + const Partial = define("Partial", partial(Child), "A partial child."); + expect(objectEntries(Partial).map(([key, entry]) => [key, entry.optional])).toStrictEqual([ + ["a", true], + ["b", true], + ]); + }); +}); + +describe("hasDeprecatedEntries", () => { + it("finds deprecated entries in nested objects", () => { + const Nested = define( + "Nested", + object({ outer: property(object({ inner: property(string(), { deprecated: "Old." }) })) }), + "Nested.", + ); + expect(hasDeprecatedEntries(defineModule("nested", "Nested", [Nested]))).toBe(true); + expect(hasDeprecatedEntries(defineModule("other", "Other", [constant("OTHER", 1, "Other.")]))).toBe(false); + }); +}); + +describe("createModuleScope", () => { + it("only imports used definitions from other modules", () => { + const First = define("First", string(), "First."); + const Second = define("Second", array(First), "Second."); + const firstModule = defineModule("first", "First", [First]); + const secondModule = defineModule("second", "Second", [Second]); + const index = indexRevision({ modules: [firstModule, secondModule] }); + + const scope = createModuleScope(secondModule, index); + expect(scope.use(First)).toBe("First"); + expect(scope.use(Second)).toBe("Second"); + expect(scope.imports()).toStrictEqual(new Map([[firstModule, ["First"]]])); + }); +}); + +describe("validateRevision", () => { + it("every revision is valid", () => { + expect.hasAssertions(); + for (const revision of revisions) { + expect(validateRevision(revision)).toStrictEqual([]); + } + }); + it("reports invalid definitions", () => { + const Outside = define("Outside", string(), "Not in any module."); + const Later = define("Later", string(), "Defined later."); + const Branded = define("Branded", array(string({ brand: true })), "A nested brand."); + const Variant = define( + "Variant", + variant("kind", [object({ kind: property(string()) }), object({ kind: property(literal("b")) })]), + "A variant.", + ); + const Messaged = define("Messaged", union([string(), number()], { message: "missing" }), "A message."); + const UsesLater = define("UsesLater", array(Later), "Uses a later definition."); + const UsesOutside = alias("UsesOutside", Outside, "Aliases an outside definition."); + const Duplicate = define("Later", string(), "A duplicate name."); + + expect( + validateRevision({ + messages: {}, + modules: [ + defineModule("module", "Module", [UsesLater, Later, Branded, Variant, Messaged, UsesOutside, Duplicate]), + ], + }), + ).toStrictEqual([ + "UsesLater references Later before it's defined in module", + "Branded has a branded string that isn't its top-level schema", + 'Variant has a variant option without a literal "kind" entry', + "Messaged uses the message \"missing\", which isn't in the revision's messages", + "UsesOutside references Outside, which isn't part of any module", + "Later is defined more than once", + ]); + }); + it("reports modules that depend on each other", () => { + const First = define("First", string(), "First."); + const Second = define("Second", array(First), "Second."); + const Third = define("Third", array(Second), "Third."); + expect( + validateRevision({ + messages: {}, + modules: [defineModule("first", "First", [First, Third]), defineModule("second", "Second", [Second])], + }), + ).toStrictEqual(["Modules depend on each other in a cycle: first -> second -> first"]); + }); +}); + +describe("walkSchema", () => { + it("visits tuple items with and without a rest item", () => { + const visited: string[] = []; + walkSchema(tuple([{ name: "first", schema: string() }]), (schema) => { + visited.push(schema.kind); + }); + walkSchema(tuple([], { rest: { name: "rest", schema: number() } }), (schema) => { + visited.push(schema.kind); + }); + expect(visited).toStrictEqual(["tuple", "string", "tuple", "number"]); + }); +}); + +describe("referencesOf", () => { + const Target = define("Target", string(), "A target."); + const Alias = alias("Alias", Target, "An alias."); + it("references an alias's target", () => { + expect(referencesOf(Alias)).toStrictEqual([Target]); + }); + it("references a constant's type, if any", () => { + expect(referencesOf(constant("TYPED", "value", "Typed.", { type: Target }))).toStrictEqual([Target]); + expect(referencesOf(constant("UNTYPED", "value", "Untyped."))).toStrictEqual([]); + }); + it("references aliases used by a schema", () => { + expect(referencesOf(define("UsesAlias", array(Alias), "Uses an alias."))).toStrictEqual([Alias]); + }); +}); + +describe("resolveAlias", () => { + it("follows aliases to their target", () => { + const Target = define("Target", string(), "A target."); + expect(resolveAlias(alias("Alias", Target, "An alias."))).toBe(Target); + expect(resolveAlias(Target)).toBe(Target); + }); +}); + +describe("categoriesOf", () => { + it("sorts a definition's categories along with its module's category", () => { + const Categorized = define("Categorized", string(), { description: "Categorized.", categories: ["B", "Z"] }); + const Uncategorized = define("Uncategorized", string(), "Uncategorized."); + const module = defineModule("module", "M", [Categorized, Uncategorized]); + expect(categoriesOf(Categorized, module)).toStrictEqual(["B", "M", "Z"]); + expect(categoriesOf(Uncategorized, module)).toStrictEqual(["M"]); + }); +}); + +describe("indexRevision", () => { + it("finds the module of each definition", () => { + const Inside = define("Inside", string(), "Inside."); + const Outside = define("Outside", string(), "Outside."); + const module = defineModule("module", "Module", [Inside]); + const index = indexRevision({ modules: [module] }); + expect(index.moduleOf(Inside)).toBe(module); + expect(index.findModule(Outside)).toBeUndefined(); + expect(() => index.moduleOf(Outside)).toThrow("Outside isn't part of any module in the revision"); + }); +}); + +describe("findCycle", () => { + it("finds a cycle", () => { + const graph = new Map([ + ["a", new Set(["b"])], + ["b", new Set(["c"])], + ["c", new Set(["b"])], + ]); + expect(findCycle(graph)).toStrictEqual(["b", "c", "b"]); + }); + it("treats dependencies without an entry as having none", () => { + expect(findCycle(new Map([["a", new Set(["b"])]]))).toBeUndefined(); + }); +}); + +describe("defineRevision", () => { + it("names the revision's folder", () => { + expect(defineRevision({ revision: "20000101", modules: [], messages: {} }).folder).toBe("v20000101"); + }); + it("throws for an invalid revision", () => { + const Duplicate = define("Duplicate", string(), "Duplicate."); + expect(() => + defineRevision({ + revision: "20000101", + modules: [defineModule("module", "Module", [Duplicate, Duplicate])], + messages: {}, + }), + ).toThrow("Invalid schema for revision 20000101:\n- Duplicate is defined more than once"); + }); +}); + +describe("renderImport", () => { + it("renders sorted, unique names", () => { + expect(renderImport(["b", "a", "b"], "./module.js")).toBe('import { a, b } from "./module.js";'); + }); + it("renders a type-only import", () => { + expect(renderImport(["A"], "./module.js", { type: true })).toBe('import type { A } from "./module.js";'); + }); + it("renders nothing without names", () => { + expect(renderImport([], "./module.js")).toBe(""); + }); +}); + +describe("joinBlocks", () => { + it("separates non-empty blocks with an empty line", () => { + expect(joinBlocks(["a", "", "b"])).toBe("a\n\nb\n"); + }); +}); + +describe("renderKey", () => { + it("only quotes keys that aren't identifiers", () => { + expect(renderKey("data_updated_at")).toBe("data_updated_at"); + expect(renderKey("content-type")).toBe('"content-type"'); + }); +}); + +describe("generatePackageFiles", () => { + it("generates every module, revision index, and the package index", () => { + const [revision] = revisions; + const files = generatePackageFiles(revisions, { + module: (module) => module.name, + revisionIndex: (current) => current.folder, + rootIndex: (latest) => `latest: ${latest.folder}`, + }); + expect(files).toHaveLength(revision.modules.length + 2); + expect(files).toContainEqual({ path: "src/v20170710/assignments.ts", contents: "assignments" }); + expect(files).toContainEqual({ path: "src/v20170710/index.ts", contents: "v20170710" }); + expect(files.at(-1)).toStrictEqual({ path: "src/index.ts", contents: "latest: v20170710" }); + }); + it("throws without any revisions", () => { + expect(() => generatePackageFiles([], { module: () => "", revisionIndex: () => "", rootIndex: () => "" })).toThrow( + "There are no revisions to generate", + ); + }); +}); + +/** + * A stand-in formatter that settles after one pass. + * + * @param _filePath The path of the file being formatted + * @param contents The contents to format + * @returns The formatted contents + */ +// oxlint-disable-next-line typescript/require-await -- Stands in for an asynchronous formatter +async function appendOnce(_filePath: string, contents: string): Promise { + const suffix = "// Formatted\n"; + return contents.endsWith(suffix) ? contents : `${contents}${suffix}`; +} + +/** + * A stand-in formatter that never settles. + * + * @param _filePath The path of the file being formatted + * @param contents The contents to format + * @returns The formatted contents + */ +// oxlint-disable-next-line typescript/require-await -- Stands in for an asynchronous formatter +async function appendAlways(_filePath: string, contents: string): Promise { + return `${contents}.`; +} + +describe("writeGeneratedFiles", () => { + it("writes formatted files and removes stale generated files only", async () => { + expect.hasAssertions(); + const root = await fs.mkdtemp(path.join(tmpdir(), "wanikani-api-schema-")); + try { + const folder = path.join(root, "src"); + await fs.mkdir(folder); + await fs.writeFile(path.join(folder, "stale.ts"), `${GENERATED_HEADER}\n`); + await fs.writeFile(path.join(folder, "manual.ts"), "// Hand-written\n"); + + const result = await writeGeneratedFiles([{ path: "src/new.ts", contents: "export {};\n" }], { + root, + format: appendOnce, + }); + + expect(result).toStrictEqual({ removed: [path.join("src", "stale.ts")], written: [path.join("src", "new.ts")] }); + await expect(fs.readFile(path.join(folder, "new.ts"), "utf8")).resolves.toBe( + `${GENERATED_HEADER}\n\nexport {};\n// Formatted\n`, + ); + await expect(fs.readdir(folder)).resolves.toStrictEqual(["manual.ts", "new.ts"]); + } finally { + await fs.rm(root, { recursive: true }); + } + }); + it("fails when formatting never settles", async () => { + expect.hasAssertions(); + const root = await fs.mkdtemp(path.join(tmpdir(), "wanikani-api-schema-")); + try { + await expect( + writeGeneratedFiles([{ path: "unsettled.ts", contents: "" }], { + root, + format: appendAlways, + }), + ).rejects.toThrow("didn't settle"); + } finally { + await fs.rm(root, { recursive: true }); + } + }); +}); diff --git a/packages/schema/tsconfig.json b/packages/schema/tsconfig.json new file mode 100644 index 00000000..24840654 --- /dev/null +++ b/packages/schema/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"], + "exclude": ["dist/**"] +} diff --git a/packages/schema/tsdown.config.ts b/packages/schema/tsdown.config.ts new file mode 100644 index 00000000..6abadd3c --- /dev/null +++ b/packages/schema/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + clean: true, + copy: "../../LICENSE", + dts: { sourcemap: true }, + entry: ["src/index.ts", "src/core/index.ts", "src/v20170710/index.ts"], + format: "esm", + target: "es2024", + platform: "node", + exports: true, + publint: { + level: "error", + }, + attw: { + profile: "esm-only", + level: "error", + }, +}); diff --git a/packages/schema/vitest.config.ts b/packages/schema/vitest.config.ts new file mode 100644 index 00000000..693640df --- /dev/null +++ b/packages/schema/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + name: { label: "schema", color: "magenta" }, + include: ["tests/**/*.{test,spec}.ts"], + typecheck: { + enabled: true, + }, + pool: "threads", + isolate: false, + }, +}); diff --git a/packages/types/package.json b/packages/types/package.json index 01969601..8a548d57 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -26,10 +26,12 @@ }, "scripts": { "build": "tsdown --config tsdown.config.ts", - "check": "tsc --noEmit" + "check": "tsc --noEmit && tsc --noEmit -p scripts", + "generate": "node scripts/generate.ts" }, "devDependencies": { - "@bachman-dev/wanikani-api-fixtures": "workspace:*" + "@bachman-dev/wanikani-api-fixtures": "workspace:*", + "@bachman-dev/wanikani-api-schema": "workspace:*" }, "peerDependencies": { "typescript": "5.9.x - 7.0.x" diff --git a/packages/types/scripts/generate.ts b/packages/types/scripts/generate.ts new file mode 100644 index 00000000..3a6ff328 --- /dev/null +++ b/packages/types/scripts/generate.ts @@ -0,0 +1,225 @@ +/* +Generates the type definitions in `src/` from @bachman-dev/wanikani-api-schema. Run with `pnpm generate`. +*/ + +import { fileURLToPath } from "node:url"; + +import { revisions } from "@bachman-dev/wanikani-api-schema"; +import { + type ConstantDefinition, + type Definition, + type Module, + type ModuleScope, + type Property, + type Revision, + type Schema, + type SchemaDefinition, + categoriesOf, + createModuleScope, + generatePackageFiles, + indexRevision, + joinBlocks, + renderImport, + renderJsDoc, + renderKey, + renderLiteral, + resolveAlias, + writeGeneratedFiles, +} from "@bachman-dev/wanikani-api-schema/core"; +import { format } from "oxfmt"; + +import formatConfig from "../../../oxfmt.config.ts"; + +/** + * Checks whether a type needs parentheses when used as an operand of `&`. + * + * @param schema The type's schema + * @returns Whether parentheses are needed + */ +function needsParentheses(schema: Schema): boolean { + switch (schema.kind) { + case "nullable": + case "union": + case "variant": + return true; + case "picklist": + return schema.options.length > 1; + default: + return false; + } +} + +/** + * Checks whether a type needs parentheses when used as an array item. + * + * @param schema The type's schema + * @returns Whether parentheses are needed + */ +function needsArrayParentheses(schema: Schema): boolean { + return ( + needsParentheses(schema) || schema.kind === "intersect" || (schema.kind === "object" && schema.extends.length > 0) + ); +} + +function renderProperties(entries: Readonly>, render: (schema: Schema) => string): string { + const properties = Object.entries(entries).map(([key, property]) => { + const docs = renderJsDoc(property.docs); + const declaration = `${renderKey(key)}${property.optional ? "?" : ""}: ${render(property.schema)};`; + return docs === "" ? declaration : `${docs}\n${declaration}`; + }); + return `{\n${properties.join("\n\n")}\n}`; +} + +function renderType(schema: Schema, scope: ModuleScope): string { + const render = (inner: Schema): string => renderType(inner, scope); + const renderOperand = (inner: Schema): string => (needsParentheses(inner) ? `(${render(inner)})` : render(inner)); + const renderArrayItem = (inner: Schema): string => + needsArrayParentheses(inner) ? `(${render(inner)})` : render(inner); + + switch (schema.kind) { + case "alias": + case "definition": + return scope.use(resolveAlias(schema)); + case "literal": + return renderLiteral(schema.value); + case "boolean": + case "never": + case "number": + case "string": + return schema.kind; + case "date": + return "Date"; + case "picklist": + return schema.options.map((option) => renderLiteral(option)).join(" | "); + case "array": + return `${renderArrayItem(schema.item)}[]`; + case "tuple": { + const items = schema.items.map((item) => `${item.name}: ${render(item.schema)}`); + if (schema.rest !== undefined) { + items.push(`...${schema.rest.name}: ${renderArrayItem(schema.rest.schema)}[]`); + } + return `[${items.join(", ")}]`; + } + case "nullable": + return `${render(schema.wrapped)} | null`; + case "object": + return [...schema.extends.map((parent) => scope.use(parent)), renderProperties(schema.entries, render)].join( + " & ", + ); + case "partial": + return `Partial<${scope.use(schema.source)}>`; + case "union": + case "variant": + return schema.options.map((option) => render(option)).join(" | "); + case "intersect": + return schema.options.map((option) => renderOperand(option)).join(" & "); + default: + throw new Error(`Unsupported schema: ${JSON.stringify(schema satisfies never)}`); + } +} + +function brandSymbolName(definition: Definition): string { + return `${definition.name.charAt(0).toLowerCase()}${definition.name.slice(1)}Brand`; +} + +function isBranded(definition: Definition): boolean { + return definition.kind === "definition" && definition.schema.kind === "string" && definition.schema.brand === true; +} + +function renderDefinitionType(definition: SchemaDefinition, scope: ModuleScope): string { + const { name, schema } = definition; + switch (schema.kind) { + case "object": { + const parents = schema.extends.map((parent) => scope.use(parent)); + const heritage = parents.length > 0 ? ` extends ${parents.join(", ")}` : ""; + const body = renderProperties(schema.entries, (inner) => renderType(inner, scope)); + return `export interface ${name}${heritage} ${body}`; + } + case "number": + // N.b. `& {}` keeps the type's name (and docs) when it's displayed, instead of collapsing it into `number` + return `export type ${name} = number & {};`; + case "string": + return schema.brand === true + ? `export type ${name} = string & { readonly [${brandSymbolName(definition)}]: ${renderLiteral(name)} };` + : `export type ${name} = string;`; + default: + return `export type ${name} = ${renderType(schema, scope)};`; + } +} + +function renderConstant(definition: ConstantDefinition, scope: ModuleScope): string { + const annotation = definition.type === undefined ? "" : `: ${scope.use(definition.type)}`; + return `export const ${definition.name}${annotation} = ${renderLiteral(definition.value)};`; +} + +function renderDefinition(definition: Definition, scope: ModuleScope): string { + const docs = renderJsDoc(definition.docs, { categories: categoriesOf(definition, scope.module) }); + switch (definition.kind) { + case "alias": + return `${docs}\nexport type ${definition.name} = ${scope.use(definition.target)};`; + case "constant": + return `${docs}\n${renderConstant(definition, scope)}`; + case "definition": + return `${docs}\n${renderDefinitionType(definition, scope)}`; + default: + throw new Error(`Unsupported definition: ${JSON.stringify(definition satisfies never)}`); + } +} + +function generateModule(module: Module, revision: Revision): string { + const index = indexRevision(revision); + const scope = createModuleScope(module, index); + + const brands = module.definitions + .filter((definition) => isBranded(definition)) + .map((definition) => { + const docs = `/** A type-only symbol used to brand {@link ${definition.name}}, so a plain \`string\` can't be mistaken for one. */`; + return `${docs}\ndeclare const ${brandSymbolName(definition)}: unique symbol;`; + }); + const definitions = module.definitions.map((definition) => renderDefinition(definition, scope)); + const imports = [...scope.imports()].map(([importedModule, names]) => + renderImport(names, `./${importedModule.name}.js`, { type: true }), + ); + + return joinBlocks([imports.join("\n"), ...brands, ...definitions]); +} + +function generateRevisionIndex(revision: Revision): string { + const exports = revision.modules.map((module) => { + const hasValues = module.definitions.some((definition) => definition.kind === "constant"); + return `export ${hasValues ? "*" : "type *"} from "./${module.name}.js";`; + }); + return joinBlocks([ + `// oxlint-disable oxc/no-barrel-file -- This is an entrypoint for the ${revision.folder} API revision\n/** @category API Reference */`, + exports.join("\n"), + ]); +} + +function generateRootIndex(revision: Revision): string { + return joinBlocks([ + "/** @category API Reference */", + "/*\nThis file exports all the types available in the recommended API revision. Thereby, things MAY break in the future.\nTry sticking to imports from a specific revision.\n*/", + `export * from "./${revision.folder}/index.js";`, + ]); +} + +const files = generatePackageFiles(revisions, { + module: generateModule, + revisionIndex: generateRevisionIndex, + rootIndex: generateRootIndex, +}); + +const { removed, written } = await writeGeneratedFiles(files, { + root: fileURLToPath(new URL("..", import.meta.url)), + format: async (filePath, contents) => { + const result = await format(filePath, contents, formatConfig); + if (result.errors.length > 0) { + throw new Error(`Failed to format ${filePath}: ${JSON.stringify(result.errors)}`); + } + return result.code; + }, +}); + +process.stdout.write( + `Generated ${written.length} files${removed.length > 0 ? `, removed ${removed.join(", ")}` : ""}\n`, +); diff --git a/packages/types/scripts/tsconfig.json b/packages/types/scripts/tsconfig.json new file mode 100644 index 00000000..09f49760 --- /dev/null +++ b/packages/types/scripts/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig", + "compilerOptions": { + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 97761b91..a15ea860 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + /** @category API Reference */ /* diff --git a/packages/types/src/v20170710/assignments.ts b/packages/types/src/v20170710/assignments.ts index b73a49f4..317ed723 100644 --- a/packages/types/src/v20170710/assignments.ts +++ b/packages/types/src/v20170710/assignments.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; import type { SpacedRepetitionSystemStageNumber } from "./spaced-repetition-systems.js"; import type { SubjectTuple, SubjectType } from "./subjects.js"; @@ -83,7 +85,6 @@ export interface AssignmentCollection extends BaseCollection { * @category Assignments * @category Parameters * @see {@link https://docs.api.wanikani.com/20170710/#get-all-assignments} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export interface AssignmentParameters extends CollectionParameters { /** Only assignments available at or after this time are returned. */ diff --git a/packages/types/src/v20170710/base.ts b/packages/types/src/v20170710/base.ts index 06920c70..0a47281c 100644 --- a/packages/types/src/v20170710/base.ts +++ b/packages/types/src/v20170710/base.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + /** A type-only symbol used to brand {@link DatableString}, so a plain `string` can't be mistaken for one. */ declare const datableStringBrand: unique symbol; @@ -119,7 +121,6 @@ export interface BaseCollection { * * @category Base * @category Parameters - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export interface CollectionParameters { /** Only resources where `data.id` matches one of the array values are returned. */ diff --git a/packages/types/src/v20170710/index.ts b/packages/types/src/v20170710/index.ts index 82463381..06016436 100644 --- a/packages/types/src/v20170710/index.ts +++ b/packages/types/src/v20170710/index.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + // oxlint-disable oxc/no-barrel-file -- This is an entrypoint for the v20170710 API revision /** @category API Reference */ diff --git a/packages/types/src/v20170710/level-progressions.ts b/packages/types/src/v20170710/level-progressions.ts index 0bf0832b..7fd36a68 100644 --- a/packages/types/src/v20170710/level-progressions.ts +++ b/packages/types/src/v20170710/level-progressions.ts @@ -1,4 +1,6 @@ -import type { BaseCollection, BaseResource, DatableString, Level, SafeInteger } from "./base.js"; +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +import type { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; /** * Level progressions contain information about a user's progress through the WaniKani levels. @@ -67,6 +69,5 @@ export interface LevelProgressionCollection extends BaseCollection { * @category Level Progressions * @category Parameters * @see {@link https://docs.api.wanikani.com/20170710/#get-all-level-progressions} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export type { CollectionParameters as LevelProgressionParameters } from "./base.js"; +export type LevelProgressionParameters = CollectionParameters; diff --git a/packages/types/src/v20170710/resets.ts b/packages/types/src/v20170710/resets.ts index 924c5a8d..01a8cc02 100644 --- a/packages/types/src/v20170710/resets.ts +++ b/packages/types/src/v20170710/resets.ts @@ -1,4 +1,6 @@ -import type { BaseCollection, BaseResource, DatableString, Level, SafeInteger } from "./base.js"; +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +import type { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; /** * Users can reset their progress back to any level at or below their current level. When they reset to a particular @@ -51,6 +53,5 @@ export interface ResetCollection extends BaseCollection { * @category Parameters * @category Resets * @see {@link https://docs.api.wanikani.com/20170710/#get-all-resets} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export type { CollectionParameters as ResetParameters } from "./base.js"; +export type ResetParameters = CollectionParameters; diff --git a/packages/types/src/v20170710/review-statistics.ts b/packages/types/src/v20170710/review-statistics.ts index 37ceabc7..4cea683a 100644 --- a/packages/types/src/v20170710/review-statistics.ts +++ b/packages/types/src/v20170710/review-statistics.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; import type { SubjectTuple, SubjectType } from "./subjects.js"; @@ -80,7 +82,6 @@ export interface ReviewStatisticCollection extends BaseCollection { * @category Parameters * @category Review Statistics * @see {@link https://docs.api.wanikani.com/20170710/#get-all-review-statistics} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export interface ReviewStatisticParameters extends CollectionParameters { /** Return review statistics with a matching value in the `hidden` attribute. */ diff --git a/packages/types/src/v20170710/reviews.ts b/packages/types/src/v20170710/reviews.ts index 06fa536c..74e00b5c 100644 --- a/packages/types/src/v20170710/reviews.ts +++ b/packages/types/src/v20170710/reviews.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type { Assignment } from "./assignments.js"; import type { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; import type { ReviewStatistic } from "./review-statistics.js"; @@ -68,7 +70,6 @@ export interface ReviewCollection extends BaseCollection { * @category Parameters * @category Reviews * @see {@link https://docs.api.wanikani.com/20170710/#get-all-reviews} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export interface ReviewParameters extends CollectionParameters { /** Only reviews where `data.assignment_id` matches one of the array values are returned. */ diff --git a/packages/types/src/v20170710/spaced-repetition-systems.ts b/packages/types/src/v20170710/spaced-repetition-systems.ts index 76f7a6e6..fa02edce 100644 --- a/packages/types/src/v20170710/spaced-repetition-systems.ts +++ b/packages/types/src/v20170710/spaced-repetition-systems.ts @@ -1,4 +1,6 @@ -import type { BaseCollection, BaseResource, DatableString, SafeInteger } from "./base.js"; +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +import type { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; /** * The minimum SRS Stage Number used in WaniKani's SRS; exported for use in lieu of a Magic Number. @@ -106,6 +108,5 @@ export interface SpacedRepetitionSystemCollection extends BaseCollection { * @category Parameters * @category Spaced Repetition Systems * @see {@link https://docs.api.wanikani.com/20170710/#get-all-spaced-repetition-systems} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export type { CollectionParameters as SpacedRepetitionSystemParameters } from "./base.js"; +export type SpacedRepetitionSystemParameters = CollectionParameters; diff --git a/packages/types/src/v20170710/study-materials.ts b/packages/types/src/v20170710/study-materials.ts index 3b66b3d3..55f5895b 100644 --- a/packages/types/src/v20170710/study-materials.ts +++ b/packages/types/src/v20170710/study-materials.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; import type { SubjectTuple, SubjectType } from "./subjects.js"; @@ -69,7 +71,6 @@ export interface StudyMaterialCollection extends BaseCollection { * @category Parameters * @category Study Materials * @see {@link https://docs.api.wanikani.com/20170710/#get-all-study-materials} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export interface StudyMaterialParameters extends CollectionParameters { /** Return study materials with a matching value in the `hidden` attribute. */ diff --git a/packages/types/src/v20170710/subjects.ts b/packages/types/src/v20170710/subjects.ts index 4e1d561c..750f016f 100644 --- a/packages/types/src/v20170710/subjects.ts +++ b/packages/types/src/v20170710/subjects.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; /** @@ -113,12 +115,15 @@ export type RadicalCharacterImage = { | { /** The content type of the image. */ content_type: "image/png"; + /** Details about the image. Each `content_type` returns a uniquely structured object. */ metadata: { /** Color of the asset in hexadecimal */ color: string; + /** Dimension of the asset in pixels */ dimensions: string; + /** A name descriptor */ style_name: string; }; @@ -391,7 +396,6 @@ export interface SubjectCollection extends BaseCollection { * @category Parameters * @category Subjects * @see {@link https://docs.api.wanikani.com/20170710/#get-all-subjects} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export interface SubjectParameters extends CollectionParameters { /** Return subjects which are or are not hidden from the user-facing application. */ diff --git a/packages/types/src/v20170710/summary.ts b/packages/types/src/v20170710/summary.ts index a61636a4..bb5f52e9 100644 --- a/packages/types/src/v20170710/summary.ts +++ b/packages/types/src/v20170710/summary.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type { BaseReport, DatableString, SafeInteger } from "./base.js"; /** diff --git a/packages/types/src/v20170710/user.ts b/packages/types/src/v20170710/user.ts index 3cb3f73b..7d63e347 100644 --- a/packages/types/src/v20170710/user.ts +++ b/packages/types/src/v20170710/user.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type { BaseResource, DatableString, Level, SafeInteger } from "./base.js"; /** @@ -30,7 +32,7 @@ export type LessonBatchSizeNumber = number & {}; export interface UserPreferences { /** * @deprecated This is a deprecated user preference. It will always return `1` and cannot be set. It exists only to - * ensure existing consumers of this API don't break. + * ensure existing consumers of this API don't break. */ default_voice_actor_id: SafeInteger; @@ -45,7 +47,7 @@ export interface UserPreferences { /** * @deprecated This is a deprecated user preference. It always returns `ascending_level_then_subject`. Setting this - * preference will do nothing. It exists only to ensure existing consumers of this API don't break. + * preference will do nothing. It exists only to ensure existing consumers of this API don't break. */ lessons_presentation_order: "ascending_level_then_shuffled" | "ascending_level_then_subject" | "shuffled"; diff --git a/packages/types/src/v20170710/voice-actors.ts b/packages/types/src/v20170710/voice-actors.ts index 55beae12..0579803c 100644 --- a/packages/types/src/v20170710/voice-actors.ts +++ b/packages/types/src/v20170710/voice-actors.ts @@ -1,4 +1,6 @@ -import type { BaseCollection, BaseResource, DatableString, SafeInteger } from "./base.js"; +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +import type { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; /** * Available voice actors used for vocabulary reading pronunciation audio. @@ -48,6 +50,5 @@ export interface VoiceActorCollection extends BaseCollection { * @category Parameters * @category Voice Actors * @see {@link https://docs.api.wanikani.com/20170710/#get-all-voice-actors} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export type { CollectionParameters as VoiceActorParameters } from "./base.js"; +export type VoiceActorParameters = CollectionParameters; diff --git a/packages/types/vitest.config.ts b/packages/types/vitest.config.ts index 0727a93f..db93d456 100644 --- a/packages/types/vitest.config.ts +++ b/packages/types/vitest.config.ts @@ -8,5 +8,6 @@ export default defineConfig({ enabled: true, }, pool: "threads", + isolate: false, }, }); diff --git a/packages/utils/vitest.config.ts b/packages/utils/vitest.config.ts index 1e0d8c5b..be73b2b7 100644 --- a/packages/utils/vitest.config.ts +++ b/packages/utils/vitest.config.ts @@ -8,5 +8,6 @@ export default defineConfig({ enabled: true, }, pool: "threads", + isolate: false, }, }); diff --git a/packages/valibot/package.json b/packages/valibot/package.json index 6460a04d..1487f001 100644 --- a/packages/valibot/package.json +++ b/packages/valibot/package.json @@ -26,13 +26,15 @@ }, "scripts": { "build": "tsdown --config tsdown.config.ts", - "check": "tsc --noEmit" + "check": "tsc --noEmit && tsc --noEmit -p scripts", + "generate": "node scripts/generate.ts" }, "dependencies": { "@bachman-dev/wanikani-api-types": "workspace:*" }, "devDependencies": { "@bachman-dev/wanikani-api-fixtures": "workspace:*", + "@bachman-dev/wanikani-api-schema": "workspace:*", "valibot": "1.5.0" }, "peerDependencies": { diff --git a/packages/valibot/scripts/generate.ts b/packages/valibot/scripts/generate.ts new file mode 100644 index 00000000..6d2e6040 --- /dev/null +++ b/packages/valibot/scripts/generate.ts @@ -0,0 +1,278 @@ +/* +Generates the Valibot schema in `src/` from @bachman-dev/wanikani-api-schema. Run with `pnpm generate`. + +Hand-written custom messages live in `src//lang/`, exported under the message keys used by the schema. +*/ + +import { fileURLToPath } from "node:url"; + +import { revisions } from "@bachman-dev/wanikani-api-schema"; +import { + type ConstantDefinition, + type Definition, + type Module, + type ModuleScope, + type NumberBound, + type Property, + type Revision, + type Schema, + type SchemaDefinition, + type StringSchema, + categoriesOf, + createModuleScope, + generatePackageFiles, + hasDeprecatedEntries, + indexRevision, + joinBlocks, + numberChecks, + objectEntries, + renderImport, + renderJsDoc, + renderKey, + renderLiteral, + resolveAlias, + writeGeneratedFiles, +} from "@bachman-dev/wanikani-api-schema/core"; +import { format } from "oxfmt"; + +import formatConfig from "../../../oxfmt.config.ts"; + +/** State for generating one module. */ +interface Context { + /** Tracks definitions used from other modules. */ + readonly scope: ModuleScope; + + /** Constants used by the module, imported from the types package. */ + readonly constants: Set; + + /** Whether the module uses custom messages. */ + usesMessages: boolean; +} + +function typesPackage(revision: Revision): string { + return `@bachman-dev/wanikani-api-types/${revision.folder}`; +} + +function renderMessage(message: string | undefined, context: Context): string { + if (message === undefined) { + return ""; + } + context.usesMessages = true; + return `, m.${message}`; +} + +function renderBound(bound: NumberBound, context: Context): string { + if (typeof bound === "number") { + return String(bound); + } + context.constants.add(bound.name); + return bound.name; +} + +function renderEntries( + entries: Iterable<[string, Property]>, + render: (key: string, property: Property) => string, +): string { + const lines = [...entries].map(([key, property]) => { + const docs = renderJsDoc(property.docs); + const entry = `${renderKey(key)}: ${render(key, property)},`; + return docs === "" ? entry : `${docs}\n${entry}`; + }); + return `{\n${lines.join("\n")}\n}`; +} + +function renderString(schema: StringSchema, brand: string | undefined): string { + const actions = ["v.string()"]; + if (schema.trim === true) { + actions.push("v.trim()"); + } + if (schema.format === "iso-timestamp") { + actions.push("v.isoTimestamp()"); + } + if (brand !== undefined) { + actions.push( + `// oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion -- Validated above\nv.transform((input) => input as ${brand})`, + ); + } + return actions.length === 1 ? "v.string()" : `v.pipe(${actions.join(",\n")})`; +} + +function renderSchema(schema: Schema, context: Context, brand?: string): string { + const render = (inner: Schema): string => renderSchema(inner, context); + const renderProperty = (property: Property): string => + property.optional ? `v.exactOptional(${render(property.schema)})` : render(property.schema); + + switch (schema.kind) { + case "alias": + case "definition": + return context.scope.use(resolveAlias(schema)); + case "literal": + return `v.literal(${renderLiteral(schema.value)})`; + case "string": + return renderString(schema, brand); + case "number": { + const checks = numberChecks(schema); + const actions = [schema.base === undefined ? "v.number()" : context.scope.use(schema.base)]; + if (checks.integer) { + actions.push("v.safeInteger()"); + } + if (checks.min !== undefined) { + actions.push(`v.minValue(${renderBound(checks.min, context)})`); + } + if (checks.max !== undefined) { + actions.push(`v.maxValue(${renderBound(checks.max, context)})`); + } + return actions.length === 1 ? actions.join("") : `v.pipe(${actions.join(",\n")})`; + } + case "boolean": + case "date": + case "never": + return `v.${schema.kind}()`; + case "picklist": + return `v.picklist([${schema.options.map((option) => renderLiteral(option)).join(", ")}])`; + case "array": + return `v.array(${render(schema.item)})`; + case "tuple": { + const items = `[${schema.items.map((item) => render(item.schema)).join(", ")}]`; + const tuple = + schema.rest === undefined ? `v.tuple(${items})` : `v.tupleWithRest(${items}, ${render(schema.rest.schema)})`; + if (schema.unique === undefined) { + return tuple; + } + const unique = "(item, index, array) => array.indexOf(item) === index"; + return `v.pipe(${tuple}, v.checkItems(${unique}${renderMessage(schema.unique.message, context)}))`; + } + case "nullable": + return `v.nullable(${render(schema.wrapped)})`; + case "object": { + const entries = renderEntries(Object.entries(schema.entries), (_key, property) => renderProperty(property)); + if (schema.extends.length === 0) { + return `v.object(${entries})`; + } + const parents = schema.extends.map((parent) => context.scope.use(parent)); + return `v.object(v.entriesFromObjects([${parents.join(", ")}, v.object(${entries})]))`; + } + case "partial": { + // N.b. v.partial() wraps entries with v.optional(), which would allow properties to be explicitly `undefined` + const name = context.scope.use(schema.source); + const entries = renderEntries(objectEntries(schema.source), (key) => `v.exactOptional(${name}.entries.${key})`); + return `v.object(${entries})`; + } + case "union": + return `v.union([${schema.options.map((option) => render(option)).join(", ")}]${renderMessage(schema.message, context)})`; + case "variant": + return `v.variant(${renderLiteral(schema.key)}, [${schema.options.map((option) => render(option)).join(", ")}])`; + case "intersect": + return `v.intersect([${schema.options.map((option) => render(option)).join(", ")}]${renderMessage(schema.message, context)})`; + default: + throw new Error(`Unsupported schema: ${JSON.stringify(schema satisfies never)}`); + } +} + +function renderTypeGuard(definition: SchemaDefinition, module: Module): string { + const docs = renderJsDoc( + { description: "A type guard that checks if the given value matches the type predicate." }, + { + categories: [module.category, "Type Guards"], + tags: ["@param value An unknown value", "@returns A type predicate"], + }, + ); + const { name } = definition; + return `${docs}\n// @__NO_SIDE_EFFECTS__\nexport function is${name}(value: unknown): value is ${name} {\n return v.is(${name}, value);\n}`; +} + +function renderDefinition(definition: Definition, context: Context): string { + const { module } = context.scope; + const docs = renderJsDoc(definition.docs, { categories: categoriesOf(definition, module) }); + switch (definition.kind) { + case "alias": { + const { name } = definition; + return `${docs} +export type ${name} = Types.${name}; +export const ${name} = ${context.scope.use(definition.target)};`; + } + case "constant": + // Constants are re-exported from the types package, see generateModule() + return ""; + case "definition": { + const { name, schema } = definition; + const brand = schema.kind === "string" && schema.brand === true ? name : undefined; + const declaration = `${docs}\nexport type ${name} = Types.${name};\nexport const ${name} = ${renderSchema(schema, context, brand)};`; + return definition.typeGuard ? `${declaration}\n\n${renderTypeGuard(definition, module)}` : declaration; + } + default: + throw new Error(`Unsupported definition: ${JSON.stringify(definition satisfies never)}`); + } +} + +function generateModule(module: Module, revision: Revision): string { + const index = indexRevision(revision); + const context: Context = { scope: createModuleScope(module, index), constants: new Set(), usesMessages: false }; + + const definitions = module.definitions.map((definition) => renderDefinition(definition, context)); + const hasSchema = module.definitions.some((definition) => definition.kind !== "constant"); + const constants = module.definitions + .filter((definition): definition is ConstantDefinition => definition.kind === "constant") + .map((definition) => definition.name); + + const directives = [ + hasDeprecatedEntries(module) + ? "/* oxlint-disable typescript/no-deprecated -- The API still returns deprecated properties, so they're validated */" + : "", + module.definitions.some((definition) => definition.kind === "alias") + ? "/* oxlint-disable unicorn/prefer-export-from -- Aliases are declared, rather than re-exported, so they're documented */" + : "", + ]; + const imports = [ + hasSchema ? `import type * as Types from "${typesPackage(revision)}";\nimport * as v from "valibot";` : "", + renderImport(context.constants, typesPackage(revision)), + context.usesMessages ? `import * as m from "./lang/index.js";` : "", + ...[...context.scope.imports()].map(([importedModule, names]) => + renderImport(names, `./${importedModule.name}.js`), + ), + ]; + const reexports = constants.length > 0 ? `export { ${constants.join(", ")} } from "${typesPackage(revision)}";` : ""; + + return joinBlocks([ + directives.filter((line) => line !== "").join("\n"), + imports.filter((line) => line !== "").join("\n"), + reexports, + ...definitions, + ]); +} + +function generateRevisionIndex(revision: Revision): string { + return joinBlocks([ + `// oxlint-disable oxc/no-barrel-file -- This is an entrypoint for the ${revision.folder} API revision\n/** @category API Reference */`, + revision.modules.map((module) => `export * from "./${module.name}.js";`).join("\n"), + ]); +} + +function generateRootIndex(revision: Revision): string { + return joinBlocks([ + "/** @category API Reference */", + "/*\nThis file exports all the types available in the recommended API revision. Thereby, things MAY break in the future.\nTry sticking to imports from a specific revision.\n*/", + `export * from "./${revision.folder}/index.js";`, + ]); +} + +const files = generatePackageFiles(revisions, { + module: generateModule, + revisionIndex: generateRevisionIndex, + rootIndex: generateRootIndex, +}); + +const { removed, written } = await writeGeneratedFiles(files, { + root: fileURLToPath(new URL("..", import.meta.url)), + format: async (filePath, contents) => { + const result = await format(filePath, contents, formatConfig); + if (result.errors.length > 0) { + throw new Error(`Failed to format ${filePath}: ${JSON.stringify(result.errors)}`); + } + return result.code; + }, +}); + +process.stdout.write( + `Generated ${written.length} files${removed.length > 0 ? `, removed ${removed.join(", ")}` : ""}\n`, +); diff --git a/packages/valibot/scripts/tsconfig.json b/packages/valibot/scripts/tsconfig.json new file mode 100644 index 00000000..09f49760 --- /dev/null +++ b/packages/valibot/scripts/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig", + "compilerOptions": { + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/packages/valibot/src/index.ts b/packages/valibot/src/index.ts index 97761b91..a15ea860 100644 --- a/packages/valibot/src/index.ts +++ b/packages/valibot/src/index.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + /** @category API Reference */ /* diff --git a/packages/valibot/src/v20170710/assignments.ts b/packages/valibot/src/v20170710/assignments.ts index 12fcdfe3..de6c3f7f 100644 --- a/packages/valibot/src/v20170710/assignments.ts +++ b/packages/valibot/src/v20170710/assignments.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; @@ -20,20 +22,44 @@ export const Assignment = v.object( v.entriesFromObjects([ BaseResource, v.object({ + /** Data for the returned assignment. */ data: v.object({ + /** When the related subject will be available in the user's review queue. */ available_at: v.nullable(DatableString), + /** When the user reaches SRS stage 9 the first time. */ burned_at: v.nullable(DatableString), + /** When the assignment was created. */ created_at: DatableString, + /** Indicates if the associated subject has been hidden, preventing it from appearing in lessons or reviews. */ hidden: v.boolean(), + /** When the user reaches SRS stage `5` for the first time. */ passed_at: v.nullable(DatableString), + /** When the subject is resurrected and placed back in the user's review queue. */ resurrected_at: v.nullable(DatableString), + /** + * The current SRS stage interval. The interval range is determined by the related subject's Spaced Repetition + * System. + */ srs_stage: SpacedRepetitionSystemStageNumber, + /** When the user completes the lesson for the related subject. */ started_at: v.nullable(DatableString), + /** Unique identifier of the associated subject. */ subject_id: SafeInteger, + /** The type of the associated subject. */ subject_type: SubjectType, + /** + * When the related subjects has its prerequisites satisfied and is made available in lessons. + * + * Prerequisites are: + * + * - The subject components have reached SRS stage 5 once (they have been “passed”). + * - The user's level is equal to or greater than the level of the assignment’s subject. + */ unlocked_at: v.nullable(DatableString), }), + /** A unique number identifying the assignment. */ id: SafeInteger, + /** The kind of object returned. */ object: v.literal("assignment"), }), ]), @@ -64,6 +90,7 @@ export const AssignmentCollection = v.object( v.entriesFromObjects([ BaseCollection, v.object({ + /** An array of returned assignments. */ data: v.array(Assignment), }), ]), @@ -88,25 +115,52 @@ export function isAssignmentCollection(value: unknown): value is AssignmentColle * @category Assignments * @category Parameters * @see {@link https://docs.api.wanikani.com/20170710/#get-all-assignments} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type AssignmentParameters = Types.AssignmentParameters; export const AssignmentParameters = v.object( v.entriesFromObjects([ CollectionParameters, v.object({ + /** Only assignments available at or after this time are returned. */ available_after: v.exactOptional(v.union([DatableString, v.date()], m.dateUnion)), + /** Only assignments available at or before this time are returned. */ available_before: v.exactOptional(v.union([DatableString, v.date()], m.dateUnion)), + /** + * When set to `true`, returns assignments that have a value in `data.burned_at`. Returns assignments with a + * `null` `data.burned_at` if `false`. + */ burned: v.exactOptional(v.boolean()), + /** Return assignments with a matching value in the `hidden` attribute. */ hidden: v.exactOptional(v.boolean()), + /** When set to `true`, returns assignments which are immediately available for lessons. */ immediately_available_for_lessons: v.exactOptional(v.boolean()), + /** When set to `true`, returns assignments which are immediately available for review. */ immediately_available_for_review: v.exactOptional(v.boolean()), + /** When set to `true`, returns assignments which are in the review state. */ in_review: v.exactOptional(v.boolean()), + /** + * Only assignments where the associated subject level matches one of the array values are returned. Valid values + * range from `1` to `60`. + */ levels: v.exactOptional(v.array(Level)), + /** + * Only assignments where `data.srs_stage` matches one of the array values are returned. Valid values range from + * `0` to `9`. + */ srs_stages: v.exactOptional(v.array(SpacedRepetitionSystemStageNumber)), + /** + * When set to `true`, returns assignments that have a value in `data.started_at`. Returns assignments with a + * `null` `data.started_at` if `false`. + */ started: v.exactOptional(v.boolean()), + /** Only assignments where `data.subject_id` matches one of the array values are returned. */ subject_ids: v.exactOptional(v.array(SafeInteger)), + /** Only assignments where `data.subject_type` matches one of the array values are returned. */ subject_types: v.exactOptional(SubjectTuple), + /** + * When set to true, returns assignments that have a value in `data.unlocked_at`. Returns assignments with a + * `null` `data.unlocked_at` if `false`. + */ unlocked: v.exactOptional(v.boolean()), }), ]), @@ -121,7 +175,9 @@ export const AssignmentParameters = v.object( */ export type AssignmentPayload = Types.AssignmentPayload; export const AssignmentPayload = v.object({ + /** Specify properties of the Assignment; currently only `started_at` is supported. */ assignment: v.object({ + /** When the assignment was started. Must be greater than or equal to the assignment's `unlocked_at` date. */ started_at: v.exactOptional(v.union([DatableString, v.date()], m.dateUnion)), }), }); diff --git a/packages/valibot/src/v20170710/base.ts b/packages/valibot/src/v20170710/base.ts index 23056e1b..f3061109 100644 --- a/packages/valibot/src/v20170710/base.ts +++ b/packages/valibot/src/v20170710/base.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; import { MAX_LEVEL, MIN_LEVEL } from "@bachman-dev/wanikani-api-types/v20170710"; @@ -39,6 +41,9 @@ export const SafeInteger = v.pipe(v.number(), v.safeInteger(), v.minValue(0)); /** * A `string` sent to/returned from the WaniKani API that can be converted into a JavaScript `Date` object. * + * @remarks + * This is a branded type. A `string` can be narrowed to it by validating it with one of the schema library packages + * (e.g. `@bachman-dev/wanikani-api-valibot`), or by using a type assertion if it's known to be valid. * @category Base */ export type DatableString = Types.DatableString; @@ -95,7 +100,9 @@ export function isLevel(value: unknown): value is Level { */ export type BaseResource = Types.BaseResource; export const BaseResource = v.object({ + /** For a resource, this is the last time that particular resource was updated. */ data_updated_at: DatableString, + /** The URL of the requested resource. */ url: v.string(), }); @@ -110,14 +117,28 @@ export const BaseResource = v.object({ */ export type BaseCollection = Types.BaseCollection; export const BaseCollection = v.object({ + /** + * For collections, this is the timestamp of the most recently updated resource in the specified scope and is not + * limited by pagination. If no items were returned for the specified scope, then this will be `null`. + */ data_updated_at: v.nullable(DatableString), + /** The kind of object returned. */ object: v.literal("collection"), + /** Pagination Info for the collection. */ pages: v.object({ + /** The URL of the next page of results. If there are no more results, the value is `null`. */ next_url: v.nullable(v.string()), + /** Maximum number of items delivered per page for this collection. */ per_page: SafeInteger, + /** + * The URL of the previous page of results. If there are no results at all or no previous page to go to, the value + * is `null`. + */ previous_url: v.nullable(v.string()), }), + /** The total number of items in the collection. */ total_count: SafeInteger, + /** The URL of the request. For collections, that will contain all the filters and options you've passed to the API. */ url: v.string(), }); @@ -126,13 +147,24 @@ export const BaseCollection = v.object({ * * @category Base * @category Parameters - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type CollectionParameters = Types.CollectionParameters; export const CollectionParameters = v.object({ + /** Only resources where `data.id` matches one of the array values are returned. */ ids: v.exactOptional(v.array(SafeInteger)), + /** + * Get a collection's next page containing `pages.per_page` resources after the given ID. + * + * This will take precedence over `page_before_id` if both are specified. + */ page_after_id: v.exactOptional(SafeInteger), + /** + * Get a collection's previous page containing `pages.per_page` resources before the given ID. + * + * The `page_after_id` parameter takes precedence over this if it is specified alongside this parameter. + */ page_before_id: v.exactOptional(SafeInteger), + /** Only resources updated after this time are returned. */ updated_after: v.exactOptional(v.union([DatableString, v.date()], m.dateUnion)), }); @@ -147,8 +179,11 @@ export const CollectionParameters = v.object({ */ export type BaseReport = Types.BaseReport; export const BaseReport = v.object({ + /** The last time the report was updated. */ data_updated_at: DatableString, + /** The kind of object returned. */ object: v.literal("report"), + /** The URL of the requested report. */ url: v.string(), }); @@ -159,7 +194,9 @@ export const BaseReport = v.object({ */ export type ApiError = Types.ApiError; export const ApiError = v.object({ + /** An HTTP status code indicating the type of error. */ code: SafeInteger, + /** A message string that describes the error. */ error: v.string(), }); diff --git a/packages/valibot/src/v20170710/index.ts b/packages/valibot/src/v20170710/index.ts index 17d161db..f8b4bc41 100644 --- a/packages/valibot/src/v20170710/index.ts +++ b/packages/valibot/src/v20170710/index.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + // oxlint-disable oxc/no-barrel-file -- This is an entrypoint for the v20170710 API revision /** @category API Reference */ diff --git a/packages/valibot/src/v20170710/lang/index.ts b/packages/valibot/src/v20170710/lang/index.ts index 8c0b54d1..7b909319 100644 --- a/packages/valibot/src/v20170710/lang/index.ts +++ b/packages/valibot/src/v20170710/lang/index.ts @@ -1,3 +1,4 @@ export { default as dateUnion } from "./date-union.js"; export { default as reviewPayloadIntersect } from "./review-payload-intersect.js"; export { default as reviewPayloadUnion } from "./review-payload-union.js"; +export { default as subjectTupleDuplicate } from "./subject-tuple-duplicate.js"; diff --git a/packages/valibot/src/v20170710/lang/subject-tuple-duplicate.ts b/packages/valibot/src/v20170710/lang/subject-tuple-duplicate.ts new file mode 100644 index 00000000..ade79682 --- /dev/null +++ b/packages/valibot/src/v20170710/lang/subject-tuple-duplicate.ts @@ -0,0 +1,23 @@ +import type * as v from "valibot"; + +import { getLocale } from "./_internal.js"; + +/* +N.b. v.CheckItemsIssue is invariant over the checked array's type, so this message accepts any issue to be usable with +whichever array schema checks for duplicates. +*/ +type Message = (issue: v.GenericIssue) => string; + +// oxlint-disable-next-line id-length -- RFC-5646 compliant language code +const en = "Duplicate Subject Type detected in Subject Tuple"; + +// @__NO_SIDE_EFFECTS__ +const subjectTupleDuplicate: Message = (issue) => { + const locale = getLocale(issue); + switch (locale) { + default: + return en; + } +}; + +export default subjectTupleDuplicate; diff --git a/packages/valibot/src/v20170710/level-progressions.ts b/packages/valibot/src/v20170710/level-progressions.ts index 47e1925c..68dd3662 100644 --- a/packages/valibot/src/v20170710/level-progressions.ts +++ b/packages/valibot/src/v20170710/level-progressions.ts @@ -1,7 +1,11 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +/* oxlint-disable unicorn/prefer-export-from -- Aliases are declared, rather than re-exported, so they're documented */ + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; -import { BaseCollection, BaseResource, DatableString, Level, SafeInteger } from "./base.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; /** * Level progressions contain information about a user's progress through the WaniKani levels. @@ -22,16 +26,29 @@ export const LevelProgression = v.object( v.entriesFromObjects([ BaseResource, v.object({ + /** Data for the returned level progression. */ data: v.object({ + /** Timestamp when the user abandons the level. This is primarily used when the user initiates a reset. */ abandoned_at: v.nullable(DatableString), + /** Timestamp when the user burns 100% of the assignments belonging to the associated subject's level. */ completed_at: v.nullable(DatableString), + /** Timestamp when the level progression is created. */ created_at: DatableString, + /** The level of the progression, with possible values from `1` to `60`. */ level: Level, + /** + * Timestamp, in ISO-8601 format, when the user passes at least 90% of the assignments with a type of `kanji` + * belonging to the associated subject's level. + */ passed_at: v.nullable(DatableString), + /** Timestamp when the user starts their first lesson of a subject belonging to the level. */ started_at: v.nullable(DatableString), + /** Timestamp when the user can access lessons and reviews for the `level`. */ unlocked_at: v.nullable(DatableString), }), + /** A unique number identifying the level progression. */ id: SafeInteger, + /** The kind of object returned. */ object: v.literal("level_progression"), }), ]), @@ -62,6 +79,7 @@ export const LevelProgressionCollection = v.object( v.entriesFromObjects([ BaseCollection, v.object({ + /** An array of returned level progressions. */ data: v.array(LevelProgression), }), ]), @@ -86,6 +104,6 @@ export function isLevelProgressionCollection(value: unknown): value is LevelProg * @category Level Progressions * @category Parameters * @see {@link https://docs.api.wanikani.com/20170710/#get-all-level-progressions} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export { CollectionParameters as LevelProgressionParameters } from "./base.js"; +export type LevelProgressionParameters = Types.LevelProgressionParameters; +export const LevelProgressionParameters = CollectionParameters; diff --git a/packages/valibot/src/v20170710/resets.ts b/packages/valibot/src/v20170710/resets.ts index cf3bd99c..63a3f5b3 100644 --- a/packages/valibot/src/v20170710/resets.ts +++ b/packages/valibot/src/v20170710/resets.ts @@ -1,7 +1,11 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +/* oxlint-disable unicorn/prefer-export-from -- Aliases are declared, rather than re-exported, so they're documented */ + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; -import { BaseCollection, BaseResource, DatableString, Level, SafeInteger } from "./base.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; /** * Users can reset their progress back to any level at or below their current level. When they reset to a particular @@ -18,13 +22,20 @@ export const Reset = v.object( v.entriesFromObjects([ BaseResource, v.object({ + /** Data for the returned reset. */ data: v.object({ + /** Timestamp when the user confirmed the reset. */ confirmed_at: v.nullable(DatableString), + /** Timestamp when the reset was created. */ created_at: DatableString, + /** The user's level before the reset, from `1` to `60`. */ original_level: Level, + /** The user's level after the reset, from `1` to `60`. It must be less than or equal to `original_level`. */ target_level: Level, }), + /** A unique number identifying the reset. */ id: SafeInteger, + /** The kind of object returned. */ object: v.literal("reset"), }), ]), @@ -55,6 +66,7 @@ export const ResetCollection = v.object( v.entriesFromObjects([ BaseCollection, v.object({ + /** An array of returned resets. */ data: v.array(Reset), }), ]), @@ -79,6 +91,6 @@ export function isResetCollection(value: unknown): value is ResetCollection { * @category Parameters * @category Resets * @see {@link https://docs.api.wanikani.com/20170710/#get-all-resets} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export { CollectionParameters as ResetParameters } from "./base.js"; +export type ResetParameters = Types.ResetParameters; +export const ResetParameters = CollectionParameters; diff --git a/packages/valibot/src/v20170710/review-statistics.ts b/packages/valibot/src/v20170710/review-statistics.ts index dbb4f731..958102b9 100644 --- a/packages/valibot/src/v20170710/review-statistics.ts +++ b/packages/valibot/src/v20170710/review-statistics.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; @@ -20,22 +22,38 @@ export const ReviewStatistic = v.object( v.entriesFromObjects([ BaseResource, v.object({ + /** Data for the returned review statistic. */ data: v.object({ + /** Timestamp when the review statistic was created. */ created_at: DatableString, + /** Indicates if the associated subject has been hidden, preventing it from appearing in lessons or reviews. */ hidden: v.boolean(), + /** Total number of correct answers submitted for the meaning of the associated subject. */ meaning_correct: SafeInteger, + /** The current, uninterrupted series of correct answers given for the meaning of the associated subject. */ meaning_current_streak: SafeInteger, + /** Total number of incorrect answers submitted for the meaning of the associated subject. */ meaning_incorrect: SafeInteger, + /** The longest, uninterrupted series of correct answers ever given for the meaning of the associated subject. */ meaning_max_streak: SafeInteger, + /** The overall correct answer rate by the user for the subject, including both meaning and reading. */ percentage_correct: v.number(), + /** Total number of correct answers submitted for the reading of the associated subject. */ reading_correct: SafeInteger, + /** The current, uninterrupted series of correct answers given for the reading of the associated subject. */ reading_current_streak: SafeInteger, + /** Total number of incorrect answers submitted for the reading of the associated subject. */ reading_incorrect: SafeInteger, + /** The longest, uninterrupted series of correct answers ever given for the reading of the associated subject. */ reading_max_streak: SafeInteger, + /** Unique identifier of the associated subject. */ subject_id: SafeInteger, + /** The type of the associated subject. */ subject_type: SubjectType, }), + /** A unique number identifying the review statistic. */ id: SafeInteger, + /** The kind of object returned. */ object: v.literal("review_statistic"), }), ]), @@ -66,6 +84,7 @@ export const ReviewStatisticCollection = v.object( v.entriesFromObjects([ BaseCollection, v.object({ + /** An array of returned review statistics. */ data: v.array(ReviewStatistic), }), ]), @@ -90,17 +109,21 @@ export function isReviewStatisticCollection(value: unknown): value is ReviewStat * @category Parameters * @category Review Statistics * @see {@link https://docs.api.wanikani.com/20170710/#get-all-review-statistics} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type ReviewStatisticParameters = Types.ReviewStatisticParameters; export const ReviewStatisticParameters = v.object( v.entriesFromObjects([ CollectionParameters, v.object({ + /** Return review statistics with a matching value in the `hidden` attribute. */ hidden: v.exactOptional(v.boolean()), + /** Return review statistics where the `percentage_correct` is greater than the value. */ percentages_greater_than: v.exactOptional(v.number()), + /** Return review statistics where the `percentage_correct` is less than the value. */ percentages_less_than: v.exactOptional(v.number()), + /** Only review statistics where `data.subject_id` matches one of the array values are returned. */ subject_ids: v.exactOptional(v.array(SafeInteger)), + /** Only review statistics where `data.subject_type` matches one of the array values are returned. */ subject_types: v.exactOptional(SubjectTuple), }), ]), diff --git a/packages/valibot/src/v20170710/reviews.ts b/packages/valibot/src/v20170710/reviews.ts index 81e3b0b7..6c97c3a1 100644 --- a/packages/valibot/src/v20170710/reviews.ts +++ b/packages/valibot/src/v20170710/reviews.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; @@ -21,17 +23,31 @@ export const Review = v.object( v.entriesFromObjects([ BaseResource, v.object({ + /** Data for the returned review. */ data: v.object({ + /** Unique identifier of the associated assignment. */ assignment_id: SafeInteger, + /** Timestamp when the review was created. */ created_at: DatableString, + /** + * The SRS stage interval calculated from the number of correct and incorrect answers, with valid values ranging + * from `1` to `9`. + */ ending_srs_stage: SpacedRepetitionSystemStageNumber, + /** The number of times the user has answered the meaning incorrectly. */ incorrect_meaning_answers: SafeInteger, + /** The number of times the user has answered the reading incorrectly. */ incorrect_reading_answers: SafeInteger, + /** Unique identifier of the associated `spaced_repetition_system`. */ spaced_repetition_system_id: SafeInteger, + /** The starting SRS stage interval, with valid values ranging from `1` to `8`. */ starting_srs_stage: SpacedRepetitionSystemStageNumber, + /** Unique identifier of the associated subject. */ subject_id: SafeInteger, }), + /** A unique number identifying the review. */ id: SafeInteger, + /** The kind of object returned. */ object: v.literal("review"), }), ]), @@ -62,6 +78,7 @@ export const ReviewCollection = v.object( v.entriesFromObjects([ BaseCollection, v.object({ + /** An array of returned reviews. */ data: v.array(Review), }), ]), @@ -86,14 +103,15 @@ export function isReviewCollection(value: unknown): value is ReviewCollection { * @category Parameters * @category Reviews * @see {@link https://docs.api.wanikani.com/20170710/#get-all-reviews} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type ReviewParameters = Types.ReviewParameters; export const ReviewParameters = v.object( v.entriesFromObjects([ CollectionParameters, v.object({ + /** Only reviews where `data.assignment_id` matches one of the array values are returned. */ assignment_ids: v.exactOptional(v.array(SafeInteger)), + /** Only reviews where `data.subject_id` matches one of the array values are returned. */ subject_ids: v.exactOptional(v.array(SafeInteger)), }), ]), @@ -108,21 +126,35 @@ export const ReviewParameters = v.object( */ export type ReviewPayload = Types.ReviewPayload; export const ReviewPayload = v.object({ + /** A review object with either the `assignment_id` or `subject_id` specified. */ review: v.intersect( [ v.object({ + /** Must be zero or a positive number. This is the number of times the meaning was answered incorrectly. */ incorrect_meaning_answers: SafeInteger, + /** + * Must be zero or a positive number. This is the number of times the reading was answered incorrectly. Note + * that subjects with a type of `radical` do not quiz on readings. Thus, set this value to `0`. + */ incorrect_reading_answers: SafeInteger, + /** + * Timestamp when the review was completed. Defaults to the time of the request if omitted from the request + * body. Must be in the past, but after `assignment.available_at`. + */ created_at: v.exactOptional(v.union([DatableString, v.date()], m.dateUnion)), }), v.union( [ v.object({ + /** Unique identifier of the assignment. This or `subject_id` must be set. */ assignment_id: SafeInteger, + /** The `subject_id` should not be set at the same time as `assignment_id`. */ subject_id: v.exactOptional(v.never()), }), v.object({ + /** Unique identifier of the subject. This or `assignment_id` must be set. */ subject_id: SafeInteger, + /** The `assignment_id` should never be set at the same time as `subject_id`. */ assignment_id: v.exactOptional(v.never()), }), ], @@ -145,8 +177,19 @@ export const CreatedReview = v.object( v.entriesFromObjects([ Review, v.object({ + /** The resources updated alongside creating the review. */ resources_updated: v.object({ + /** + * The updated assignment upon creating the review. + * + * @see {@link https://docs.api.wanikani.com/20170710/#assignments} + */ assignment: Assignment, + /** + * The updated review statistic upon creating the review. + * + * @see {@link https://docs.api.wanikani.com/20170710/#review-statistics} + */ review_statistic: ReviewStatistic, }), }), diff --git a/packages/valibot/src/v20170710/spaced-repetition-systems.ts b/packages/valibot/src/v20170710/spaced-repetition-systems.ts index 18fee86e..a5e0e3a6 100644 --- a/packages/valibot/src/v20170710/spaced-repetition-systems.ts +++ b/packages/valibot/src/v20170710/spaced-repetition-systems.ts @@ -1,8 +1,12 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +/* oxlint-disable unicorn/prefer-export-from -- Aliases are declared, rather than re-exported, so they're documented */ + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; -import { MAX_SRS_STAGE, MIN_SRS_STAGE } from "@bachman-dev/wanikani-api-types/v20170710"; +import { MAX_SRS_STAGE } from "@bachman-dev/wanikani-api-types/v20170710"; -import { BaseCollection, BaseResource, DatableString, SafeInteger } from "./base.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; export { MIN_SRS_STAGE, MAX_SRS_REVIEW_STAGE, MAX_SRS_STAGE } from "@bachman-dev/wanikani-api-types/v20170710"; @@ -12,11 +16,7 @@ export { MIN_SRS_STAGE, MAX_SRS_REVIEW_STAGE, MAX_SRS_STAGE } from "@bachman-dev * @category Spaced Repetition Systems */ export type SpacedRepetitionSystemStageNumber = Types.SpacedRepetitionSystemStageNumber; -export const SpacedRepetitionSystemStageNumber = v.pipe( - SafeInteger, - v.minValue(MIN_SRS_STAGE), - v.maxValue(MAX_SRS_STAGE), -); +export const SpacedRepetitionSystemStageNumber = v.pipe(SafeInteger, v.maxValue(MAX_SRS_STAGE)); /** * A type guard that checks if the given value matches the type predicate. @@ -39,8 +39,11 @@ export function isSpacedRepetitionSystemStageNumber(value: unknown): value is Sp */ export type SpacedRepetitionSystemStage = Types.SpacedRepetitionSystemStage; export const SpacedRepetitionSystemStage = v.object({ + /** The length of time added to the time of review registration, adjusted to the beginning of the hour. */ interval: v.nullable(SafeInteger), + /** Unit of time. Can be the following: `milliseconds`, `seconds`, `minutes`, `hours`, `days`, and `weeks`. */ interval_unit: v.nullable(v.picklist(["days", "hours", "milliseconds", "minutes", "seconds", "weeks"])), + /** The position of the stage within the continuous order. */ position: SpacedRepetitionSystemStageNumber, }); @@ -57,17 +60,28 @@ export const SpacedRepetitionSystem = v.object( v.entriesFromObjects([ BaseResource, v.object({ + /** Data for the return Spaced Repetition System. */ data: v.object({ + /** `position` of the burning stage. */ burning_stage_position: SpacedRepetitionSystemStageNumber, + /** Timestamp when the `spaced_repetition_system` was created. */ created_at: DatableString, + /** Details about the spaced repetition system. */ description: v.string(), + /** The name of the spaced repetition system. */ name: v.string(), + /** `position` of the passing stage. */ passing_stage_position: SpacedRepetitionSystemStageNumber, + /** A collection of stages. */ stages: v.array(SpacedRepetitionSystemStage), + /** `position` of the starting stage. */ starting_stage_position: SpacedRepetitionSystemStageNumber, + /** `position` of the unlocking stage. */ unlocking_stage_position: SpacedRepetitionSystemStageNumber, }), + /** A unique number identifying the Spaced Repetition System. */ id: SafeInteger, + /** The kind of object returned. */ object: v.literal("spaced_repetition_system"), }), ]), @@ -98,6 +112,7 @@ export const SpacedRepetitionSystemCollection = v.object( v.entriesFromObjects([ BaseCollection, v.object({ + /** An array of returned Spaced Repetition Systems. */ data: v.array(SpacedRepetitionSystem), }), ]), @@ -122,6 +137,6 @@ export function isSpacedRepetitionSystemCollection(value: unknown): value is Spa * @category Parameters * @category Spaced Repetition Systems * @see {@link https://docs.api.wanikani.com/20170710/#get-all-spaced-repetition-systems} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export { CollectionParameters as SpacedRepetitionSystemParameters } from "./base.js"; +export type SpacedRepetitionSystemParameters = Types.SpacedRepetitionSystemParameters; +export const SpacedRepetitionSystemParameters = CollectionParameters; diff --git a/packages/valibot/src/v20170710/study-materials.ts b/packages/valibot/src/v20170710/study-materials.ts index 8db2e17d..1ecdda8e 100644 --- a/packages/valibot/src/v20170710/study-materials.ts +++ b/packages/valibot/src/v20170710/study-materials.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; @@ -14,8 +16,11 @@ import { SubjectTuple, SubjectType } from "./subjects.js"; */ export type StudyMaterialBaseData = Types.StudyMaterialBaseData; export const StudyMaterialBaseData = v.object({ + /** Free form note related to the meaning(s) of the associated subject. */ meaning_note: v.string(), + /** Synonyms for the meaning of the subject. These are used as additional correct answers during reviews. */ meaning_synonyms: v.array(v.string()), + /** Free form note related to the reading(s) of the associated subject. */ reading_note: v.string(), }); @@ -32,16 +37,25 @@ export const StudyMaterial = v.object( v.entriesFromObjects([ BaseResource, v.object({ - data: v.intersect([ - StudyMaterialBaseData, - v.object({ - created_at: DatableString, - hidden: v.boolean(), - subject_id: SafeInteger, - subject_type: SubjectType, - }), - ]), + /** Data for the returned study material. */ + data: v.object( + v.entriesFromObjects([ + StudyMaterialBaseData, + v.object({ + /** Timestamp when the study material was created. */ + created_at: DatableString, + /** Indicates if the associated subject has been hidden, preventing it from appearing in lessons or reviews. */ + hidden: v.boolean(), + /** Unique identifier of the associated subject. */ + subject_id: SafeInteger, + /** The type of the associated subject. */ + subject_type: SubjectType, + }), + ]), + ), + /** A unique number identifying the study material. */ id: SafeInteger, + /** The kind of object returned. */ object: v.literal("study_material"), }), ]), @@ -72,6 +86,7 @@ export const StudyMaterialCollection = v.object( v.entriesFromObjects([ BaseCollection, v.object({ + /** An array of returned study materials. */ data: v.array(StudyMaterial), }), ]), @@ -96,15 +111,17 @@ export function isStudyMaterialCollection(value: unknown): value is StudyMateria * @category Parameters * @category Study Materials * @see {@link https://docs.api.wanikani.com/20170710/#get-all-study-materials} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type StudyMaterialParameters = Types.StudyMaterialParameters; export const StudyMaterialParameters = v.object( v.entriesFromObjects([ CollectionParameters, v.object({ + /** Return study materials with a matching value in the `hidden` attribute. */ hidden: v.exactOptional(v.boolean()), + /** Only study material records where `data.subject_id` matches one of the array values are returned. */ subject_ids: v.exactOptional(v.array(SafeInteger)), + /** Only study material records where `data.subject_type` matches one of the array values are returned. */ subject_types: v.exactOptional(SubjectTuple), }), ]), @@ -118,10 +135,12 @@ export const StudyMaterialParameters = v.object( * @see {@link https://docs.api.wanikani.com/20170710/#update-a-study-material} */ export type StudyMaterialUpdatePayload = Types.StudyMaterialUpdatePayload; -// N.b. v.partial() wraps entries with v.optional(), which would allow properties to be explicitly `undefined` export const StudyMaterialUpdatePayload = v.object({ + /** Free form note related to the meaning(s) of the associated subject. */ meaning_note: v.exactOptional(StudyMaterialBaseData.entries.meaning_note), + /** Synonyms for the meaning of the subject. These are used as additional correct answers during reviews. */ meaning_synonyms: v.exactOptional(StudyMaterialBaseData.entries.meaning_synonyms), + /** Free form note related to the reading(s) of the associated subject. */ reading_note: v.exactOptional(StudyMaterialBaseData.entries.reading_note), }); @@ -137,6 +156,7 @@ export const StudyMaterialCreatePayload = v.object( v.entriesFromObjects([ StudyMaterialUpdatePayload, v.object({ + /** Unique identifier of the associated subject. */ subject_id: SafeInteger, }), ]), diff --git a/packages/valibot/src/v20170710/subjects.ts b/packages/valibot/src/v20170710/subjects.ts index 8a5f8b14..2cd71fbf 100644 --- a/packages/valibot/src/v20170710/subjects.ts +++ b/packages/valibot/src/v20170710/subjects.ts @@ -1,6 +1,9 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; +import * as m from "./lang/index.js"; import { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; /** @@ -34,11 +37,7 @@ export function isSubjectType(value: unknown): value is SubjectType { export type SubjectTuple = Types.SubjectTuple; export const SubjectTuple = v.pipe( v.tupleWithRest([SubjectType], SubjectType), - v.nonEmpty(), - v.checkItems( - (item, index, array) => array.indexOf(item) === index, - "Duplicate Subject Type detected in Subject Tuple", - ), + v.checkItems((item, index, array) => array.indexOf(item) === index, m.subjectTupleDuplicate), ); /** @@ -62,7 +61,12 @@ export function isSubjectTuple(value: unknown): value is SubjectTuple { */ export type SubjectAuxiliaryMeaning = Types.SubjectAuxiliaryMeaning; export const SubjectAuxiliaryMeaning = v.object({ + /** A singular subject meaning. */ meaning: v.string(), + /** + * Either `whitelist` or `blacklist`. When evaluating user input, whitelisted meanings are used to match for + * correctness. Blacklisted meanings are used to match for incorrectness. + */ type: v.picklist(["blacklist", "whitelist"]), }); @@ -74,8 +78,11 @@ export const SubjectAuxiliaryMeaning = v.object({ */ export type SubjectMeaning = Types.SubjectMeaning; export const SubjectMeaning = v.object({ + /** Indicates if the meaning is used to evaluate user input for correctness. */ accepted_answer: v.boolean(), + /** A singular subject meaning. */ meaning: v.string(), + /** Indicates priority in the WaniKani system. */ primary: v.boolean(), }); @@ -90,15 +97,34 @@ export const SubjectMeaning = v.object({ */ export type SubjectBaseData = Types.SubjectBaseData; export const SubjectBaseData = v.object({ + /** Collection of auxiliary meanings. */ auxiliary_meanings: v.array(SubjectAuxiliaryMeaning), + /** Timestamp when the subject was created. */ created_at: DatableString, + /** A URL pointing to the page on wanikani.com that provides detailed information about this subject. */ document_url: v.string(), + /** + * Timestamp when the subject was hidden, indicating associated assignments will no longer appear in lessons or + * reviews and that the subject page is no longer visible on wanikani.com. + */ hidden_at: v.nullable(DatableString), + /** + * The position that the subject appears in lessons. Note that the value is scoped to the level of the subject, so + * there are duplicate values across levels. + */ lesson_position: SafeInteger, + /** The level of the subject, from `1` to `60`. */ level: Level, + /** The subject's meaning mnemonic. */ meaning_mnemonic: v.string(), + /** The subject meanings. */ meanings: v.array(SubjectMeaning), + /** + * The string that is used when generating the document URL for the subject. Radicals use their meaning, downcased. + * Kanji and vocabulary use their characters. + */ slug: v.string(), + /** Unique identifier of the associated Spaced Repetition System. */ spaced_repetition_system_id: SafeInteger, }); @@ -111,23 +137,32 @@ export const SubjectBaseData = v.object({ export type RadicalCharacterImage = Types.RadicalCharacterImage; export const RadicalCharacterImage = v.intersect([ v.object({ + /** The location of the image. */ url: v.string(), }), v.variant("content_type", [ v.object({ - content_type: v.literal("image/svg+xml"), - metadata: v.object({ - inline_styles: v.boolean(), - }), - }), - v.object({ + /** The content type of the image. */ content_type: v.literal("image/png"), + /** Details about the image. Each `content_type` returns a uniquely structured object. */ metadata: v.object({ + /** Color of the asset in hexadecimal */ color: v.string(), + /** Dimension of the asset in pixels */ dimensions: v.string(), + /** A name descriptor */ style_name: v.string(), }), }), + v.object({ + /** The content type of the image. */ + content_type: v.literal("image/svg+xml"), + /** Details about the image. Each `content_type` returns a uniquely structured object. */ + metadata: v.object({ + /** The SVG asset contains built-in CSS styling. */ + inline_styles: v.boolean(), + }), + }), ]), ]); @@ -142,8 +177,14 @@ export const RadicalData = v.object( v.entriesFromObjects([ SubjectBaseData, v.object({ + /** An array of numeric identifiers for the kanji that have the radical as a component. */ amalgamation_subject_ids: v.array(SafeInteger), + /** A collection of images of the radical. */ character_images: v.array(RadicalCharacterImage), + /** + * Unlike kanji and vocabulary, radicals can have a null value for characters. Not all radicals have a UTF entry, + * so the radical must be visually represented with an image instead. + */ characters: v.nullable(v.string()), }), ]), @@ -157,9 +198,13 @@ export const RadicalData = v.object( */ export type KanjiReading = Types.KanjiReading; export const KanjiReading = v.object({ + /** Indicates if the reading is used to evaluate user input for correctness. */ accepted_answer: v.boolean(), + /** Indicates priority in the WaniKani system. */ primary: v.boolean(), + /** A singular subject reading. */ reading: v.string(), + /** The kanji reading's classfication: `kunyomi`, `nanori`, or `onyomi`. */ type: v.picklist(["kunyomi", "nanori", "onyomi"]), }); @@ -174,13 +219,24 @@ export const KanjiData = v.object( v.entriesFromObjects([ SubjectBaseData, v.object({ + /** An array of numeric identifiers for the vocabulary that have the kanji as a component. */ amalgamation_subject_ids: v.array(SafeInteger), + /** The UTF-8 characters for the subject, including kanji and hiragana. */ characters: v.string(), + /** + * An array of numeric identifiers for the radicals that make up this kanji. Note that these are the subjects that + * must have passed assignments in order to unlock this subject's assignment. + */ component_subject_ids: v.array(SafeInteger), + /** Meaning hint for the kanji. */ meaning_hint: v.nullable(v.string()), + /** Reading hint for the kanji. */ reading_hint: v.nullable(v.string()), + /** The kanji's reading mnemonic. */ reading_mnemonic: v.string(), + /** Selected readings for the kanji. */ readings: v.array(KanjiReading), + /** An array of numeric identifiers for kanji which are visually similar to the kanji in question. */ visually_similar_subject_ids: v.array(SafeInteger), }), ]), @@ -194,7 +250,9 @@ export const KanjiData = v.object( */ export type VocabularyContextSentence = Types.VocabularyContextSentence; export const VocabularyContextSentence = v.object({ + /** English translation of the sentence. */ en: v.string(), + /** Japanese context sentence. */ ja: v.string(), }); @@ -206,15 +264,24 @@ export const VocabularyContextSentence = v.object({ */ export type VocabularyPronunciationAudio = Types.VocabularyPronunciationAudio; export const VocabularyPronunciationAudio = v.object({ + /** The content type of the audio. Currently the API delivers `audio/mpeg`, `audio/ogg`, and `audio/webm`. */ content_type: v.picklist(["audio/mpeg", "audio/ogg", "audio/webm"]), + /** Details about the pronunciation audio. */ metadata: v.object({ + /** The gender of the voice actor. */ gender: v.picklist(["female", "male"]), + /** Vocabulary being pronounced in kana. */ pronunciation: v.string(), + /** A unique ID shared between same source pronunciation audio. */ source_id: SafeInteger, + /** A unique ID belonging to the voice actor. */ voice_actor_id: SafeInteger, + /** Humanized name of the voice actor. */ voice_actor_name: v.string(), + /** Description of the voice. */ voice_description: v.string(), }), + /** The location of the audio. */ url: v.string(), }); @@ -226,8 +293,11 @@ export const VocabularyPronunciationAudio = v.object({ */ export type VocabularyReading = Types.VocabularyReading; export const VocabularyReading = v.object({ + /** Indicates if the reading is used to evaluate user input for correctness. */ accepted_answer: v.boolean(), + /** Indicates priority in the WaniKani system. */ primary: v.boolean(), + /** A singular subject reading. */ reading: v.string(), }); @@ -242,12 +312,22 @@ export const VocabularyData = v.object( v.entriesFromObjects([ SubjectBaseData, v.object({ + /** The UTF-8 characters for the subject, including kanji and hiragana. */ characters: v.string(), + /** + * An array of numeric identifiers for the kanji that make up this vocabulary. Note that these are the subjects + * that must be have passed assignments in order to unlock this subject's assignment. + */ component_subject_ids: v.array(SafeInteger), + /** A collection of context sentences. */ context_sentences: v.array(VocabularyContextSentence), + /** Parts of speech. */ parts_of_speech: v.array(v.string()), + /** A collection of pronunciation audio. */ pronunciation_audios: v.array(VocabularyPronunciationAudio), + /** The vocabulary's reading mnemonic. */ reading_mnemonic: v.string(), + /** Selected readings for the vocabulary. */ readings: v.array(VocabularyReading), }), ]), @@ -264,9 +344,13 @@ export const KanaVocabularyData = v.object( v.entriesFromObjects([ SubjectBaseData, v.object({ + /** The UTF-8 characters for the subject, including kanji and hiragana. */ characters: v.string(), + /** A collection of context sentences. */ context_sentences: v.array(VocabularyContextSentence), + /** Parts of speech. */ parts_of_speech: v.array(v.string()), + /** A collection of pronunciation audio. */ pronunciation_audios: v.array(VocabularyPronunciationAudio), }), ]), @@ -285,25 +369,38 @@ export const KanaVocabularyData = v.object( */ export type Subject = Types.Subject; export const Subject = v.intersect([ - BaseResource, - v.object({ - id: SafeInteger, - }), + v.object( + v.entriesFromObjects([ + BaseResource, + v.object({ + /** A unique number identifying the subject. */ + id: SafeInteger, + }), + ]), + ), v.variant("object", [ v.object({ + /** Data for the returned kana-only vocabulary. */ data: KanaVocabularyData, + /** The kind of object returned. */ object: v.literal("kana_vocabulary"), }), v.object({ + /** Data for the returned kanji. */ data: KanjiData, + /** The kind of object returned. */ object: v.literal("kanji"), }), v.object({ + /** Data for the returned radical. */ data: RadicalData, + /** The kind of object returned. */ object: v.literal("radical"), }), v.object({ + /** Data for the returned vocabulary. */ data: VocabularyData, + /** The kind of object returned. */ object: v.literal("vocabulary"), }), ]), @@ -334,6 +431,7 @@ export const SubjectCollection = v.object( v.entriesFromObjects([ BaseCollection, v.object({ + /** An array of returned subjects of mixed or unknown type. */ data: v.array(Subject), }), ]), @@ -358,16 +456,19 @@ export function isSubjectCollection(value: unknown): value is SubjectCollection * @category Parameters * @category Subjects * @see {@link https://docs.api.wanikani.com/20170710/#get-all-subjects} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type SubjectParameters = Types.SubjectParameters; export const SubjectParameters = v.object( v.entriesFromObjects([ CollectionParameters, v.object({ + /** Return subjects which are or are not hidden from the user-facing application. */ hidden: v.exactOptional(v.boolean()), + /** Return subjects at the specified levels. */ levels: v.exactOptional(v.array(Level)), + /** Return subjects of the specified slug. */ slugs: v.exactOptional(v.array(v.string())), + /** Return subjects of the specified types. */ types: v.exactOptional(SubjectTuple), }), ]), diff --git a/packages/valibot/src/v20170710/summary.ts b/packages/valibot/src/v20170710/summary.ts index 283640aa..b963ac73 100644 --- a/packages/valibot/src/v20170710/summary.ts +++ b/packages/valibot/src/v20170710/summary.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; @@ -11,7 +13,12 @@ import { BaseReport, DatableString, SafeInteger } from "./base.js"; */ export type SummaryInterval = Types.SummaryInterval; export const SummaryInterval = v.object({ + /** + * When the paired `subject_ids` are available for lessons. Always beginning of the current hour when the API endpoint + * is accessed. + */ available_at: DatableString, + /** Collection of unique identifiers for subjects. */ subject_ids: v.array(SafeInteger), }); @@ -28,9 +35,13 @@ export const Summary = v.object( v.entriesFromObjects([ BaseReport, v.object({ + /** Data for the Summary report. */ data: v.object({ + /** Details about subjects available for lessons. */ lessons: v.array(SummaryInterval), + /** Earliest date when the reviews are available. Is `null` when the user has no reviews scheduled. */ next_reviews_at: v.nullable(DatableString), + /** Details about subjects available for reviews now and in the next 24 hours by the hour (total of 25 objects). */ reviews: v.array(SummaryInterval), }), }), diff --git a/packages/valibot/src/v20170710/user.ts b/packages/valibot/src/v20170710/user.ts index 1a974380..6a7cca59 100644 --- a/packages/valibot/src/v20170710/user.ts +++ b/packages/valibot/src/v20170710/user.ts @@ -1,3 +1,7 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +/* oxlint-disable typescript/no-deprecated -- The API still returns deprecated properties, so they're validated */ + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; import { MAX_LESSON_BATCH_SIZE, MIN_LESSON_BATCH_SIZE } from "@bachman-dev/wanikani-api-types/v20170710"; @@ -39,13 +43,27 @@ export function isLessonBatchSizeNumber(value: unknown): value is LessonBatchSiz */ export type UserPreferences = Types.UserPreferences; export const UserPreferences = v.object({ + /** + * @deprecated This is a deprecated user preference. It will always return `1` and cannot be set. It exists only to + * ensure existing consumers of this API don't break. + */ default_voice_actor_id: SafeInteger, + /** Automatically play pronunciation audio for vocabulary during extra study. */ extra_study_autoplay_audio: v.boolean(), + /** Automatically play pronunciation audio for vocabulary during lessons. */ lessons_autoplay_audio: v.boolean(), + /** Number of subjects introduced to the user during lessons before quizzing. */ lessons_batch_size: LessonBatchSizeNumber, + /** + * @deprecated This is a deprecated user preference. It always returns `ascending_level_then_subject`. Setting this + * preference will do nothing. It exists only to ensure existing consumers of this API don't break. + */ lessons_presentation_order: v.picklist(["ascending_level_then_shuffled", "ascending_level_then_subject", "shuffled"]), + /** Automatically play pronunciation audio for vocabulary during reviews. */ reviews_autoplay_audio: v.boolean(), + /** Toggle for display SRS change indicator after a subject has been completely answered during review. */ reviews_display_srs_indicator: v.boolean(), + /** The order in which reviews are presented. The options are `lower_levels_first` and `shuffled`. */ reviews_presentation_order: v.picklist(["lower_levels_first", "shuffled"]), }); @@ -61,21 +79,50 @@ export const User = v.object( v.entriesFromObjects([ BaseResource, v.object({ + /** Data for the returned user. */ data: v.object({ + /** + * If the user is on vacation, this will be the timestamp of when that vacation started. If the user is not on + * vacation, this is `null`. + */ current_vacation_started_at: v.nullable(DatableString), + /** A user's unique ID string. */ id: v.string(), + /** The current level of the user. This ignores subscription status. */ level: Level, + /** User settings specific to the WaniKani application. */ preferences: UserPreferences, + /** The URL to the user's public facing profile page. */ profile_url: v.string(), + /** The signup date for the user. */ started_at: DatableString, + /** Details about the user's subscription state. */ subscription: v.object({ + /** Whether or not the user currently has a paid subscription. */ active: v.boolean(), + /** + * The maximum level of content accessible to the user for lessons, reviews, and content review. For + * unsubscribed/free users, the maximum level is `3`. For subscribed users, this is `60`. + * + * **Any application that uses data from the WaniKani API must respect these access limits.** + */ max_level_granted: Level, + /** + * The date when the user's subscription period ends. If the user has subscription type `lifetime` or `free` + * then the value is `null`. + */ period_ends_at: v.nullable(DatableString), + /** + * The type of subscription the user has. Options are following: `free`, `recurring`, and `lifetime`. A type + * of `unknown` means the user subscription state isn't exactly known. This is a weird state on WaniKani, + * should be treated as `free`, and reported to the WaniKani developers. + */ type: v.picklist(["free", "lifetime", "recurring", "unknown"]), }), + /** The user's username. */ username: v.string(), }), + /** The kind of object returned. */ object: v.literal("user"), }), ]), @@ -103,16 +150,31 @@ export function isUser(value: unknown): value is User { */ export type UserPreferencesPayload = Types.UserPreferencesPayload; export const UserPreferencesPayload = v.object({ + /** The user object, as part of the payload. */ user: v.object({ - // N.b. v.partial() wraps entries with v.optional(), which would allow properties to be explicitly `undefined` + /** The user preferences to be updated; only those specified in the object will be updated. */ preferences: v.object({ + /** + * @deprecated This is a deprecated user preference. It will always return `1` and cannot be set. It exists only to + * ensure existing consumers of this API don't break. + */ default_voice_actor_id: v.exactOptional(UserPreferences.entries.default_voice_actor_id), + /** Automatically play pronunciation audio for vocabulary during extra study. */ extra_study_autoplay_audio: v.exactOptional(UserPreferences.entries.extra_study_autoplay_audio), + /** Automatically play pronunciation audio for vocabulary during lessons. */ lessons_autoplay_audio: v.exactOptional(UserPreferences.entries.lessons_autoplay_audio), + /** Number of subjects introduced to the user during lessons before quizzing. */ lessons_batch_size: v.exactOptional(UserPreferences.entries.lessons_batch_size), + /** + * @deprecated This is a deprecated user preference. It always returns `ascending_level_then_subject`. Setting this + * preference will do nothing. It exists only to ensure existing consumers of this API don't break. + */ lessons_presentation_order: v.exactOptional(UserPreferences.entries.lessons_presentation_order), + /** Automatically play pronunciation audio for vocabulary during reviews. */ reviews_autoplay_audio: v.exactOptional(UserPreferences.entries.reviews_autoplay_audio), + /** Toggle for display SRS change indicator after a subject has been completely answered during review. */ reviews_display_srs_indicator: v.exactOptional(UserPreferences.entries.reviews_display_srs_indicator), + /** The order in which reviews are presented. The options are `lower_levels_first` and `shuffled`. */ reviews_presentation_order: v.exactOptional(UserPreferences.entries.reviews_presentation_order), }), }), diff --git a/packages/valibot/src/v20170710/voice-actors.ts b/packages/valibot/src/v20170710/voice-actors.ts index f402565b..1417508c 100644 --- a/packages/valibot/src/v20170710/voice-actors.ts +++ b/packages/valibot/src/v20170710/voice-actors.ts @@ -1,7 +1,11 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +/* oxlint-disable unicorn/prefer-export-from -- Aliases are declared, rather than re-exported, so they're documented */ + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as v from "valibot"; -import { BaseCollection, BaseResource, DatableString, SafeInteger } from "./base.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; /** * Available voice actors used for vocabulary reading pronunciation audio. @@ -15,13 +19,20 @@ export const VoiceActor = v.object( v.entriesFromObjects([ BaseResource, v.object({ + /** Data for the returned voice actor. */ data: v.object({ + /** Timestamp for when the voice actor was added to WaniKani. */ created_at: DatableString, + /** Details about the voice actor. */ description: v.string(), + /** The voice actor's gender, either `male` or `female`. */ gender: v.picklist(["female", "male"]), + /** The voice actor's name. */ name: v.string(), }), + /** A unique number identifying the voice actor. */ id: SafeInteger, + /** The kind of object returned. */ object: v.literal("voice_actor"), }), ]), @@ -52,6 +63,7 @@ export const VoiceActorCollection = v.object( v.entriesFromObjects([ BaseCollection, v.object({ + /** An array of returned voice actors. */ data: v.array(VoiceActor), }), ]), @@ -76,6 +88,6 @@ export function isVoiceActorCollection(value: unknown): value is VoiceActorColle * @category Parameters * @category Voice Actors * @see {@link https://docs.api.wanikani.com/20170710/#get-all-voice-actors} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export { CollectionParameters as VoiceActorParameters } from "./base.js"; +export type VoiceActorParameters = Types.VoiceActorParameters; +export const VoiceActorParameters = CollectionParameters; diff --git a/packages/valibot/vitest.config.ts b/packages/valibot/vitest.config.ts index 29a4c596..cb380305 100644 --- a/packages/valibot/vitest.config.ts +++ b/packages/valibot/vitest.config.ts @@ -8,5 +8,6 @@ export default defineConfig({ enabled: true, }, pool: "threads", + isolate: false, }, }); diff --git a/packages/zod/package.json b/packages/zod/package.json index f430609c..ec77415b 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -26,13 +26,15 @@ }, "scripts": { "build": "tsdown --config tsdown.config.ts", - "check": "tsc --noEmit" + "check": "tsc --noEmit && tsc --noEmit -p scripts", + "generate": "node scripts/generate.ts" }, "dependencies": { "@bachman-dev/wanikani-api-types": "workspace:*" }, "devDependencies": { "@bachman-dev/wanikani-api-fixtures": "workspace:*", + "@bachman-dev/wanikani-api-schema": "workspace:*", "zod": "4.6.5" }, "peerDependencies": { diff --git a/packages/zod/scripts/generate.ts b/packages/zod/scripts/generate.ts new file mode 100644 index 00000000..ba1cc964 --- /dev/null +++ b/packages/zod/scripts/generate.ts @@ -0,0 +1,291 @@ +/* +Generates the Zod (Mini) schema in `src/` from @bachman-dev/wanikani-api-schema. Run with `pnpm generate`. + +Hand-written custom messages live in `src//lang/`, exported under the message keys used by the schema. +*/ + +import { fileURLToPath } from "node:url"; + +import { revisions } from "@bachman-dev/wanikani-api-schema"; +import { + type ConstantDefinition, + type Definition, + type Module, + type ModuleScope, + type NumberBound, + type Property, + type Revision, + type Schema, + type SchemaDefinition, + type StringSchema, + categoriesOf, + createModuleScope, + generatePackageFiles, + hasDeprecatedEntries, + indexRevision, + joinBlocks, + numberChecks, + objectEntries, + renderImport, + renderJsDoc, + renderKey, + renderLiteral, + resolveAlias, + writeGeneratedFiles, +} from "@bachman-dev/wanikani-api-schema/core"; +import { format } from "oxfmt"; + +import formatConfig from "../../../oxfmt.config.ts"; + +/** State for generating one module. */ +interface Context { + /** Tracks definitions used from other modules. */ + readonly scope: ModuleScope; + + /** Constants used by the module, imported from the types package. */ + readonly constants: Set; + + /** Whether the module uses custom messages. */ + usesMessages: boolean; +} + +function typesPackage(revision: Revision): string { + return `@bachman-dev/wanikani-api-types/${revision.folder}`; +} + +function renderMessage(message: string | undefined, context: Context): string { + if (message === undefined) { + return ""; + } + context.usesMessages = true; + return `, { error: m.${message} }`; +} + +function renderBound(bound: NumberBound, context: Context): string { + if (typeof bound === "number") { + return String(bound); + } + context.constants.add(bound.name); + return bound.name; +} + +function renderEntries( + entries: Iterable<[string, Property]>, + render: (key: string, property: Property) => string, + spreads: readonly string[] = [], +): string { + const lines = [...entries].map(([key, property]) => { + const docs = renderJsDoc(property.docs); + const entry = `${renderKey(key)}: ${render(key, property)},`; + return docs === "" ? entry : `${docs}\n${entry}`; + }); + return `{\n${[...spreads.map((spread) => `...${spread}.shape,`), ...lines].join("\n")}\n}`; +} + +function renderString(schema: StringSchema, brand: string | undefined): string { + const string = schema.trim === true ? "z.string().check(z.trim())" : "z.string()"; + let rendered = string; + if (schema.format === "iso-timestamp") { + const timestamp = "z.iso.datetime({ offset: true })"; + rendered = schema.trim === true ? `z.pipe(${string}, ${timestamp})` : timestamp; + } + if (brand === undefined) { + return rendered; + } + return `z.pipe(${rendered},\n// oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion -- Validated above\nz.transform((value) => value as ${brand}))`; +} + +function renderSchema(schema: Schema, context: Context, brand?: string): string { + const render = (inner: Schema): string => renderSchema(inner, context); + const renderProperty = (property: Property): string => + property.optional ? `z.exactOptional(${render(property.schema)})` : render(property.schema); + + switch (schema.kind) { + case "alias": + case "definition": + return context.scope.use(resolveAlias(schema)); + case "literal": + return `z.literal(${renderLiteral(schema.value)})`; + case "string": + return renderString(schema, brand); + case "number": { + const checks = numberChecks(schema); + const base = schema.base === undefined ? "z.number()" : context.scope.use(schema.base); + const rendered: string[] = []; + if (checks.integer) { + rendered.push("z.int()"); + } + if (checks.min !== undefined) { + rendered.push(`z.minimum(${renderBound(checks.min, context)})`); + } + if (checks.max !== undefined) { + rendered.push(`z.maximum(${renderBound(checks.max, context)})`); + } + return rendered.length === 0 ? base : `${base}.check(${rendered.join(", ")})`; + } + case "boolean": + case "date": + case "never": + return `z.${schema.kind}()`; + case "picklist": + return `z.enum([${schema.options.map((option) => renderLiteral(option)).join(", ")}])`; + case "array": + return `z.array(${render(schema.item)})`; + case "tuple": { + const items = `[${schema.items.map((item) => render(item.schema)).join(", ")}]`; + const tuple = + schema.rest === undefined ? `z.tuple(${items})` : `z.tuple(${items}, ${render(schema.rest.schema)})`; + if (schema.unique === undefined) { + return tuple; + } + const message = renderMessage(schema.unique.message, context); + return `${tuple}.check(z.refine((items) => new Set(items).size === items.length${message}))`; + } + case "nullable": + return `z.nullable(${render(schema.wrapped)})`; + case "object": { + const spreads = schema.extends.map((parent) => context.scope.use(parent)); + const entries = Object.entries(schema.entries); + return `z.object(${renderEntries(entries, (_key, property) => renderProperty(property), spreads)})`; + } + case "partial": { + // N.b. z.partial() wraps entries with z.optional(), which would allow properties to be explicitly `undefined` + const name = context.scope.use(schema.source); + const entries = renderEntries(objectEntries(schema.source), (key) => `z.exactOptional(${name}.shape.${key})`); + return `z.object(${entries})`; + } + case "union": + return `z.union([${schema.options.map((option) => render(option)).join(", ")}]${renderMessage(schema.message, context)})`; + case "variant": + return `z.discriminatedUnion(${renderLiteral(schema.key)}, [${schema.options.map((option) => render(option)).join(", ")}])`; + case "intersect": { + // N.b. Zod throws an error for unmergeable intersections rather than reporting an issue, so there's no message + const [first, ...rest] = schema.options; + let rendered = render(first); + for (const option of rest) { + rendered = `z.intersection(${rendered}, ${render(option)})`; + } + return rendered; + } + default: + throw new Error(`Unsupported schema: ${JSON.stringify(schema satisfies never)}`); + } +} + +function renderTypeGuard(definition: SchemaDefinition, module: Module): string { + const docs = renderJsDoc( + { description: "A type guard that checks if the given value matches the type predicate." }, + { + categories: [module.category, "Type Guards"], + tags: ["@param value An unknown value", "@returns A type predicate"], + }, + ); + const { name } = definition; + return `${docs}\n// @__NO_SIDE_EFFECTS__\nexport function is${name}(value: unknown): value is ${name} {\n return z.validate(${name}, value);\n}`; +} + +function renderValue(definition: SchemaDefinition, context: Context): string { + const { name, schema } = definition; + const rendered = renderSchema(schema, context, schema.kind === "string" && schema.brand === true ? name : undefined); + if (schema.kind === "number" && schema.base === undefined) { + // N.b. z.toZod() can't match `number & {}` with the `number` Zod infers, though they're identical at runtime + const directive = + "// oxlint-disable-next-line typescript/consistent-type-assertions -- `number & {}` is identical to `number` at runtime"; + return `${directive}\nexport const ${name} = ${rendered} as z.ZodMiniType;`; + } + return `export const ${name} = z.toZod()(${rendered});`; +} + +function renderDefinition(definition: Definition, context: Context): string { + const { module } = context.scope; + const docs = renderJsDoc(definition.docs, { categories: categoriesOf(definition, module) }); + switch (definition.kind) { + case "alias": { + const { name } = definition; + return `${docs} +export type ${name} = Types.${name}; +export const ${name} = ${context.scope.use(definition.target)};`; + } + case "constant": + // Constants are re-exported from the types package, see generateModule() + return ""; + case "definition": { + const declaration = `${docs}\nexport type ${definition.name} = Types.${definition.name};\n${renderValue(definition, context)}`; + return definition.typeGuard ? `${declaration}\n\n${renderTypeGuard(definition, module)}` : declaration; + } + default: + throw new Error(`Unsupported definition: ${JSON.stringify(definition satisfies never)}`); + } +} + +function generateModule(module: Module, revision: Revision): string { + const index = indexRevision(revision); + const context: Context = { scope: createModuleScope(module, index), constants: new Set(), usesMessages: false }; + + const definitions = module.definitions.map((definition) => renderDefinition(definition, context)); + const hasSchema = module.definitions.some((definition) => definition.kind !== "constant"); + const constants = module.definitions + .filter((definition): definition is ConstantDefinition => definition.kind === "constant") + .map((definition) => definition.name); + + const directives = [ + hasDeprecatedEntries(module) + ? "/* oxlint-disable typescript/no-deprecated -- The API still returns deprecated properties, so they're validated */" + : "", + module.definitions.some((definition) => definition.kind === "alias") + ? "/* oxlint-disable unicorn/prefer-export-from -- Aliases are declared, rather than re-exported, so they're documented */" + : "", + ]; + const imports = [ + hasSchema ? `import type * as Types from "${typesPackage(revision)}";\nimport * as z from "zod/mini";` : "", + renderImport(context.constants, typesPackage(revision)), + context.usesMessages ? `import * as m from "./lang/index.js";` : "", + ...[...context.scope.imports()].map(([importedModule, names]) => + renderImport(names, `./${importedModule.name}.js`), + ), + ]; + const reexports = constants.length > 0 ? `export { ${constants.join(", ")} } from "${typesPackage(revision)}";` : ""; + + return joinBlocks([ + directives.filter((line) => line !== "").join("\n"), + imports.filter((line) => line !== "").join("\n"), + reexports, + ...definitions, + ]); +} + +function generateRevisionIndex(revision: Revision): string { + return joinBlocks([ + `// oxlint-disable oxc/no-barrel-file -- This is an entrypoint for the ${revision.folder} API revision\n/** @category API Reference */`, + revision.modules.map((module) => `export * from "./${module.name}.js";`).join("\n"), + ]); +} + +function generateRootIndex(revision: Revision): string { + return joinBlocks([ + "// oxlint-disable oxc/no-barrel-file -- This is an entrypoint for the package\n/** @category API Reference */", + "/*\nThis file exports all the types available in the recommended API revision. Thereby, things MAY break in the future.\nTry sticking to imports from a specific revision.\n*/", + `export * from "./${revision.folder}/index.js";`, + ]); +} + +const files = generatePackageFiles(revisions, { + module: generateModule, + revisionIndex: generateRevisionIndex, + rootIndex: generateRootIndex, +}); + +const { removed, written } = await writeGeneratedFiles(files, { + root: fileURLToPath(new URL("..", import.meta.url)), + format: async (filePath, contents) => { + const result = await format(filePath, contents, formatConfig); + if (result.errors.length > 0) { + throw new Error(`Failed to format ${filePath}: ${JSON.stringify(result.errors)}`); + } + return result.code; + }, +}); + +process.stdout.write( + `Generated ${written.length} files${removed.length > 0 ? `, removed ${removed.join(", ")}` : ""}\n`, +); diff --git a/packages/zod/scripts/tsconfig.json b/packages/zod/scripts/tsconfig.json new file mode 100644 index 00000000..09f49760 --- /dev/null +++ b/packages/zod/scripts/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../tsconfig", + "compilerOptions": { + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/packages/zod/src/index.ts b/packages/zod/src/index.ts index 51561b56..7a979be2 100644 --- a/packages/zod/src/index.ts +++ b/packages/zod/src/index.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + // oxlint-disable oxc/no-barrel-file -- This is an entrypoint for the package /** @category API Reference */ diff --git a/packages/zod/src/v20170710/assignments.ts b/packages/zod/src/v20170710/assignments.ts index 47ff0f6e..aefdb2d2 100644 --- a/packages/zod/src/v20170710/assignments.ts +++ b/packages/zod/src/v20170710/assignments.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; @@ -19,20 +21,44 @@ export type Assignment = Types.Assignment; export const Assignment = z.toZod()( z.object({ ...BaseResource.shape, + /** Data for the returned assignment. */ data: z.object({ + /** When the related subject will be available in the user's review queue. */ available_at: z.nullable(DatableString), + /** When the user reaches SRS stage 9 the first time. */ burned_at: z.nullable(DatableString), + /** When the assignment was created. */ created_at: DatableString, + /** Indicates if the associated subject has been hidden, preventing it from appearing in lessons or reviews. */ hidden: z.boolean(), + /** When the user reaches SRS stage `5` for the first time. */ passed_at: z.nullable(DatableString), + /** When the subject is resurrected and placed back in the user's review queue. */ resurrected_at: z.nullable(DatableString), + /** + * The current SRS stage interval. The interval range is determined by the related subject's Spaced Repetition + * System. + */ srs_stage: SpacedRepetitionSystemStageNumber, + /** When the user completes the lesson for the related subject. */ started_at: z.nullable(DatableString), + /** Unique identifier of the associated subject. */ subject_id: SafeInteger, + /** The type of the associated subject. */ subject_type: SubjectType, + /** + * When the related subjects has its prerequisites satisfied and is made available in lessons. + * + * Prerequisites are: + * + * - The subject components have reached SRS stage 5 once (they have been “passed”). + * - The user's level is equal to or greater than the level of the assignment’s subject. + */ unlocked_at: z.nullable(DatableString), }), + /** A unique number identifying the assignment. */ id: SafeInteger, + /** The kind of object returned. */ object: z.literal("assignment"), }), ); @@ -58,10 +84,10 @@ export function isAssignment(value: unknown): value is Assignment { * @see {@link https://docs.api.wanikani.com/20170710/#get-all-assignments} */ export type AssignmentCollection = Types.AssignmentCollection; - export const AssignmentCollection = z.toZod()( z.object({ ...BaseCollection.shape, + /** An array of returned assignments. */ data: z.array(Assignment), }), ); @@ -85,24 +111,51 @@ export function isAssignmentCollection(value: unknown): value is AssignmentColle * @category Assignments * @category Parameters * @see {@link https://docs.api.wanikani.com/20170710/#get-all-assignments} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type AssignmentParameters = Types.AssignmentParameters; export const AssignmentParameters = z.toZod()( z.object({ ...CollectionParameters.shape, + /** Only assignments available at or after this time are returned. */ available_after: z.exactOptional(z.union([DatableString, z.date()], { error: m.dateUnion })), + /** Only assignments available at or before this time are returned. */ available_before: z.exactOptional(z.union([DatableString, z.date()], { error: m.dateUnion })), + /** + * When set to `true`, returns assignments that have a value in `data.burned_at`. Returns assignments with a `null` + * `data.burned_at` if `false`. + */ burned: z.exactOptional(z.boolean()), + /** Return assignments with a matching value in the `hidden` attribute. */ hidden: z.exactOptional(z.boolean()), + /** When set to `true`, returns assignments which are immediately available for lessons. */ immediately_available_for_lessons: z.exactOptional(z.boolean()), + /** When set to `true`, returns assignments which are immediately available for review. */ immediately_available_for_review: z.exactOptional(z.boolean()), + /** When set to `true`, returns assignments which are in the review state. */ in_review: z.exactOptional(z.boolean()), + /** + * Only assignments where the associated subject level matches one of the array values are returned. Valid values + * range from `1` to `60`. + */ levels: z.exactOptional(z.array(Level)), + /** + * Only assignments where `data.srs_stage` matches one of the array values are returned. Valid values range from `0` + * to `9`. + */ srs_stages: z.exactOptional(z.array(SpacedRepetitionSystemStageNumber)), + /** + * When set to `true`, returns assignments that have a value in `data.started_at`. Returns assignments with a `null` + * `data.started_at` if `false`. + */ started: z.exactOptional(z.boolean()), + /** Only assignments where `data.subject_id` matches one of the array values are returned. */ subject_ids: z.exactOptional(z.array(SafeInteger)), + /** Only assignments where `data.subject_type` matches one of the array values are returned. */ subject_types: z.exactOptional(SubjectTuple), + /** + * When set to true, returns assignments that have a value in `data.unlocked_at`. Returns assignments with a `null` + * `data.unlocked_at` if `false`. + */ unlocked: z.exactOptional(z.boolean()), }), ); @@ -117,7 +170,9 @@ export const AssignmentParameters = z.toZod()( export type AssignmentPayload = Types.AssignmentPayload; export const AssignmentPayload = z.toZod()( z.object({ + /** Specify properties of the Assignment; currently only `started_at` is supported. */ assignment: z.object({ + /** When the assignment was started. Must be greater than or equal to the assignment's `unlocked_at` date. */ started_at: z.exactOptional(z.union([DatableString, z.date()], { error: m.dateUnion })), }), }), diff --git a/packages/zod/src/v20170710/base.ts b/packages/zod/src/v20170710/base.ts index aa53e491..686a17af 100644 --- a/packages/zod/src/v20170710/base.ts +++ b/packages/zod/src/v20170710/base.ts @@ -1,5 +1,8 @@ -import * as Types from "@bachman-dev/wanikani-api-types/v20170710"; +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; +import { MAX_LEVEL, MIN_LEVEL } from "@bachman-dev/wanikani-api-types/v20170710"; import * as m from "./lang/index.js"; @@ -12,7 +15,7 @@ export { API_REVISION, MIN_LEVEL, MAX_LEVEL } from "@bachman-dev/wanikani-api-ty * @see {@link https://docs.api.wanikani.com/20170710/#revisions-aka-versioning} */ export type ApiRevision = Types.ApiRevision; -export const ApiRevision = z.toZod()(z.literal(Types.API_REVISION)); +export const ApiRevision = z.toZod()(z.literal("20170710")); /** * A type guard that checks if the given value matches the type predicate. @@ -32,19 +35,22 @@ export function isApiRevision(value: unknown): value is ApiRevision { * * @category Base */ -// TODO: If we no longer need to alias the type, switch to z.toZod() -// oxlint-disable-next-line typescript/consistent-type-assertions -- number & {} is identical to number at runtime, we may eventually switch it back to number depending on how docs turn out -export const SafeInteger = z.number().check(z.int(), z.nonnegative()) as z.ZodMiniType; +export type SafeInteger = Types.SafeInteger; +// oxlint-disable-next-line typescript/consistent-type-assertions -- `number & {}` is identical to `number` at runtime +export const SafeInteger = z.number().check(z.int(), z.minimum(0)) as z.ZodMiniType; /** * A `string` sent to/returned from the WaniKani API that can be converted into a JavaScript `Date` object. * + * @remarks + * This is a branded type. A `string` can be narrowed to it by validating it with one of the schema library packages + * (e.g. `@bachman-dev/wanikani-api-valibot`), or by using a type assertion if it's known to be valid. * @category Base */ export type DatableString = Types.DatableString; export const DatableString = z.toZod()( z.pipe( - z.iso.datetime({ offset: true }), + z.pipe(z.string().check(z.trim()), z.iso.datetime({ offset: true })), // oxlint-disable-next-line typescript/consistent-type-assertions, typescript/no-unsafe-type-assertion -- Validated above z.transform((value) => value as DatableString), ), @@ -69,7 +75,7 @@ export function isDatableString(value: unknown): value is DatableString { * @category Base */ export type Level = Types.Level; -export const Level = z.toZod()(SafeInteger.check(z.minimum(Types.MIN_LEVEL), z.maximum(Types.MAX_LEVEL))); +export const Level = z.toZod()(SafeInteger.check(z.minimum(MIN_LEVEL), z.maximum(MAX_LEVEL))); /** * A type guard that checks if the given value matches the type predicate. @@ -96,7 +102,9 @@ export function isLevel(value: unknown): value is Level { export type BaseResource = Types.BaseResource; export const BaseResource = z.toZod()( z.object({ + /** For a resource, this is the last time that particular resource was updated. */ data_updated_at: DatableString, + /** The URL of the requested resource. */ url: z.string(), }), ); @@ -113,14 +121,28 @@ export const BaseResource = z.toZod()( export type BaseCollection = Types.BaseCollection; export const BaseCollection = z.toZod()( z.object({ + /** + * For collections, this is the timestamp of the most recently updated resource in the specified scope and is not + * limited by pagination. If no items were returned for the specified scope, then this will be `null`. + */ data_updated_at: z.nullable(DatableString), + /** The kind of object returned. */ object: z.literal("collection"), + /** Pagination Info for the collection. */ pages: z.object({ + /** The URL of the next page of results. If there are no more results, the value is `null`. */ next_url: z.nullable(z.string()), + /** Maximum number of items delivered per page for this collection. */ per_page: SafeInteger, + /** + * The URL of the previous page of results. If there are no results at all or no previous page to go to, the value + * is `null`. + */ previous_url: z.nullable(z.string()), }), + /** The total number of items in the collection. */ total_count: SafeInteger, + /** The URL of the request. For collections, that will contain all the filters and options you've passed to the API. */ url: z.string(), }), ); @@ -130,14 +152,25 @@ export const BaseCollection = z.toZod()( * * @category Base * @category Parameters - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type CollectionParameters = Types.CollectionParameters; export const CollectionParameters = z.toZod()( z.object({ + /** Only resources where `data.id` matches one of the array values are returned. */ ids: z.exactOptional(z.array(SafeInteger)), + /** + * Get a collection's next page containing `pages.per_page` resources after the given ID. + * + * This will take precedence over `page_before_id` if both are specified. + */ page_after_id: z.exactOptional(SafeInteger), + /** + * Get a collection's previous page containing `pages.per_page` resources before the given ID. + * + * The `page_after_id` parameter takes precedence over this if it is specified alongside this parameter. + */ page_before_id: z.exactOptional(SafeInteger), + /** Only resources updated after this time are returned. */ updated_after: z.exactOptional(z.union([DatableString, z.date()], { error: m.dateUnion })), }), ); @@ -154,8 +187,11 @@ export const CollectionParameters = z.toZod()( export type BaseReport = Types.BaseReport; export const BaseReport = z.toZod()( z.object({ + /** The last time the report was updated. */ data_updated_at: DatableString, + /** The kind of object returned. */ object: z.literal("report"), + /** The URL of the requested report. */ url: z.string(), }), ); @@ -168,7 +204,9 @@ export const BaseReport = z.toZod()( export type ApiError = Types.ApiError; export const ApiError = z.toZod()( z.object({ + /** An HTTP status code indicating the type of error. */ code: SafeInteger, + /** A message string that describes the error. */ error: z.string(), }), ); diff --git a/packages/zod/src/v20170710/index.ts b/packages/zod/src/v20170710/index.ts index fbaadc42..f8b4bc41 100644 --- a/packages/zod/src/v20170710/index.ts +++ b/packages/zod/src/v20170710/index.ts @@ -1,15 +1,17 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + // oxlint-disable oxc/no-barrel-file -- This is an entrypoint for the v20170710 API revision /** @category API Reference */ -export * from "./assignments.ts"; +export * from "./assignments.js"; export * from "./base.js"; -export * from "./level-progressions.ts"; -export * from "./resets.ts"; -export * from "./review-statistics.ts"; -export * from "./reviews.ts"; -export * from "./spaced-repetition-systems.ts"; -export * from "./study-materials.ts"; -export * from "./subjects.ts"; -export * from "./summary.ts"; -export * from "./user.ts"; -export * from "./voice-actors.ts"; +export * from "./level-progressions.js"; +export * from "./resets.js"; +export * from "./review-statistics.js"; +export * from "./reviews.js"; +export * from "./spaced-repetition-systems.js"; +export * from "./study-materials.js"; +export * from "./subjects.js"; +export * from "./summary.js"; +export * from "./user.js"; +export * from "./voice-actors.js"; diff --git a/packages/zod/src/v20170710/level-progressions.ts b/packages/zod/src/v20170710/level-progressions.ts index 0332878c..6aa3ad59 100644 --- a/packages/zod/src/v20170710/level-progressions.ts +++ b/packages/zod/src/v20170710/level-progressions.ts @@ -1,7 +1,11 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +/* oxlint-disable unicorn/prefer-export-from -- Aliases are declared, rather than re-exported, so they're documented */ + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; -import { BaseCollection, BaseResource, DatableString, Level, SafeInteger } from "./base.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; /** * Level progressions contain information about a user's progress through the WaniKani levels. @@ -21,16 +25,29 @@ export type LevelProgression = Types.LevelProgression; export const LevelProgression = z.toZod()( z.object({ ...BaseResource.shape, + /** Data for the returned level progression. */ data: z.object({ + /** Timestamp when the user abandons the level. This is primarily used when the user initiates a reset. */ abandoned_at: z.nullable(DatableString), + /** Timestamp when the user burns 100% of the assignments belonging to the associated subject's level. */ completed_at: z.nullable(DatableString), + /** Timestamp when the level progression is created. */ created_at: DatableString, + /** The level of the progression, with possible values from `1` to `60`. */ level: Level, + /** + * Timestamp, in ISO-8601 format, when the user passes at least 90% of the assignments with a type of `kanji` + * belonging to the associated subject's level. + */ passed_at: z.nullable(DatableString), + /** Timestamp when the user starts their first lesson of a subject belonging to the level. */ started_at: z.nullable(DatableString), + /** Timestamp when the user can access lessons and reviews for the `level`. */ unlocked_at: z.nullable(DatableString), }), + /** A unique number identifying the level progression. */ id: SafeInteger, + /** The kind of object returned. */ object: z.literal("level_progression"), }), ); @@ -59,6 +76,7 @@ export type LevelProgressionCollection = Types.LevelProgressionCollection; export const LevelProgressionCollection = z.toZod()( z.object({ ...BaseCollection.shape, + /** An array of returned level progressions. */ data: z.array(LevelProgression), }), ); @@ -82,6 +100,6 @@ export function isLevelProgressionCollection(value: unknown): value is LevelProg * @category Level Progressions * @category Parameters * @see {@link https://docs.api.wanikani.com/20170710/#get-all-level-progressions} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export { CollectionParameters as LevelProgressionParameters } from "./base.js"; +export type LevelProgressionParameters = Types.LevelProgressionParameters; +export const LevelProgressionParameters = CollectionParameters; diff --git a/packages/zod/src/v20170710/resets.ts b/packages/zod/src/v20170710/resets.ts index 564257c3..af23a655 100644 --- a/packages/zod/src/v20170710/resets.ts +++ b/packages/zod/src/v20170710/resets.ts @@ -1,7 +1,11 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +/* oxlint-disable unicorn/prefer-export-from -- Aliases are declared, rather than re-exported, so they're documented */ + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; -import { BaseCollection, BaseResource, DatableString, Level, SafeInteger } from "./base.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; /** * Users can reset their progress back to any level at or below their current level. When they reset to a particular @@ -17,13 +21,20 @@ export type Reset = Types.Reset; export const Reset = z.toZod()( z.object({ ...BaseResource.shape, + /** Data for the returned reset. */ data: z.object({ + /** Timestamp when the user confirmed the reset. */ confirmed_at: z.nullable(DatableString), + /** Timestamp when the reset was created. */ created_at: DatableString, + /** The user's level before the reset, from `1` to `60`. */ original_level: Level, + /** The user's level after the reset, from `1` to `60`. It must be less than or equal to `original_level`. */ target_level: Level, }), + /** A unique number identifying the reset. */ id: SafeInteger, + /** The kind of object returned. */ object: z.literal("reset"), }), ); @@ -52,6 +63,7 @@ export type ResetCollection = Types.ResetCollection; export const ResetCollection = z.toZod()( z.object({ ...BaseCollection.shape, + /** An array of returned resets. */ data: z.array(Reset), }), ); @@ -75,6 +87,6 @@ export function isResetCollection(value: unknown): value is ResetCollection { * @category Parameters * @category Resets * @see {@link https://docs.api.wanikani.com/20170710/#get-all-resets} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export { CollectionParameters as ResetParameters } from "./base.js"; +export type ResetParameters = Types.ResetParameters; +export const ResetParameters = CollectionParameters; diff --git a/packages/zod/src/v20170710/review-statistics.ts b/packages/zod/src/v20170710/review-statistics.ts index 363a6edc..cc2fc2ed 100644 --- a/packages/zod/src/v20170710/review-statistics.ts +++ b/packages/zod/src/v20170710/review-statistics.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; @@ -19,22 +21,38 @@ export type ReviewStatistic = Types.ReviewStatistic; export const ReviewStatistic = z.toZod()( z.object({ ...BaseResource.shape, + /** Data for the returned review statistic. */ data: z.object({ + /** Timestamp when the review statistic was created. */ created_at: DatableString, + /** Indicates if the associated subject has been hidden, preventing it from appearing in lessons or reviews. */ hidden: z.boolean(), + /** Total number of correct answers submitted for the meaning of the associated subject. */ meaning_correct: SafeInteger, + /** The current, uninterrupted series of correct answers given for the meaning of the associated subject. */ meaning_current_streak: SafeInteger, + /** Total number of incorrect answers submitted for the meaning of the associated subject. */ meaning_incorrect: SafeInteger, + /** The longest, uninterrupted series of correct answers ever given for the meaning of the associated subject. */ meaning_max_streak: SafeInteger, + /** The overall correct answer rate by the user for the subject, including both meaning and reading. */ percentage_correct: z.number(), + /** Total number of correct answers submitted for the reading of the associated subject. */ reading_correct: SafeInteger, + /** The current, uninterrupted series of correct answers given for the reading of the associated subject. */ reading_current_streak: SafeInteger, + /** Total number of incorrect answers submitted for the reading of the associated subject. */ reading_incorrect: SafeInteger, + /** The longest, uninterrupted series of correct answers ever given for the reading of the associated subject. */ reading_max_streak: SafeInteger, + /** Unique identifier of the associated subject. */ subject_id: SafeInteger, + /** The type of the associated subject. */ subject_type: SubjectType, }), + /** A unique number identifying the review statistic. */ id: SafeInteger, + /** The kind of object returned. */ object: z.literal("review_statistic"), }), ); @@ -63,6 +81,7 @@ export type ReviewStatisticCollection = Types.ReviewStatisticCollection; export const ReviewStatisticCollection = z.toZod()( z.object({ ...BaseCollection.shape, + /** An array of returned review statistics. */ data: z.array(ReviewStatistic), }), ); @@ -86,16 +105,20 @@ export function isReviewStatisticCollection(value: unknown): value is ReviewStat * @category Parameters * @category Review Statistics * @see {@link https://docs.api.wanikani.com/20170710/#get-all-review-statistics} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type ReviewStatisticParameters = Types.ReviewStatisticParameters; export const ReviewStatisticParameters = z.toZod()( z.object({ ...CollectionParameters.shape, + /** Return review statistics with a matching value in the `hidden` attribute. */ hidden: z.exactOptional(z.boolean()), + /** Return review statistics where the `percentage_correct` is greater than the value. */ percentages_greater_than: z.exactOptional(z.number()), + /** Return review statistics where the `percentage_correct` is less than the value. */ percentages_less_than: z.exactOptional(z.number()), + /** Only review statistics where `data.subject_id` matches one of the array values are returned. */ subject_ids: z.exactOptional(z.array(SafeInteger)), + /** Only review statistics where `data.subject_type` matches one of the array values are returned. */ subject_types: z.exactOptional(SubjectTuple), }), ); diff --git a/packages/zod/src/v20170710/reviews.ts b/packages/zod/src/v20170710/reviews.ts index e61aaff3..cce8d417 100644 --- a/packages/zod/src/v20170710/reviews.ts +++ b/packages/zod/src/v20170710/reviews.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; @@ -20,17 +22,31 @@ export type Review = Types.Review; export const Review = z.toZod()( z.object({ ...BaseResource.shape, + /** Data for the returned review. */ data: z.object({ + /** Unique identifier of the associated assignment. */ assignment_id: SafeInteger, + /** Timestamp when the review was created. */ created_at: DatableString, + /** + * The SRS stage interval calculated from the number of correct and incorrect answers, with valid values ranging + * from `1` to `9`. + */ ending_srs_stage: SpacedRepetitionSystemStageNumber, + /** The number of times the user has answered the meaning incorrectly. */ incorrect_meaning_answers: SafeInteger, + /** The number of times the user has answered the reading incorrectly. */ incorrect_reading_answers: SafeInteger, + /** Unique identifier of the associated `spaced_repetition_system`. */ spaced_repetition_system_id: SafeInteger, + /** The starting SRS stage interval, with valid values ranging from `1` to `8`. */ starting_srs_stage: SpacedRepetitionSystemStageNumber, + /** Unique identifier of the associated subject. */ subject_id: SafeInteger, }), + /** A unique number identifying the review. */ id: SafeInteger, + /** The kind of object returned. */ object: z.literal("review"), }), ); @@ -59,6 +75,7 @@ export type ReviewCollection = Types.ReviewCollection; export const ReviewCollection = z.toZod()( z.object({ ...BaseCollection.shape, + /** An array of returned reviews. */ data: z.array(Review), }), ); @@ -82,13 +99,14 @@ export function isReviewCollection(value: unknown): value is ReviewCollection { * @category Parameters * @category Reviews * @see {@link https://docs.api.wanikani.com/20170710/#get-all-reviews} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type ReviewParameters = Types.ReviewParameters; export const ReviewParameters = z.toZod()( z.object({ ...CollectionParameters.shape, + /** Only reviews where `data.assignment_id` matches one of the array values are returned. */ assignment_ids: z.exactOptional(z.array(SafeInteger)), + /** Only reviews where `data.subject_id` matches one of the array values are returned. */ subject_ids: z.exactOptional(z.array(SafeInteger)), }), ); @@ -103,21 +121,35 @@ export const ReviewParameters = z.toZod()( export type ReviewPayload = Types.ReviewPayload; export const ReviewPayload = z.toZod()( z.object({ + /** A review object with either the `assignment_id` or `subject_id` specified. */ review: z.intersection( z.object({ + /** Must be zero or a positive number. This is the number of times the meaning was answered incorrectly. */ incorrect_meaning_answers: SafeInteger, + /** + * Must be zero or a positive number. This is the number of times the reading was answered incorrectly. Note + * that subjects with a type of `radical` do not quiz on readings. Thus, set this value to `0`. + */ incorrect_reading_answers: SafeInteger, + /** + * Timestamp when the review was completed. Defaults to the time of the request if omitted from the request + * body. Must be in the past, but after `assignment.available_at`. + */ created_at: z.exactOptional(z.union([DatableString, z.date()], { error: m.dateUnion })), }), z.union( [ z.object({ + /** Unique identifier of the assignment. This or `subject_id` must be set. */ assignment_id: SafeInteger, + /** The `subject_id` should not be set at the same time as `assignment_id`. */ subject_id: z.exactOptional(z.never()), }), z.object({ - assignment_id: z.exactOptional(z.never()), + /** Unique identifier of the subject. This or `assignment_id` must be set. */ subject_id: SafeInteger, + /** The `assignment_id` should never be set at the same time as `subject_id`. */ + assignment_id: z.exactOptional(z.never()), }), ], { error: m.reviewPayloadUnion }, @@ -137,8 +169,19 @@ export type CreatedReview = Types.CreatedReview; export const CreatedReview = z.toZod()( z.object({ ...Review.shape, + /** The resources updated alongside creating the review. */ resources_updated: z.object({ + /** + * The updated assignment upon creating the review. + * + * @see {@link https://docs.api.wanikani.com/20170710/#assignments} + */ assignment: Assignment, + /** + * The updated review statistic upon creating the review. + * + * @see {@link https://docs.api.wanikani.com/20170710/#review-statistics} + */ review_statistic: ReviewStatistic, }), }), diff --git a/packages/zod/src/v20170710/spaced-repetition-systems.ts b/packages/zod/src/v20170710/spaced-repetition-systems.ts index 5ef4857f..19b055b9 100644 --- a/packages/zod/src/v20170710/spaced-repetition-systems.ts +++ b/packages/zod/src/v20170710/spaced-repetition-systems.ts @@ -1,8 +1,12 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +/* oxlint-disable unicorn/prefer-export-from -- Aliases are declared, rather than re-exported, so they're documented */ + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; import { MAX_SRS_STAGE } from "@bachman-dev/wanikani-api-types/v20170710"; -import { BaseCollection, BaseResource, DatableString, SafeInteger } from "./base.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; export { MIN_SRS_STAGE, MAX_SRS_REVIEW_STAGE, MAX_SRS_STAGE } from "@bachman-dev/wanikani-api-types/v20170710"; @@ -38,8 +42,11 @@ export function isSpacedRepetitionSystemStageNumber(value: unknown): value is Sp export type SpacedRepetitionSystemStage = Types.SpacedRepetitionSystemStage; export const SpacedRepetitionSystemStage = z.toZod()( z.object({ + /** The length of time added to the time of review registration, adjusted to the beginning of the hour. */ interval: z.nullable(SafeInteger), + /** Unit of time. Can be the following: `milliseconds`, `seconds`, `minutes`, `hours`, `days`, and `weeks`. */ interval_unit: z.nullable(z.enum(["days", "hours", "milliseconds", "minutes", "seconds", "weeks"])), + /** The position of the stage within the continuous order. */ position: SpacedRepetitionSystemStageNumber, }), ); @@ -56,17 +63,28 @@ export type SpacedRepetitionSystem = Types.SpacedRepetitionSystem; export const SpacedRepetitionSystem = z.toZod()( z.object({ ...BaseResource.shape, + /** Data for the return Spaced Repetition System. */ data: z.object({ + /** `position` of the burning stage. */ burning_stage_position: SpacedRepetitionSystemStageNumber, + /** Timestamp when the `spaced_repetition_system` was created. */ created_at: DatableString, + /** Details about the spaced repetition system. */ description: z.string(), + /** The name of the spaced repetition system. */ name: z.string(), + /** `position` of the passing stage. */ passing_stage_position: SpacedRepetitionSystemStageNumber, + /** A collection of stages. */ stages: z.array(SpacedRepetitionSystemStage), + /** `position` of the starting stage. */ starting_stage_position: SpacedRepetitionSystemStageNumber, + /** `position` of the unlocking stage. */ unlocking_stage_position: SpacedRepetitionSystemStageNumber, }), + /** A unique number identifying the Spaced Repetition System. */ id: SafeInteger, + /** The kind of object returned. */ object: z.literal("spaced_repetition_system"), }), ); @@ -95,6 +113,7 @@ export type SpacedRepetitionSystemCollection = Types.SpacedRepetitionSystemColle export const SpacedRepetitionSystemCollection = z.toZod()( z.object({ ...BaseCollection.shape, + /** An array of returned Spaced Repetition Systems. */ data: z.array(SpacedRepetitionSystem), }), ); @@ -118,6 +137,6 @@ export function isSpacedRepetitionSystemCollection(value: unknown): value is Spa * @category Parameters * @category Spaced Repetition Systems * @see {@link https://docs.api.wanikani.com/20170710/#get-all-spaced-repetition-systems} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export { CollectionParameters as SpacedRepetitionSystemParameters } from "./base.js"; +export type SpacedRepetitionSystemParameters = Types.SpacedRepetitionSystemParameters; +export const SpacedRepetitionSystemParameters = CollectionParameters; diff --git a/packages/zod/src/v20170710/study-materials.ts b/packages/zod/src/v20170710/study-materials.ts index bd553ff2..18340fe6 100644 --- a/packages/zod/src/v20170710/study-materials.ts +++ b/packages/zod/src/v20170710/study-materials.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; @@ -15,8 +17,11 @@ import { SubjectTuple, SubjectType } from "./subjects.js"; export type StudyMaterialBaseData = Types.StudyMaterialBaseData; export const StudyMaterialBaseData = z.toZod()( z.object({ + /** Free form note related to the meaning(s) of the associated subject. */ meaning_note: z.string(), + /** Synonyms for the meaning of the subject. These are used as additional correct answers during reviews. */ meaning_synonyms: z.array(z.string()), + /** Free form note related to the reading(s) of the associated subject. */ reading_note: z.string(), }), ); @@ -33,14 +38,21 @@ export type StudyMaterial = Types.StudyMaterial; export const StudyMaterial = z.toZod()( z.object({ ...BaseResource.shape, + /** Data for the returned study material. */ data: z.object({ ...StudyMaterialBaseData.shape, + /** Timestamp when the study material was created. */ created_at: DatableString, + /** Indicates if the associated subject has been hidden, preventing it from appearing in lessons or reviews. */ hidden: z.boolean(), + /** Unique identifier of the associated subject. */ subject_id: SafeInteger, + /** The type of the associated subject. */ subject_type: SubjectType, }), + /** A unique number identifying the study material. */ id: SafeInteger, + /** The kind of object returned. */ object: z.literal("study_material"), }), ); @@ -69,6 +81,7 @@ export type StudyMaterialCollection = Types.StudyMaterialCollection; export const StudyMaterialCollection = z.toZod()( z.object({ ...BaseCollection.shape, + /** An array of returned study materials. */ data: z.array(StudyMaterial), }), ); @@ -92,14 +105,16 @@ export function isStudyMaterialCollection(value: unknown): value is StudyMateria * @category Parameters * @category Study Materials * @see {@link https://docs.api.wanikani.com/20170710/#get-all-study-materials} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type StudyMaterialParameters = Types.StudyMaterialParameters; export const StudyMaterialParameters = z.toZod()( z.object({ ...CollectionParameters.shape, + /** Return study materials with a matching value in the `hidden` attribute. */ hidden: z.exactOptional(z.boolean()), + /** Only study material records where `data.subject_id` matches one of the array values are returned. */ subject_ids: z.exactOptional(z.array(SafeInteger)), + /** Only study material records where `data.subject_type` matches one of the array values are returned. */ subject_types: z.exactOptional(SubjectTuple), }), ); @@ -114,8 +129,11 @@ export const StudyMaterialParameters = z.toZod()( export type StudyMaterialUpdatePayload = Types.StudyMaterialUpdatePayload; export const StudyMaterialUpdatePayload = z.toZod()( z.object({ + /** Free form note related to the meaning(s) of the associated subject. */ meaning_note: z.exactOptional(StudyMaterialBaseData.shape.meaning_note), + /** Synonyms for the meaning of the subject. These are used as additional correct answers during reviews. */ meaning_synonyms: z.exactOptional(StudyMaterialBaseData.shape.meaning_synonyms), + /** Free form note related to the reading(s) of the associated subject. */ reading_note: z.exactOptional(StudyMaterialBaseData.shape.reading_note), }), ); @@ -131,6 +149,7 @@ export type StudyMaterialCreatePayload = Types.StudyMaterialCreatePayload; export const StudyMaterialCreatePayload = z.toZod()( z.object({ ...StudyMaterialUpdatePayload.shape, + /** Unique identifier of the associated subject. */ subject_id: SafeInteger, }), ); diff --git a/packages/zod/src/v20170710/subjects.ts b/packages/zod/src/v20170710/subjects.ts index ec0ec010..ee298f89 100644 --- a/packages/zod/src/v20170710/subjects.ts +++ b/packages/zod/src/v20170710/subjects.ts @@ -1,7 +1,9 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; -import * as m from "./lang/index.ts"; +import * as m from "./lang/index.js"; import { BaseCollection, BaseResource, CollectionParameters, DatableString, Level, SafeInteger } from "./base.js"; /** @@ -34,10 +36,9 @@ export function isSubjectType(value: unknown): value is SubjectType { */ export type SubjectTuple = Types.SubjectTuple; export const SubjectTuple = z.toZod()( - z.tuple([SubjectType], SubjectType).check( - z.minLength(1), - z.refine((items) => new Set(items).size === items.length, { error: m.subjectTupleDuplicate }), - ), + z + .tuple([SubjectType], SubjectType) + .check(z.refine((items) => new Set(items).size === items.length, { error: m.subjectTupleDuplicate })), ); /** @@ -62,7 +63,12 @@ export function isSubjectTuple(value: unknown): value is SubjectTuple { export type SubjectAuxiliaryMeaning = Types.SubjectAuxiliaryMeaning; export const SubjectAuxiliaryMeaning = z.toZod()( z.object({ + /** A singular subject meaning. */ meaning: z.string(), + /** + * Either `whitelist` or `blacklist`. When evaluating user input, whitelisted meanings are used to match for + * correctness. Blacklisted meanings are used to match for incorrectness. + */ type: z.enum(["blacklist", "whitelist"]), }), ); @@ -76,8 +82,11 @@ export const SubjectAuxiliaryMeaning = z.toZod()( export type SubjectMeaning = Types.SubjectMeaning; export const SubjectMeaning = z.toZod()( z.object({ + /** Indicates if the meaning is used to evaluate user input for correctness. */ accepted_answer: z.boolean(), + /** A singular subject meaning. */ meaning: z.string(), + /** Indicates priority in the WaniKani system. */ primary: z.boolean(), }), ); @@ -94,15 +103,34 @@ export const SubjectMeaning = z.toZod()( export type SubjectBaseData = Types.SubjectBaseData; export const SubjectBaseData = z.toZod()( z.object({ + /** Collection of auxiliary meanings. */ auxiliary_meanings: z.array(SubjectAuxiliaryMeaning), + /** Timestamp when the subject was created. */ created_at: DatableString, + /** A URL pointing to the page on wanikani.com that provides detailed information about this subject. */ document_url: z.string(), + /** + * Timestamp when the subject was hidden, indicating associated assignments will no longer appear in lessons or + * reviews and that the subject page is no longer visible on wanikani.com. + */ hidden_at: z.nullable(DatableString), + /** + * The position that the subject appears in lessons. Note that the value is scoped to the level of the subject, so + * there are duplicate values across levels. + */ lesson_position: SafeInteger, + /** The level of the subject, from `1` to `60`. */ level: Level, + /** The subject's meaning mnemonic. */ meaning_mnemonic: z.string(), + /** The subject meanings. */ meanings: z.array(SubjectMeaning), + /** + * The string that is used when generating the document URL for the subject. Radicals use their meaning, downcased. + * Kanji and vocabulary use their characters. + */ slug: z.string(), + /** Unique identifier of the associated Spaced Repetition System. */ spaced_repetition_system_id: SafeInteger, }), ); @@ -117,23 +145,32 @@ export type RadicalCharacterImage = Types.RadicalCharacterImage; export const RadicalCharacterImage = z.toZod()( z.intersection( z.object({ + /** The location of the image. */ url: z.string(), }), z.discriminatedUnion("content_type", [ z.object({ - content_type: z.literal("image/svg+xml"), - metadata: z.object({ - inline_styles: z.boolean(), - }), - }), - z.object({ + /** The content type of the image. */ content_type: z.literal("image/png"), + /** Details about the image. Each `content_type` returns a uniquely structured object. */ metadata: z.object({ + /** Color of the asset in hexadecimal */ color: z.string(), + /** Dimension of the asset in pixels */ dimensions: z.string(), + /** A name descriptor */ style_name: z.string(), }), }), + z.object({ + /** The content type of the image. */ + content_type: z.literal("image/svg+xml"), + /** Details about the image. Each `content_type` returns a uniquely structured object. */ + metadata: z.object({ + /** The SVG asset contains built-in CSS styling. */ + inline_styles: z.boolean(), + }), + }), ]), ), ); @@ -148,8 +185,14 @@ export type RadicalData = Types.RadicalData; export const RadicalData = z.toZod()( z.object({ ...SubjectBaseData.shape, + /** An array of numeric identifiers for the kanji that have the radical as a component. */ amalgamation_subject_ids: z.array(SafeInteger), + /** A collection of images of the radical. */ character_images: z.array(RadicalCharacterImage), + /** + * Unlike kanji and vocabulary, radicals can have a null value for characters. Not all radicals have a UTF entry, so + * the radical must be visually represented with an image instead. + */ characters: z.nullable(z.string()), }), ); @@ -163,9 +206,13 @@ export const RadicalData = z.toZod()( export type KanjiReading = Types.KanjiReading; export const KanjiReading = z.toZod()( z.object({ + /** Indicates if the reading is used to evaluate user input for correctness. */ accepted_answer: z.boolean(), + /** Indicates priority in the WaniKani system. */ primary: z.boolean(), + /** A singular subject reading. */ reading: z.string(), + /** The kanji reading's classfication: `kunyomi`, `nanori`, or `onyomi`. */ type: z.enum(["kunyomi", "nanori", "onyomi"]), }), ); @@ -180,13 +227,24 @@ export type KanjiData = Types.KanjiData; export const KanjiData = z.toZod()( z.object({ ...SubjectBaseData.shape, + /** An array of numeric identifiers for the vocabulary that have the kanji as a component. */ amalgamation_subject_ids: z.array(SafeInteger), + /** The UTF-8 characters for the subject, including kanji and hiragana. */ characters: z.string(), + /** + * An array of numeric identifiers for the radicals that make up this kanji. Note that these are the subjects that + * must have passed assignments in order to unlock this subject's assignment. + */ component_subject_ids: z.array(SafeInteger), + /** Meaning hint for the kanji. */ meaning_hint: z.nullable(z.string()), + /** Reading hint for the kanji. */ reading_hint: z.nullable(z.string()), + /** The kanji's reading mnemonic. */ reading_mnemonic: z.string(), + /** Selected readings for the kanji. */ readings: z.array(KanjiReading), + /** An array of numeric identifiers for kanji which are visually similar to the kanji in question. */ visually_similar_subject_ids: z.array(SafeInteger), }), ); @@ -200,7 +258,9 @@ export const KanjiData = z.toZod()( export type VocabularyContextSentence = Types.VocabularyContextSentence; export const VocabularyContextSentence = z.toZod()( z.object({ + /** English translation of the sentence. */ en: z.string(), + /** Japanese context sentence. */ ja: z.string(), }), ); @@ -214,15 +274,24 @@ export const VocabularyContextSentence = z.toZod()( z.object({ + /** The content type of the audio. Currently the API delivers `audio/mpeg`, `audio/ogg`, and `audio/webm`. */ content_type: z.enum(["audio/mpeg", "audio/ogg", "audio/webm"]), + /** Details about the pronunciation audio. */ metadata: z.object({ + /** The gender of the voice actor. */ gender: z.enum(["female", "male"]), + /** Vocabulary being pronounced in kana. */ pronunciation: z.string(), + /** A unique ID shared between same source pronunciation audio. */ source_id: SafeInteger, + /** A unique ID belonging to the voice actor. */ voice_actor_id: SafeInteger, + /** Humanized name of the voice actor. */ voice_actor_name: z.string(), + /** Description of the voice. */ voice_description: z.string(), }), + /** The location of the audio. */ url: z.string(), }), ); @@ -236,8 +305,11 @@ export const VocabularyPronunciationAudio = z.toZod()( z.object({ + /** Indicates if the reading is used to evaluate user input for correctness. */ accepted_answer: z.boolean(), + /** Indicates priority in the WaniKani system. */ primary: z.boolean(), + /** A singular subject reading. */ reading: z.string(), }), ); @@ -252,12 +324,22 @@ export type VocabularyData = Types.VocabularyData; export const VocabularyData = z.toZod()( z.object({ ...SubjectBaseData.shape, + /** The UTF-8 characters for the subject, including kanji and hiragana. */ characters: z.string(), + /** + * An array of numeric identifiers for the kanji that make up this vocabulary. Note that these are the subjects that + * must be have passed assignments in order to unlock this subject's assignment. + */ component_subject_ids: z.array(SafeInteger), + /** A collection of context sentences. */ context_sentences: z.array(VocabularyContextSentence), + /** Parts of speech. */ parts_of_speech: z.array(z.string()), + /** A collection of pronunciation audio. */ pronunciation_audios: z.array(VocabularyPronunciationAudio), + /** The vocabulary's reading mnemonic. */ reading_mnemonic: z.string(), + /** Selected readings for the vocabulary. */ readings: z.array(VocabularyReading), }), ); @@ -272,9 +354,13 @@ export type KanaVocabularyData = Types.KanaVocabularyData; export const KanaVocabularyData = z.toZod()( z.object({ ...SubjectBaseData.shape, + /** The UTF-8 characters for the subject, including kanji and hiragana. */ characters: z.string(), + /** A collection of context sentences. */ context_sentences: z.array(VocabularyContextSentence), + /** Parts of speech. */ parts_of_speech: z.array(z.string()), + /** A collection of pronunciation audio. */ pronunciation_audios: z.array(VocabularyPronunciationAudio), }), ); @@ -295,23 +381,32 @@ export const Subject = z.toZod()( z.intersection( z.object({ ...BaseResource.shape, + /** A unique number identifying the subject. */ id: SafeInteger, }), z.discriminatedUnion("object", [ z.object({ + /** Data for the returned kana-only vocabulary. */ data: KanaVocabularyData, + /** The kind of object returned. */ object: z.literal("kana_vocabulary"), }), z.object({ + /** Data for the returned kanji. */ data: KanjiData, + /** The kind of object returned. */ object: z.literal("kanji"), }), z.object({ + /** Data for the returned radical. */ data: RadicalData, + /** The kind of object returned. */ object: z.literal("radical"), }), z.object({ + /** Data for the returned vocabulary. */ data: VocabularyData, + /** The kind of object returned. */ object: z.literal("vocabulary"), }), ]), @@ -342,6 +437,7 @@ export type SubjectCollection = Types.SubjectCollection; export const SubjectCollection = z.toZod()( z.object({ ...BaseCollection.shape, + /** An array of returned subjects of mixed or unknown type. */ data: z.array(Subject), }), ); @@ -365,16 +461,18 @@ export function isSubjectCollection(value: unknown): value is SubjectCollection * @category Parameters * @category Subjects * @see {@link https://docs.api.wanikani.com/20170710/#get-all-subjects} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ export type SubjectParameters = Types.SubjectParameters; - export const SubjectParameters = z.toZod()( z.object({ ...CollectionParameters.shape, + /** Return subjects which are or are not hidden from the user-facing application. */ hidden: z.exactOptional(z.boolean()), + /** Return subjects at the specified levels. */ levels: z.exactOptional(z.array(Level)), + /** Return subjects of the specified slug. */ slugs: z.exactOptional(z.array(z.string())), + /** Return subjects of the specified types. */ types: z.exactOptional(SubjectTuple), }), ); diff --git a/packages/zod/src/v20170710/summary.ts b/packages/zod/src/v20170710/summary.ts index dcedc7f7..793db96e 100644 --- a/packages/zod/src/v20170710/summary.ts +++ b/packages/zod/src/v20170710/summary.ts @@ -1,3 +1,5 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; @@ -12,7 +14,12 @@ import { BaseReport, DatableString, SafeInteger } from "./base.js"; export type SummaryInterval = Types.SummaryInterval; export const SummaryInterval = z.toZod()( z.object({ + /** + * When the paired `subject_ids` are available for lessons. Always beginning of the current hour when the API + * endpoint is accessed. + */ available_at: DatableString, + /** Collection of unique identifiers for subjects. */ subject_ids: z.array(SafeInteger), }), ); @@ -29,9 +36,13 @@ export type Summary = Types.Summary; export const Summary = z.toZod()( z.object({ ...BaseReport.shape, + /** Data for the Summary report. */ data: z.object({ + /** Details about subjects available for lessons. */ lessons: z.array(SummaryInterval), + /** Earliest date when the reviews are available. Is `null` when the user has no reviews scheduled. */ next_reviews_at: z.nullable(DatableString), + /** Details about subjects available for reviews now and in the next 24 hours by the hour (total of 25 objects). */ reviews: z.array(SummaryInterval), }), }), diff --git a/packages/zod/src/v20170710/user.ts b/packages/zod/src/v20170710/user.ts index 6956089a..112ad2cc 100644 --- a/packages/zod/src/v20170710/user.ts +++ b/packages/zod/src/v20170710/user.ts @@ -1,3 +1,7 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +/* oxlint-disable typescript/no-deprecated -- The API still returns deprecated properties, so they're validated */ + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; import { MAX_LESSON_BATCH_SIZE, MIN_LESSON_BATCH_SIZE } from "@bachman-dev/wanikani-api-types/v20170710"; @@ -38,13 +42,27 @@ export function isLessonBatchSizeNumber(value: unknown): value is LessonBatchSiz export type UserPreferences = Types.UserPreferences; export const UserPreferences = z.toZod()( z.object({ + /** + * @deprecated This is a deprecated user preference. It will always return `1` and cannot be set. It exists only to + * ensure existing consumers of this API don't break. + */ default_voice_actor_id: SafeInteger, + /** Automatically play pronunciation audio for vocabulary during extra study. */ extra_study_autoplay_audio: z.boolean(), + /** Automatically play pronunciation audio for vocabulary during lessons. */ lessons_autoplay_audio: z.boolean(), + /** Number of subjects introduced to the user during lessons before quizzing. */ lessons_batch_size: LessonBatchSizeNumber, + /** + * @deprecated This is a deprecated user preference. It always returns `ascending_level_then_subject`. Setting this + * preference will do nothing. It exists only to ensure existing consumers of this API don't break. + */ lessons_presentation_order: z.enum(["ascending_level_then_shuffled", "ascending_level_then_subject", "shuffled"]), + /** Automatically play pronunciation audio for vocabulary during reviews. */ reviews_autoplay_audio: z.boolean(), + /** Toggle for display SRS change indicator after a subject has been completely answered during review. */ reviews_display_srs_indicator: z.boolean(), + /** The order in which reviews are presented. The options are `lower_levels_first` and `shuffled`. */ reviews_presentation_order: z.enum(["lower_levels_first", "shuffled"]), }), ); @@ -60,21 +78,50 @@ export type User = Types.User; export const User = z.toZod()( z.object({ ...BaseResource.shape, + /** Data for the returned user. */ data: z.object({ + /** + * If the user is on vacation, this will be the timestamp of when that vacation started. If the user is not on + * vacation, this is `null`. + */ current_vacation_started_at: z.nullable(DatableString), + /** A user's unique ID string. */ id: z.string(), + /** The current level of the user. This ignores subscription status. */ level: Level, + /** User settings specific to the WaniKani application. */ preferences: UserPreferences, + /** The URL to the user's public facing profile page. */ profile_url: z.string(), + /** The signup date for the user. */ started_at: DatableString, + /** Details about the user's subscription state. */ subscription: z.object({ + /** Whether or not the user currently has a paid subscription. */ active: z.boolean(), + /** + * The maximum level of content accessible to the user for lessons, reviews, and content review. For + * unsubscribed/free users, the maximum level is `3`. For subscribed users, this is `60`. + * + * **Any application that uses data from the WaniKani API must respect these access limits.** + */ max_level_granted: Level, + /** + * The date when the user's subscription period ends. If the user has subscription type `lifetime` or `free` + * then the value is `null`. + */ period_ends_at: z.nullable(DatableString), + /** + * The type of subscription the user has. Options are following: `free`, `recurring`, and `lifetime`. A type of + * `unknown` means the user subscription state isn't exactly known. This is a weird state on WaniKani, should be + * treated as `free`, and reported to the WaniKani developers. + */ type: z.enum(["free", "lifetime", "recurring", "unknown"]), }), + /** The user's username. */ username: z.string(), }), + /** The kind of object returned. */ object: z.literal("user"), }), ); @@ -102,15 +149,31 @@ export function isUser(value: unknown): value is User { export type UserPreferencesPayload = Types.UserPreferencesPayload; export const UserPreferencesPayload = z.toZod()( z.object({ + /** The user object, as part of the payload. */ user: z.object({ + /** The user preferences to be updated; only those specified in the object will be updated. */ preferences: z.object({ + /** + * @deprecated This is a deprecated user preference. It will always return `1` and cannot be set. It exists only to + * ensure existing consumers of this API don't break. + */ default_voice_actor_id: z.exactOptional(UserPreferences.shape.default_voice_actor_id), + /** Automatically play pronunciation audio for vocabulary during extra study. */ extra_study_autoplay_audio: z.exactOptional(UserPreferences.shape.extra_study_autoplay_audio), + /** Automatically play pronunciation audio for vocabulary during lessons. */ lessons_autoplay_audio: z.exactOptional(UserPreferences.shape.lessons_autoplay_audio), + /** Number of subjects introduced to the user during lessons before quizzing. */ lessons_batch_size: z.exactOptional(UserPreferences.shape.lessons_batch_size), + /** + * @deprecated This is a deprecated user preference. It always returns `ascending_level_then_subject`. Setting this + * preference will do nothing. It exists only to ensure existing consumers of this API don't break. + */ lessons_presentation_order: z.exactOptional(UserPreferences.shape.lessons_presentation_order), + /** Automatically play pronunciation audio for vocabulary during reviews. */ reviews_autoplay_audio: z.exactOptional(UserPreferences.shape.reviews_autoplay_audio), + /** Toggle for display SRS change indicator after a subject has been completely answered during review. */ reviews_display_srs_indicator: z.exactOptional(UserPreferences.shape.reviews_display_srs_indicator), + /** The order in which reviews are presented. The options are `lower_levels_first` and `shuffled`. */ reviews_presentation_order: z.exactOptional(UserPreferences.shape.reviews_presentation_order), }), }), diff --git a/packages/zod/src/v20170710/voice-actors.ts b/packages/zod/src/v20170710/voice-actors.ts index dd1de4c8..9a10a0dd 100644 --- a/packages/zod/src/v20170710/voice-actors.ts +++ b/packages/zod/src/v20170710/voice-actors.ts @@ -1,7 +1,11 @@ +// This file is generated by scripts/generate.ts from @bachman-dev/wanikani-api-schema; do not edit it by hand. + +/* oxlint-disable unicorn/prefer-export-from -- Aliases are declared, rather than re-exported, so they're documented */ + import type * as Types from "@bachman-dev/wanikani-api-types/v20170710"; import * as z from "zod/mini"; -import { BaseCollection, BaseResource, DatableString, SafeInteger } from "./base.js"; +import { BaseCollection, BaseResource, CollectionParameters, DatableString, SafeInteger } from "./base.js"; /** * Available voice actors used for vocabulary reading pronunciation audio. @@ -14,13 +18,20 @@ export type VoiceActor = Types.VoiceActor; export const VoiceActor = z.toZod()( z.object({ ...BaseResource.shape, + /** Data for the returned voice actor. */ data: z.object({ + /** Timestamp for when the voice actor was added to WaniKani. */ created_at: DatableString, + /** Details about the voice actor. */ description: z.string(), + /** The voice actor's gender, either `male` or `female`. */ gender: z.enum(["female", "male"]), + /** The voice actor's name. */ name: z.string(), }), + /** A unique number identifying the voice actor. */ id: SafeInteger, + /** The kind of object returned. */ object: z.literal("voice_actor"), }), ); @@ -49,6 +60,7 @@ export type VoiceActorCollection = Types.VoiceActorCollection; export const VoiceActorCollection = z.toZod()( z.object({ ...BaseCollection.shape, + /** An array of returned voice actors. */ data: z.array(VoiceActor), }), ); @@ -72,6 +84,6 @@ export function isVoiceActorCollection(value: unknown): value is VoiceActorColle * @category Parameters * @category Voice Actors * @see {@link https://docs.api.wanikani.com/20170710/#get-all-voice-actors} - * @see {@link @bachman-dev/wanikani-api-requests!v20170710.stringifyParameters} */ -export { CollectionParameters as VoiceActorParameters } from "./base.js"; +export type VoiceActorParameters = Types.VoiceActorParameters; +export const VoiceActorParameters = CollectionParameters; diff --git a/packages/zod/vitest.config.ts b/packages/zod/vitest.config.ts index 1a6da20b..802b6adc 100644 --- a/packages/zod/vitest.config.ts +++ b/packages/zod/vitest.config.ts @@ -8,5 +8,6 @@ export default defineConfig({ enabled: true, }, pool: "threads", + isolate: false, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23dbcb56..25e99867 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: '@bachman-dev/oxc-config': specifier: 0.3.0 version: 0.3.0(oxfmt@0.72.0)(oxlint@1.87.0(oxlint-tsgolint@7.0.2003)) + '@types/node': + specifier: 24.19.2 + version: 24.19.2 '@vitest/coverage-v8': specifier: 5.0.3 version: 5.0.3(vitest@5.0.3) @@ -201,7 +204,7 @@ importers: version: 7.0.2 vitest: specifier: 5.0.3 - version: 5.0.3(@vitest/coverage-v8@5.0.3)(vite@8.3.4) + version: 5.0.3(@types/node@24.19.2)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.2)) packages/fixtures: {} @@ -211,6 +214,8 @@ importers: specifier: workspace:* version: link:../types + packages/schema: {} + packages/types: dependencies: typescript: @@ -220,6 +225,9 @@ importers: '@bachman-dev/wanikani-api-fixtures': specifier: workspace:* version: link:../fixtures + '@bachman-dev/wanikani-api-schema': + specifier: workspace:* + version: link:../schema packages/utils: {} @@ -232,6 +240,9 @@ importers: '@bachman-dev/wanikani-api-fixtures': specifier: workspace:* version: link:../fixtures + '@bachman-dev/wanikani-api-schema': + specifier: workspace:* + version: link:../schema valibot: specifier: 1.5.0 version: 1.5.0(typescript@7.0.2) @@ -245,6 +256,9 @@ importers: '@bachman-dev/wanikani-api-fixtures': specifier: workspace:* version: link:../fixtures + '@bachman-dev/wanikani-api-schema': + specifier: workspace:* + version: link:../schema zod: specifier: 4.6.5 version: 4.6.5 @@ -306,8 +320,8 @@ packages: '@loaderkit/resolve@1.0.6': resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} - '@oxc-project/types@0.153.0': - resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} '@oxfmt/binding-android-arm-eabi@0.72.0': resolution: {integrity: sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw==} @@ -590,98 +604,98 @@ packages: '@quansync/fs@1.1.0': resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==} - '@rolldown/binding-android-arm-eabi@1.2.13': - resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.13': - resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.13': - resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.13': - resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.13': - resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.13': - resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.13': - resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.13': - resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.13': - resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.13': - resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.13': - resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.13': - resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.13': - resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.13': - resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.13': - resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -728,6 +742,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1045,8 +1062,8 @@ packages: ast-v8-to-istanbul@1.0.7: resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} - cac@7.0.1: - resolution: {integrity: sha512-86diuUyeeWM8CrqhpGCaDafrzjS1s++7sWy2AbAPoFNz/xKv7ID57CR4pZsMuGCh60PtsnEx4z68b+Vgt07VPw==} + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} chai@6.3.0: @@ -1195,8 +1212,8 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} - magic-string@1.4.3: - resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} magicast@0.5.5: resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} @@ -1205,8 +1222,8 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} - nanoid@3.3.20: - resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1380,8 +1397,8 @@ packages: vite-plus: optional: true - package-manager-detector@1.9.0: - resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1390,8 +1407,8 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - postcss@8.5.29: - resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} publint@0.3.25: @@ -1427,8 +1444,8 @@ packages: vue-tsc: optional: true - rolldown@1.2.13: - resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -1448,8 +1465,8 @@ packages: std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} - tinybench@6.2.1: - resolution: {integrity: sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==} + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} engines: {node: '>=20.0.0'} tinyexec@1.3.1: @@ -1523,6 +1540,9 @@ packages: unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + valibot@1.5.0: resolution: {integrity: sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==} peerDependencies: @@ -1539,8 +1559,8 @@ packages: resolution: {integrity: sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==} engines: {node: '>=18.12.0'} - vite@8.3.4: - resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + vite@8.3.2: + resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1690,7 +1710,7 @@ snapshots: dependencies: '@braidai/lang': 1.1.2 - '@oxc-project/types@0.153.0': {} + '@oxc-project/types@0.152.0': {} '@oxfmt/binding-android-arm-eabi@0.72.0': optional: true @@ -1832,49 +1852,49 @@ snapshots: dependencies: quansync: 1.0.0 - '@rolldown/binding-android-arm-eabi@1.2.13': + '@rolldown/binding-android-arm-eabi@1.2.12': optional: true - '@rolldown/binding-android-arm64@1.2.13': + '@rolldown/binding-android-arm64@1.2.12': optional: true - '@rolldown/binding-darwin-arm64@1.2.13': + '@rolldown/binding-darwin-arm64@1.2.12': optional: true - '@rolldown/binding-darwin-x64@1.2.13': + '@rolldown/binding-darwin-x64@1.2.12': optional: true - '@rolldown/binding-freebsd-x64@1.2.13': + '@rolldown/binding-freebsd-x64@1.2.12': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.13': + '@rolldown/binding-linux-arm64-gnu@1.2.12': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.13': + '@rolldown/binding-linux-arm64-musl@1.2.12': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.13': + '@rolldown/binding-linux-ppc64-gnu@1.2.12': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.13': + '@rolldown/binding-linux-s390x-gnu@1.2.12': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.13': + '@rolldown/binding-linux-x64-gnu@1.2.12': optional: true - '@rolldown/binding-linux-x64-musl@1.2.13': + '@rolldown/binding-linux-x64-musl@1.2.12': optional: true - '@rolldown/binding-openharmony-arm64@1.2.13': + '@rolldown/binding-openharmony-arm64@1.2.12': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.13': + '@rolldown/binding-win32-arm64-msvc@1.2.12': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.13': + '@rolldown/binding-win32-x64-msvc@1.2.12': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -1906,6 +1926,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/node@24.19.2': + dependencies: + undici-types: 7.24.6 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1976,7 +2000,7 @@ snapshots: obug: 2.2.1 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 5.0.3(@vitest/coverage-v8@5.0.3)(vite@8.3.4) + vitest: 5.0.3(@types/node@24.19.2)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.2)) '@vitest/istanbul-lib-coverage@1.0.2': {} @@ -1984,14 +2008,14 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.2 - '@vitest/mocker@5.0.3(vite@8.3.4)': + '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@24.19.2))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.3 estree-walker: 3.0.3 - magic-string: 1.4.3 + magic-string: 1.4.2 optionalDependencies: - vite: 8.3.4 + vite: 8.3.2(@types/node@24.19.2) '@vitest/spy@5.0.3': {} @@ -2077,7 +2101,7 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - cac@7.0.1: {} + cac@7.0.0: {} chai@6.3.0: {} @@ -2169,7 +2193,7 @@ snapshots: lru-cache@11.5.3: {} - magic-string@1.4.3: + magic-string@1.4.2: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -2181,7 +2205,7 @@ snapshots: mri@1.2.0: {} - nanoid@3.3.20: {} + nanoid@3.3.19: {} node@runtime:24.21.0: {} @@ -2245,22 +2269,22 @@ snapshots: '@oxlint/binding-win32-x64-msvc': 1.87.0 oxlint-tsgolint: 7.0.2003 - package-manager-detector@1.9.0: {} + package-manager-detector@1.8.0: {} picocolors@1.1.1: {} picomatch@4.0.7: {} - postcss@8.5.29: + postcss@8.5.28: dependencies: - nanoid: 3.3.20 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.2 publint@0.3.25: dependencies: '@publint/pack': 0.1.7 - package-manager-detector: 1.9.0 + package-manager-detector: 1.8.0 picocolors: 1.1.1 sade: 1.8.1 @@ -2268,12 +2292,12 @@ snapshots: resolve-pkg-maps@1.0.0: {} - rolldown-plugin-dts@0.28.6(rolldown@1.2.13)(typescript@7.0.2): + rolldown-plugin-dts@0.28.6(rolldown@1.2.12)(typescript@7.0.2): dependencies: dts-resolver: 3.0.0 get-tsconfig: 5.0.0-beta.6 obug: 3.0.0 - rolldown: 1.2.13 + rolldown: 1.2.12 yuku-ast: 0.10.2 yuku-codegen: 0.10.2 yuku-parser: 0.10.2 @@ -2282,26 +2306,26 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown@1.2.13: + rolldown@1.2.12: dependencies: - '@oxc-project/types': 0.153.0 + '@oxc-project/types': 0.152.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.13 - '@rolldown/binding-android-arm64': 1.2.13 - '@rolldown/binding-darwin-arm64': 1.2.13 - '@rolldown/binding-darwin-x64': 1.2.13 - '@rolldown/binding-freebsd-x64': 1.2.13 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 - '@rolldown/binding-linux-arm64-gnu': 1.2.13 - '@rolldown/binding-linux-arm64-musl': 1.2.13 - '@rolldown/binding-linux-ppc64-gnu': 1.2.13 - '@rolldown/binding-linux-s390x-gnu': 1.2.13 - '@rolldown/binding-linux-x64-gnu': 1.2.13 - '@rolldown/binding-linux-x64-musl': 1.2.13 - '@rolldown/binding-openharmony-arm64': 1.2.13 - '@rolldown/binding-win32-arm64-msvc': 1.2.13 - '@rolldown/binding-win32-x64-msvc': 1.2.13 + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 sade@1.8.1: dependencies: @@ -2313,7 +2337,7 @@ snapshots: std-env@4.3.0: {} - tinybench@6.2.1: {} + tinybench@6.2.0: {} tinyexec@1.3.1: {} @@ -2330,15 +2354,15 @@ snapshots: tsdown@0.23.0(@arethetypeswrong/core@0.18.5)(publint@0.3.25)(typescript@7.0.2): dependencies: - cac: 7.0.1 + cac: 7.0.0 defu: 6.1.7 empathic: 2.1.0 hookable: 6.1.2 import-without-cache: 0.4.1 obug: 2.2.1 picomatch: 4.0.7 - rolldown: 1.2.13 - rolldown-plugin-dts: 0.28.6(rolldown@1.2.13)(typescript@7.0.2) + rolldown: 1.2.12 + rolldown-plugin-dts: 0.28.6(rolldown@1.2.12)(typescript@7.0.2) tinyexec: 1.3.1 tinyglobby: 0.2.17 tree-kill: 1.2.2 @@ -2394,6 +2418,8 @@ snapshots: '@quansync/fs': 1.1.0 quansync: 1.0.0 + undici-types@7.24.6: {} + valibot@1.5.0(typescript@7.0.2): optionalDependencies: typescript: 7.0.2 @@ -2402,33 +2428,35 @@ snapshots: verkit@0.4.1: {} - vite@8.3.4: + vite@8.3.2(@types/node@24.19.2): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.29 - rolldown: 1.2.13 + postcss: 8.5.28 + rolldown: 1.2.12 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 24.19.2 fsevents: 2.3.3 - vitest@5.0.3(@vitest/coverage-v8@5.0.3)(vite@8.3.4): + vitest@5.0.3(@types/node@24.19.2)(@vitest/coverage-v8@5.0.3)(vite@8.3.2(@types/node@24.19.2)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.3(vite@8.3.4) + '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@24.19.2)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 - magic-string: 1.4.3 + magic-string: 1.4.2 obug: 2.2.1 picomatch: 4.0.7 std-env: 4.3.0 - tinybench: 6.2.1 + tinybench: 6.2.0 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4 + vite: 8.3.2(@types/node@24.19.2) why-is-node-running: 3.2.1 optionalDependencies: + '@types/node': 24.19.2 '@vitest/coverage-v8': 5.0.3(vitest@5.0.3) transitivePeerDependencies: - msw diff --git a/tsconfig.json b/tsconfig.json index 9295898e..c1559bdc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "skipLibCheck": true, "strict": true, "target": "es2024", + "types": [], "useUnknownInCatchVariables": true, "verbatimModuleSyntax": true } diff --git a/turbo.json b/turbo.json index 3aeb1dda..848fda5b 100644 --- a/turbo.json +++ b/turbo.json @@ -9,6 +9,10 @@ }, "check": { "dependsOn": ["^build"] + }, + "generate": { + "cache": false, + "dependsOn": ["^build"] } } }