sizuku 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,22 +72,26 @@ export const postRelations = relations(post, ({ one }) => ({
72
72
 
73
73
  Prepare sizuku.json:
74
74
 
75
- ```json
76
- {
77
- "input": "db/schema.ts",
78
- "zod": {
79
- "output": "zod/index.ts",
80
- "comment": true,
81
- "type": true
75
+ ```ts
76
+ import defineConfig from 'sizuku/config'
77
+
78
+ export default defineConfig({
79
+ input: 'db/schema.ts',
80
+ zod: {
81
+ output: 'zod/index.ts',
82
+ comment: true,
83
+ type: true,
84
+ zod: 'v4',
85
+ },
86
+ valibot: {
87
+ output: 'valibot/index.ts',
88
+ comment: true,
89
+ type: true,
82
90
  },
83
- "valibot": {
84
- "output": "valibot/index.ts",
85
- "comment": true
91
+ mermaid: {
92
+ output: 'mermaid-er/ER.md',
86
93
  },
87
- "mermaid": {
88
- "output": "mermaid-er/ER.md"
89
- }
90
- }
94
+ })
91
95
  ```
92
96
 
93
97
  Run Sizuku:
@@ -160,6 +164,8 @@ export const UserSchema = v.object({
160
164
  name: v.pipe(v.string(), v.minLength(1), v.maxLength(50)),
161
165
  })
162
166
 
167
+ export type User = v.InferInput<typeof UserSchema>
168
+
163
169
  export const PostSchema = v.object({
164
170
  /**
165
171
  * Primary key
@@ -179,9 +185,15 @@ export const PostSchema = v.object({
179
185
  userId: v.pipe(v.string(), v.uuid()),
180
186
  })
181
187
 
188
+ export type Post = v.InferInput<typeof PostSchema>
189
+
182
190
  export const UserRelationsSchema = v.object({ ...UserSchema.entries, posts: v.array(PostSchema) })
183
191
 
192
+ export type UserRelations = v.InferInput<typeof UserRelationsSchema>
193
+
184
194
  export const PostRelationsSchema = v.object({ ...PostSchema.entries, user: UserSchema })
195
+
196
+ export type PostRelations = v.InferInput<typeof PostRelationsSchema>
185
197
  ```
186
198
 
187
199
  ### Mermaid ER
@@ -1,18 +1,27 @@
1
- export type Config = {
2
- input?: `${string}.ts`;
1
+ export type Config = Readonly<{
2
+ input: `${string}.ts`;
3
3
  zod?: {
4
- output?: `${string}.ts`;
4
+ output: `${string}.ts`;
5
5
  comment?: boolean;
6
6
  type?: boolean;
7
7
  zod?: 'v4' | 'mini' | '@hono/zod-openapi';
8
+ relation?: boolean;
8
9
  };
9
10
  valibot?: {
10
- output?: `${string}.ts`;
11
+ output: `${string}.ts`;
11
12
  comment?: boolean;
12
13
  type?: boolean;
14
+ relation?: boolean;
13
15
  };
14
16
  mermaid?: {
15
- output?: string;
17
+ output: string;
16
18
  };
17
- };
18
- export declare function getConfig(): Config;
19
+ }>;
20
+ export declare function config(): Promise<{
21
+ ok: true;
22
+ value: Config;
23
+ } | {
24
+ ok: false;
25
+ error: string;
26
+ }>;
27
+ export default function defineConfig(config: Config): Config;
@@ -1,13 +1,85 @@
1
- import fs from 'node:fs';
2
- export function getConfig() {
3
- if (!fs.existsSync('sizuku.json')) {
4
- throw new Error('sizuku.json not found');
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { register } from 'tsx/esm/api';
5
+ export async function config() {
6
+ const isTs = (o) => o.endsWith('.ts');
7
+ const abs = resolve(process.cwd(), 'sizuku.config.ts');
8
+ if (!existsSync(abs)) {
9
+ return { ok: false, error: `Config not found: ${abs}` };
5
10
  }
6
- const parsed = JSON.parse(fs.readFileSync('sizuku.json', 'utf-8'));
7
- return {
8
- input: parsed.input,
9
- zod: parsed.zod,
10
- valibot: parsed.valibot,
11
- mermaid: parsed.mermaid,
12
- };
11
+ try {
12
+ register();
13
+ const mod = await import(pathToFileURL(abs).href);
14
+ if (!('default' in mod)) {
15
+ return { ok: false, error: 'Config must export default object' };
16
+ }
17
+ if (mod.default !== undefined) {
18
+ if (!isTs(mod.default.input)) {
19
+ return { ok: false, error: 'Input must be a .ts file' };
20
+ }
21
+ // zod
22
+ if (mod.default.zod !== undefined) {
23
+ if (!isTs(mod.default.zod.output)) {
24
+ return { ok: false, error: 'Zod output must be a .ts file' };
25
+ }
26
+ if (mod.default.zod.comment !== undefined) {
27
+ if (typeof mod.default.zod.comment !== 'boolean') {
28
+ return { ok: false, error: 'Zod comment must be a boolean' };
29
+ }
30
+ }
31
+ if (mod.default.zod.type !== undefined) {
32
+ if (typeof mod.default.zod.type !== 'boolean') {
33
+ return { ok: false, error: 'Zod type must be a boolean' };
34
+ }
35
+ }
36
+ if (mod.default.zod.zod !== undefined) {
37
+ if (mod.default.zod.zod !== 'v4' &&
38
+ mod.default.zod.zod !== 'mini' &&
39
+ mod.default.zod.zod !== '@hono/zod-openapi') {
40
+ return { ok: false, error: 'zod must be v4, mini, or @hono/zod-openapi' };
41
+ }
42
+ }
43
+ if (mod.default.zod.relation !== undefined) {
44
+ if (typeof mod.default.zod.relation !== 'boolean') {
45
+ return { ok: false, error: 'Zod relation must be a boolean' };
46
+ }
47
+ }
48
+ }
49
+ }
50
+ // valibot
51
+ if (mod.default.valibot !== undefined) {
52
+ if (!isTs(mod.default.valibot.output)) {
53
+ return { ok: false, error: 'Valibot output must be a .ts file' };
54
+ }
55
+ if (mod.default.valibot.comment !== undefined) {
56
+ if (typeof mod.default.valibot.comment !== 'boolean') {
57
+ return { ok: false, error: 'Valibot comment must be a boolean' };
58
+ }
59
+ }
60
+ if (mod.default.valibot.type !== undefined) {
61
+ if (typeof mod.default.valibot.type !== 'boolean') {
62
+ return { ok: false, error: 'Valibot type must be a boolean' };
63
+ }
64
+ }
65
+ if (mod.default.valibot.relation !== undefined) {
66
+ if (typeof mod.default.valibot.relation !== 'boolean') {
67
+ return { ok: false, error: 'Valibot relation must be a boolean' };
68
+ }
69
+ }
70
+ }
71
+ // mermaid
72
+ if (mod.default.mermaid !== undefined) {
73
+ if (typeof mod.default.mermaid.output !== 'string') {
74
+ return { ok: false, error: 'Mermaid output must be a string' };
75
+ }
76
+ }
77
+ return { ok: true, value: mod.default };
78
+ }
79
+ catch (e) {
80
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
81
+ }
82
+ }
83
+ export default function defineConfig(config) {
84
+ return config;
13
85
  }
@@ -14,7 +14,7 @@ export function erContent(relations, tables) {
14
14
  // Generate per-table definitions
15
15
  ...tables.flatMap((table) => [
16
16
  ` ${table.name} {`,
17
- ...table.fields.map((field) => ` ${field.type} ${field.name} ${field.description ? `"${field.description}"` : ''}`),
17
+ ...table.fields.map((field) => ` ${field.type} ${field.name}${field.description ? ` "${field.description}"` : ''}`),
18
18
  ' }',
19
19
  ]),
20
20
  ...ER_FOOTER,
@@ -43,7 +43,8 @@ export function parseTableInfo(code) {
43
43
  const fieldType = baseBuilderName(initExpr);
44
44
  const initText = initExpr.getText();
45
45
  const lineIdx = prop.getStartLineNumber() - 1;
46
- const baseDesc = code
46
+ // Find the immediate comment above this field
47
+ const immediateComment = code
47
48
  .slice(0, lineIdx)
48
49
  .reverse()
49
50
  .find((line) => {
@@ -59,10 +60,12 @@ export function parseTableInfo(code) {
59
60
  : initText.includes('.references(')
60
61
  ? '(FK) '
61
62
  : '';
63
+ // Only include description if there's a comment
64
+ const description = immediateComment ? `${prefix}${immediateComment}`.trim() : null;
62
65
  return {
63
66
  name: fieldName,
64
67
  type: fieldType,
65
- description: `${prefix}${baseDesc}`.trim(),
68
+ description,
66
69
  };
67
70
  })
68
71
  .filter(isFieldInfo);
@@ -5,7 +5,7 @@
5
5
  * @param comment - Whether to include comments in the generated code
6
6
  * @param type - Whether to include type information in the generated code
7
7
  */
8
- export declare function sizukuValibot(code: string[], output: `${string}.ts`, comment?: boolean, type?: boolean, relations?: boolean): Promise<{
8
+ export declare function sizukuValibot(code: string[], output: `${string}.ts`, comment?: boolean, type?: boolean, relation?: boolean): Promise<{
9
9
  ok: true;
10
10
  value: undefined;
11
11
  } | {
@@ -11,14 +11,14 @@ import { valibotCode } from './generator/valibot-code.js';
11
11
  * @param comment - Whether to include comments in the generated code
12
12
  * @param type - Whether to include type information in the generated code
13
13
  */
14
- export async function sizukuValibot(code, output, comment, type, relations) {
14
+ export async function sizukuValibot(code, output, comment, type, relation) {
15
15
  const baseSchemas = extractSchemas(code, 'valibot');
16
16
  const relationSchemas = extractRelationSchemas(code, 'valibot');
17
17
  const valibotGeneratedCode = [
18
18
  "import * as v from 'valibot'",
19
19
  '',
20
20
  ...baseSchemas.map((schema) => valibotCode(schema, comment ?? false, type ?? false)),
21
- ...(relations
21
+ ...(relation
22
22
  ? relationSchemas.map((schema) => relationValibotCode(schema, type ?? false))
23
23
  : []),
24
24
  ].join('\n');
@@ -6,7 +6,7 @@
6
6
  * @param type - Whether to include type information in the generated code
7
7
  * @param zod - The Zod version to use
8
8
  */
9
- export declare function sizukuZod(code: string[], output: `${string}.ts`, comment?: boolean, type?: boolean, zod?: 'v4' | 'mini' | '@hono/zod-openapi', relations?: boolean): Promise<{
9
+ export declare function sizukuZod(code: string[], output: `${string}.ts`, comment?: boolean, type?: boolean, zod?: 'v4' | 'mini' | '@hono/zod-openapi', relation?: boolean): Promise<{
10
10
  ok: true;
11
11
  value: undefined;
12
12
  } | {
@@ -12,7 +12,7 @@ import { zodCode } from './generator/zod-code.js';
12
12
  * @param type - Whether to include type information in the generated code
13
13
  * @param zod - The Zod version to use
14
14
  */
15
- export async function sizukuZod(code, output, comment, type, zod, relations) {
15
+ export async function sizukuZod(code, output, comment, type, zod, relation) {
16
16
  const importLine = zod === 'mini'
17
17
  ? `import * as z from 'zod/mini'`
18
18
  : zod === '@hono/zod-openapi'
@@ -24,7 +24,7 @@ export async function sizukuZod(code, output, comment, type, zod, relations) {
24
24
  importLine,
25
25
  '',
26
26
  ...baseSchemas.map((schema) => zodCode(schema, comment ?? false, type ?? false)),
27
- ...(relations ? relationSchemas.map((schema) => relationZodCode(schema, type ?? false)) : []),
27
+ ...(relation ? relationSchemas.map((schema) => relationZodCode(schema, type ?? false)) : []),
28
28
  ].join('\n');
29
29
  const mkdirResult = await mkdir(path.dirname(output));
30
30
  if (!mkdirResult.ok) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import type { Config } from './config/index.js';
2
- export declare function main(config?: Config): Promise<{
1
+ export declare function main(): Promise<{
3
2
  ok: true;
4
3
  value: string;
5
4
  } | {
package/dist/index.js CHANGED
@@ -1,17 +1,19 @@
1
1
  // #!/usr/bin/env node
2
- import { getConfig } from './config/index.js';
2
+ import { config } from './config/index.js';
3
3
  import { sizukuMermaidER } from './generator/mermaid-er/index.js';
4
4
  import { sizukuValibot } from './generator/valibot/index.js';
5
5
  import { sizukuZod } from './generator/zod/index.js';
6
6
  import { readFileSync } from './shared/fs/index.js';
7
- export async function main(config = getConfig()) {
8
- if (!config.input) {
7
+ export async function main() {
8
+ const configResult = await config();
9
+ if (!configResult.ok) {
9
10
  return {
10
11
  ok: false,
11
- error: 'input is not found',
12
+ error: configResult.error,
12
13
  };
13
14
  }
14
- const contentResult = readFileSync(config.input);
15
+ const c = configResult.value;
16
+ const contentResult = readFileSync(c.input);
15
17
  if (!contentResult.ok) {
16
18
  return {
17
19
  ok: false,
@@ -24,37 +26,37 @@ export async function main(config = getConfig()) {
24
26
  const code = lines.slice(codeStart);
25
27
  const results = [];
26
28
  /* zod */
27
- if (config.zod?.output) {
28
- const zodResult = await sizukuZod(code, config.zod.output, config.zod.comment, config.zod.type, config.zod.zod, true);
29
+ if (c.zod?.output) {
30
+ const zodResult = await sizukuZod(code, c.zod.output, c.zod.comment, c.zod.type, c.zod.zod, c.zod.relation);
29
31
  if (!zodResult.ok) {
30
32
  return {
31
33
  ok: false,
32
34
  error: zodResult.error,
33
35
  };
34
36
  }
35
- results.push(`Generated Zod schema at: ${config.zod?.output}`);
37
+ results.push(`Generated Zod schema at: ${c.zod?.output}`);
36
38
  }
37
39
  /* valibot */
38
- if (config.valibot?.output) {
39
- const valibotResult = await sizukuValibot(code, config.valibot.output, config.valibot.comment, config.valibot.type, true);
40
+ if (c.valibot?.output) {
41
+ const valibotResult = await sizukuValibot(code, c.valibot.output, c.valibot.comment, c.valibot.type, c.valibot.relation);
40
42
  if (!valibotResult.ok) {
41
43
  return {
42
44
  ok: false,
43
45
  error: valibotResult.error,
44
46
  };
45
47
  }
46
- results.push(`Generated Valibot schema at: ${config.valibot?.output}`);
48
+ results.push(`Generated Valibot schema at: ${c.valibot?.output}`);
47
49
  }
48
50
  /* mermaid */
49
- if (config.mermaid?.output) {
50
- const mermaidResult = await sizukuMermaidER(code, config.mermaid.output);
51
+ if (c.mermaid?.output) {
52
+ const mermaidResult = await sizukuMermaidER(code, c.mermaid.output);
51
53
  if (!mermaidResult.ok) {
52
54
  return {
53
55
  ok: false,
54
56
  error: mermaidResult.error,
55
57
  };
56
58
  }
57
- results.push(`Generated Mermaid ER at: ${config.mermaid?.output}`);
59
+ results.push(`Generated Mermaid ER at: ${c.mermaid?.output}`);
58
60
  }
59
61
  return {
60
62
  ok: true,
@@ -73,19 +73,7 @@ export function joinWithSpace(arr) {
73
73
  * @returns Array of strings split by newline.
74
74
  */
75
75
  export function splitByNewline(str) {
76
- const result = [];
77
- let current = '';
78
- for (let i = 0; i < str.length; i++) {
79
- if (str[i] === '\n') {
80
- result.push(current);
81
- current = '';
82
- }
83
- else {
84
- current += str[i];
85
- }
86
- }
87
- result.push(current);
88
- return result;
76
+ return str.split('\n');
89
77
  }
90
78
  /**
91
79
  * Trim whitespace from string.
@@ -94,17 +82,7 @@ export function splitByNewline(str) {
94
82
  * @returns Trimmed string.
95
83
  */
96
84
  export function trimString(str) {
97
- let start = 0;
98
- let end = str.length - 1;
99
- while (start <= end &&
100
- (str[start] === ' ' || str[start] === '\t' || str[start] === '\r' || str[start] === '\n')) {
101
- start++;
102
- }
103
- while (end >= start &&
104
- (str[end] === ' ' || str[end] === '\t' || str[end] === '\r' || str[end] === '\n')) {
105
- end--;
106
- }
107
- return str.substring(start, end + 1);
85
+ return str.trim();
108
86
  }
109
87
  /**
110
88
  * Parse relation line and extract components.
@@ -113,13 +91,13 @@ export function trimString(str) {
113
91
  * @returns Parsed relation or null if not a relation line.
114
92
  */
115
93
  export function parseRelationLine(line) {
116
- if (!startsWith(line, '@relation'))
94
+ if (!line.startsWith('@relation'))
117
95
  return null;
118
- const parts = splitByWhitespace(line);
96
+ const parts = line.trim().split(/\s+/);
119
97
  if (parts.length < 5)
120
98
  return null;
121
- const fromParts = splitByDot(parts[1]);
122
- const toParts = splitByDot(parts[2]);
99
+ const fromParts = parts[1].split('.');
100
+ const toParts = parts[2].split('.');
123
101
  if (fromParts.length !== 2 || toParts.length !== 2)
124
102
  return null;
125
103
  return {
@@ -159,28 +137,10 @@ export function removeOptionalSuffix(str) {
159
137
  * @returns Array of strings split by whitespace.
160
138
  */
161
139
  export function splitByWhitespace(str) {
162
- const result = [];
163
- let current = '';
164
- let inWord = false;
165
- for (let i = 0; i < str.length; i++) {
166
- const char = str[i];
167
- const isWhitespace = char === ' ' || char === '\t' || char === '\r' || char === '\n';
168
- if (isWhitespace) {
169
- if (inWord) {
170
- result.push(current);
171
- current = '';
172
- inWord = false;
173
- }
174
- }
175
- else {
176
- current += char;
177
- inWord = true;
178
- }
179
- }
180
- if (inWord) {
181
- result.push(current);
182
- }
183
- return result;
140
+ return str
141
+ .trim()
142
+ .split(/\s+/)
143
+ .filter((s) => s.length > 0);
184
144
  }
185
145
  /**
186
146
  * Split string by dot character.
@@ -189,19 +149,7 @@ export function splitByWhitespace(str) {
189
149
  * @returns Array of strings split by dot.
190
150
  */
191
151
  export function splitByDot(str) {
192
- const result = [];
193
- let current = '';
194
- for (let i = 0; i < str.length; i++) {
195
- if (str[i] === '.') {
196
- result.push(current);
197
- current = '';
198
- }
199
- else {
200
- current += str[i];
201
- }
202
- }
203
- result.push(current);
204
- return result;
152
+ return str.split('.');
205
153
  }
206
154
  /* ========================================================================== *
207
155
  * parse
@@ -214,24 +162,25 @@ export function splitByDot(str) {
214
162
  * @returns Parsed definition and description
215
163
  */
216
164
  export function parseFieldComments(commentLines, tag) {
217
- const cleaned = commentLines.map((line) => removeTripleSlash(line).trim()).filter(isNonEmpty);
218
- // Extract object type from strictObject/looseObject tags
219
- const objectTypeLine = cleaned.find((line) => containsSubstring(line, `${tag.slice(1)}strictObject`) ||
220
- containsSubstring(line, `${tag.slice(1)}looseObject`));
221
- const objectType = objectTypeLine && containsSubstring(objectTypeLine, 'strictObject')
222
- ? 'strict'
223
- : objectTypeLine && containsSubstring(objectTypeLine, 'looseObject')
224
- ? 'loose'
225
- : undefined;
226
- // Extract definition (excluding strictObject/looseObject tags)
227
- const definitionLine = cleaned.find((line) => startsWith(line, tag) &&
228
- !containsSubstring(line, 'strictObject') &&
229
- !containsSubstring(line, 'looseObject'));
230
- const definition = definitionLine ? removeAtSign(definitionLine) : '';
231
- const descriptionLines = cleaned.filter((line) => !(containsSubstring(line, '@z.') ||
232
- containsSubstring(line, '@v.') ||
233
- containsSubstring(line, '@relation.')));
234
- const description = descriptionLines.length > 0 ? joinWithSpace(descriptionLines) : undefined;
165
+ const cleaned = commentLines
166
+ .map((line) => (line.startsWith('///') ? line.substring(3) : line).trim())
167
+ .filter((line) => line.length > 0);
168
+ const objectTypeLine = cleaned.find((line) => line.includes(`${tag.slice(1)}strictObject`) || line.includes(`${tag.slice(1)}looseObject`));
169
+ const objectType = objectTypeLine
170
+ ? objectTypeLine.includes('strictObject')
171
+ ? 'strict'
172
+ : objectTypeLine.includes('looseObject')
173
+ ? 'loose'
174
+ : undefined
175
+ : undefined;
176
+ const definitionLine = cleaned.find((line) => line.startsWith(tag) && !line.includes('strictObject') && !line.includes('looseObject'));
177
+ const definition = definitionLine
178
+ ? definitionLine.startsWith('@')
179
+ ? definitionLine.substring(1)
180
+ : definitionLine
181
+ : '';
182
+ const descriptionLines = cleaned.filter((line) => !(line.includes('@z.') || line.includes('@v.') || line.includes('@relation.')));
183
+ const description = descriptionLines.length > 0 ? descriptionLines.join(' ') : undefined;
235
184
  return { definition, description, objectType };
236
185
  }
237
186
  /* ========================================================================== *
@@ -246,14 +195,14 @@ export function parseFieldComments(commentLines, tag) {
246
195
  */
247
196
  export function extractFieldComments(sourceText, fieldStartPos) {
248
197
  const beforeField = sourceText.substring(0, fieldStartPos);
249
- const lines = splitByNewline(beforeField);
198
+ const lines = beforeField.split('\n');
250
199
  const reverseIndex = lines
251
- .map((line, index) => ({ line: trimString(line), index }))
200
+ .map((line, index) => ({ line: line.trim(), index }))
252
201
  .reverse()
253
202
  .reduce((acc, { line }) => {
254
203
  if (acc.shouldStop)
255
204
  return acc;
256
- if (startsWith(line, '///')) {
205
+ if (line.startsWith('///')) {
257
206
  return {
258
207
  commentLines: [line, ...acc.commentLines],
259
208
  shouldStop: false,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sizuku",
3
3
  "type": "module",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "description": "Sizuku is a tool that generates validation schemas for Zod and Valibot, as well as ER diagrams, from Drizzle schemas annotated with comments.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -29,6 +29,12 @@
29
29
  "bin": {
30
30
  "sizuku": "dist/index.js"
31
31
  },
32
+ "exports": {
33
+ "./config": {
34
+ "types": "./dist/config/index.d.ts",
35
+ "import": "./dist/config/index.js"
36
+ }
37
+ },
32
38
  "scripts": {
33
39
  "deps": "rm -rf node_modules && pnpm install",
34
40
  "build": "tsc",
@@ -48,6 +54,7 @@
48
54
  },
49
55
  "dependencies": {
50
56
  "prettier": "^3.6.2",
51
- "ts-morph": "^26.0.0"
57
+ "ts-morph": "^26.0.0",
58
+ "tsx": "^4.20.5"
52
59
  }
53
60
  }