From 7fcddd6d16a51d154fdafb1e4431cd1525e70ae6 Mon Sep 17 00:00:00 2001 From: David Blass Date: Wed, 8 Jan 2025 21:26:23 -0500 Subject: [PATCH 01/43] onUndeclaredKey config --- ark/docs/components/apiData.ts | 2 +- ark/docs/content/docs/configuration/index.mdx | 26 ++++++++++++++++++- ark/docs/content/docs/configuration/meta.json | 2 +- ark/docs/content/docs/type-api.mdx | 4 --- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/ark/docs/components/apiData.ts b/ark/docs/components/apiData.ts index 9ddcf25143..2dcbbd8cbe 100644 --- a/ark/docs/components/apiData.ts +++ b/ark/docs/components/apiData.ts @@ -123,7 +123,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "A syntactic representation similar to native TypeScript" + value: "A syntax string similar to native TypeScript" } ], notes: [ diff --git a/ark/docs/content/docs/configuration/index.mdx b/ark/docs/content/docs/configuration/index.mdx index dffee6c9b2..076de32ba1 100644 --- a/ark/docs/content/docs/configuration/index.mdx +++ b/ark/docs/content/docs/configuration/index.mdx @@ -50,7 +50,31 @@ console.log(formData.age) ### onUndeclaredKey -🚧 Coming soon ™️🚧 +Like TypeScript, ArkType defaults to ignoring undeclared keys during validation. However, it also supports two additional behaviors: + +- `"ignore"` (default): Allow undeclared keys on input, preserve them on output +- `"delete"`: Allow undeclared keys on input, delete them before returning output +- `"reject"`: Reject input with undeclared keys + +These behaviors can be associated with individual Types via the builtin `"+"` syntax (see [those docs](/docs/objects/properties-undeclared) for more on how they work). You can also change the default globally: + +```ts +import { configure } from "arktype/config" + +configure({ onUndeclaredKey: "delete" }) + +import { type } from "arktype" + +const userForm = type({ + name: "string" +}) + +// out is now { name: "Alice" } +const out = userForm({ + name: "Alice", + age: "42" +}) +``` ### jitless diff --git a/ark/docs/content/docs/configuration/meta.json b/ark/docs/content/docs/configuration/meta.json index fabb09c530..b809fc8dad 100644 --- a/ark/docs/content/docs/configuration/meta.json +++ b/ark/docs/content/docs/configuration/meta.json @@ -3,7 +3,7 @@ "pages": [ "[errors](/docs/configuration#errors)", "[clone](/docs/configuration#clone)", - "[onUndeclaredKey](/docs/configuration#onUndeclaredKey)", + "[onUndeclaredKey](/docs/configuration#onundeclaredkey)", "[jitless](/docs/configuration#jitless)" ] } diff --git a/ark/docs/content/docs/type-api.mdx b/ark/docs/content/docs/type-api.mdx index 1e9d7482d6..481b5aa2dc 100644 --- a/ark/docs/content/docs/type-api.mdx +++ b/ark/docs/content/docs/type-api.mdx @@ -6,10 +6,6 @@ import { ApiTable } from "../../components/ApiTable.tsx" -### Properties - -🚧 Coming soon ™️🚧 - ### Utilities 🚧 Coming soon ™️🚧 From ead14e69743e33b6c7456e35cd849507ec3013d4 Mon Sep 17 00:00:00 2001 From: David Blass Date: Wed, 8 Jan 2025 23:14:48 -0500 Subject: [PATCH 02/43] improve --- ark/docs/components/ApiTable.tsx | 17 ++- ark/docs/components/apiData.ts | 220 ++++++++++++++++++++++++++---- ark/repo/jsdocGen.ts | 39 ++++-- ark/repo/scratch.ts | 15 +- ark/schema/structure/structure.ts | 8 +- ark/type/methods/base.ts | 86 +++++------- ark/util/strings.ts | 9 ++ 7 files changed, 275 insertions(+), 119 deletions(-) diff --git a/ark/docs/components/ApiTable.tsx b/ark/docs/components/ApiTable.tsx index 7616eb3930..0032a8528e 100644 --- a/ark/docs/components/ApiTable.tsx +++ b/ark/docs/components/ApiTable.tsx @@ -6,7 +6,6 @@ import { LocalFriendlyUrl } from "./LocalFriendlyUrl.tsx" export type ApiTableProps = { group: ApiGroup - rows: JSX.Element[] } export const ApiTable = ({ group }: ApiTableProps) => ( @@ -37,7 +36,7 @@ const ApiTableHeader = () => ( Name Summary - Example + Notes & Examples ) @@ -51,7 +50,17 @@ interface ApiTableRowProps { const ApiTableRow = ({ name, summary, example, notes }: ApiTableRowProps) => ( - {name} + + {name} + {JsDocParts(summary)} {notes.map((note, i) => ( @@ -84,6 +93,8 @@ const JsDocParts = (parts: readonly ParsedJsDocPart[]) => __html: part.value .replace(/(\*\*|__)([^*_]+)\1/g, "$2") .replace(/(\*|_)([^*_]+)\1/g, "$2") + .replace(/`([^`]+)`/g, "$1") + .replace(/\s*-(.*)/g, "• $1") }} /> } diff --git a/ark/docs/components/apiData.ts b/ark/docs/components/apiData.ts index 2dcbbd8cbe..e4c147fb5d 100644 --- a/ark/docs/components/apiData.ts +++ b/ark/docs/components/apiData.ts @@ -6,18 +6,13 @@ export const apiDocsByGroup: ApiDocsByGroup = { group: "Type", name: "$", summary: [ - { - kind: "text", - value: "The" - }, { kind: "reference", value: "Scope" }, { kind: "text", - value: - "in which definitions for this Type its chained methods are parsed" + value: "in which chained methods are parsed" } ], notes: [] @@ -28,15 +23,21 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "The type of data this returns" + value: "type of data this returns" } ], notes: [ + [ + { + kind: "noteStart", + value: "" + } + ], [ { kind: "noteStart", value: - "🥸 Inference-only property that will be `undefined` at runtime" + "🥸 inference-only property that will be `undefined` at runtime" } ] ], @@ -49,15 +50,21 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "The type of data this expects" + value: "type of data this expects" } ], notes: [ + [ + { + kind: "noteStart", + value: "" + } + ], [ { kind: "noteStart", value: - "🥸 Inference-only property that will be `undefined` at runtime" + "🥸 inference-only property that will be `undefined` at runtime" } ] ], @@ -70,7 +77,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "The internal JSON representation" + value: "internal JSON representation" } ], notes: [] @@ -81,7 +88,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "Generate a JSON Schema" + value: "generate a JSON Schema" } ], notes: [] @@ -92,10 +99,32 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "Metadata like custom descriptions and error messages" + value: "metadata like custom descriptions and error messages" } ], - notes: [] + notes: [ + [ + { + kind: "noteStart", + value: "" + } + ], + [ + { + kind: "noteStart", + value: "✅ type" + }, + { + kind: "link", + url: "https://arktype.io/docs/configuration#custom", + value: "can be customized" + }, + { + kind: "text", + value: "for your project" + } + ] + ] }, { group: "Type", @@ -103,14 +132,20 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "An English description" + value: "a human-readable English description" } ], notes: [ [ { kind: "noteStart", - value: "Work best for primitive values" + value: "" + } + ], + [ + { + kind: "noteStart", + value: "✅ works best for primitive values" } ] ], @@ -123,14 +158,20 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "A syntax string similar to native TypeScript" + value: "syntax string similar to native TypeScript" } ], notes: [ [ { kind: "noteStart", - value: "Works well for both primitives and structures" + value: "" + } + ], + [ + { + kind: "noteStart", + value: "✅ works well for both primitives and structures" } ] ], @@ -143,15 +184,20 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: - "Validate and morph data, throwing a descriptive AggregateError on failure" + value: "validate and return transformed data or throw" } ], notes: [ [ { kind: "noteStart", - value: "Sugar to avoid checking for" + value: "" + } + ], + [ + { + kind: "noteStart", + value: "✅ sugar to avoid checking for" }, { kind: "reference", @@ -172,15 +218,21 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "Validate input data without applying morphs" + value: "check input without applying morphs" } ], notes: [ + [ + { + kind: "noteStart", + value: "" + } + ], [ { kind: "noteStart", value: - "Good for cases like filtering that don't benefit from detailed errors" + "✅ good for stuff like filtering that doesn't benefit from detailed errors" } ] ], @@ -193,21 +245,21 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "Clone and add metadata to shallow references" + value: "clone and add metadata to shallow references" } ], notes: [ [ { kind: "noteStart", - value: - "Does not affect error messages within properties of an object" + value: "" } ], [ { kind: "noteStart", - value: "Overlapping keys on existing meta will be overwritten" + value: + "⚠️ does not affect error messages within properties of an object" } ] ], @@ -220,14 +272,20 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "Clone and add the description to shallow references" + value: "clone and add the description to shallow references" } ], notes: [ [ { kind: "noteStart", - value: "Equivalent to `.configure({ description })` (see" + value: "" + } + ], + [ + { + kind: "noteStart", + value: "🔗 equivalent to `.configure({ description })` (see" }, { kind: "reference", @@ -242,12 +300,114 @@ export const apiDocsByGroup: ApiDocsByGroup = { { kind: "noteStart", value: - "Does not affect error messages within properties of an object" + "⚠️ does not affect error messages within properties of an object" } ] ], example: 'const aToZ = type(/^a.*z$/).describe("a string like \'a...z\'")\nconst good = aToZ("alcatraz") // "alcatraz"\n// notice how our description is integrated with other parts of the message\nconst badPattern = aToZ("albatross") // must be a string like \'a...z\' (was "albatross")\nconst nonString = aToZ(123) // must be a string like \'a...z\' (was 123)' + }, + { + group: "Type", + name: "onUndeclaredKey", + summary: [ + { + kind: "text", + value: + "clone to a new Type with the specified undeclared key behavior" + } + ], + notes: [ + [ + { + kind: "noteStart", + value: "" + } + ], + [ + { + kind: "noteStart", + value: + '- `"ignore"` (default) - allow and preserve extra properties' + } + ], + [ + { + kind: "noteStart", + value: "" + } + ], + [ + { + kind: "noteStart", + value: '- `"reject"` - disallow extra properties' + } + ], + [ + { + kind: "noteStart", + value: "" + } + ], + [ + { + kind: "noteStart", + value: + '- `"delete"` - clone and remove extra properties from output' + } + ] + ] + }, + { + group: "Type", + name: "onDeepUndeclaredKey", + summary: [ + { + kind: "text", + value: + "deeply clone to a new Type with the specified undeclared key behavior" + } + ], + notes: [ + [ + { + kind: "noteStart", + value: "" + } + ], + [ + { + kind: "noteStart", + value: + '- `"ignore"` (default) - allow and preserve extra properties' + } + ], + [ + { + kind: "noteStart", + value: "" + } + ], + [ + { + kind: "noteStart", + value: '- `"reject"` - disallow extra properties' + } + ], + [ + { + kind: "noteStart", + value: "" + } + ], + [ + { + kind: "noteStart", + value: + '- `"delete"` - clone and remove extra properties from output' + } + ] + ] } ] } diff --git a/ark/repo/jsdocGen.ts b/ark/repo/jsdocGen.ts index 45c78c30ae..06e3218cf5 100644 --- a/ark/repo/jsdocGen.ts +++ b/ark/repo/jsdocGen.ts @@ -11,15 +11,23 @@ import { import ts from "typescript" import { bootstrapFs, bootstrapUtil, repoDirs } from "./shared.ts" -const { flatMorph, includes, throwInternalError } = bootstrapUtil +const { flatMorph, throwInternalError, emojiToUnicode } = bootstrapUtil const { writeFile, shell } = bootstrapFs const inheritDocToken = "@inheritDoc" const typeOnlyToken = "@typeonly" + +// used to delimit notes in JSDoc. +// add to the list if you need new ones! +const noteEmoji = ["✅", "🥸", "⚠️", "🔗"] + +const noteEmojiUnicode = noteEmoji.map(emojiToUnicode) +const noteDelimiterRegex = new RegExp(`(?=\\n\\s*[-${noteEmojiUnicode}])`, "u") + const typeOnlyMessage = - "- 🥸 Inference-only property that will be `undefined` at runtime" + "🥸 inference-only property that will be `undefined` at runtime" const typeNoopToken = "@typenoop" -const typeNoopMessage = "- 🥸 Inference-only function that does nothing runtime" +const typeNoopMessage = "🥸 inference-only function that does nothing runtime" const arkTypeBuildDir = join(repoDirs.arkDir, "type", "out") const jsdocSourcesGlob = `${arkTypeBuildDir}/**/*.d.ts` @@ -75,9 +83,7 @@ export const getAllJsDoc = (project: Project) => { ) } -const apiGroups = ["Type"] as const - -export type ApiGroup = (typeof apiGroups)[number] +export type ApiGroup = "Type" export type JsdocComment = ReturnType @@ -128,16 +134,14 @@ const parseBlock = (doc: JSDoc): ParsedJsDocBlock | undefined => { if (!name) return - const tags = doc.getTags() - const group = tags.find(t => t.getTagName() === "api")?.getCommentText() + const filePath = doc.getSourceFile().getFilePath() + let group: ApiGroup + if (filePath.includes("methods")) group = "Type" + else return - if (!group) return + if (!doc.getInnerText().trim().startsWith("#")) return - if (!includes(apiGroups, group)) { - return throwInternalError( - `Invalid API group ${group} for name ${name}. Should be defined like @api Type` - ) - } + const tags = doc.getTags() const rootComment = doc.getComment() @@ -153,6 +157,11 @@ const parseBlock = (doc: JSDoc): ParsedJsDocBlock | undefined => { const summaryParts: ParsedJsDocPart[] = [] const notePartGroups: ParsedJsDocPart[][] = [] + if (allParts[0].kind === "text") { + allParts[0].value = allParts[0].value.replace(/^#+\s*/, "") + if (allParts[0].value === "") allParts.shift() + } + allParts.forEach(part => { if (part.kind === "noteStart") notePartGroups.push([part]) else if (notePartGroups.length) notePartGroups.at(-1)!.push(part) @@ -186,7 +195,7 @@ const parseJsdocPart = (part: JsdocPart): ParsedJsDocPart[] => { } const parseJsdocText = (text: string): ParsedJsDocPart[] => { - const sections = text.split(/\n\s*-/) + const sections = text.split(noteDelimiterRegex) return sections.map((sectionText, i) => ({ kind: i === 0 ? "text" : "noteStart", value: sectionText.trim() diff --git a/ark/repo/scratch.ts b/ark/repo/scratch.ts index 9d7296bdd2..4d65e37235 100644 --- a/ark/repo/scratch.ts +++ b/ark/repo/scratch.ts @@ -10,17 +10,4 @@ import { buildApi, jsdocGen } from "./jsdocGen.ts" const t = type("(number % 2) > 0") -// buildApi() - -t.configure - -t.description //? -// an integer and more than 0 and at most 10 - -const text = `FOo bar -- baz - - back - track - -squz` -const s = text.split(/\n\s*-/) -console.log(s) +buildApi() diff --git a/ark/schema/structure/structure.ts b/ark/schema/structure/structure.ts index 8fe38b1a30..c72d821868 100644 --- a/ark/schema/structure/structure.ts +++ b/ark/schema/structure/structure.ts @@ -53,11 +53,9 @@ import type { Sequence } from "./sequence.ts" import { arrayIndexMatcherReference } from "./shared.ts" /** - * `"ignore"` (default) - allow and preserve extra properties - * - * `"reject"` - disallow extra properties - * - * `"delete"` - clone and remove extra properties from output + * - `"ignore"` (default) - allow and preserve extra properties + * - `"reject"` - disallow extra properties + * - `"delete"` - clone and remove extra properties from output */ export type UndeclaredKeyBehavior = "ignore" | UndeclaredKeyHandling diff --git a/ark/type/methods/base.ts b/ark/type/methods/base.ts index dcca3ad1d1..0cebf2d50e 100644 --- a/ark/type/methods/base.ts +++ b/ark/type/methods/base.ts @@ -41,43 +41,41 @@ interface Type [inferred]: t /** - * The precompiled JS used to optimize validation. - * Will be `undefined` in [jitless](https://arktype.io/docs/configuration#jitless) mode. + * the precompiled JS used to optimize validation + * + * ⚠️ will be `undefined` in [jitless](https://arktype.io/docs/configuration#jitless) mode */ precompilation: string | undefined /** - * The generic parameter representing this Type + * the generic parameter representing this Type * * @typeonly * - * - ⚠️ May contain types representing morphs or default values that would + * ⚠️ May contain types representing morphs or default values that would * be inaccurate if used directly for runtime values. In those cases, * you should use {@link infer} or {@link inferIn} on this object instead. */ t: t /** - * The {@link Scope} in which definitions for this Type its chained methods are parsed - * @api Type + * #### {@link Scope} in which chained methods are parsed */ $: Scope<$> /** - * The type of data this returns + * #### type of data this returns * * @typeonly * * @example * const parseNumber = type("string").pipe(s => Number.parseInt(s)) * type ParsedNumber = typeof parseNumber.infer // number - * - * @api Type */ infer: this["inferOut"] /** - * Alias of {@link infer} + * alias of {@link infer} * * @typeonly * @@ -88,10 +86,11 @@ interface Type inferOut: distill.Out /** - * The type of output that can be introspected at runtime (e.g. via {@link out}) + * type of output that can be introspected at runtime (e.g. via {@link out}) * - * - If your Type contains morphs, they will be inferred as `unknown` unless + * ⚠️ If your Type contains morphs, they will be inferred as `unknown` unless * they are an ArkType keyword or have an explicitly defined output validator. + * * @typeonly * * @example @@ -112,73 +111,66 @@ interface Type inferIntrospectableOut: distill.introspectable.Out /** - * The type of data this expects + * #### type of data this expects * * @typeonly * * @example * const parseNumber = type("string").pipe(s => Number.parseInt(s)) * type UnparsedNumber = typeof parseNumber.inferIn // string - * @api Type */ inferIn: distill.In /** - * The internal JSON representation - * @api Type + * #### internal JSON representation */ json: JsonStructure /** - * Alias of {@link json} for `JSON.stringify` compatibility + * alias of {@link json} for `JSON.stringify` compatibility */ toJSON(): JsonStructure /** - * Generate a JSON Schema + * #### generate a JSON Schema + * * @throws {JsonSchema.UnjsonifiableError} if this cannot be converted to JSON Schema - * @api Type */ toJsonSchema(): JsonSchema /** - * Metadata like custom descriptions and error messages + * #### metadata like custom descriptions and error messages * - * @description The type of this property {@link https://arktype.io/docs/configuration#custom | can be extended} by your project. - * @api Type + * ✅ type {@link https://arktype.io/docs/configuration#custom | can be customized} for your project */ meta: ArkAmbient.meta /** - * An English description + * #### a human-readable English description * - * - Work best for primitive values + * ✅ works best for primitive values * * @example * const n = type("0 < number <= 100") * console.log(n.description) // positive and at most 100 - * - * @api Type */ description: string /** - * A syntax string similar to native TypeScript + * #### syntax string similar to native TypeScript * - * - Works well for both primitives and structures + * ✅ works well for both primitives and structures * * @example * const loc = type({ coords: ["number", "number"] }) * console.log(loc.expression) // { coords: [number, number] } - * - * @api Type */ expression: string /** - * Validate and morph data, throwing a descriptive AggregateError on failure + * #### validate and return transformed data or throw * - * - Sugar to avoid checking for {@link type.errors} if they are unrecoverable + * ✅ sugar to avoid checking for {@link type.errors} if they are unrecoverable * * @example * const criticalPayload = type({ @@ -189,29 +181,25 @@ interface Type * console.log(data.superImportantValue) // valid output can be accessed directly * * @throws {AggregateError} - * @api Type */ assert(data: unknown): this["infer"] /** - * Validate input data without applying morphs + * #### check input without applying morphs * - * - Good for cases like filtering that don't benefit from detailed errors + * ✅ good for stuff like filtering that doesn't benefit from detailed errors * * @example * const numeric = type("number | bigint") * // [0, 2n] * const numerics = [0, "one", 2n].filter(numeric.allows) - * - * @api Type */ allows(data: unknown): data is this["inferIn"] /** - * Clone and add metadata to shallow references + * #### clone and add metadata to shallow references * - * - Does not affect error messages within properties of an object - * - Overlapping keys on existing meta will be overwritten + * ⚠️ does not affect error messages within properties of an object * * @example * const notOdd = type("number % 2").configure({ description: "not odd" }) @@ -228,16 +216,14 @@ interface Type * const oddProp = notOddBox({ notOdd: 3 }) // notOdd must be even (was 3) * // error message at root is affected, leading to a misleading description * const nonObject = notOddBox(null) // must be not odd (was null) - * - * @api Type */ configure(meta: MetaSchema): this /** - * Clone and add the description to shallow references + * #### clone and add the description to shallow references * - * - Equivalent to `.configure({ description })` (see {@link configure}) - * - Does not affect error messages within properties of an object + * 🔗 equivalent to `.configure({ description })` (see {@link configure}) + * ⚠️ does not affect error messages within properties of an object * * @example * const aToZ = type(/^a.*z$/).describe("a string like 'a...z'") @@ -245,29 +231,25 @@ interface Type * // notice how our description is integrated with other parts of the message * const badPattern = aToZ("albatross") // must be a string like 'a...z' (was "albatross") * const nonString = aToZ(123) // must be a string like 'a...z' (was 123) - * - * @api Type */ describe(description: string): this /** - * Clone to a new Type with the specified undeclared key behavior. + * #### clone to a new Type with the specified undeclared key behavior * * {@inheritDoc UndeclaredKeyBehavior} - * @api Type */ onUndeclaredKey(behavior: UndeclaredKeyBehavior): this /** - * Deeply clone to a new Type with the specified undeclared key behavior. + * #### deeply clone to a new Type with the specified undeclared key behavior * * {@inheritDoc UndeclaredKeyBehavior} - * @api Type **/ onDeepUndeclaredKey(behavior: UndeclaredKeyBehavior): this /** - * Identical to `assert`, but with a typed input as a convenience for providing a typed value. + * Alias for {@link assert} with typed input * @example const ConfigT = type({ foo: "string" }); export const config = ConfigT.from({ foo: "bar" }) */ from(literal: this["inferIn"]): this["infer"] diff --git a/ark/util/strings.ts b/ark/util/strings.ts index e7b8e403a7..b7ddee9aae 100644 --- a/ark/util/strings.ts +++ b/ark/util/strings.ts @@ -90,3 +90,12 @@ export type isStringLiteral = : false : false : false + +export const emojiToUnicode = (emoji: string): string => + emoji + .split("") + .map(char => { + const codePoint = char.codePointAt(0) + return codePoint ? `\\u${codePoint.toString(16).padStart(4, "0")}` : "" + }) + .join("") From 95989f4aec3a0c6a902b7389f6c8c5011c4949bb Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 00:02:30 -0500 Subject: [PATCH 03/43] improve error highlight --- ark/docs/components/ApiTable.tsx | 1 - ark/docs/components/apiData.ts | 26 +------------------------- ark/docs/lib/shiki.ts | 23 ++++++++++++++++++++++- ark/type/methods/base.ts | 5 ++--- 4 files changed, 25 insertions(+), 30 deletions(-) diff --git a/ark/docs/components/ApiTable.tsx b/ark/docs/components/ApiTable.tsx index 0032a8528e..5b778ef4d6 100644 --- a/ark/docs/components/ApiTable.tsx +++ b/ark/docs/components/ApiTable.tsx @@ -1,4 +1,3 @@ -import type { JSX } from "react" import type { ApiGroup, ParsedJsDocPart } from "../../repo/jsdocGen.ts" import { apiDocsByGroup } from "./apiData.ts" import { CodeBlock } from "./CodeBlock.tsx" diff --git a/ark/docs/components/apiData.ts b/ark/docs/components/apiData.ts index e4c147fb5d..ecd5a085c6 100644 --- a/ark/docs/components/apiData.ts +++ b/ark/docs/components/apiData.ts @@ -305,7 +305,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { ] ], example: - 'const aToZ = type(/^a.*z$/).describe("a string like \'a...z\'")\nconst good = aToZ("alcatraz") // "alcatraz"\n// notice how our description is integrated with other parts of the message\nconst badPattern = aToZ("albatross") // must be a string like \'a...z\' (was "albatross")\nconst nonString = aToZ(123) // must be a string like \'a...z\' (was 123)' + 'const aToZ = type(/^a.*z$/).describe("a string like \'a...z\'")\nconst good = aToZ("alcatraz") // "alcatraz"\n// ArkErrors: must be a string like \'a...z\' (was "albatross")\nconst badPattern = aToZ("albatross")' }, { group: "Type", @@ -331,24 +331,12 @@ export const apiDocsByGroup: ApiDocsByGroup = { '- `"ignore"` (default) - allow and preserve extra properties' } ], - [ - { - kind: "noteStart", - value: "" - } - ], [ { kind: "noteStart", value: '- `"reject"` - disallow extra properties' } ], - [ - { - kind: "noteStart", - value: "" - } - ], [ { kind: "noteStart", @@ -382,24 +370,12 @@ export const apiDocsByGroup: ApiDocsByGroup = { '- `"ignore"` (default) - allow and preserve extra properties' } ], - [ - { - kind: "noteStart", - value: "" - } - ], [ { kind: "noteStart", value: '- `"reject"` - disallow extra properties' } ], - [ - { - kind: "noteStart", - value: "" - } - ], [ { kind: "noteStart", diff --git a/ark/docs/lib/shiki.ts b/ark/docs/lib/shiki.ts index 9cf03769a7..a85e7b46b9 100644 --- a/ark/docs/lib/shiki.ts +++ b/ark/docs/lib/shiki.ts @@ -2,6 +2,7 @@ import { transformerNotationErrorLevel } from "@shikijs/transformers" import type { RehypeCodeOptions } from "fumadocs-core/mdx-plugins" import { transformerTwoslash } from "fumadocs-twoslash" import { createRequire } from "node:module" +import type { ShikiTransformer } from "shiki" /** for some reason a standard import with an attribute like: @@ -131,13 +132,33 @@ declare global { } }) +type HastElement = Parameters[0] +const includesErrorDescendant = (node: HastElement): boolean => + node.children && + node.children.some( + c => + (c.type === "text" && c.value.includes("ArkErrors:")) || + includesErrorDescendant(c as never) + ) + export const shikiConfig = { themes: { dark: arkDarkTheme, light: arkDarkTheme }, langs: ["json", "bash", { ...arkTypeTmJson, name: "ts" }], - transformers: [twoslash, transformerNotationErrorLevel()] + transformers: [ + { + line(node) { + if (includesErrorDescendant(node)) { + this.addClassToHast(node, "highlighted") + this.addClassToHast(node, "error") + } + } + }, + twoslash, + transformerNotationErrorLevel() + ] } as const satisfies RehypeCodeOptions export type shikiConfig = typeof shikiConfig diff --git a/ark/type/methods/base.ts b/ark/type/methods/base.ts index 0cebf2d50e..27f7d401b6 100644 --- a/ark/type/methods/base.ts +++ b/ark/type/methods/base.ts @@ -228,9 +228,8 @@ interface Type * @example * const aToZ = type(/^a.*z$/).describe("a string like 'a...z'") * const good = aToZ("alcatraz") // "alcatraz" - * // notice how our description is integrated with other parts of the message - * const badPattern = aToZ("albatross") // must be a string like 'a...z' (was "albatross") - * const nonString = aToZ(123) // must be a string like 'a...z' (was 123) + * // ArkErrors: must be a string like 'a...z' (was "albatross") + * const badPattern = aToZ("albatross") */ describe(description: string): this From 88af08923eeda25d82a09f9ab948fcf006f577cb Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 01:36:35 -0500 Subject: [PATCH 04/43] better error highlighting --- ark/docs/app/global.css | 13 +++++++++++-- ark/docs/components/apiData.ts | 21 +++++++++++++++++++++ ark/docs/lib/shiki.ts | 12 ++++++++---- ark/type/methods/base.ts | 7 +++++-- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/ark/docs/app/global.css b/ark/docs/app/global.css index 0e83774864..03ed128213 100644 --- a/ark/docs/app/global.css +++ b/ark/docs/app/global.css @@ -90,9 +90,9 @@ div.twoslash-popup-container { .error.highlighted { position: relative; - background-color: #f8585822; - border-left: 3px solid var(--ark-runtime-error); padding: 4px; + background-color: var(--twoslash-error-bg); + border-left: 3px solid var(--ark-error); padding-right: 16px; margin: 0.2em 0; min-width: 100%; @@ -100,6 +100,15 @@ div.twoslash-popup-container { } .error.highlighted > span { + color: var(--twoslash-error-color) !important; +} + +.error.highlighted.runtime-error { + background-color: #f8585822; + border-left: 3px solid var(--ark-runtime-error); +} + +.error.highlighted.runtime-error > span { color: var(--ark-runtime-error) !important; } diff --git a/ark/docs/components/apiData.ts b/ark/docs/components/apiData.ts index ecd5a085c6..89cff9ae00 100644 --- a/ark/docs/components/apiData.ts +++ b/ark/docs/components/apiData.ts @@ -384,6 +384,27 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ] ] + }, + { + group: "Type", + name: "from", + summary: [ + { + kind: "text", + value: "alias for" + }, + { + kind: "reference", + value: "assert" + }, + { + kind: "text", + value: "with typed input" + } + ], + notes: [], + example: + 'const t = type({ foo: "string" });\n// TypeScript: foo must be a string (was 5)\nconst data = t.from({ foo: 5 });' } ] } diff --git a/ark/docs/lib/shiki.ts b/ark/docs/lib/shiki.ts index a85e7b46b9..92afbec021 100644 --- a/ark/docs/lib/shiki.ts +++ b/ark/docs/lib/shiki.ts @@ -133,12 +133,12 @@ declare global { }) type HastElement = Parameters[0] -const includesErrorDescendant = (node: HastElement): boolean => +const descendantIncludesText = (node: HastElement, text: string): boolean => node.children && node.children.some( c => - (c.type === "text" && c.value.includes("ArkErrors:")) || - includesErrorDescendant(c as never) + (c.type === "text" && c.value.includes(text)) || + descendantIncludesText(c as never, text) ) export const shikiConfig = { @@ -150,7 +150,11 @@ export const shikiConfig = { transformers: [ { line(node) { - if (includesErrorDescendant(node)) { + if (descendantIncludesText(node, "// ArkErrors:")) { + this.addClassToHast(node, "highlighted") + this.addClassToHast(node, "error") + this.addClassToHast(node, "runtime-error") + } else if (descendantIncludesText(node, "// TypeScript:")) { this.addClassToHast(node, "highlighted") this.addClassToHast(node, "error") } diff --git a/ark/type/methods/base.ts b/ark/type/methods/base.ts index 27f7d401b6..5416607925 100644 --- a/ark/type/methods/base.ts +++ b/ark/type/methods/base.ts @@ -248,8 +248,11 @@ interface Type onDeepUndeclaredKey(behavior: UndeclaredKeyBehavior): this /** - * Alias for {@link assert} with typed input - * @example const ConfigT = type({ foo: "string" }); export const config = ConfigT.from({ foo: "bar" }) + * #### alias for {@link assert} with typed input + * @example + * const t = type({ foo: "string" }); + * // ArkErrors: foo must be a string (was 5) + * const data = t.from({ foo: 5 }); */ from(literal: this["inferIn"]): this["infer"] From 170cce7899bccb5d7fef51c0d49a2a8df67be6af Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 01:46:47 -0500 Subject: [PATCH 05/43] bigas --- ark/docs/components/ApiTable.tsx | 2 +- ark/docs/components/apiData.ts | 79 +++++--------------------------- ark/repo/jsdocGen.ts | 3 +- 3 files changed, 15 insertions(+), 69 deletions(-) diff --git a/ark/docs/components/ApiTable.tsx b/ark/docs/components/ApiTable.tsx index 5b778ef4d6..e0280e6d31 100644 --- a/ark/docs/components/ApiTable.tsx +++ b/ark/docs/components/ApiTable.tsx @@ -93,7 +93,7 @@ const JsDocParts = (parts: readonly ParsedJsDocPart[]) => .replace(/(\*\*|__)([^*_]+)\1/g, "$2") .replace(/(\*|_)([^*_]+)\1/g, "$2") .replace(/`([^`]+)`/g, "$1") - .replace(/\s*-(.*)/g, "• $1") + .replace(/^-(.*)/g, "• $1") }} /> } diff --git a/ark/docs/components/apiData.ts b/ark/docs/components/apiData.ts index 89cff9ae00..5f759486ec 100644 --- a/ark/docs/components/apiData.ts +++ b/ark/docs/components/apiData.ts @@ -27,12 +27,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ], notes: [ - [ - { - kind: "noteStart", - value: "" - } - ], + [], [ { kind: "noteStart", @@ -54,12 +49,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ], notes: [ - [ - { - kind: "noteStart", - value: "" - } - ], + [], [ { kind: "noteStart", @@ -103,12 +93,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ], notes: [ - [ - { - kind: "noteStart", - value: "" - } - ], + [], [ { kind: "noteStart", @@ -136,12 +121,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ], notes: [ - [ - { - kind: "noteStart", - value: "" - } - ], + [], [ { kind: "noteStart", @@ -162,12 +142,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ], notes: [ - [ - { - kind: "noteStart", - value: "" - } - ], + [], [ { kind: "noteStart", @@ -188,12 +163,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ], notes: [ - [ - { - kind: "noteStart", - value: "" - } - ], + [], [ { kind: "noteStart", @@ -222,12 +192,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ], notes: [ - [ - { - kind: "noteStart", - value: "" - } - ], + [], [ { kind: "noteStart", @@ -249,12 +214,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ], notes: [ - [ - { - kind: "noteStart", - value: "" - } - ], + [], [ { kind: "noteStart", @@ -276,12 +236,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ], notes: [ - [ - { - kind: "noteStart", - value: "" - } - ], + [], [ { kind: "noteStart", @@ -318,12 +273,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ], notes: [ - [ - { - kind: "noteStart", - value: "" - } - ], + [], [ { kind: "noteStart", @@ -357,12 +307,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { } ], notes: [ - [ - { - kind: "noteStart", - value: "" - } - ], + [], [ { kind: "noteStart", @@ -404,7 +349,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { ], notes: [], example: - 'const t = type({ foo: "string" });\n// TypeScript: foo must be a string (was 5)\nconst data = t.from({ foo: 5 });' + 'const t = type({ foo: "string" });\n// ArkErrors: foo must be a string (was 5)\nconst data = t.from({ foo: 5 });' } ] } diff --git a/ark/repo/jsdocGen.ts b/ark/repo/jsdocGen.ts index 06e3218cf5..4b9ae88918 100644 --- a/ark/repo/jsdocGen.ts +++ b/ark/repo/jsdocGen.ts @@ -163,7 +163,8 @@ const parseBlock = (doc: JSDoc): ParsedJsDocBlock | undefined => { } allParts.forEach(part => { - if (part.kind === "noteStart") notePartGroups.push([part]) + if (part.kind === "noteStart") notePartGroups.push(part.value ? [part] : []) + else if (part.value === "") return else if (notePartGroups.length) notePartGroups.at(-1)!.push(part) else summaryParts.push(part) }) From 5d3c1f7df28f46c976678469a1d656248aae5a6e Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 05:08:02 -0500 Subject: [PATCH 06/43] more docs --- ark/docs/components/ApiTable.tsx | 8 +- ark/docs/components/LocalFriendlyUrl.tsx | 6 +- ark/docs/components/apiData.ts | 4 +- ark/docs/components/snippets/contentsById.ts | 14 --- ark/docs/content/docs/expressions/index.mdx | 41 +++++++ ark/docs/lib/shiki.ts | 11 +- ark/repo/build.ts | 2 +- ark/repo/{jsdocGen.ts => jsDocGen.ts} | 91 +++++++-------- ark/repo/scratch.ts | 18 ++- ark/type/methods/base.ts | 114 +++++++++++++++---- 10 files changed, 208 insertions(+), 101 deletions(-) rename ark/repo/{jsdocGen.ts => jsDocGen.ts} (78%) diff --git a/ark/docs/components/ApiTable.tsx b/ark/docs/components/ApiTable.tsx index e0280e6d31..a17211f445 100644 --- a/ark/docs/components/ApiTable.tsx +++ b/ark/docs/components/ApiTable.tsx @@ -1,4 +1,4 @@ -import type { ApiGroup, ParsedJsDocPart } from "../../repo/jsdocGen.ts" +import type { ApiGroup, ParsedJsDocPart } from "../../repo/jsDocGen.ts" import { apiDocsByGroup } from "./apiData.ts" import { CodeBlock } from "./CodeBlock.tsx" import { LocalFriendlyUrl } from "./LocalFriendlyUrl.tsx" @@ -78,13 +78,9 @@ const JsDocParts = (parts: readonly ParsedJsDocPart[]) => {part.value} : part.kind === "reference" ? - + {part.value} - : part.kind === "tag" ? -

- {part.name} {JsDocParts(part.value)} -

:

{ } return ( - + {props.children} ) diff --git a/ark/docs/components/apiData.ts b/ark/docs/components/apiData.ts index 5f759486ec..ad2e574952 100644 --- a/ark/docs/components/apiData.ts +++ b/ark/docs/components/apiData.ts @@ -1,4 +1,4 @@ -import type { ApiDocsByGroup } from "../../repo/jsdocGen.ts" +import type { ApiDocsByGroup } from "../../repo/jsDocGen.ts" export const apiDocsByGroup: ApiDocsByGroup = { Type: [ @@ -349,7 +349,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { ], notes: [], example: - 'const t = type({ foo: "string" });\n// ArkErrors: foo must be a string (was 5)\nconst data = t.from({ foo: 5 });' + 'const t = type({ foo: "string" });\n// TypeScript: foo must be a string (was 5)\nconst data = t.from({ foo: 5 });' } ] } diff --git a/ark/docs/components/snippets/contentsById.ts b/ark/docs/components/snippets/contentsById.ts index 45e4e8ad64..e69de29bb2 100644 --- a/ark/docs/components/snippets/contentsById.ts +++ b/ark/docs/components/snippets/contentsById.ts @@ -1,14 +0,0 @@ -export default { - betterErrors: - 'import { type, type ArkErrors } from "arktype"\n\nconst user = type({\n\tname: "string",\n\tplatform: "\'android\' | \'ios\'",\n\t"versions?": "(number | string)[]"\n})\n\ninterface RuntimeErrors extends ArkErrors {\n\t/**platform must be "android" or "ios" (was "enigma")\nversions[2] must be a number or a string (was bigint)*/\n\tsummary: string\n}\n\nconst narrowMessage = (e: ArkErrors): e is RuntimeErrors => true\n\n// ---cut---\nconst out = user({\n\tname: "Alan Turing",\n\tplatform: "enigma",\n\tversions: [0, "1", 0n]\n})\n\nif (out instanceof type.errors) {\n\t// ---cut-start---\n\tif (!narrowMessage(out)) throw new Error()\n\t// ---cut-end---\n\t// hover summary to see validation errors\n\tconsole.error(out.summary)\n}\n', - clarityAndConcision: - '// @errors: 2322\nimport { type } from "arktype"\n// this file is written in JS so that it can include a syntax error\n// without creating a type error while still displaying the error in twoslash\n// ---cut---\n// hover me\nconst user = type({\n\tname: "string",\n\tplatform: "\'android\' | \'ios\'",\n\t"versions?": "number | string)[]"\n})\n', - deepIntrospectability: - 'import { type } from "arktype"\n\nconst user = type({\n\tname: "string",\n\tdevice: {\n\t\tplatform: "\'android\' | \'ios\'",\n\t\t"version?": "number | string"\n\t}\n})\n\n// ---cut---\nuser.extends("object") // true\nuser.extends("string") // false\n// true (string is narrower than unknown)\nuser.extends({\n\tname: "unknown"\n})\n// false (string is wider than "Alan")\nuser.extends({\n\tname: "\'Alan\'"\n})\n', - intrinsicOptimization: - 'import { type } from "arktype"\n// prettier-ignore\n// ---cut---\n// all unions are optimally discriminated\n// even if multiple/nested paths are needed\nconst account = type({\n\tkind: "\'admin\'",\n\t"powers?": "string[]"\n}).or({\n\tkind: "\'superadmin\'",\n\t"superpowers?": "string[]"\n}).or({\n\tkind: "\'pleb\'"\n})\n', - unparalleledDx: - '// @noErrors\nimport { type } from "arktype"\n// prettier-ignore\n// ---cut---\nconst user = type({\n\tname: "string",\n\tplatform: "\'android\' | \'ios\'",\n\t"version?": "number | s"\n\t// ^|\n})\n', - nestedTypeInScopeError: - '// @errors: 2322\nimport { scope } from "arktype"\n// ---cut---\nconst myScope = scope({\n\tid: "string#id",\n\tuser: type({\n\t\tname: "string",\n\t\tid: "id"\n\t})\n})\n' -} diff --git a/ark/docs/content/docs/expressions/index.mdx b/ark/docs/content/docs/expressions/index.mdx index 30be2d6d74..5989edffbb 100644 --- a/ark/docs/content/docs/expressions/index.mdx +++ b/ark/docs/content/docs/expressions/index.mdx @@ -118,6 +118,47 @@ const unions = type({ + + + +```ts +// operands overlap, but neither transforms data +const okay = type("number > 0").or("number < 10") +// operand transforms data, but there's no overlap between the inputs +const alsoOkay = type("string.numeric.parse").or({ box: "string" }) +// operands overlap and transform data, but in the same way +const stillOkay = type("string > 5", "=>", Number.parseFloat).or([ + "0 < string < 10", + "=>", + Number.parseFloat +]) +// ParseError: An unordered union of a type including a morph and a type with overlapping input is indeterminate +const bad = type({ box: "string.numeric.parse" }).or({ box: "string" }) +const sameError = type({ a: "string.numeric.parse" }).or({ b: "string.numeric.parse" }) +``` + +

+ Learn the basic set theory behind this restriction + +If you're relatively new to set-based types, that error might be daunting, but if you take a second to think through the example, it becomes clear why this isn't allowed. The logic of `bad` is essentially: + +- If the input is an object where `box` is a `string`, parse and return it as a number +- If the input is an object where `box` is a `string`, return it as a string + +There is no way to deterministically return an output for this type without sacrificing the [commutativity](https://en.wikipedia.org/wiki/Commutative_property) of the union operator. + +`sameError` may look more innocuous, but has the same problem for an input like `{ a: "1", b: "2" }`. + +- Left branch would only parse `a`, resulting in `{ a: 1, b: "2" }` +- Right branch would only parse `b`, resulting in `{ a: "1", b: 2 }` + +
+ + + ### Brand Add a type-only symbol to an existing type so that the only values that satisfy is are those that have been directly validated. diff --git a/ark/docs/lib/shiki.ts b/ark/docs/lib/shiki.ts index 92afbec021..394f6ffb8c 100644 --- a/ark/docs/lib/shiki.ts +++ b/ark/docs/lib/shiki.ts @@ -133,12 +133,15 @@ declare global { }) type HastElement = Parameters[0] -const descendantIncludesText = (node: HastElement, text: string): boolean => +const descendantIncludesText = ( + node: HastElement, + ...texts: string[] +): boolean => node.children && node.children.some( c => - (c.type === "text" && c.value.includes(text)) || - descendantIncludesText(c as never, text) + (c.type === "text" && texts.some(s => c.value.includes(s))) || + descendantIncludesText(c as never, ...texts) ) export const shikiConfig = { @@ -150,7 +153,7 @@ export const shikiConfig = { transformers: [ { line(node) { - if (descendantIncludesText(node, "// ArkErrors:")) { + if (descendantIncludesText(node, "// ArkErrors:", "// ParseError:")) { this.addClassToHast(node, "highlighted") this.addClassToHast(node, "error") this.addClassToHast(node, "runtime-error") diff --git a/ark/repo/build.ts b/ark/repo/build.ts index f359d94911..4ba24115cb 100644 --- a/ark/repo/build.ts +++ b/ark/repo/build.ts @@ -9,7 +9,7 @@ import { shell, writeJson } from "../fs/index.ts" -import { buildApi } from "./jsdocGen.ts" +import { buildApi } from "./jsDocGen.ts" const buildKind = process.argv.includes("--cjs") || process.env.ARKTYPE_CJS ? "cjs" : "esm" diff --git a/ark/repo/jsdocGen.ts b/ark/repo/jsDocGen.ts similarity index 78% rename from ark/repo/jsdocGen.ts rename to ark/repo/jsDocGen.ts index 4b9ae88918..e3bbd1a2f5 100644 --- a/ark/repo/jsdocGen.ts +++ b/ark/repo/jsDocGen.ts @@ -15,7 +15,6 @@ const { flatMorph, throwInternalError, emojiToUnicode } = bootstrapUtil const { writeFile, shell } = bootstrapFs const inheritDocToken = "@inheritDoc" -const typeOnlyToken = "@typeonly" // used to delimit notes in JSDoc. // add to the list if you need new ones! @@ -24,19 +23,21 @@ const noteEmoji = ["✅", "🥸", "⚠️", "🔗"] const noteEmojiUnicode = noteEmoji.map(emojiToUnicode) const noteDelimiterRegex = new RegExp(`(?=\\n\\s*[-${noteEmojiUnicode}])`, "u") -const typeOnlyMessage = - "🥸 inference-only property that will be `undefined` at runtime" -const typeNoopToken = "@typenoop" -const typeNoopMessage = "🥸 inference-only function that does nothing runtime" +const replacedDecorators = { + "@typeonly": "🥸 inference-only property that will be `undefined` at runtime", + "@typenoop": "🥸 inference-only function that does nothing runtime", + "@chainedDefinition": + "⚠️ unlike most other methods, this creates a definition rather than a Type (read why)" +} as const const arkTypeBuildDir = join(repoDirs.arkDir, "type", "out") -const jsdocSourcesGlob = `${arkTypeBuildDir}/**/*.d.ts` +const jsDocSourcesGlob = `${arkTypeBuildDir}/**/*.d.ts` let updateCount = 0 export const buildApi = () => { const project = createProject() - jsdocGen(project) + jsDocGen(project) const docs = getAllJsDoc(project) const apiDocsByGroup = flatMorph(docs, (i, doc) => { @@ -51,7 +52,7 @@ export const buildApi = () => { writeFile( apiDataPath, - `import type { ApiDocsByGroup } from "../../repo/jsdocGen.ts" + `import type { ApiDocsByGroup } from "../../repo/jsDocGen.ts" export const apiDocsByGroup: ApiDocsByGroup = ${JSON.stringify(apiDocsByGroup, null, 4)}` ) @@ -59,7 +60,7 @@ export const apiDocsByGroup: ApiDocsByGroup = ${JSON.stringify(apiDocsByGroup, n shell(`prettier --write ${apiDataPath}`) } -export const jsdocGen = (project: Project) => { +export const jsDocGen = (project: Project) => { const sourceFiles = project.getSourceFiles() console.log( @@ -85,24 +86,19 @@ export const getAllJsDoc = (project: Project) => { export type ApiGroup = "Type" -export type JsdocComment = ReturnType +export type JsDocComment = ReturnType -export type JsdocPart = Extract[number] & {} +export type RawJsDocPart = Extract< + JsDocComment, + readonly unknown[] +>[number] & {} -export type ParsedJsDocPart = ShallowJsDocPart | ParsedJsDocTag - -export type ShallowJsDocPart = +export type ParsedJsDocPart = | { kind: "text"; value: string } | { kind: "noteStart"; value: string } | { kind: "reference"; value: string } | { kind: "link"; value: string; url: string } -export type ParsedJsDocTag = { - kind: "tag" - name: string - value: ParsedJsDocPart[] -} - export type ApiDocsByGroup = { readonly [k in ApiGroup]: readonly ParsedJsDocBlock[] } @@ -120,11 +116,11 @@ const createProject = () => { if (!existsSync(arkTypeBuildDir)) { throw new Error( - `jsdocGen rewrites ${arkTypeBuildDir} but that directory doesn't exist. Did you run "pnpm build" there first?` + `jsDocGen rewrites ${arkTypeBuildDir} but that directory doesn't exist. Did you run "pnpm build" there first?` ) } - project.addSourceFilesAtPaths(jsdocSourcesGlob) + project.addSourceFilesAtPaths(jsDocSourcesGlob) return project } @@ -150,9 +146,9 @@ const parseBlock = (doc: JSDoc): ParsedJsDocBlock | undefined => { const allParts: ParsedJsDocPart[] = typeof rootComment === "string" ? - parseJsdocText(rootComment) + parseJsDocText(rootComment) // remove any undefined parts before parsing - : rootComment.filter(part => !!part).flatMap(parseJsdocPart) + : rootComment.filter(part => !!part).flatMap(parseJsDocPart) const summaryParts: ParsedJsDocPart[] = [] const notePartGroups: ParsedJsDocPart[][] = [] @@ -182,12 +178,12 @@ const parseBlock = (doc: JSDoc): ParsedJsDocBlock | undefined => { return result } -const parseJsdocPart = (part: JsdocPart): ParsedJsDocPart[] => { +const parseJsDocPart = (part: RawJsDocPart): ParsedJsDocPart[] => { switch (part.getKindName()) { case "JSDocText": - return parseJsdocText(part.compilerNode.text) + return parseJsDocText(part.compilerNode.text) case "JSDocLink": - return [parseJsdocLink(part)] + return [parseJsDocLink(part)] default: return throwInternalError( `Unsupported JSDoc part kind ${part.getKindName()} at position ${part.getPos()} in ${part.getSourceFile().getFilePath()}` @@ -195,7 +191,7 @@ const parseJsdocPart = (part: JsdocPart): ParsedJsDocPart[] => { } } -const parseJsdocText = (text: string): ParsedJsDocPart[] => { +const parseJsDocText = (text: string): ParsedJsDocPart[] => { const sections = text.split(noteDelimiterRegex) return sections.map((sectionText, i) => ({ kind: i === 0 ? "text" : "noteStart", @@ -206,7 +202,7 @@ const parseJsdocText = (text: string): ParsedJsDocPart[] => { const describedLinkRegex = /\{@link\s+(https?:\/\/[^\s|}]+)(?:\s*\|\s*([^}]*))?\}/ -const parseJsdocLink = (part: JsdocPart): ParsedJsDocPart => { +const parseJsDocLink = (part: RawJsDocPart): ParsedJsDocPart => { const linkText = part.getText() const match = describedLinkRegex.exec(linkText) if (match) { @@ -237,37 +233,36 @@ const parseJsdocLink = (part: JsdocPart): ParsedJsDocPart => { } type MatchContext = { - matchedJsdoc: JSDoc - updateJsdoc: (text: string) => void + matchedJsDoc: JSDoc + updateJsDoc: (text: string) => void inheritDocsSource: string | undefined } const docgenForFile = (sourceFile: SourceFile) => { const path = sourceFile.getFilePath() - const jsdocNodes = sourceFile.getDescendantsOfKind(SyntaxKind.JSDoc) + const jsDocNodes = sourceFile.getDescendantsOfKind(SyntaxKind.JSDoc) - const matchContexts: MatchContext[] = jsdocNodes.flatMap(jsdoc => { - const text = jsdoc.getText() + const matchContexts: MatchContext[] = jsDocNodes.flatMap(jsDoc => { + const text = jsDoc.getText() const inheritDocsSource = extractInheritDocName(path, text) if ( !inheritDocsSource && - !text.includes(typeOnlyToken) && - !text.includes(typeNoopToken) + !Object.keys(replacedDecorators).some(k => text.includes(k)) ) return [] return { - matchedJsdoc: jsdoc, + matchedJsDoc: jsDoc, inheritDocsSource, - updateJsdoc: text => { - const parent = jsdoc.getParent() as JSDocableNode + updateJsDoc: text => { + const parent = jsDoc.getParent() as JSDocableNode // replace the original JSDoc node in the AST with a new one // created from updatedContents - jsdoc.remove() + jsDoc.remove() parent.addJsDoc(text) updateCount++ @@ -278,21 +273,23 @@ const docgenForFile = (sourceFile: SourceFile) => { matchContexts.forEach(ctx => { const inheritedDocs = findInheritedDocs(sourceFile, ctx) - let updatedContents = ctx.matchedJsdoc.getInnerText() + let updatedContents = ctx.matchedJsDoc.getInnerText() if (inheritedDocs) updatedContents = `${inheritedDocs.originalSummary}\n${inheritedDocs.inheritedDescription}` - updatedContents = updatedContents.replace(typeOnlyToken, typeOnlyMessage) - updatedContents = updatedContents.replace(typeNoopToken, typeNoopMessage) + updatedContents = Object.entries(replacedDecorators).reduce( + (contents, [decorator, message]) => contents.replace(decorator, message), + updatedContents + ) - ctx.updateJsdoc(updatedContents) + ctx.updateJsDoc(updatedContents) }) } const findInheritedDocs = ( sourceFile: SourceFile, - { inheritDocsSource, matchedJsdoc }: MatchContext + { inheritDocsSource, matchedJsDoc }: MatchContext ) => { if (!inheritDocsSource) return @@ -304,7 +301,7 @@ const findInheritedDocs = ( if (!sourceDeclaration || !canHaveJsDoc(sourceDeclaration)) return - const matchedDescription = matchedJsdoc.getDescription() + const matchedDescription = matchedJsDoc.getDescription() const inheritedDescription = sourceDeclaration.getJsDocs()[0].getDescription() @@ -366,6 +363,6 @@ const throwJsDocgenParseError = ( message: string ): never => { throw new Error( - `jsdocGen ParseError in ${path}: ${message}\nComment text: ${commentText}` + `jsDocGen ParseError in ${path}: ${message}\nComment text: ${commentText}` ) } diff --git a/ark/repo/scratch.ts b/ark/repo/scratch.ts index 4d65e37235..503bf16126 100644 --- a/ark/repo/scratch.ts +++ b/ark/repo/scratch.ts @@ -1,5 +1,5 @@ import { type } from "arktype" -import { buildApi, jsdocGen } from "./jsdocGen.ts" +import { buildApi, jsDocGen } from "./jsDocGen.ts" // type stats on attribute removal merge 12/18/2024 // { @@ -7,7 +7,19 @@ import { buildApi, jsdocGen } from "./jsdocGen.ts" // "types": 409252, // "instantiations": 5066185 // } +console.log(2 ** 100) -const t = type("(number % 2) > 0") +const stillOkay = type("string > 5", "=>", Number.parseFloat).or([ + "string < 10", + "=>", + Number.parseFloat +]) -buildApi() +console.log(stillOkay) +// buildApi() + +const prop = type({ foo: "number" }) +const state = type({ count: type.number.default(0) }) +const forObj = type({ + key: type({ nested: "boolean" }).default(() => ({ nested: false })) +}) diff --git a/ark/type/methods/base.ts b/ark/type/methods/base.ts index 5416607925..74c68a881c 100644 --- a/ark/type/methods/base.ts +++ b/ark/type/methods/base.ts @@ -249,72 +249,140 @@ interface Type /** * #### alias for {@link assert} with typed input + * * @example * const t = type({ foo: "string" }); - * // ArkErrors: foo must be a string (was 5) + * // TypeScript: foo must be a string (was 5) * const data = t.from({ foo: 5 }); */ from(literal: this["inferIn"]): this["infer"] /** - * A `Type` representing the deeply-extracted input of the `Type` (before morphs are applied). - * @example const inputT = T.in + * #### deeply extract inputs to a new Type + * + * ✅ will never include morphs + * ✅ good for generating JSON Schema or other non-transforming formats + * + * @example + * const createUser = type({ + * age: "string.numeric.parse" + * }) + * // { age: 25 } (age parsed to a number) + * const out = createUser({ age: "25" }) + * // { age: "25" } (age is still a string) + * const inOut = createUser.in({ age: "25" }) */ get in(): instantiateType /** - * A `Type` representing the deeply-extracted output of the `Type` (after morphs are applied).\ - * **IMPORTANT**: If your type includes morphs, their output will likely be unknown - * unless they were defined with an explicit output validator via `.to(outputType)`, `.pipe(morph, outputType)`, etc. - * @example const outputT = T.out + * #### deeply extract outputs to a new Type + * + * ✅ will never include morphs + * ⚠️ if your type includes morphs, their output will likely be unknown unless they + * were defined with an explicit output validator via `.to(outputDef)` or `.pipe(morph, outputType)` + * + * @example + * const userMorph = type("string[]").pipe(a => a.join(",")) + * + * const t = type({ + * // all keywords have introspectable output + * keyword: "string.numeric.parse", + * // TypeScript knows this returns a boolean, but we can't introspect that at runtime + * unvalidated: userMorph, + * // if needed, it can be made introspectable with an output validator + * validated: userMorph.to("string") + * }) + * + * // Type<{ keyword: number; unvalidated: unknown; validated: string }> + * const baseOut = base.out */ get out(): instantiateType /** - * Cast the way this `Type` is inferred (has no effect at runtime). - * const branded = type(/^a/).as<`a${string}`>() // Type<`a${string}`> + * #### cast the way this is inferred + * + * @typenoop + * + * @example + * const t = type(/^a/).as<`a${string}`>() // Type<`a${string}`> */ as( ...args: validateChainedAsArgs ): instantiateType + /** + * #### add a compile-time brand to output + * + * @typenoop + * + * @example + * const t = type(/^a/).as<`a${string}`>() // Type<`a${string}`> + */ brand>( name: name ): instantiateType /** - * Intersect another `Type` definition, throwing an error if the result is unsatisfiable. - * @example const intersection = type({ foo: "number" }).intersect({ bar: "string" }) // Type<{ foo: number; bar: string }> + * #### create an intersection with another Type, throwing if the result is unsatisfiable + * + * @example + * // Type<{ foo: number; bar: string }> + * const t = type({ foo: "number" }).and({ bar: "string" }) + * // ParseError: Intersection at foo of number and string results in an unsatisfiable type + * const bad = type({ foo: "number" }).and({ foo: "string" }) */ and>( def: type.validate ): instantiateType, $> /** - * Union another `Type` definition.\ - * If the types contain morphs, input shapes should be distinct. Otherwise an error will be thrown. - * @example const union = type({ foo: "number" }).or({ foo: "string" }) // Type<{ foo: number } | { foo: string }> - * @example const union = type("string.numeric.parse").or("number") // Type<((In: string) => Out) | number> + * #### create a union with another Type + * + * ⚠️ a union that could apply different morphs to the same data is a ParseError ([docs](https://arktype.io/docs/expressions/union-morphs)) + * + * @example + * // Type + * const t = type("string").or({ box: "string" }) */ or>( def: type.validate ): instantiateType /** - * Create a `Type` for array with elements of this `Type` - * @example const T = type(/^foo/); const array = T.array() // Type + * #### create a Type representing an array of this + * + * @example + * // Type<{ rebmun: number }[]> + * const t = type({ rebmun: "number" }).array(); */ array(): ArrayType + /** + * #### create an [optional definition](https://arktype.io/docs/objects#properties-optional) for this + * + * @chainedDefinition + * + * @example + * const prop = type({ foo: "number" }) + * // Type<{ bar?: { foo: number } }> + * const obj = type({ bar: prop.optional() }) + */ optional(): [this, "?"] /** - * Add a default value for this `Type` when it is used as a property.\ - * Default value should be a valid input value for this `Type, or a function that returns a valid input value.\ - * If the type has a morph, it will be applied to the default value. - * @example const withDefault = type({ foo: type("string").default("bar") }); withDefault({}) // { foo: "bar" } - * @example const withFactory = type({ foo: type("number[]").default(() => [1])) }); withFactory({baz: 'a'}) // { foo: [1], baz: 'a' } - * @example const withMorph = type({ foo: type("string.numeric.parse").default("123") }); withMorph({}) // { foo: 123 } + * #### create a [defaultable definition](https://arktype.io/docs/objects#properties-defaultable) for this + * + * ✅ object defaults can be returned from a function + * ⚠️ throws if the default value is not allowed + * @chainedDefinition + * + * @example + * // Type<{ count: Default }> + * const state = type({ count: type.number.default(0) }) + * const prop = type({ nested: "boolean" }) + * const forObj = type({ + * key: nested.default(() => ({ nested: false })) + * }) */ default>( value: value From b1c226f15524e320f5de757d1241e214196f1b2e Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 08:47:15 -0500 Subject: [PATCH 07/43] fix filter --- ark/repo/scratch.ts | 21 +++++++++++ ark/schema/roots/root.ts | 2 +- ark/type/__tests__/filter.test.ts | 52 +++++++++++++++++++++++++++ ark/type/methods/base.ts | 59 ++++++++++++++++++++----------- 4 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 ark/type/__tests__/filter.test.ts diff --git a/ark/repo/scratch.ts b/ark/repo/scratch.ts index 503bf16126..8c3b0bc34b 100644 --- a/ark/repo/scratch.ts +++ b/ark/repo/scratch.ts @@ -23,3 +23,24 @@ const state = type({ count: type.number.default(0) }) const forObj = type({ key: type({ nested: "boolean" }).default(() => ({ nested: false })) }) + +const t = type("string").brand("id") + +const palindrome = type("string") + .narrow(s => s === [...s].reverse().join("")) + .brand("palindrome") + +const out = palindrome("racecar") + +const stringifyUser = type({ name: "string" }).pipe(user => + JSON.stringify(user) +) +const stringifySafe = stringifyUser.filter(user => user.name !== "Bobby Tables") +// +const stringifyUnsafe = stringifyUser.filter( + (user): user is { name: "Bobby Tables" } => user.name === "Bobby Tables" +) + +const z = stringifyUser.filter( + (user): user is { name: "Bobby Tables" } => user.name === "Bobby Tables" +) diff --git a/ark/schema/roots/root.ts b/ark/schema/roots/root.ts index 0c90a9addd..c3c45c0d31 100644 --- a/ark/schema/roots/root.ts +++ b/ark/schema/roots/root.ts @@ -520,7 +520,7 @@ export abstract class BaseRoot< } filter(predicate: Predicate): BaseRoot { - return this.constrain("predicate", predicate) + return this.constrainIn("predicate", predicate) } divisibleBy(schema: Divisor.Schema): BaseRoot { diff --git a/ark/type/__tests__/filter.test.ts b/ark/type/__tests__/filter.test.ts new file mode 100644 index 0000000000..236ca89bce --- /dev/null +++ b/ark/type/__tests__/filter.test.ts @@ -0,0 +1,52 @@ +import { attest, contextualize } from "@ark/attest" +import { type } from "arktype" +import type { Out } from "arktype/internal/attributes.ts" + +contextualize(() => { + const parseNumber = (s: string) => Number(s) + + it("applies to input", () => { + const stringIsLong = (s: string) => s.length > 5 + const parseLongNumber = type("string") + .pipe(parseNumber) + .filter(stringIsLong) + + attest<(In: string) => Out>(parseLongNumber.t) + + attest(parseLongNumber.json).snap({ + in: { domain: "string", predicate: ["$ark.stringIsLong"] }, + morphs: ["$ark.parseNumber"] + }) + + attest(parseLongNumber("123456")).snap(123456) + attest(parseLongNumber("123").toString()).snap( + 'must be valid according to stringIsLong (was "123")' + ) + attest(parseLongNumber(123456).toString()).snap( + "must be a string (was a number)" + ) + }) + + it("predicate inferred on input", () => { + const stringIsIntegerLike = (s: string): s is `${bigint}` => + /^-?\d+$/.test(s) + const parseIntegerLike = type("string") + .pipe(parseNumber) + .filter(stringIsIntegerLike) + + attest<(In: `${bigint}`) => Out>(parseIntegerLike.t) + + attest(parseIntegerLike.json).snap({ + in: { domain: "string", predicate: ["$ark.stringIsIntegerLike"] }, + morphs: ["$ark.parseNumber"] + }) + + attest(parseIntegerLike("123456")).snap(123456) + attest(parseIntegerLike("3.14159").toString()).snap( + 'must be valid according to stringIsIntegerLike (was "3.14159")' + ) + attest(parseIntegerLike(123456).toString()).snap( + "must be a string (was a number)" + ) + }) +}) diff --git a/ark/type/methods/base.ts b/ark/type/methods/base.ts index 74c68a881c..a262304be8 100644 --- a/ark/type/methods/base.ts +++ b/ark/type/methods/base.ts @@ -41,14 +41,14 @@ interface Type [inferred]: t /** - * the precompiled JS used to optimize validation + * precompiled JS used to optimize validation * * ⚠️ will be `undefined` in [jitless](https://arktype.io/docs/configuration#jitless) mode */ precompilation: string | undefined /** - * the generic parameter representing this Type + * generic parameter representing this Type * * @typeonly * @@ -64,7 +64,7 @@ interface Type $: Scope<$> /** - * #### type of data this returns + * #### type of output this returns * * @typeonly * @@ -75,10 +75,12 @@ interface Type infer: this["inferOut"] /** - * alias of {@link infer} + * type of output this returns * + * 🔗 alias of {@link infer} * @typeonly * + * * @example * const parseNumber = type("string").pipe(s => Number.parseInt(s)) * type ParsedNumber = typeof parseNumber.infer // number @@ -111,7 +113,7 @@ interface Type inferIntrospectableOut: distill.introspectable.Out /** - * #### type of data this expects + * #### type of input this allows * * @typeonly * @@ -146,7 +148,7 @@ interface Type meta: ArkAmbient.meta /** - * #### a human-readable English description + * #### human-readable English description * * ✅ works best for primitive values * @@ -197,7 +199,7 @@ interface Type allows(data: unknown): data is this["inferIn"] /** - * #### clone and add metadata to shallow references + * #### add metadata to shallow references * * ⚠️ does not affect error messages within properties of an object * @@ -220,7 +222,7 @@ interface Type configure(meta: MetaSchema): this /** - * #### clone and add the description to shallow references + * #### add description to shallow references * * 🔗 equivalent to `.configure({ description })` (see {@link configure}) * ⚠️ does not affect error messages within properties of an object @@ -234,14 +236,14 @@ interface Type describe(description: string): this /** - * #### clone to a new Type with the specified undeclared key behavior + * #### apply undeclared key behavior * * {@inheritDoc UndeclaredKeyBehavior} */ onUndeclaredKey(behavior: UndeclaredKeyBehavior): this /** - * #### deeply clone to a new Type with the specified undeclared key behavior + * #### deeply apply undeclared key behavior * * {@inheritDoc UndeclaredKeyBehavior} **/ @@ -258,7 +260,7 @@ interface Type from(literal: this["inferIn"]): this["infer"] /** - * #### deeply extract inputs to a new Type + * #### deeply extract inputs * * ✅ will never include morphs * ✅ good for generating JSON Schema or other non-transforming formats @@ -275,7 +277,7 @@ interface Type get in(): instantiateType /** - * #### deeply extract outputs to a new Type + * #### deeply extract outputs * * ✅ will never include morphs * ⚠️ if your type includes morphs, their output will likely be unknown unless they @@ -304,7 +306,8 @@ interface Type * @typenoop * * @example - * const t = type(/^a/).as<`a${string}`>() // Type<`a${string}`> + * // Type<`a${string}`> + * const t = type(/^a/).as<`a${string}`>() */ as( ...args: validateChainedAsArgs @@ -316,14 +319,18 @@ interface Type * @typenoop * * @example - * const t = type(/^a/).as<`a${string}`>() // Type<`a${string}`> + * const palindrome = type("string") + * .narrow(s => s === [...s].reverse().join("")) + * .brand("palindrome") + * // Brand + * const out = palindrome.assert("racecar") */ brand>( name: name ): instantiateType /** - * #### create an intersection with another Type, throwing if the result is unsatisfiable + * #### intersect another Type, throwing if the result is unsatisfiable * * @example * // Type<{ foo: number; bar: string }> @@ -336,7 +343,7 @@ interface Type ): instantiateType, $> /** - * #### create a union with another Type + * #### union with another Type * * ⚠️ a union that could apply different morphs to the same data is a ParseError ([docs](https://arktype.io/docs/expressions/union-morphs)) * @@ -349,7 +356,7 @@ interface Type ): instantiateType /** - * #### create a Type representing an array of this + * #### an array of this * * @example * // Type<{ rebmun: number }[]> @@ -358,7 +365,7 @@ interface Type array(): ArrayType /** - * #### create an [optional definition](https://arktype.io/docs/objects#properties-optional) for this + * #### [optional definition](https://arktype.io/docs/objects#properties-optional) for this * * @chainedDefinition * @@ -370,7 +377,7 @@ interface Type optional(): [this, "?"] /** - * #### create a [defaultable definition](https://arktype.io/docs/objects#properties-defaultable) for this + * #### [defaultable definition](https://arktype.io/docs/objects#properties-defaultable) for this * * ✅ object defaults can be returned from a function * ⚠️ throws if the default value is not allowed @@ -388,13 +395,25 @@ interface Type value: value ): [this, "=", value] + /** + * #### apply a predicate function to input + * + * ⚠️ the behavior of {@link narrow}, this method's output counterpart, is usually more desirable + * ✅ useful primarily when dealing with morphs whose inputs have richer types than their outputs + * + * @example + * const stringifyUser = type({ name: "string" }).pipe(user => JSON.stringify(user)) + * const stringifySafe = stringifyUser.filter(user => user.name !== "Bobby Tables") + * // + * const stringifyUnsafe = stringifyUser.filter((user) => user.name === "Bobby Tables") + */ filter< narrowed extends this["inferIn"] = never, r = [narrowed] extends [never] ? t : t extends InferredMorph ? (In: narrowed) => o : narrowed >( - predicate: Predicate.Castable + predicate: Predicate.Castable ): instantiateType /** From bc92978257f8390c76c4f8f38bafe2f9551b7d88 Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 09:28:15 -0500 Subject: [PATCH 08/43] fix delete undeclared io kinds --- ark/schema/kinds.ts | 2 +- ark/schema/structure/structure.ts | 33 ++++++++++++++++++----- ark/type/__tests__/realWorld.test.ts | 39 ++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/ark/schema/kinds.ts b/ark/schema/kinds.ts index 46b02b6be1..14acdba55e 100644 --- a/ark/schema/kinds.ts +++ b/ark/schema/kinds.ts @@ -86,7 +86,7 @@ $ark.defaultConfig = Object.assign( implementation.defaults ]), { - jitless: envHasCsp(), + jitless: true, clone: deepClone, onUndeclaredKey: "ignore" } satisfies Omit diff --git a/ark/schema/structure/structure.ts b/ark/schema/structure/structure.ts index c72d821868..8d1770c8df 100644 --- a/ark/schema/structure/structure.ts +++ b/ark/schema/structure/structure.ts @@ -167,7 +167,18 @@ const implementation: nodeImplementationOf = parse: constraintKeyParser("sequence") }, undeclared: { - parse: behavior => (behavior === "ignore" ? undefined : behavior) + parse: behavior => (behavior === "ignore" ? undefined : behavior), + reduceIo: (ioKind, inner, value) => { + if (value !== "delete") return + + // if base is "delete", undeclared keys are "ignore" (i.e. unconstrained) + // on input and "reject" on output + + if (value === "delete") { + if (ioKind === "in") delete inner.undeclared + else inner.undeclared = "reject" + } + } } }, defaults: { @@ -439,9 +450,6 @@ export class StructureNode extends BaseConstraint { return required ? result : result.or($ark.intrinsic.undefined) } - readonly exhaustive: boolean = - this.undeclared !== undefined || this.index !== undefined - pick(...keys: KeyOrKeyNode[]): StructureNode { this.assertHasKeys(keys) return this.$.node("structure", this.filterKeys("pick", keys)) @@ -542,7 +550,7 @@ export class StructureNode extends BaseConstraint { } } - if (!this.exhaustive) return true + if (!requireExhasutiveTraversal(this, traversalKind)) return true const keys: Key[] = Object.keys(data) keys.push(...Object.getOwnPropertySymbols(data)) @@ -624,7 +632,7 @@ export class StructureNode extends BaseConstraint { if (js.traversalKind === "Apply") js.returnIfFailFast() } - if (this.exhaustive) { + if (requireExhasutiveTraversal(this, js.traversalKind)) { js.const("keys", "Object.keys(data)") js.line("keys.push(...Object.getOwnPropertySymbols(data))") js.for("i < keys.length", () => this.compileExhaustiveEntry(js)) @@ -758,6 +766,19 @@ export const Structure = { Node: StructureNode } +const requireExhasutiveTraversal = ( + node: Structure.Node, + traversalKind: TraversalKind +) => { + if (node.index || node.undeclared === "reject") return true + + // when applying key deletion, we must queue morphs for all undeclared keys + // when checking whether an input is allowed, they are irrelevant because it always will be + if (node.undeclared === "delete" && traversalKind === "Apply") return true + + return false +} + const indexerToKey = (indexable: GettableKeyOrNode): KeyOrKeyNode => { if (hasArkKind(indexable, "root") && indexable.hasKind("unit")) indexable = indexable.unit as Key diff --git a/ark/type/__tests__/realWorld.test.ts b/ark/type/__tests__/realWorld.test.ts index 6be3ea5ca9..2dae6ec6bc 100644 --- a/ark/type/__tests__/realWorld.test.ts +++ b/ark/type/__tests__/realWorld.test.ts @@ -1031,4 +1031,43 @@ nospace must be matched by ^\\S*$ (was "One space")`) ) attest(feedbackSchema.t).type.toString.snap(`{ contact: string }`) }) + + it("deleted undeclared keys allowed in input", () => { + const t = type({ foo: "string" }).onUndeclaredKey("delete") + + attest(t.json).snap({ + undeclared: "delete", + required: [{ key: "foo", value: "string" }], + domain: "object" + }) + + attest(t.in.json).snap({ + required: [{ key: "foo", value: "string" }], + domain: "object" + }) + + const extras = { foo: "hi", bar: 3 } + + attest(t(extras)).equals({ foo: "hi" }) + attest(t.allows(extras)).equals(true) + attest(t.in(extras)).equals(extras) + }) + + it("deleted undeclared keys rejected in output", () => { + const t = type({ foo: "string" }).onUndeclaredKey("delete") + + attest(t.json).snap({ + undeclared: "delete", + required: [{ key: "foo", value: "string" }], + domain: "object" + }) + + attest(t.out.json).snap({ + undeclared: "reject", + required: [{ key: "foo", value: "string" }], + domain: "object" + }) + + attest(t.out({ foo: "hi", bar: 3 }).toString()).snap("bar must be removed") + }) }) From 27c350f166093e8dd5562fe7ea919f8d84d58217 Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 09:29:13 -0500 Subject: [PATCH 09/43] ok --- ark/schema/kinds.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/ark/schema/kinds.ts b/ark/schema/kinds.ts index 14acdba55e..e6b043c38f 100644 --- a/ark/schema/kinds.ts +++ b/ark/schema/kinds.ts @@ -1,10 +1,4 @@ -import { - deepClone, - envHasCsp, - flatMorph, - type array, - type listable -} from "@ark/util" +import { deepClone, flatMorph, type array, type listable } from "@ark/util" import type { ResolvedArkConfig } from "./config.ts" import type { BaseNode } from "./node.ts" import { Predicate } from "./predicate.ts" From d61949d8d4f51b2a6096a0f6f26678fe5db449ca Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 11:39:08 -0500 Subject: [PATCH 10/43] fixo --- ark/schema/structure/structure.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ark/schema/structure/structure.ts b/ark/schema/structure/structure.ts index 8d1770c8df..12a89356b5 100644 --- a/ark/schema/structure/structure.ts +++ b/ark/schema/structure/structure.ts @@ -174,10 +174,8 @@ const implementation: nodeImplementationOf = // if base is "delete", undeclared keys are "ignore" (i.e. unconstrained) // on input and "reject" on output - if (value === "delete") { - if (ioKind === "in") delete inner.undeclared - else inner.undeclared = "reject" - } + if (ioKind === "in") delete inner.undeclared + else inner.undeclared = "reject" } } }, From 1ce57adc01e47be0f32899d28d79af7f5ec9dbf1 Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 12:51:00 -0500 Subject: [PATCH 11/43] continuuu --- ark/docs/components/snippets/contentsById.ts | 14 ++++++++++ ark/repo/jsDocGen.ts | 4 ++- ark/repo/scratch.ts | 15 +++++------ ark/type/methods/base.ts | 27 +++++++++++++++----- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/ark/docs/components/snippets/contentsById.ts b/ark/docs/components/snippets/contentsById.ts index e69de29bb2..45e4e8ad64 100644 --- a/ark/docs/components/snippets/contentsById.ts +++ b/ark/docs/components/snippets/contentsById.ts @@ -0,0 +1,14 @@ +export default { + betterErrors: + 'import { type, type ArkErrors } from "arktype"\n\nconst user = type({\n\tname: "string",\n\tplatform: "\'android\' | \'ios\'",\n\t"versions?": "(number | string)[]"\n})\n\ninterface RuntimeErrors extends ArkErrors {\n\t/**platform must be "android" or "ios" (was "enigma")\nversions[2] must be a number or a string (was bigint)*/\n\tsummary: string\n}\n\nconst narrowMessage = (e: ArkErrors): e is RuntimeErrors => true\n\n// ---cut---\nconst out = user({\n\tname: "Alan Turing",\n\tplatform: "enigma",\n\tversions: [0, "1", 0n]\n})\n\nif (out instanceof type.errors) {\n\t// ---cut-start---\n\tif (!narrowMessage(out)) throw new Error()\n\t// ---cut-end---\n\t// hover summary to see validation errors\n\tconsole.error(out.summary)\n}\n', + clarityAndConcision: + '// @errors: 2322\nimport { type } from "arktype"\n// this file is written in JS so that it can include a syntax error\n// without creating a type error while still displaying the error in twoslash\n// ---cut---\n// hover me\nconst user = type({\n\tname: "string",\n\tplatform: "\'android\' | \'ios\'",\n\t"versions?": "number | string)[]"\n})\n', + deepIntrospectability: + 'import { type } from "arktype"\n\nconst user = type({\n\tname: "string",\n\tdevice: {\n\t\tplatform: "\'android\' | \'ios\'",\n\t\t"version?": "number | string"\n\t}\n})\n\n// ---cut---\nuser.extends("object") // true\nuser.extends("string") // false\n// true (string is narrower than unknown)\nuser.extends({\n\tname: "unknown"\n})\n// false (string is wider than "Alan")\nuser.extends({\n\tname: "\'Alan\'"\n})\n', + intrinsicOptimization: + 'import { type } from "arktype"\n// prettier-ignore\n// ---cut---\n// all unions are optimally discriminated\n// even if multiple/nested paths are needed\nconst account = type({\n\tkind: "\'admin\'",\n\t"powers?": "string[]"\n}).or({\n\tkind: "\'superadmin\'",\n\t"superpowers?": "string[]"\n}).or({\n\tkind: "\'pleb\'"\n})\n', + unparalleledDx: + '// @noErrors\nimport { type } from "arktype"\n// prettier-ignore\n// ---cut---\nconst user = type({\n\tname: "string",\n\tplatform: "\'android\' | \'ios\'",\n\t"version?": "number | s"\n\t// ^|\n})\n', + nestedTypeInScopeError: + '// @errors: 2322\nimport { scope } from "arktype"\n// ---cut---\nconst myScope = scope({\n\tid: "string#id",\n\tuser: type({\n\t\tname: "string",\n\t\tid: "id"\n\t})\n})\n' +} diff --git a/ark/repo/jsDocGen.ts b/ark/repo/jsDocGen.ts index e3bbd1a2f5..e583278f70 100644 --- a/ark/repo/jsDocGen.ts +++ b/ark/repo/jsDocGen.ts @@ -27,7 +27,9 @@ const replacedDecorators = { "@typeonly": "🥸 inference-only property that will be `undefined` at runtime", "@typenoop": "🥸 inference-only function that does nothing runtime", "@chainedDefinition": - "⚠️ unlike most other methods, this creates a definition rather than a Type (read why)" + "⚠️ unlike most other methods, this creates a definition rather than a Type (read why)", + "@predicateCast": + "🥸 {@link https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates | Type predicates} can be used to cast" } as const const arkTypeBuildDir = join(repoDirs.arkDir, "type", "out") diff --git a/ark/repo/scratch.ts b/ark/repo/scratch.ts index 8c3b0bc34b..8d2607c4d8 100644 --- a/ark/repo/scratch.ts +++ b/ark/repo/scratch.ts @@ -30,17 +30,16 @@ const palindrome = type("string") .narrow(s => s === [...s].reverse().join("")) .brand("palindrome") -const out = palindrome("racecar") - const stringifyUser = type({ name: "string" }).pipe(user => JSON.stringify(user) ) -const stringifySafe = stringifyUser.filter(user => user.name !== "Bobby Tables") -// -const stringifyUnsafe = stringifyUser.filter( - (user): user is { name: "Bobby Tables" } => user.name === "Bobby Tables" + +const parseZDate = type("string.date.parse").filter((s): s is `${string}Z` => + s.endsWith("Z") ) -const z = stringifyUser.filter( - (user): user is { name: "Bobby Tables" } => user.name === "Bobby Tables" +new Date().getFullYear() + +const withPredicate = type("string").narrow((s): s is `${string}.tsx` => + /\.tsx?$/.test(s) ) diff --git a/ark/type/methods/base.ts b/ark/type/methods/base.ts index a262304be8..6b587b1e79 100644 --- a/ark/type/methods/base.ts +++ b/ark/type/methods/base.ts @@ -399,13 +399,16 @@ interface Type * #### apply a predicate function to input * * ⚠️ the behavior of {@link narrow}, this method's output counterpart, is usually more desirable - * ✅ useful primarily when dealing with morphs whose inputs have richer types than their outputs + * ✅ most useful for morphs with input types that are re-used externally + * @predicateCast * * @example * const stringifyUser = type({ name: "string" }).pipe(user => JSON.stringify(user)) * const stringifySafe = stringifyUser.filter(user => user.name !== "Bobby Tables") - * // - * const stringifyUnsafe = stringifyUser.filter((user) => user.name === "Bobby Tables") + * // Type<(In: `${string}Z`) => To> + * const withPredicate = type("string.date.parse").filter((s): s is `${string}Z` => + * s.endsWith("Z") + * ) */ filter< narrowed extends this["inferIn"] = never, @@ -417,10 +420,20 @@ interface Type ): instantiateType /** - * Add a custom predicate to this `Type`. - * @example const nan = type('number').narrow(n => Number.isNaN(n)) // Type - * @example const foo = type("string").narrow((s): s is `foo${string}` => s.startsWith('foo') || ctx.mustBe('string starting with "foo"')) // Type<"foo${string}"> - * @example const unique = type('string[]').narrow((a, ctx) => new Set(a).size === a.length || ctx.mustBe('array with unique elements')) + * #### apply a predicate function to output + * + * ✅ go-to fallback for validation not composable via built-in types and operators + * ✅ runs after all other validators and morphs, if present + * @predicateCast + * + * @example + * const palindrome = type("string").narrow(s => s === [...s].reverse().join("")) + * + * const palindromicEmail = type("string.date.parse").narrow((date, ctx) => + * date.getFullYear() === 2025 || ctx.mustBe("the current year") + * ) + * // Type<`${string}.tsx`> + * const withPredicate = type("string").narrow((s): s is `${string}.tsx` => /\.tsx?$/.test(s)) */ narrow< narrowed extends this["infer"] = never, From b5fa17b4576980511084e3bcad9704ec895c9fc3 Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 13:33:05 -0500 Subject: [PATCH 12/43] continuuu --- ark/docs/app/docs/[[...slug]]/page.tsx | 7 +- ark/docs/components/apiData.ts | 259 ++++++++++++++++++++++++- ark/repo/jsDocGen.ts | 2 +- ark/type/methods/base.ts | 13 +- 4 files changed, 268 insertions(+), 13 deletions(-) diff --git a/ark/docs/app/docs/[[...slug]]/page.tsx b/ark/docs/app/docs/[[...slug]]/page.tsx index 13165cf6cd..fb959878c9 100644 --- a/ark/docs/app/docs/[[...slug]]/page.tsx +++ b/ark/docs/app/docs/[[...slug]]/page.tsx @@ -25,8 +25,13 @@ export default async (props: { params: Promise<{ slug?: string[] }> }) => { const MDX = page.data.body + const isApiPage = page.data.title.endsWith("API") + return ( - + {page.data.title} {page.data.description} diff --git a/ark/docs/components/apiData.ts b/ark/docs/components/apiData.ts index ad2e574952..c72536b615 100644 --- a/ark/docs/components/apiData.ts +++ b/ark/docs/components/apiData.ts @@ -23,7 +23,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "type of data this returns" + value: "type of output this returns" } ], notes: [ @@ -45,7 +45,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "type of data this expects" + value: "type of input this allows" } ], notes: [ @@ -117,7 +117,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "a human-readable English description" + value: "human-readable English description" } ], notes: [ @@ -210,7 +210,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "clone and add metadata to shallow references" + value: "add metadata to shallow references" } ], notes: [ @@ -232,7 +232,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: "clone and add the description to shallow references" + value: "add description to shallow references" } ], notes: [ @@ -268,8 +268,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: - "clone to a new Type with the specified undeclared key behavior" + value: "apply undeclared key behavior" } ], notes: [ @@ -302,8 +301,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { summary: [ { kind: "text", - value: - "deeply clone to a new Type with the specified undeclared key behavior" + value: "deeply apply undeclared key behavior" } ], notes: [ @@ -350,6 +348,249 @@ export const apiDocsByGroup: ApiDocsByGroup = { notes: [], example: 'const t = type({ foo: "string" });\n// TypeScript: foo must be a string (was 5)\nconst data = t.from({ foo: 5 });' + }, + { + group: "Type", + name: "as", + summary: [ + { + kind: "text", + value: "cast the way this is inferred" + } + ], + notes: [ + [], + [ + { + kind: "noteStart", + value: "🥸 inference-only function that does nothing runtime" + } + ] + ], + example: "// Type<`a${string}`>\nconst t = type(/^a/).as<`a${string}`>()" + }, + { + group: "Type", + name: "brand", + summary: [ + { + kind: "text", + value: "add a compile-time brand to output" + } + ], + notes: [ + [], + [ + { + kind: "noteStart", + value: "🥸 inference-only function that does nothing runtime" + } + ] + ], + example: + 'const palindrome = type("string")\n .narrow(s => s === [...s].reverse().join(""))\n .brand("palindrome")\n// Brand\nconst out = palindrome.assert("racecar")' + }, + { + group: "Type", + name: "and", + summary: [ + { + kind: "text", + value: + "intersect another Type, throwing if the result is unsatisfiable" + } + ], + notes: [], + example: + '// Type<{ foo: number; bar: string }>\nconst t = type({ foo: "number" }).and({ bar: "string" })\n// ParseError: Intersection at foo of number and string results in an unsatisfiable type\nconst bad = type({ foo: "number" }).and({ foo: "string" })' + }, + { + group: "Type", + name: "or", + summary: [ + { + kind: "text", + value: "union with another Type" + } + ], + notes: [ + [], + [ + { + kind: "noteStart", + value: + "⚠️ a union that could apply different morphs to the same data is a ParseError ([docs](https://arktype.io/docs/expressions/union-morphs))" + } + ] + ], + example: + '// Type\nconst t = type("string").or({ box: "string" })' + }, + { + group: "Type", + name: "array", + summary: [ + { + kind: "text", + value: "an array of this" + } + ], + notes: [], + example: + '// Type<{ rebmun: number }[]>\nconst t = type({ rebmun: "number" }).array();' + }, + { + group: "Type", + name: "optional", + summary: [ + { + kind: "text", + value: + "[optional definition](https://arktype.io/docs/objects#properties-optional) for this" + } + ], + notes: [ + [], + [ + { + kind: "noteStart", + value: + "⚠️ unlike most other methods, this creates a definition rather than a Type (read why)" + } + ] + ], + example: + 'const prop = type({ foo: "number" })\n// Type<{ bar?: { foo: number } }>\nconst obj = type({ bar: prop.optional() })' + }, + { + group: "Type", + name: "default", + summary: [ + { + kind: "text", + value: + "[defaultable definition](https://arktype.io/docs/objects#properties-defaultable) for this" + } + ], + notes: [ + [], + [ + { + kind: "noteStart", + value: "✅ object defaults can be returned from a function" + } + ], + [ + { + kind: "noteStart", + value: "⚠️ throws if the default value is not allowed" + } + ], + [ + { + kind: "noteStart", + value: + "⚠️ unlike most other methods, this creates a definition rather than a Type (read why)" + } + ] + ], + example: + '// Type<{ count: Default }>\nconst state = type({ count: type.number.default(0) })\nconst prop = type({ nested: "boolean" })\nconst forObj = type({\n key: nested.default(() => ({ nested: false }))\n})' + }, + { + group: "Type", + name: "filter", + summary: [ + { + kind: "text", + value: "apply a predicate function to input" + } + ], + notes: [ + [], + [ + { + kind: "noteStart", + value: "⚠️ the behavior of" + }, + { + kind: "reference", + value: "narrow" + }, + { + kind: "text", + value: + ", this method's output counterpart, is usually more desirable" + } + ], + [ + { + kind: "noteStart", + value: + "✅ most useful for morphs with input types that are re-used externally" + } + ], + [ + { + kind: "noteStart", + value: "🥸" + }, + { + kind: "link", + url: "https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates", + value: "Type predicates" + }, + { + kind: "text", + value: "can be used to cast" + } + ] + ], + example: + 'const stringifyUser = type({ name: "string" }).pipe(user => JSON.stringify(user))\nconst stringifySafe = stringifyUser.filter(user => user.name !== "Bobby Tables")\n// Type<(In: `${string}Z`) => To>\nconst withPredicate = type("string.date.parse").filter((s): s is `${string}Z` =>\n s.endsWith("Z")\n)' + }, + { + group: "Type", + name: "narrow", + summary: [ + { + kind: "text", + value: "apply a predicate function to output" + } + ], + notes: [ + [], + [ + { + kind: "noteStart", + value: + "✅ go-to fallback for validation not composable via built-in types and operators" + } + ], + [ + { + kind: "noteStart", + value: "✅ runs after all other validators and morphs, if present" + } + ], + [ + { + kind: "noteStart", + value: "🥸" + }, + { + kind: "link", + url: "https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates", + value: "Type predicates" + }, + { + kind: "text", + value: "can be used to cast" + } + ] + ], + example: + 'const palindrome = type("string").narrow(s => s === [...s].reverse().join(""))\n\nconst palindromicEmail = type("string.date.parse").narrow((date, ctx) =>\n\t\tdate.getFullYear() === 2025 || ctx.mustBe("the current year")\n)\n// Type<`${string}.tsx`>\nconst withPredicate = type("string").narrow((s): s is `${string}.tsx` => /\\.tsx?$/.test(s))' } ] } diff --git a/ark/repo/jsDocGen.ts b/ark/repo/jsDocGen.ts index e583278f70..7b42bbdae1 100644 --- a/ark/repo/jsDocGen.ts +++ b/ark/repo/jsDocGen.ts @@ -29,7 +29,7 @@ const replacedDecorators = { "@chainedDefinition": "⚠️ unlike most other methods, this creates a definition rather than a Type (read why)", "@predicateCast": - "🥸 {@link https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates | Type predicates} can be used to cast" + "🥸 {@link https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates | Type predicates} can be used as casts" } as const const arkTypeBuildDir = join(repoDirs.arkDir, "type", "out") diff --git a/ark/type/methods/base.ts b/ark/type/methods/base.ts index 6b587b1e79..655754337d 100644 --- a/ark/type/methods/base.ts +++ b/ark/type/methods/base.ts @@ -365,7 +365,7 @@ interface Type array(): ArrayType /** - * #### [optional definition](https://arktype.io/docs/objects#properties-optional) for this + * #### {@link https://arktype.io/docs/objects#properties-optional | optional definition} * * @chainedDefinition * @@ -377,7 +377,7 @@ interface Type optional(): [this, "?"] /** - * #### [defaultable definition](https://arktype.io/docs/objects#properties-defaultable) for this + * #### {@link https://arktype.io/docs/objects#properties-defaultable | defaultable definition} * * ✅ object defaults can be returned from a function * ⚠️ throws if the default value is not allowed @@ -462,6 +462,15 @@ interface Type * @example const intersection = type({ foo: "number" }).intersect({ bar: "string" }) // Type<{ foo: number; bar: string }> * @example const intersection = type({ foo: "number" }).intersect({ foo: "string" }) // Disjoint */ + /** + * #### intersect another Type, returning an introspectable {@link Disjoint} if the result is unsatisfiable + * + * @example + * // Type<{ foo: number; bar: string }> + * const t = type({ foo: "number" }).intersect({ bar: "string" }) + * // ParseError: Intersection at foo of number and string results in an unsatisfiable type + * const bad = type({ foo: "number" }).intersect({ foo: "string" }) + */ intersect>( def: type.validate ): instantiateType, $> | Disjoint From 10f7bd3540edd8ae61ffb4524a57713bebc52c19 Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 16:13:13 -0500 Subject: [PATCH 13/43] error typo --- ark/schema/structure/structure.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ark/schema/structure/structure.ts b/ark/schema/structure/structure.ts index 12a89356b5..6ad06283a5 100644 --- a/ark/schema/structure/structure.ts +++ b/ark/schema/structure/structure.ts @@ -703,7 +703,7 @@ export class StructureNode extends BaseConstraint { this.props.forEach(prop => { if (typeof prop.key === "symbol") { return JsonSchema.throwUnjsonifiableError( - `Sybolic key ${prop.serializedKey}` + `Symbolic key ${prop.serializedKey}` ) } From 51033d07d5abca3f81418e6422152589af28f7b6 Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 17:36:34 -0500 Subject: [PATCH 14/43] moar moar --- ark/dark/injected.tmLanguage.json | 2 +- ark/dark/tsWithArkType.tmLanguage.json | 2 +- ark/docs/components/apiData.ts | 167 +++++++++++++++++++++++-- ark/repo/jsDocGen.ts | 4 +- ark/repo/scratch.ts | 37 +----- ark/schema/shared/disjoint.ts | 4 + ark/type/__tests__/type.test.ts | 9 ++ ark/type/methods/base.ts | 52 ++++++-- 8 files changed, 225 insertions(+), 52 deletions(-) diff --git a/ark/dark/injected.tmLanguage.json b/ark/dark/injected.tmLanguage.json index b7174cdcbf..bc1cf52c3d 100644 --- a/ark/dark/injected.tmLanguage.json +++ b/ark/dark/injected.tmLanguage.json @@ -40,7 +40,7 @@ }, "arkChained": { "contentName": "meta.embedded.arktype.definition", - "begin": "([^\\)\\(\\s]+)?(\\.)\\b(and|or|when|extends|ifExtends|intersect|merge|exclude|extract|overlaps|subsumes|to)(\\()", + "begin": "([^\\)\\(\\s]+)?(\\.)\\b(and|or|when|extends|ifExtends|intersect|merge|exclude|extract|overlaps|subsumes|to|satisfies)(\\()", "beginCaptures": { "2": { "name": "punctuation.accessor.ts" diff --git a/ark/dark/tsWithArkType.tmLanguage.json b/ark/dark/tsWithArkType.tmLanguage.json index bc561f821c..0538c054ec 100644 --- a/ark/dark/tsWithArkType.tmLanguage.json +++ b/ark/dark/tsWithArkType.tmLanguage.json @@ -48,7 +48,7 @@ }, "arkChained": { "contentName": "meta.embedded.arktype.definition", - "begin": "([^\\)\\(\\s]+)?(\\.)\\b(and|or|when|extends|ifExtends|intersect|merge|exclude|extract|overlaps|subsumes|to)(\\()", + "begin": "([^\\)\\(\\s]+)?(\\.)\\b(and|or|when|extends|ifExtends|intersect|merge|exclude|extract|overlaps|subsumes|to|satisfies)(\\()", "beginCaptures": { "2": { "name": "punctuation.accessor.ts" diff --git a/ark/docs/components/apiData.ts b/ark/docs/components/apiData.ts index c72536b615..f23a30c710 100644 --- a/ark/docs/components/apiData.ts +++ b/ark/docs/components/apiData.ts @@ -444,13 +444,12 @@ export const apiDocsByGroup: ApiDocsByGroup = { name: "optional", summary: [ { - kind: "text", - value: - "[optional definition](https://arktype.io/docs/objects#properties-optional) for this" + kind: "link", + url: "https://arktype.io/docs/objects#properties-optional", + value: "optional definition" } ], notes: [ - [], [ { kind: "noteStart", @@ -467,13 +466,12 @@ export const apiDocsByGroup: ApiDocsByGroup = { name: "default", summary: [ { - kind: "text", - value: - "[defaultable definition](https://arktype.io/docs/objects#properties-defaultable) for this" + kind: "link", + url: "https://arktype.io/docs/objects#properties-defaultable", + value: "defaultable definition" } ], notes: [ - [], [ { kind: "noteStart", @@ -542,7 +540,7 @@ export const apiDocsByGroup: ApiDocsByGroup = { }, { kind: "text", - value: "can be used to cast" + value: "can be used as casts" } ] ], @@ -585,12 +583,161 @@ export const apiDocsByGroup: ApiDocsByGroup = { }, { kind: "text", - value: "can be used to cast" + value: "can be used as casts" } ] ], example: 'const palindrome = type("string").narrow(s => s === [...s].reverse().join(""))\n\nconst palindromicEmail = type("string.date.parse").narrow((date, ctx) =>\n\t\tdate.getFullYear() === 2025 || ctx.mustBe("the current year")\n)\n// Type<`${string}.tsx`>\nconst withPredicate = type("string").narrow((s): s is `${string}.tsx` => /\\.tsx?$/.test(s))' + }, + { + group: "Type", + name: "intersect", + summary: [ + { + kind: "text", + value: "intersect another Type, returning an introspectable" + }, + { + kind: "reference", + value: "Disjoint" + }, + { + kind: "text", + value: "if the result is unsatisfiable" + } + ], + notes: [], + example: + '// Type<{ foo: number; bar: string }>\nconst t = type({ foo: "number" }).intersect({ bar: "string" })\nconst bad = type("number > 10").intersect("number < 5")\n// logs "Intersection of > 10 and < 5 results in an unsatisfiable type"\nif (bad instanceof Disjoint) console.log(`${bad.summary}`)' + }, + { + group: "Type", + name: "equals", + summary: [ + { + kind: "text", + value: "check if another Type's constraints are identical" + } + ], + notes: [ + [], + [ + { + kind: "noteStart", + value: + "✅ equal types have identical input and output constraints and transforms" + } + ], + [ + { + kind: "noteStart", + value: "✅ ignores associated" + }, + { + kind: "reference", + value: "meta" + }, + { + kind: "text", + value: ", which does not affect the set of allowed values" + } + ] + ], + example: + 'const divisibleBy6 = type.number.divisibleBy(6).moreThan(0)\n// false (left side must also be positive)\ndivisibleBy6.equals("number % 6")\n// false (right side has an additional <100 constraint)\nconsole.log(divisibleBy6.equals("0 < (number % 6) < 100"))\nconst thirdTry = type("(number % 2) > 0").divisibleBy(3)\n// true (types are normalized and reduced)\nconsole.log(divisibleBy6.equals(thirdTry))' + }, + { + group: "Type", + name: "ifEquals", + summary: [ + { + kind: "text", + value: "narrow this based on an" + }, + { + kind: "reference", + value: "equals" + }, + { + kind: "text", + value: "check" + } + ], + notes: [], + example: + 'const n = type.raw(`${Math.random()}`)\n// Type<0.5> | undefined\nconst ez = n.ifEquals("0.5")' + }, + { + group: "Type", + name: "extends", + summary: [ + { + kind: "text", + value: "check if this is a subtype of another Type" + } + ], + notes: [ + [], + [ + { + kind: "noteStart", + value: + "✅ a subtype must include all constraints from the base type" + } + ], + [ + { + kind: "noteStart", + value: "✅ unlike" + }, + { + kind: "reference", + value: "equals" + }, + { + kind: "text", + value: ", additional constraints may be present" + } + ], + [ + { + kind: "noteStart", + value: "✅ ignores associated" + }, + { + kind: "reference", + value: "meta" + }, + { + kind: "text", + value: ", which does not affect the set of allowed values" + } + ] + ], + example: + 'type.string.extends("unknown") // true\ntype.string.extends(/^a.*z$/) // false' + }, + { + group: "Type", + name: "ifExtends", + summary: [ + { + kind: "text", + value: "narrow this based on an" + }, + { + kind: "reference", + value: "extends" + }, + { + kind: "text", + value: "check" + } + ], + notes: [], + example: + 'const n = type(Math.random() > 0.5 ? "true" : "0") // Type<0 | true>\nconst ez = n.ifExtends("boolean") // Type | undefined' } ] } diff --git a/ark/repo/jsDocGen.ts b/ark/repo/jsDocGen.ts index 7b42bbdae1..2149936b3a 100644 --- a/ark/repo/jsDocGen.ts +++ b/ark/repo/jsDocGen.ts @@ -29,7 +29,9 @@ const replacedDecorators = { "@chainedDefinition": "⚠️ unlike most other methods, this creates a definition rather than a Type (read why)", "@predicateCast": - "🥸 {@link https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates | Type predicates} can be used as casts" + "🥸 {@link https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates | Type predicates} can be used as casts", + "@ignoresMeta": + "✅ ignores associated {@link meta}, which does not affect the set of allowed values" } as const const arkTypeBuildDir = join(repoDirs.arkDir, "type", "out") diff --git a/ark/repo/scratch.ts b/ark/repo/scratch.ts index 8d2607c4d8..eda72af1d2 100644 --- a/ark/repo/scratch.ts +++ b/ark/repo/scratch.ts @@ -1,4 +1,5 @@ import { type } from "arktype" +import { Disjoint } from "../schema/shared/disjoint.ts" import { buildApi, jsDocGen } from "./jsDocGen.ts" // type stats on attribute removal merge 12/18/2024 @@ -9,37 +10,9 @@ import { buildApi, jsDocGen } from "./jsDocGen.ts" // } console.log(2 ** 100) -const stillOkay = type("string > 5", "=>", Number.parseFloat).or([ - "string < 10", - "=>", - Number.parseFloat -]) +const t = type("number > 10").intersect("number < 5") +const n = type.raw(`${Math.random()}`) -console.log(stillOkay) -// buildApi() +const ez = n.ifEquals("0.5") -const prop = type({ foo: "number" }) -const state = type({ count: type.number.default(0) }) -const forObj = type({ - key: type({ nested: "boolean" }).default(() => ({ nested: false })) -}) - -const t = type("string").brand("id") - -const palindrome = type("string") - .narrow(s => s === [...s].reverse().join("")) - .brand("palindrome") - -const stringifyUser = type({ name: "string" }).pipe(user => - JSON.stringify(user) -) - -const parseZDate = type("string.date.parse").filter((s): s is `${string}Z` => - s.endsWith("Z") -) - -new Date().getFullYear() - -const withPredicate = type("string").narrow((s): s is `${string}.tsx` => - /\.tsx?$/.test(s) -) +const tt = type(Math.random() > 0.5 ? "1" : "0") diff --git a/ark/schema/shared/disjoint.ts b/ark/schema/shared/disjoint.ts index c84a3d4c2b..897f27fa65 100644 --- a/ark/schema/shared/disjoint.ts +++ b/ark/schema/shared/disjoint.ts @@ -63,6 +63,10 @@ export class Disjoint extends Array { return this } + get summary(): string { + return this.describeReasons() + } + describeReasons(): string { if (this.length === 1) { const { path, l, r } = this[0] diff --git a/ark/type/__tests__/type.test.ts b/ark/type/__tests__/type.test.ts index f4282f58da..8e242ec0b8 100644 --- a/ark/type/__tests__/type.test.ts +++ b/ark/type/__tests__/type.test.ts @@ -28,6 +28,15 @@ contextualize(() => { attest(numerics).snap([0, 2n]) }) + it("extends doc example", () => { + const n = type(Math.random() > 0.5 ? "boolean" : "string") + attest(n.expression).satisfies("string | boolean") + attest(n.t).type.toString.snap("string | boolean") + const ez = n.ifExtends("boolean") + attest(ez?.expression).satisfies("boolean | undefined") + attest(ez?.t).type.toString.snap("boolean | undefined") + }) + it("errors can be thrown", () => { const t = type("number") try { diff --git a/ark/type/methods/base.ts b/ark/type/methods/base.ts index 655754337d..975e24f343 100644 --- a/ark/type/methods/base.ts +++ b/ark/type/methods/base.ts @@ -457,32 +457,70 @@ interface Type // inferring r into an alias improves perf and avoids return type inference // that can lead to incorrect results. See: // https://discord.com/channels/957797212103016458/1285420361415917680/1285545752172429312 - /** - * Intersect another `Type` definition, returning an introspectable `Disjoint` if the result is unsatisfiable. - * @example const intersection = type({ foo: "number" }).intersect({ bar: "string" }) // Type<{ foo: number; bar: string }> - * @example const intersection = type({ foo: "number" }).intersect({ foo: "string" }) // Disjoint - */ /** * #### intersect another Type, returning an introspectable {@link Disjoint} if the result is unsatisfiable * * @example * // Type<{ foo: number; bar: string }> * const t = type({ foo: "number" }).intersect({ bar: "string" }) - * // ParseError: Intersection at foo of number and string results in an unsatisfiable type - * const bad = type({ foo: "number" }).intersect({ foo: "string" }) + * const bad = type("number > 10").intersect("number < 5") + * // logs "Intersection of > 10 and < 5 results in an unsatisfiable type" + * if (bad instanceof Disjoint) console.log(`${bad.summary}`) */ intersect>( def: type.validate ): instantiateType, $> | Disjoint + /** + * #### check if another Type's constraints are identical + * + * ✅ equal types have identical input and output constraints and transforms + * @ignoresMeta + * + * @example + * const divisibleBy6 = type.number.divisibleBy(6).moreThan(0) + * // false (left side must also be positive) + * divisibleBy6.equals("number % 6") + * // false (right side has an additional <100 constraint) + * console.log(divisibleBy6.equals("0 < (number % 6) < 100")) + * const thirdTry = type("(number % 2) > 0").divisibleBy(3) + * // true (types are normalized and reduced) + * console.log(divisibleBy6.equals(thirdTry)) + */ equals(def: type.validate): boolean + /** + * #### narrow this based on an {@link equals} check + * + * @example + * const n = type.raw(`${Math.random()}`) + * // Type<0.5> | undefined + * const ez = n.ifEquals("0.5") + */ ifEquals>( def: type.validate ): instantiateType | undefined + /** + * #### check if this is a subtype of another Type + * + * ✅ a subtype must include all constraints from the base type + * ✅ unlike {@link equals}, additional constraints may be present + * @ignoresMeta + * + * @example + * type.string.extends("unknown") // true + * type.string.extends(/^a.*z$/) // false + */ extends(other: type.validate): boolean + /** + * #### narrow this based on an {@link extends} check + * + * @example + * const n = type(Math.random() > 0.5 ? "true" : "0") // Type<0 | true> + * const ez = n.ifExtends("boolean") // Type | undefined + */ ifExtends>( other: type.validate ): instantiateType | undefined From d9af4a9a8cdda979ce5a9c390751dce04a48050a Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 18:22:25 -0500 Subject: [PATCH 15/43] finish docsing typso --- ark/type/methods/base.ts | 48 ++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/ark/type/methods/base.ts b/ark/type/methods/base.ts index 975e24f343..579cd624b1 100644 --- a/ark/type/methods/base.ts +++ b/ark/type/methods/base.ts @@ -306,8 +306,8 @@ interface Type * @typenoop * * @example - * // Type<`a${string}`> - * const t = type(/^a/).as<`a${string}`>() + * // Type<`LEEEEEEEE${string}ROY`> + * const leeroy = type(/^LE{8,}ROY$/).as<`LEEEEEEEE${string}ROY`>() */ as( ...args: validateChainedAsArgs @@ -330,7 +330,7 @@ interface Type ): instantiateType /** - * #### intersect another Type, throwing if the result is unsatisfiable + * #### intersect the parsed Type, throwing if the result is unsatisfiable * * @example * // Type<{ foo: number; bar: string }> @@ -343,7 +343,7 @@ interface Type ): instantiateType, $> /** - * #### union with another Type + * #### union with the parsed Type * * ⚠️ a union that could apply different morphs to the same data is a ParseError ([docs](https://arktype.io/docs/expressions/union-morphs)) * @@ -458,7 +458,7 @@ interface Type // that can lead to incorrect results. See: // https://discord.com/channels/957797212103016458/1285420361415917680/1285545752172429312 /** - * #### intersect another Type, returning an introspectable {@link Disjoint} if the result is unsatisfiable + * #### intersect the parsed Type, returning an introspectable {@link Disjoint} if the result is unsatisfiable * * @example * // Type<{ foo: number; bar: string }> @@ -472,7 +472,7 @@ interface Type ): instantiateType, $> | Disjoint /** - * #### check if another Type's constraints are identical + * #### check if the parsed Type's constraints are identical * * ✅ equal types have identical input and output constraints and transforms * @ignoresMeta @@ -492,6 +492,8 @@ interface Type /** * #### narrow this based on an {@link equals} check * + * @ignoresMeta + * * @example * const n = type.raw(`${Math.random()}`) * // Type<0.5> | undefined @@ -502,7 +504,7 @@ interface Type ): instantiateType | undefined /** - * #### check if this is a subtype of another Type + * #### check if this is a subtype of the parsed Type * * ✅ a subtype must include all constraints from the base type * ✅ unlike {@link equals}, additional constraints may be present @@ -517,6 +519,8 @@ interface Type /** * #### narrow this based on an {@link extends} check * + * @ignoresMeta + * * @example * const n = type(Math.random() > 0.5 ? "true" : "0") // Type<0 | true> * const ez = n.ifExtends("boolean") // Type | undefined @@ -525,18 +529,44 @@ interface Type other: type.validate ): instantiateType | undefined + /** + * #### check if a value could satisfy this and the parsed Type + * + * ⚠️ will return true unless a {@link Disjoint} can be proven + * + * @example + * type.string.overlaps("string | number") // true (e.g. "foo") + * type("string | number").overlaps("1") // true (1) + * type("number > 0").overlaps("number < 0") // false (no values exist) + * + * const noAt = type("string").narrow(s => !s.includes("@")) + * noAt.overlaps("string.email") // true (no values exist, but not provable) + */ overlaps(r: type.validate): boolean + /** + * #### extract branches {@link extend}ing the parsed Type + * + * @example + * // Type + * const t = type("boolean | 0 | 'one' | 2 | bigint").extract("number | 0n | true") + */ extract>( r: type.validate ): instantiateType, $> + /** + * #### exclude branches {@link extend}ing the parsed Type + * + * @example + * + * // Type + * const t = type("boolean | 0 | 'one' | 2 | bigint").exclude("number | 0n | true") + */ exclude>( r: type.validate ): instantiateType, $> - traverse(data: unknown): this["infer"] | ArkErrors - /** * @experimental * Map and optionally reduce branches of a union. Types that are not unions From 2b5edefbef85100dd88c1cdb15eecd319c2fd6ff Mon Sep 17 00:00:00 2001 From: David Blass Date: Thu, 9 Jan 2025 18:33:18 -0500 Subject: [PATCH 16/43] moar --- ark/docs/components/snippets/contentsById.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/ark/docs/components/snippets/contentsById.ts b/ark/docs/components/snippets/contentsById.ts index 45e4e8ad64..e69de29bb2 100644 --- a/ark/docs/components/snippets/contentsById.ts +++ b/ark/docs/components/snippets/contentsById.ts @@ -1,14 +0,0 @@ -export default { - betterErrors: - 'import { type, type ArkErrors } from "arktype"\n\nconst user = type({\n\tname: "string",\n\tplatform: "\'android\' | \'ios\'",\n\t"versions?": "(number | string)[]"\n})\n\ninterface RuntimeErrors extends ArkErrors {\n\t/**platform must be "android" or "ios" (was "enigma")\nversions[2] must be a number or a string (was bigint)*/\n\tsummary: string\n}\n\nconst narrowMessage = (e: ArkErrors): e is RuntimeErrors => true\n\n// ---cut---\nconst out = user({\n\tname: "Alan Turing",\n\tplatform: "enigma",\n\tversions: [0, "1", 0n]\n})\n\nif (out instanceof type.errors) {\n\t// ---cut-start---\n\tif (!narrowMessage(out)) throw new Error()\n\t// ---cut-end---\n\t// hover summary to see validation errors\n\tconsole.error(out.summary)\n}\n', - clarityAndConcision: - '// @errors: 2322\nimport { type } from "arktype"\n// this file is written in JS so that it can include a syntax error\n// without creating a type error while still displaying the error in twoslash\n// ---cut---\n// hover me\nconst user = type({\n\tname: "string",\n\tplatform: "\'android\' | \'ios\'",\n\t"versions?": "number | string)[]"\n})\n', - deepIntrospectability: - 'import { type } from "arktype"\n\nconst user = type({\n\tname: "string",\n\tdevice: {\n\t\tplatform: "\'android\' | \'ios\'",\n\t\t"version?": "number | string"\n\t}\n})\n\n// ---cut---\nuser.extends("object") // true\nuser.extends("string") // false\n// true (string is narrower than unknown)\nuser.extends({\n\tname: "unknown"\n})\n// false (string is wider than "Alan")\nuser.extends({\n\tname: "\'Alan\'"\n})\n', - intrinsicOptimization: - 'import { type } from "arktype"\n// prettier-ignore\n// ---cut---\n// all unions are optimally discriminated\n// even if multiple/nested paths are needed\nconst account = type({\n\tkind: "\'admin\'",\n\t"powers?": "string[]"\n}).or({\n\tkind: "\'superadmin\'",\n\t"superpowers?": "string[]"\n}).or({\n\tkind: "\'pleb\'"\n})\n', - unparalleledDx: - '// @noErrors\nimport { type } from "arktype"\n// prettier-ignore\n// ---cut---\nconst user = type({\n\tname: "string",\n\tplatform: "\'android\' | \'ios\'",\n\t"version?": "number | s"\n\t// ^|\n})\n', - nestedTypeInScopeError: - '// @errors: 2322\nimport { scope } from "arktype"\n// ---cut---\nconst myScope = scope({\n\tid: "string#id",\n\tuser: type({\n\t\tname: "string",\n\t\tid: "id"\n\t})\n})\n' -} From b94837a3abc0a7c2a060541d4a9920832796e43a Mon Sep 17 00:00:00 2001 From: David Blass Date: Fri, 10 Jan 2025 14:45:28 -0500 Subject: [PATCH 17/43] add arrayIndex alias --- ark/repo/scratch.ts | 2 ++ ark/type/__tests__/get.test.ts | 2 +- ark/type/__tests__/type.test.ts | 1 + ark/type/keywords/keywords.ts | 2 ++ 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ark/repo/scratch.ts b/ark/repo/scratch.ts index eda72af1d2..8b99799682 100644 --- a/ark/repo/scratch.ts +++ b/ark/repo/scratch.ts @@ -16,3 +16,5 @@ const n = type.raw(`${Math.random()}`) const ez = n.ifEquals("0.5") const tt = type(Math.random() > 0.5 ? "1" : "0") + +const z = type("string[]").get(type.keywords.Array.index) diff --git a/ark/type/__tests__/get.test.ts b/ark/type/__tests__/get.test.ts index 3bb88ed27d..28b69e4de6 100644 --- a/ark/type/__tests__/get.test.ts +++ b/ark/type/__tests__/get.test.ts @@ -132,7 +132,7 @@ contextualize(() => { writeInvalidKeysMessage(t.expression, ["5.5"]) ) - attest(t.get(keywords.Array.index).expression).snap("string | undefined") + attest(t.get(type.arrayIndex).expression).snap("string | undefined") }) it("number access on non-variadic", () => { diff --git a/ark/type/__tests__/type.test.ts b/ark/type/__tests__/type.test.ts index 8e242ec0b8..6a83b265fd 100644 --- a/ark/type/__tests__/type.test.ts +++ b/ark/type/__tests__/type.test.ts @@ -122,6 +122,7 @@ contextualize(() => { true: "true", unknown: "unknown", undefined: "undefined", + arrayIndex: type.arrayIndex.expression, Key: "string | symbol", Record: keywords.Record.internal.json, Date: "Date", diff --git a/ark/type/keywords/keywords.ts b/ark/type/keywords/keywords.ts index 7920c8d336..8a18aa8526 100644 --- a/ark/type/keywords/keywords.ts +++ b/ark/type/keywords/keywords.ts @@ -42,6 +42,7 @@ export declare namespace Ark { } export interface typeAttachments extends arkTsKeywords.$ { + arrayIndex: arkPrototypes.$["Array"]["index"] Key: arkBuiltins.$["Key"] Record: arkTsGenerics.$["Record"] Date: arkPrototypes.$["Date"] @@ -54,6 +55,7 @@ export declare namespace Ark { $arkTypeRegistry.typeAttachments = { ...arkTsKeywords, + arrayIndex: arkPrototypes.Array.index, Key: arkBuiltins.Key, Record: arkTsGenerics.Record, Array: arkPrototypes.Array.root, From 01b91e8c1ef2d841da88172eb4f03a16197d582f Mon Sep 17 00:00:00 2001 From: David Blass Date: Fri, 10 Jan 2025 19:46:21 -0500 Subject: [PATCH 18/43] fix banner --- ark/docs/app/(home)/layout.tsx | 8 +- ark/docs/app/layout.tsx | 2 + ark/docs/components/Banner.tsx | 145 ++++++++++++++++++++++++++ ark/docs/components/FloatYourBoat.tsx | 84 ++++++++++----- ark/docs/components/ReleaseBanner.tsx | 10 ++ ark/docs/content/docs/blog/2.0.mdx | 3 + ark/docs/content/docs/blog/index.mdx | 3 + ark/docs/content/docs/blog/meta.json | 5 + ark/docs/content/docs/meta.json | 1 + ark/repo/scratch.ts | 10 -- 10 files changed, 235 insertions(+), 36 deletions(-) create mode 100644 ark/docs/components/Banner.tsx create mode 100644 ark/docs/components/ReleaseBanner.tsx create mode 100644 ark/docs/content/docs/blog/2.0.mdx create mode 100644 ark/docs/content/docs/blog/index.mdx create mode 100644 ark/docs/content/docs/blog/meta.json diff --git a/ark/docs/app/(home)/layout.tsx b/ark/docs/app/(home)/layout.tsx index 673b9b57d2..350be2d177 100644 --- a/ark/docs/app/(home)/layout.tsx +++ b/ark/docs/app/(home)/layout.tsx @@ -9,8 +9,14 @@ export type LayoutProps = { export default ({ children }: LayoutProps): React.ReactElement => ( }} + nav={{ + ...baseOptions.nav, + children: + }} > {children} diff --git a/ark/docs/app/layout.tsx b/ark/docs/app/layout.tsx index 35f2cfa927..bfe6ceef78 100644 --- a/ark/docs/app/layout.tsx +++ b/ark/docs/app/layout.tsx @@ -1,4 +1,5 @@ import "app/global.css" +import { ReleaseBanner } from "components/ReleaseBanner.tsx" import "fumadocs-twoslash/twoslash.css" import { RootProvider } from "fumadocs-ui/provider" import { Raleway } from "next/font/google" @@ -23,6 +24,7 @@ export default ({ children }: { children: ReactNode }) => ( defaultTheme: "dark" }} > + {children} diff --git a/ark/docs/components/Banner.tsx b/ark/docs/components/Banner.tsx new file mode 100644 index 0000000000..3e7379a11d --- /dev/null +++ b/ark/docs/components/Banner.tsx @@ -0,0 +1,145 @@ +"use client" + +import { buttonVariants, cn } from "fumadocs-ui/components/api" +import { X } from "lucide-react" +import { type HTMLAttributes, useCallback, useEffect, useState } from "react" +import { FloatYourBoat } from "./FloatYourBoat.tsx" + +// Based on: +// https://github.com/fuma-nama/fumadocs/blob/1e6ece043987c8bf607249b66a8945632b229982/packages/ui/src/components/banner.tsx#L65 + +export const Banner = ({ + id = "banner", + changeLayout = true, + boat, + children, + ...props +}: HTMLAttributes & { + boat?: boolean + /** + * Change Fumadocs layout styles + * + * @defaultValue true + */ + changeLayout?: boolean +}): React.ReactElement => { + const [open, setOpen] = useState(true) + const globalKey = id ? `nd-banner-${id}` : undefined + + useEffect(() => { + if (globalKey) setOpen(localStorage.getItem(globalKey) !== "true") + }, [globalKey]) + + const onClick = useCallback(() => { + setOpen(false) + if (globalKey) localStorage.setItem(globalKey, "true") + }, [globalKey]) + + return ( +
+
+ {changeLayout && open ? + + : null} + {globalKey ? + + : null} + {id ? +