Skip to content

Commit 78a40de

Browse files
authored
standard-schema + str presets (#36)
1 parent 281e3bb commit 78a40de

File tree

8 files changed

+293
-4
lines changed

8 files changed

+293
-4
lines changed

src/presets/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,6 @@ export * from './markdown-from-jsdoc'
5555
export * from './markdown-from-tests'
5656
export * from './markdown-toc'
5757
export * from './monorepo-toc'
58+
export * from './standard-schema'
59+
export * from './str'
5860
// codegen:end

src/presets/standard-schema.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {z} from 'zod/v4'
2+
import {definePreset} from './util/standard-schema-preset'
3+
4+
/**
5+
* Generates standard-schema boilerplate code. By default, includes:
6+
*
7+
* - the contract as defined in github.com/standard-schema/standard-schema.
8+
* - some utils for checking if arbitrary values look like standard-schema schemas and errors.
9+
* - a function for prettifying standard-schema errors.
10+
* - a custom error class for standard-schema errors.
11+
*
12+
* All parts are optional - and of course you can use this generator to create the boilerplate once, then remove the codegen directives to modify manually
13+
*/
14+
export const standardSchema = definePreset(
15+
z.object({
16+
include: z.enum(['contract', 'errors', 'utils']).array().default(['contract', 'errors', 'utils']),
17+
}),
18+
({options, meta, dependencies}) => {
19+
const src = options.include
20+
.map(include => {
21+
if (include === 'contract') return standardSchemaV1ContractSrc
22+
if (include === 'utils') return standardSchemaV1UtilsSrc
23+
if (include === 'errors') return standardSchemaV1ErrorsSrc
24+
})
25+
.join('\n')
26+
27+
if (dependencies.simplify.equivalentSimplified(src, meta.existingContent)) return meta.existingContent || ''
28+
return src
29+
},
30+
)
31+
32+
// codegen:start {preset: str, source: ../standard-schema/contract.ts, const: standardSchemaV1ContractSrc}
33+
const standardSchemaV1ContractSrc =
34+
"// from https://github.com/standard-schema/standard-schema\n\n/** The Standard Schema interface. */\nexport interface StandardSchemaV1<Input = unknown, Output = Input> {\n /** The Standard Schema properties. */\n readonly '~standard': StandardSchemaV1.Props<Input, Output>\n}\n\nexport declare namespace StandardSchemaV1 {\n /** The Standard Schema properties interface. */\n export interface Props<Input = unknown, Output = Input> {\n /** The version number of the standard. */\n readonly version: 1\n /** The vendor name of the schema library. */\n readonly vendor: string\n /** Validates unknown input values. */\n readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>\n /** Inferred types associated with the schema. */\n readonly types?: Types<Input, Output> | undefined\n }\n\n /** The result interface of the validate function. */\n export type Result<Output> = SuccessResult<Output> | FailureResult\n\n /** The result interface if validation succeeds. */\n export interface SuccessResult<Output> {\n /** The typed output value. */\n readonly value: Output\n /** The non-existent issues. */\n readonly issues?: undefined\n }\n\n /** The result interface if validation fails. */\n export interface FailureResult {\n /** The issues of failed validation. */\n readonly issues: ReadonlyArray<Issue>\n }\n\n /** The issue interface of the failure output. */\n export interface Issue {\n /** The error message of the issue. */\n readonly message: string\n /** The path of the issue, if any. */\n readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined\n }\n\n /** The path segment interface of the issue. */\n export interface PathSegment {\n /** The key representing a path segment. */\n readonly key: PropertyKey\n }\n\n /** The Standard Schema types interface. */\n export interface Types<Input = unknown, Output = Input> {\n /** The input type of the schema. */\n readonly input: Input\n /** The output type of the schema. */\n readonly output: Output\n }\n\n /** Infers the input type of a Standard Schema. */\n export type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['input']\n\n /** Infers the output type of a Standard Schema. */\n export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output']\n}\n"
35+
// codegen:end
36+
37+
// codegen:start {preset: str, source: ../standard-schema/errors.ts, const: standardSchemaV1ErrorsSrc, excludeLines: ['^import']}
38+
const standardSchemaV1ErrorsSrc =
39+
"\nexport const prettifyStandardSchemaError = (error: unknown): string | null => {\n if (!looksLikeStandardSchemaFailure(error)) return null\n\n const issues = [...error.issues]\n .map(issue => {\n const path = issue.path || []\n const primitivePathSegments = path.map(segment => {\n if (typeof segment === 'string' || typeof segment === 'number' || typeof segment === 'symbol') return segment\n return segment.key\n })\n const dotPath = toDotPath(primitivePathSegments)\n return {\n issue,\n path,\n primitivePathSegments,\n dotPath,\n }\n })\n .sort((a, b) => a.path.length - b.path.length)\n\n const lines: string[] = []\n\n for (const {issue, dotPath} of issues) {\n let message = `✖ ${issue.message}`\n if (dotPath) message += ` → at ${dotPath}`\n lines.push(message)\n }\n\n return lines.join('\\n')\n}\n\nexport function toDotPath(path: (string | number | symbol)[]): string {\n const segs: string[] = []\n for (const seg of path) {\n if (typeof seg === 'number') segs.push(`[${seg}]`)\n else if (typeof seg === 'symbol') segs.push(`[${JSON.stringify(String(seg))}]`)\n else if (/[^\\w$]/.test(seg)) segs.push(`[${JSON.stringify(seg)}]`)\n else {\n if (segs.length) segs.push('.')\n segs.push(seg)\n }\n }\n\n return segs.join('')\n}\n\nexport class StandardSchemaV1Error extends Error implements StandardSchemaV1.FailureResult {\n issues: StandardSchemaV1.FailureResult['issues']\n constructor(failure: StandardSchemaV1.FailureResult, options?: {cause?: Error}) {\n super('Standard Schema error - details in `issues`.', options)\n this.issues = failure.issues\n }\n}\n"
40+
// codegen:end
41+
42+
// codegen:start {preset: str, source: ../standard-schema/utils.ts, const: standardSchemaV1UtilsSrc, excludeLines: ['^import']}
43+
const standardSchemaV1UtilsSrc =
44+
"\nexport const looksLikeStandardSchemaFailure = (error: unknown): error is StandardSchemaV1.FailureResult => {\n return !!error && typeof error === 'object' && 'issues' in error && Array.isArray(error.issues)\n}\n\nexport const looksLikeStandardSchema = (thing: unknown): thing is StandardSchemaV1 => {\n return !!thing && typeof thing === 'object' && '~standard' in thing && typeof thing['~standard'] === 'object'\n}\n"
45+
// codegen:end

src/presets/str.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {definePreset} from './util/standard-schema-preset'
2+
3+
/**
4+
* Copies a whole other file into a const variable. Useful for capturing a file's full contents as a hard-coded string.
5+
* Obviously this creates duplicated data, so use judiciously!
6+
*
7+
* ##### basic usage
8+
* ```js
9+
* // codegen:start {preset: copy, source: ../../another-project/src/some-file.ts}
10+
* import {z} from 'zod'
11+
* export const MyObject = z.object({ foo: z.string() })
12+
* // codegen:end
13+
* ```
14+
*
15+
* #### excludeLines
16+
*
17+
* ```ts
18+
* ;
19+
* import {z} from 'zod/v4' // in this project we use zod v4, but we're copying from a project that uses zod v3
20+
* // codegen:start {preset: copy, source: ../../another-project/src/some-file.ts, excludeLines: ['^import']}
21+
* ;
22+
* export const MyObject = z.object({ foo: z.string() })
23+
* // codegen:end
24+
* ```
25+
*
26+
* #### onlyIfExists
27+
* ```js
28+
* // copy a file from a sibling project, but only if the sibling project actually exists
29+
* // in this case this will effectively skip the copying step on machines that don't have the sibling project installed
30+
* // e.g. on CI runners.
31+
* // codegen:start {preset: copy, source: ../../another-project/src/some-file.ts, onlyIfExists: ../../another-project/package.json}
32+
* ;
33+
* import {z} from 'zod'
34+
* ;
35+
* export const MyObject = z.object({ foo: z.string() });
36+
* ;
37+
* // codegen:end
38+
* ```
39+
*
40+
* #### comparison
41+
* ```js
42+
* // by default, the content will perform a "simplified" comparison with existing content, so differences from tools like prettier
43+
* // are ignored. if you care about whitespace and similar differences, you can set the comparison option to `strict`.
44+
* // codegen:start {preset: copy, source: ../../another-project/src/some-file.ts, comparison: strict}
45+
* ;
46+
* import {z} from "zod"
47+
* ;
48+
* export const MyObject = z.object({ foo: z.string() })
49+
* ;
50+
* // codegen:end
51+
* ```
52+
*/
53+
export const str = definePreset(
54+
{
55+
source: 'string',
56+
const: 'string',
57+
'export?': 'boolean',
58+
/** path to the file to copy. can be absolute or relative to the file being linted */
59+
/** if provided, only runs if this file exists - if it's missing, the existing content is returned (defaulting to empty string) */
60+
'onlyIfExists?': 'string',
61+
/** if provided, these lines will be removed from the copied content. e.g. `excludeLines: ['^import', '// codegen:']` */
62+
'excludeLines?': 'string[]',
63+
/**
64+
* if set to `strict` the content will update if it's not a perfect match. by default (`simplified`) it will only update
65+
* if the "simplified" version of the content is different.
66+
*/
67+
'comparison?': '"simplified" | "strict"',
68+
},
69+
({options, meta, context, dependencies: {fs, path, simplify}}) => {
70+
// todo: add an option to allow syncing the other way - that is, if the content on the file being linted is newer,
71+
// don't autofix - offer two suggestions: 1) write to the source file, 2) write to the file being linted.
72+
const getAbsolutePath = (filepath: string) => path.resolve(path.dirname(context.physicalFilename), filepath)
73+
const shouldRun = options.onlyIfExists ? fs.existsSync(getAbsolutePath(options.onlyIfExists)) : true
74+
if (!shouldRun) return meta.existingContent || ''
75+
76+
let content = fs.readFileSync(getAbsolutePath(options.source), 'utf8')
77+
if (options.excludeLines) {
78+
const regexes = options.excludeLines.map(line => new RegExp(line))
79+
const lines = content.split('\n')
80+
content = lines.filter(line => !regexes.some(regex => regex.test(line))).join('\n')
81+
}
82+
83+
content = `const ${options.const} = ${JSON.stringify(content)}`
84+
if (options.export) content = `export ${content}`
85+
86+
let isUpToDate: boolean
87+
// eslint-disable-next-line unicorn/prefer-ternary
88+
if (!options.comparison || options.comparison === 'simplified') {
89+
// we only want to declare it outdated if the simplified versions are different
90+
isUpToDate = simplify.equivalentSimplified(content, meta.existingContent)
91+
} else {
92+
isUpToDate = content === meta.existingContent
93+
}
94+
95+
if (isUpToDate) return meta.existingContent || ''
96+
97+
return content
98+
},
99+
)

src/presets/util/standard-schema-preset.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import {Preset} from '../..'
22
import {arktype} from '../../esm-modules'
3-
import {StandardSchemaV1, looksLikeStandardSchemaFailure, prettifyStandardSchemaError} from '../../standard-schema'
3+
import {
4+
StandardSchemaV1,
5+
looksLikePromise,
6+
looksLikeStandardSchemaFailure,
7+
prettifyStandardSchemaError,
8+
} from '../../standard-schema'
49

510
type $ = {}
611
/** define a preset using an arktype schema definition. note that you don't have to import arktype */
@@ -32,11 +37,11 @@ const definePresetFromStandardSchema = <Input extends {}, Output extends {} = In
3237
): Preset<Output> => {
3338
return params => {
3439
const result = schema['~standard'].validate(params.options)
35-
if (result instanceof Promise) {
40+
if (looksLikePromise(result)) {
3641
throw new Error('Standard Schema validation is async')
3742
}
3843
if (looksLikeStandardSchemaFailure(result)) {
39-
throw new Error(`Invalid options: ${prettifyStandardSchemaError(result.issues)}`)
44+
throw new Error(`Invalid options: ${prettifyStandardSchemaError(result)}`)
4045
}
4146
return fn({...params, options: result.value as never})
4247
}

src/standard-schema.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export declare namespace StandardSchemaV1 {
6363
export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output']
6464
}
6565

66+
export const looksLikePromise = (error: unknown): error is Promise<unknown> => {
67+
return !!error && typeof error === 'object' && 'then' in error && typeof error.then === 'function'
68+
}
69+
6670
export const looksLikeStandardSchemaFailure = (error: unknown): error is StandardSchemaV1.FailureResult => {
6771
return !!error && typeof error === 'object' && 'issues' in error && Array.isArray(error.issues)
6872
}
@@ -71,9 +75,12 @@ export const looksLikeStandardSchema = (thing: unknown): thing is StandardSchema
7175
return !!thing && typeof thing === 'object' && '~standard' in thing && typeof thing['~standard'] === 'object'
7276
}
7377

74-
export const prettifyStandardSchemaError = (error: unknown): string | null => {
78+
export const prettifyErrorIfStandardSchema = (error: unknown): string | null => {
7579
if (!looksLikeStandardSchemaFailure(error)) return null
80+
return prettifyStandardSchemaError(error)
81+
}
7682

83+
export const prettifyStandardSchemaError = (error: StandardSchemaV1.FailureResult): string | null => {
7784
const issues = [...error.issues]
7885
.map(issue => {
7986
const path = issue.path || []

src/standard-schema/contract.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// from https://github.com/standard-schema/standard-schema
2+
3+
/** The Standard Schema interface. */
4+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
5+
/** The Standard Schema properties. */
6+
readonly '~standard': StandardSchemaV1.Props<Input, Output>
7+
}
8+
9+
export declare namespace StandardSchemaV1 {
10+
/** The Standard Schema properties interface. */
11+
export interface Props<Input = unknown, Output = Input> {
12+
/** The version number of the standard. */
13+
readonly version: 1
14+
/** The vendor name of the schema library. */
15+
readonly vendor: string
16+
/** Validates unknown input values. */
17+
readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>
18+
/** Inferred types associated with the schema. */
19+
readonly types?: Types<Input, Output> | undefined
20+
}
21+
22+
/** The result interface of the validate function. */
23+
export type Result<Output> = SuccessResult<Output> | FailureResult
24+
25+
/** The result interface if validation succeeds. */
26+
export interface SuccessResult<Output> {
27+
/** The typed output value. */
28+
readonly value: Output
29+
/** The non-existent issues. */
30+
readonly issues?: undefined
31+
}
32+
33+
/** The result interface if validation fails. */
34+
export interface FailureResult {
35+
/** The issues of failed validation. */
36+
readonly issues: ReadonlyArray<Issue>
37+
}
38+
39+
/** The issue interface of the failure output. */
40+
export interface Issue {
41+
/** The error message of the issue. */
42+
readonly message: string
43+
/** The path of the issue, if any. */
44+
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined
45+
}
46+
47+
/** The path segment interface of the issue. */
48+
export interface PathSegment {
49+
/** The key representing a path segment. */
50+
readonly key: PropertyKey
51+
}
52+
53+
/** The Standard Schema types interface. */
54+
export interface Types<Input = unknown, Output = Input> {
55+
/** The input type of the schema. */
56+
readonly input: Input
57+
/** The output type of the schema. */
58+
readonly output: Output
59+
}
60+
61+
/** Infers the input type of a Standard Schema. */
62+
export type InferInput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['input']
63+
64+
/** Infers the output type of a Standard Schema. */
65+
export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output']
66+
}

src/standard-schema/errors.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type {StandardSchemaV1} from './contract'
2+
import {looksLikeStandardSchemaFailure} from './utils'
3+
4+
export const prettifyStandardSchemaError = (error: unknown): string | null => {
5+
if (!looksLikeStandardSchemaFailure(error)) return null
6+
7+
const issues = [...error.issues]
8+
.map(issue => {
9+
const path = issue.path || []
10+
const primitivePathSegments = path.map(segment => {
11+
if (typeof segment === 'string' || typeof segment === 'number' || typeof segment === 'symbol') return segment
12+
return segment.key
13+
})
14+
const dotPath = toDotPath(primitivePathSegments)
15+
return {
16+
issue,
17+
path,
18+
primitivePathSegments,
19+
dotPath,
20+
}
21+
})
22+
.sort((a, b) => a.path.length - b.path.length)
23+
24+
const lines: string[] = []
25+
26+
for (const {issue, dotPath} of issues) {
27+
let message = `✖ ${issue.message}`
28+
if (dotPath) message += ` → at ${dotPath}`
29+
lines.push(message)
30+
}
31+
32+
return lines.join('\n')
33+
}
34+
35+
export function toDotPath(path: (string | number | symbol)[]): string {
36+
const segs: string[] = []
37+
for (const seg of path) {
38+
if (typeof seg === 'number') segs.push(`[${seg}]`)
39+
else if (typeof seg === 'symbol') segs.push(`[${JSON.stringify(String(seg))}]`)
40+
else if (/[^\w$]/.test(seg)) segs.push(`[${JSON.stringify(seg)}]`)
41+
else {
42+
if (segs.length) segs.push('.')
43+
segs.push(seg)
44+
}
45+
}
46+
47+
return segs.join('')
48+
}
49+
50+
export class StandardSchemaV1Error extends Error implements StandardSchemaV1.FailureResult {
51+
issues: StandardSchemaV1.FailureResult['issues']
52+
constructor(failure: StandardSchemaV1.FailureResult, options?: {cause?: Error}) {
53+
super('Standard Schema error - details in `issues`.', options)
54+
this.issues = failure.issues
55+
}
56+
}

src/standard-schema/utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type {StandardSchemaV1} from './contract'
2+
3+
export const looksLikeStandardSchemaFailure = (error: unknown): error is StandardSchemaV1.FailureResult => {
4+
return !!error && typeof error === 'object' && 'issues' in error && Array.isArray(error.issues)
5+
}
6+
7+
export const looksLikeStandardSchema = (thing: unknown): thing is StandardSchemaV1 => {
8+
return !!thing && typeof thing === 'object' && '~standard' in thing && typeof thing['~standard'] === 'object'
9+
}

0 commit comments

Comments
 (0)