diff --git a/.gitignore b/.gitignore index 4040fc8..c7875ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +*.g.ts ### IntelliJ IDEA ### .idea/ diff --git a/common/src/generator/typescript/static.ts b/common/src/generator/typescript/static.ts index 3e1d613..8481035 100644 --- a/common/src/generator/typescript/static.ts +++ b/common/src/generator/typescript/static.ts @@ -65,30 +65,32 @@ export function generateTypeScriptStaticSpec(ir: IR): string { formatBlockDocComment(derivedType.description, derivedType.since).forEach((line) => { l(line); }); + l(`export interface ${normalizeDerivedStructName(struct.name, derivedType.tagValue)} {`); + l(` /** 数据类型区分字段,表示自身为${derivedType.description} */`); + l(` ${struct.tagFieldName}: '${derivedType.tagValue}';`); + struct.baseFields.forEach((field) => { + formatBlockDocComment(field.description, field.since, ' ').forEach((line) => { + l(line); + }); + l(` ${field.name}${field.isOptional ? '?' : ''}: ${getTypeScriptTypeProjection(field)};`); + }); + if (derivedType.derivingType === 'struct') { - l(`export interface ${normalizeDerivedStructName(struct.name, derivedType.tagValue)}Data {`); + l(` /** 数据内容 */`); + l(` data: {`); derivedType.fields.forEach((field) => { - formatBlockDocComment(field.description, field.since, ' ').forEach((line) => { + formatBlockDocComment(field.description, field.since, ' ').forEach((line) => { l(line); }); - l(` ${field.name}${field.isOptional ? '?' : ''}: ${getTypeScriptTypeProjection(field)};`); + l(` ${field.name}${field.isOptional ? '?' : ''}: ${getTypeScriptTypeProjection(field)};`); }); - l('}'); + l(' }'); } else { // ref type - l( - `export type ${normalizeDerivedStructName(struct.name, derivedType.tagValue)}Data = ${derivedType.refStructName};`, - ); - } - // add type aliases for Event - if (struct.name === 'Event') { - formatBlockDocComment(derivedType.description, derivedType.since).forEach((line) => { - l(line); - }); - l( - `export type ${normalizeDerivedStructName(struct.name, derivedType.tagValue)} = ${normalizeDerivedStructName(struct.name, derivedType.tagValue)}Data;`, - ); + l(` /** 数据内容 */`); + l(` data: ${derivedType.refStructName};`); } + l('}'); l(); }); formatBlockDocComment(struct.description, struct.since).forEach((line) => { @@ -96,10 +98,9 @@ export function generateTypeScriptStaticSpec(ir: IR): string { }); l(`export type ${struct.name} =`); struct.derivedTypes.forEach((derivedType, index) => { - l(' | {'); - l(` ${struct.tagFieldName}: '${derivedType.tagValue}';`); - l(` data: ${normalizeDerivedStructName(struct.name, derivedType.tagValue)}Data;`); - l(` }${index === struct.derivedTypes.length - 1 ? ';' : ''}`); + l( + ` | ${normalizeDerivedStructName(struct.name, derivedType.tagValue)}${index === struct.derivedTypes.length - 1 ? ';' : ''}`, + ); }); } } @@ -165,6 +166,22 @@ export function generateTypeScriptStaticSpec(ir: IR): string { }); l('}'); l(); + l('export interface ApiEndpoints {'); + ir.apiCategories.forEach((category) => { + category.apis.forEach((api) => { + const typeNames = getApiTypeNames(api.endpoint); + formatBlockDocComment(api.description, api.since, ' ').forEach((line) => { + l(line); + }); + l(` /** ${api.description} */`); + l(` '${api.endpoint}': {`); + l(` request: ${typeNames.inputName};`); + l(` response: ${typeNames.outputName};`); + l(' };'); + }); + }); + l('}'); + l(); return writer.toString(); } diff --git a/common/src/generator/typescript/zod.ts b/common/src/generator/typescript/zod.ts index 7649b92..28d4113 100644 --- a/common/src/generator/typescript/zod.ts +++ b/common/src/generator/typescript/zod.ts @@ -2,7 +2,7 @@ import type { IR, IRField } from '@saltify/milky-protocol'; import { getApiTypeNames } from '../shared/ir'; import { normalizeDerivedStructName, snakeCaseToPascalCase } from '../shared/naming'; -import { createLineWriter } from '../shared/text'; +import { createLineWriter, indentLines } from '../shared/text'; import { getTypeScriptTypeProjection } from './shared'; const applyDropBadElementArrayStructNames = new Set(['GroupNotification']); @@ -10,17 +10,11 @@ const applyDropBadElementArrayStructNames = new Set(['GroupNotification']); const specialReplacements = new Map([ [ 'IncomingReplySegmentData.segments', - [' get segments() {', " return z.array(z.lazy(() => IncomingSegment)).describe('回复消息内容');", ' },'].join( - '\n', - ), + " get segments() { return z.array(IncomingSegment).describe('回复消息内容'); },", ], [ 'OutgoingForwardSegmentData.messages', - [ - ' get messages() {', - " return z.array(z.lazy(() => OutgoingForwardedMessage)).describe('转发消息内容');", - ' },', - ].join('\n'), + " get messages() { return z.array(OutgoingForwardedMessage).describe('转发消息内容'); },", ], ]); @@ -162,46 +156,45 @@ export function generateTypeScriptZodSpec(ir: IR) { struct.derivedTypes.forEach((derived) => { const structName = `${normalizeDerivedStructName(struct.name, derived.tagValue)}Data`; if (derived.derivingType === 'struct') { - l( - `export const ${structName} = ${renderIRObject(ir, structName, derived.fields, derived.description, true)};`, - ); - l(`export type ${structName} = z.infer;`); - l(); - // add type aliases for Event - if (struct.name === 'Event') { - l(`export const ${normalizeDerivedStructName(struct.name, derived.tagValue)} = ${structName};`); + l(`export const ${normalizeDerivedStructName(struct.name, derived.tagValue)} = z.object({`); + l(` ${struct.tagFieldName}: z.literal('${derived.tagValue}'),`); + struct.baseFields.forEach((field) => { l( - `export type ${normalizeDerivedStructName(struct.name, derived.tagValue)} = z.infer;`, + ` ${field.name}: ${getZodTypeSpec(ir, field, { + dropBadElementArrayStructNames: applyDropBadElementArrayStructNames, + })}.describe('${field.description}'),`, ); - l(); - } - } - }); - l(`export const ${struct.name} = z.discriminatedUnion('${struct.tagFieldName}', [`); - struct.derivedTypes.forEach((derived, index) => { - l(' z.object({'); - l(` ${struct.tagFieldName}: z.literal('${derived.tagValue}'),`); - struct.baseFields.forEach((field) => { + }); l( - ` ${field.name}: ${getZodTypeSpec(ir, field, { - dropBadElementArrayStructNames: applyDropBadElementArrayStructNames, - })}.describe('${field.description}'),`, + ` data: ${indentLines(renderIRObject(ir, structName, derived.fields, derived.description, false), ' ').trimStart()},`, ); - }); - if (derived.derivingType === 'struct') { + l(`}).describe('${derived.description}');`); l( - ` data: ${normalizeDerivedStructName(struct.name, derived.tagValue)}Data.describe('${derived.description}'),`, + `export type ${normalizeDerivedStructName(struct.name, derived.tagValue)} = z.infer;`, ); - l(` }).describe('${derived.description}'),`); + l(); } else { - // ref type - l(` data: z.lazy(() => ${derived.refStructName}).describe('${derived.description}'),`); - l(` }).describe('${derived.description}'),`); - } - if (index !== struct.derivedTypes.length - 1) { + l(`export const ${normalizeDerivedStructName(struct.name, derived.tagValue)} = z.object({`); + l(` ${struct.tagFieldName}: z.literal('${derived.tagValue}'),`); + struct.baseFields.forEach((field) => { + l( + ` ${field.name}: ${getZodTypeSpec(ir, field, { + dropBadElementArrayStructNames: applyDropBadElementArrayStructNames, + })}.describe('${field.description}'),`, + ); + }); + l(` data: z.lazy(() => ${derived.refStructName}).describe('${derived.description}'),`); + l(`}).describe('${derived.description}');`); + l( + `export type ${normalizeDerivedStructName(struct.name, derived.tagValue)} = z.infer;`, + ); l(); } }); + l(`export const ${struct.name} = z.discriminatedUnion('${struct.tagFieldName}', [`); + struct.derivedTypes.forEach((derived) => { + l(` ${normalizeDerivedStructName(struct.name, derived.tagValue)},`); + }); } if (struct.name === 'IncomingSegment') { l(']).catch({'); @@ -271,6 +264,18 @@ export function generateTypeScriptZodSpec(ir: IR) { }); l('};'); l(); + l('export const zodApiEndpoints = {'); + ir.apiCategories.forEach((category) => { + category.apis.forEach((spec) => { + l(` ${spec.endpoint}: {`); + l(` description: '${spec.description}',`); + l(` requestSchema: ${spec.requestFields ? getApiTypeNames(spec.endpoint).inputName : 'null'},`); + l(` responseSchema: ${spec.responseFields ? getApiTypeNames(spec.endpoint).outputName : 'null'},`); + l(' },'); + }); + }); + l('};'); + l(); return writer.toString(); } diff --git a/docs/app/raw/json-schema/schema.json/route.ts b/docs/app/raw/json-schema/schema.json/route.ts index c376405..e45426e 100644 --- a/docs/app/raw/json-schema/schema.json/route.ts +++ b/docs/app/raw/json-schema/schema.json/route.ts @@ -4,5 +4,13 @@ import { ir } from '@saltify/milky-protocol'; export const dynamic = 'force-static'; export async function GET() { - return new Response(JSON.stringify(await generateJsonSchema(ir, await import('@saltify/milky-types')))); + return new Response( + JSON.stringify( + await generateJsonSchema( + ir, + // @ts-expect-error + await import('@/zod-types.g'), + ), + ), + ); } diff --git a/docs/app/raw/openapi/openapi.json/route.ts b/docs/app/raw/openapi/openapi.json/route.ts index 7f4d82c..809c26d 100644 --- a/docs/app/raw/openapi/openapi.json/route.ts +++ b/docs/app/raw/openapi/openapi.json/route.ts @@ -4,5 +4,13 @@ import { ir } from '@saltify/milky-protocol'; export const dynamic = 'force-static'; export async function GET() { - return new Response(JSON.stringify(await generateOpenApiSpec(ir, await import('@saltify/milky-types')))); + return new Response( + JSON.stringify( + await generateOpenApiSpec( + ir, + // @ts-expect-error + await import('@/zod-types.g'), + ), + ), + ); } diff --git a/docs/content/awesome.md b/docs/content/awesome.md index 333f359..b81e2c2 100644 --- a/docs/content/awesome.md +++ b/docs/content/awesome.md @@ -46,10 +46,8 @@ Milky 的协议内容以 Milky IR 的形式发布在 npm 包 [@saltify/milky-pro 以下是由 Milky 社区提供的类型定义包,可以在项目中直接引用。虽然没有直接提供实现或对接功能,但可以帮助开发者快速编写 Milky 协议的实现或 SDK。 -- **TypeScript** - [@saltify/milky-types](https://www.npmjs.com/package/@saltify/milky-types) (MIT) - **.NET** - [Milky.Net.Model](https://www.nuget.org/packages/Milky.Net.Model) (MIT) - **Rust** - [milky-types](https://crates.io/crates/milky-types) (MIT **or** Apache 2.0) -- **Kotlin** - [milky-kt-types](https://central.sonatype.com/artifact/org.ntqqrev/milky-kt-types) (MIT) ### 原始文件与实用资源 diff --git a/docs/package.json b/docs/package.json index 6334892..47ceb54 100644 --- a/docs/package.json +++ b/docs/package.json @@ -11,7 +11,6 @@ "dependencies": { "@saltify/milky-common": "workspace:*", "@saltify/milky-protocol": "workspace:*", - "@saltify/milky-types": "workspace:*", "next": "^16.2.4", "nextra": "^4.6.1", "nextra-theme-docs": "^4.6.1", diff --git a/package.json b/package.json index 21f5a3f..80600ba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "milky", "scripts": { - "prepare": "tsx generator/src/cli.ts generate typescript/zod -v local -o types/src/index.g.ts", + "prepare": "tsx generator/src/cli.ts generate typescript/zod -v local -o docs/zod-types.g.ts", "docs:dev": "pnpm --filter @saltify/milky-docs dev", "check": "biome check .", "check:fix": "biome check --write .", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9776c1..4b088a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,6 @@ importers: '@saltify/milky-protocol': specifier: workspace:* version: link:../protocol - '@saltify/milky-types': - specifier: workspace:* - version: link:../types next: specifier: ^16.2.4 version: 16.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -96,16 +93,6 @@ importers: protocol: {} - types: - dependencies: - zod: - specifier: ^4.3.6 - version: 4.3.6 - devDependencies: - '@saltify/milky-protocol': - specifier: workspace:* - version: link:../protocol - packages: '@antfu/install-pkg@1.1.0': diff --git a/protocol/package.json b/protocol/package.json index 27af9e4..649af17 100644 --- a/protocol/package.json +++ b/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@saltify/milky-protocol", "type": "module", - "version": "1.2.2", + "version": "1.3.0-rc.1", "description": "Milky protocol definition in form of IR", "main": "src/index.ts", "publishConfig": { diff --git a/protocol/src/ir/api/file.ts b/protocol/src/ir/api/file.ts index 6135d9d..8add557 100644 --- a/protocol/src/ir/api/file.ts +++ b/protocol/src/ir/api/file.ts @@ -53,6 +53,10 @@ export const fileApiCategory: IRApiCategory = category('file', '文件 API', [ scalarField('group_id', '群号', 'int64', { dataType: 'uin' }), scalarField('file_id', '文件 ID', 'string') ]), + api('persist_group_file', '转存群文件为永久文件', [ + scalarField('group_id', '群号', 'int64', { dataType: 'uin' }), + scalarField('file_id', '文件 ID', 'string') + ], undefined, { since: '1.3' }), api('create_group_folder', '创建群文件夹', [ scalarField('group_id', '群号', 'int64', { dataType: 'uin' }), scalarField('folder_name', '文件夹名称', 'string') diff --git a/protocol/src/ir/common.ts b/protocol/src/ir/common.ts index 3abcd2d..bb3c88c 100644 --- a/protocol/src/ir/common.ts +++ b/protocol/src/ir/common.ts @@ -96,6 +96,10 @@ const Event = nestedUnion('Event', '事件', 'event_type', [ scalarField('user_id', '发生变更的用户 QQ 号', 'int64', { dataType: 'uin' }), scalarField('operator_id', '管理员 QQ 号,如果是管理员踢出', 'int64', { isOptional: true }) ]), + nestedUnionStructVariant('group_disband', '群解散事件', [ + scalarField('group_id', '群号', 'int64', { dataType: 'uin' }), + scalarField('operator_id', '操作者 QQ 号', 'int64', { dataType: 'uin' }) + ], { since: '1.3' }), nestedUnionStructVariant('group_name_change', '群名称变更事件', [ scalarField('group_id', '群号', 'int64', { dataType: 'uin' }), scalarField('new_group_name', '新的群名称', 'string'), @@ -373,12 +377,16 @@ const IncomingSegment = nestedUnion('IncomingSegment', '接收消息段', 'type' nestedUnionStructVariant('xml', 'XML 消息段', [ scalarField('service_id', '服务 ID', 'int32'), scalarField('xml_payload', 'XML 数据', 'string') + ]), + nestedUnionStructVariant('markdown', 'Markdown 消息段', [ + scalarField('content', 'Markdown 内容', 'string') ]) ]); const OutgoingForwardedMessage = struct('OutgoingForwardedMessage', '发送转发消息', [ scalarField('user_id', '发送者 QQ 号', 'int64', { dataType: 'uin' }), scalarField('sender_name', '发送者名称', 'string'), + scalarField('time', '消息 Unix 时间戳(秒)', 'int64', { isOptional: true, since: '1.3' }), refField('segments', '消息段列表', 'OutgoingSegment', { isArray: true }) ]); @@ -392,7 +400,7 @@ const OutgoingSegment = nestedUnion('OutgoingSegment', '发送消息段', 'type' nestedUnionStructVariant('mention_all', '提及全体消息段', []), nestedUnionStructVariant('face', '表情消息段', [ scalarField('face_id', '表情 ID', 'string'), - scalarField('is_large', '是否为超级表情', 'bool', { defaultValue: false }) + scalarField('is_large', '是否为超级表情', 'bool', { defaultValue: false, since: '1.1' }) ]), nestedUnionStructVariant('reply', '回复消息段', [ scalarField('message_seq', '被引用的消息序列号', 'int64') diff --git a/types/.gitignore b/types/.gitignore deleted file mode 100644 index 660bad3..0000000 --- a/types/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist/ -*.g.ts \ No newline at end of file diff --git a/types/README.md b/types/README.md deleted file mode 100644 index 374a23e..0000000 --- a/types/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# @saltify/milky-types - -这是 Milky 协议的 TypeScript 类型定义包,使用 [Zod](https://zod.dev/) 进行运行时类型验证。 - -## 安装 - -```bash -npm install @saltify/milky-types -``` - -## 使用方法 - -### 进行运行时类型验证 - -```typescript -import { FriendEntity } from '@saltify/milky-types'; - -const friend1 = FriendEntity.parse({ - user_id: 123456789, - nickname: 'Alice', - sex: 'female', - qid: 'Saltify', - remark: 'Best friend', - category: { - id: 1, - name: 'Close Friends', - }, -}); // 成功解析,friend1 的类型为 FriendEntity - -const friend2 = FriendEntity.parse({ - user_id: 'not a number', - nickname: 'Bob', -}); // 解析失败,抛出 ZodError 错误 -``` - -### 获取 TypeScript 类型定义 - -```typescript -import { FriendEntity } from '@saltify/milky-types'; - -const friend1: FriendEntity = { - user_id: 123456789, - nickname: 'Alice', - sex: 'female', - qid: 'Saltify', - remark: 'Best friend', - category: { - id: 1, - name: 'Close Friends', - }, -}; // friend1 的类型为 FriendEntity - -const friend2: FriendEntity = { - user_id: 'not a number', - nickname: 'Bob', -}; // TypeScript 编译错误,user_id 应为 number 类型,并且缺少 sex、qid、remark 和 category 字段 -``` diff --git a/types/package.json b/types/package.json deleted file mode 100644 index c35416d..0000000 --- a/types/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@saltify/milky-types", - "type": "module", - "version": "1.2.2", - "description": "Type definitions for Milky protocol", - "main": "src/index.ts", - "publishConfig": { - "main": "dist/index.mjs", - "typings": "dist/index.d.mts" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsdown", - "prepack": "npm run build" - }, - "keywords": [], - "author": "SaltifyDev", - "repository": { - "type": "git", - "url": "git+https://github.com/SaltifyDev/milky.git" - }, - "license": "MIT", - "dependencies": { - "zod": "^4.3.6" - }, - "devDependencies": { - "@saltify/milky-protocol": "workspace:*" - } -} diff --git a/types/src/index.ts b/types/src/index.ts deleted file mode 100644 index bb14cd2..0000000 --- a/types/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './index.g'; diff --git a/types/tsconfig.json b/types/tsconfig.json deleted file mode 100644 index 5d27393..0000000 --- a/types/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "paths": { "@/*": ["./src/*"] }, - "target": "ESNext", - "useDefineForClassFields": true, - "module": "ESNext", - "lib": ["ESNext", "DOM"], - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": false, - "allowSyntheticDefaultImports": true, - "resolveJsonModule": true, - "isolatedModules": true, - "declaration": true, - "strict": true, - "noFallthroughCasesInSwitch": true - } -} diff --git a/types/tsdown.config.ts b/types/tsdown.config.ts deleted file mode 100644 index 861aed5..0000000 --- a/types/tsdown.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - entry: 'src/index.ts', - format: 'esm', - dts: true, - clean: true, - target: false, -});