runtypex 0.2.1 → 0.2.3

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
@@ -57,6 +57,24 @@ export default defineConfig({
57
57
  });
58
58
  ```
59
59
 
60
+ To generate mapper documentation by convention:
61
+
62
+ ```ts
63
+ export default defineConfig({
64
+ plugins: [
65
+ runtypex({
66
+ docs: {
67
+ include: "src/features/**/*.mapper.ts",
68
+ },
69
+ }),
70
+ ],
71
+ });
72
+ ```
73
+
74
+ The docs generator finds `defineMap<TDto, TDomainSource>()(...)` calls under the
75
+ included files, removes the `Source` suffix, and writes generated interfaces to
76
+ `runtypex.generated.ts` next to the mapper file.
77
+
60
78
  ## Feature Docs
61
79
 
62
80
  | Feature | Description |
@@ -64,7 +82,7 @@ export default defineConfig({
64
82
  | [Runtime validation](docs/runtime-validation.md) | Generate `makeValidate<T>()` and `makeAssert<T>()` implementations from TypeScript types. |
65
83
  | [Mapper](docs/mapper.md) | Convert DTO shapes into domain shapes with typed mapping specs. |
66
84
  | [Mapping policy](docs/mapping-policy.md) | Keep DTO path to domain field names consistent across multiple mappers. |
67
- | [JSDoc generation](docs/jsdoc-generation.md) | Generate field documentation from mapper metadata. |
85
+ | [JSDoc generation](docs/jsdoc-generation.md) | Generate field documentation from domain JSDoc and mapper metadata. |
68
86
  | [Build integrations](docs/build-integrations.md) | Configure Vite, ts-loader, ESM exports, and build behavior. |
69
87
 
70
88
  ## Mapper Example
@@ -78,14 +96,14 @@ interface UserDto {
78
96
  status: "ACTIVE" | "INACTIVE";
79
97
  }
80
98
 
81
- interface User {
99
+ interface UserSource {
82
100
  /** User id */
83
101
  id: string;
84
102
  displayName: string;
85
103
  isActive: boolean;
86
104
  }
87
105
 
88
- const userMap = defineMap<UserDto, User>()({
106
+ const userMap = defineMap<UserDto, UserSource>()({
89
107
  id: source("user_id", {
90
108
  db: "users.user_id",
91
109
  dtoDescription: "User identifier from the user DTO.",
@@ -94,7 +112,7 @@ const userMap = defineMap<UserDto, User>()({
94
112
  isActive: transform("status", (value) => value === "ACTIVE"),
95
113
  });
96
114
 
97
- const toUser = makeMapper<UserDto, User>(userMap);
115
+ const toUser = makeMapper<UserDto, UserSource>(userMap);
98
116
  ```
99
117
 
100
118
  ## Why runtypex?
@@ -0,0 +1,18 @@
1
+ import ts from "typescript";
2
+ export type RuntypexDocsOptions = boolean | {
3
+ include?: string | string[];
4
+ exclude?: string | string[];
5
+ sourceSuffix?: string;
6
+ generatedFileName?: string;
7
+ outDir?: "near-source";
8
+ policyMode?: "warn" | "error";
9
+ };
10
+ export type GeneratedDocsFile = {
11
+ fileName: string;
12
+ content: string;
13
+ };
14
+ export declare function generateDocsFromProgram(params: {
15
+ program: ts.Program;
16
+ rootDir: string;
17
+ docs: RuntypexDocsOptions;
18
+ }): GeneratedDocsFile[];
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.generateDocsFromProgram = generateDocsFromProgram;
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const typescript_1 = __importDefault(require("typescript"));
9
+ const generate_jsdoc_js_1 = require("./generate-jsdoc.js");
10
+ function generateDocsFromProgram(params) {
11
+ const options = _normalizeDocsOptions(params.docs);
12
+ if (!options)
13
+ return [];
14
+ const checker = params.program.getTypeChecker();
15
+ const groups = new Map();
16
+ const sourceFiles = params.program
17
+ .getSourceFiles()
18
+ .filter((sourceFile) => !sourceFile.isDeclarationFile && _isIncluded(sourceFile.fileName, params.rootDir, options))
19
+ .sort((a, b) => _toPosix(a.fileName).localeCompare(_toPosix(b.fileName)));
20
+ for (const sourceFile of sourceFiles) {
21
+ for (const doc of _findMapperDocs(checker, sourceFile, options)) {
22
+ const fileName = node_path_1.default.join(node_path_1.default.dirname(sourceFile.fileName), options.generatedFileName);
23
+ const docs = groups.get(fileName) ?? [];
24
+ docs.push(doc);
25
+ groups.set(fileName, docs);
26
+ }
27
+ }
28
+ return Array.from(groups.entries())
29
+ .sort(([a], [b]) => _toPosix(a).localeCompare(_toPosix(b)))
30
+ .map(([fileName, docs]) => ({
31
+ fileName,
32
+ content: _generateFileContent(checker, fileName, docs, options),
33
+ }));
34
+ }
35
+ function _normalizeDocsOptions(options) {
36
+ if (!options)
37
+ return null;
38
+ const object = typeof options === "object" ? options : {};
39
+ if (object.outDir && object.outDir !== "near-source") {
40
+ throw new Error('[runtypex/docs] docs.outDir currently supports only "near-source".');
41
+ }
42
+ const generatedFileName = object.generatedFileName ?? "runtypex.generated.ts";
43
+ return {
44
+ include: _array(object.include ?? ["**/*.mapper.ts", "**/*.mapper.tsx"]),
45
+ exclude: [..._array(object.exclude), `**/${generatedFileName}`],
46
+ sourceSuffix: object.sourceSuffix ?? "Source",
47
+ generatedFileName,
48
+ policyMode: object.policyMode ?? "warn",
49
+ };
50
+ }
51
+ function _isIncluded(fileName, rootDir, options) {
52
+ return (_matchesAny(fileName, rootDir, options.include) &&
53
+ !_matchesAny(fileName, rootDir, options.exclude));
54
+ }
55
+ function _findMapperDocs(checker, sourceFile, options) {
56
+ const docs = [];
57
+ const visit = (node) => {
58
+ if (typescript_1.default.isVariableDeclaration(node) && typescript_1.default.isIdentifier(node.name) && node.initializer) {
59
+ const map = _readDefineMapCall(node.initializer);
60
+ if (map) {
61
+ const domainType = checker.getTypeFromTypeNode(map.domainTypeNode);
62
+ const domainName = _typeName(checker, domainType, map.domainTypeNode, sourceFile);
63
+ const generatedName = _generatedName(domainName, options.sourceSuffix);
64
+ if (generatedName) {
65
+ docs.push({
66
+ generatedName,
67
+ dtoType: checker.getTypeFromTypeNode(map.dtoTypeNode),
68
+ domainType,
69
+ specNode: map.specNode,
70
+ });
71
+ }
72
+ else {
73
+ _handleConventionIssue(`[runtypex/docs] Skipping ${node.name.text}: ${_generatedNameIssue(domainName, options.sourceSuffix)}.`, options.policyMode);
74
+ }
75
+ }
76
+ }
77
+ node.forEachChild(visit);
78
+ };
79
+ visit(sourceFile);
80
+ return docs;
81
+ }
82
+ function _readDefineMapCall(node) {
83
+ const specCall = _skip(node);
84
+ if (!typescript_1.default.isCallExpression(specCall) || !specCall.arguments[0])
85
+ return null;
86
+ const factoryCall = _skip(specCall.expression);
87
+ if (!typescript_1.default.isCallExpression(factoryCall) || factoryCall.typeArguments?.length !== 2)
88
+ return null;
89
+ if (!_isDefineMapExpression(_skip(factoryCall.expression)))
90
+ return null;
91
+ return {
92
+ dtoTypeNode: factoryCall.typeArguments[0],
93
+ domainTypeNode: factoryCall.typeArguments[1],
94
+ specNode: specCall,
95
+ };
96
+ }
97
+ function _isDefineMapExpression(node) {
98
+ return ((typescript_1.default.isIdentifier(node) && node.text === "defineMap") ||
99
+ (typescript_1.default.isPropertyAccessExpression(node) && node.name.text === "defineMap"));
100
+ }
101
+ function _generateFileContent(checker, fileName, docs, options) {
102
+ const names = new Set();
103
+ const interfaces = [];
104
+ for (const doc of docs) {
105
+ if (names.has(doc.generatedName)) {
106
+ throw new Error(`[runtypex/docs] Generated interface "${doc.generatedName}" conflicts in ${fileName}.`);
107
+ }
108
+ names.add(doc.generatedName);
109
+ interfaces.push((0, generate_jsdoc_js_1.generateJSDocFromSpec)({
110
+ checker,
111
+ dtoType: doc.dtoType,
112
+ domainType: doc.domainType,
113
+ specNode: doc.specNode,
114
+ options: { name: doc.generatedName, policyMode: options.policyMode },
115
+ }));
116
+ }
117
+ return `${interfaces.join("\n\n")}\n`;
118
+ }
119
+ function _generatedName(domainName, sourceSuffix) {
120
+ if (!sourceSuffix)
121
+ return domainName;
122
+ if (!domainName.endsWith(sourceSuffix))
123
+ return null;
124
+ const generatedName = domainName.slice(0, -sourceSuffix.length);
125
+ return generatedName || null;
126
+ }
127
+ function _generatedNameIssue(domainName, sourceSuffix) {
128
+ if (!sourceSuffix)
129
+ return `domain type "${domainName}" does not produce a generated interface name`;
130
+ if (!domainName.endsWith(sourceSuffix)) {
131
+ return `domain type "${domainName}" does not end with "${sourceSuffix}"`;
132
+ }
133
+ return `domain type "${domainName}" would produce an empty generated interface name`;
134
+ }
135
+ function _typeName(checker, type, typeNode, sourceFile) {
136
+ return (type.aliasSymbol?.name ??
137
+ type.symbol?.name ??
138
+ checker.typeToString(type, typeNode) ??
139
+ typeNode.getText(sourceFile));
140
+ }
141
+ function _handleConventionIssue(message, mode) {
142
+ if (mode === "error")
143
+ throw new Error(message);
144
+ console.warn(message);
145
+ }
146
+ function _matchesAny(fileName, rootDir, patterns) {
147
+ const absolute = _toPosix(node_path_1.default.resolve(fileName));
148
+ const relative = _toPosix(node_path_1.default.relative(rootDir, fileName));
149
+ return patterns.some((pattern) => {
150
+ const normalized = _toPosix(pattern);
151
+ const target = node_path_1.default.isAbsolute(pattern) ? absolute : relative;
152
+ return _globToRegExp(normalized).test(target);
153
+ });
154
+ }
155
+ function _globToRegExp(pattern) {
156
+ let regex = "^";
157
+ for (let i = 0; i < pattern.length; i += 1) {
158
+ const char = pattern[i];
159
+ const next = pattern[i + 1];
160
+ if (char === "*") {
161
+ if (next === "*") {
162
+ const after = pattern[i + 2];
163
+ if (after === "/") {
164
+ regex += "(?:.*/)?";
165
+ i += 2;
166
+ }
167
+ else {
168
+ regex += ".*";
169
+ i += 1;
170
+ }
171
+ }
172
+ else {
173
+ regex += "[^/]*";
174
+ }
175
+ continue;
176
+ }
177
+ if (char === "?") {
178
+ regex += "[^/]";
179
+ continue;
180
+ }
181
+ regex += _escapeRegex(char);
182
+ }
183
+ return new RegExp(`${regex}$`);
184
+ }
185
+ function _escapeRegex(value) {
186
+ return /[\\^$+?.()|[\]{}]/.test(value) ? `\\${value}` : value;
187
+ }
188
+ function _skip(node) {
189
+ let expr = node;
190
+ while (typescript_1.default.isParenthesizedExpression(expr) || typescript_1.default.isAsExpression(expr) || typescript_1.default.isTypeAssertionExpression(expr)) {
191
+ expr = expr.expression;
192
+ }
193
+ return expr;
194
+ }
195
+ function _array(value) {
196
+ if (value === undefined)
197
+ return [];
198
+ return Array.isArray(value) ? value : [value];
199
+ }
200
+ function _toPosix(value) {
201
+ return value.replace(/\\/g, "/");
202
+ }
@@ -30,14 +30,14 @@ function generateJSDocFromSpec(params) {
30
30
  _pushJSDocText(lines, _escapeComment(description));
31
31
  lines.push(" *");
32
32
  }
33
- _pushJSDocField(lines, "DTO", `${dtoName}.${rule.from}`);
33
+ _pushJSDocBulletField(lines, "DTO", _formatCodeSpan(`${dtoName}.${rule.from}`));
34
34
  if (rule.dtoDescription) {
35
- _pushJSDocText(lines, _escapeComment(rule.dtoDescription), " ");
35
+ _pushJSDocBulletField(lines, "DTO description", _escapeComment(rule.dtoDescription));
36
36
  }
37
- _pushJSDocField(lines, "DTO type", dtoPathType ? checker.typeToString(dtoPathType) : "unknown");
37
+ _pushJSDocBulletField(lines, "DTO type", _formatCodeSpan(dtoPathType ? checker.typeToString(dtoPathType) : "unknown"));
38
38
  if (rule.db)
39
- _pushJSDocField(lines, "DB", _escapeComment(rule.db));
40
- _pushJSDocField(lines, "Domain type", checker.typeToString(domainType));
39
+ _pushJSDocBulletField(lines, "Origin", _formatCodeSpan(rule.db));
40
+ _pushJSDocBulletField(lines, "Domain type", _formatCodeSpan(checker.typeToString(domainType)));
41
41
  lines.push(" */");
42
42
  lines.push(` ${_propertyName(prop.name)}${optional}: ${checker.typeToString(domainType)};`);
43
43
  lines.push("");
@@ -75,8 +75,8 @@ function _getDomainDescription(checker, prop) {
75
75
  const description = typescript_1.default.displayPartsToString(prop.getDocumentationComment(checker)).trim();
76
76
  return description || null;
77
77
  }
78
- function _pushJSDocField(lines, label, value) {
79
- _pushJSDocText(lines, `${label}: ${value}`, "", " ".repeat(label.length + 2));
78
+ function _pushJSDocBulletField(lines, label, value) {
79
+ _pushJSDocText(lines, `- ${label}: ${value}`, "", " ");
80
80
  }
81
81
  function _pushJSDocText(lines, text, firstIndent = "", continuationIndent = firstIndent) {
82
82
  for (const line of _wrapJSDocText(text, firstIndent, continuationIndent)) {
@@ -101,3 +101,9 @@ function _wrapJSDocText(text, firstIndent, continuationIndent) {
101
101
  lines.push(current.trimEnd());
102
102
  return lines;
103
103
  }
104
+ function _formatCodeSpan(value) {
105
+ return `\`${_escapeMarkdownCode(_escapeComment(value))}\``;
106
+ }
107
+ function _escapeMarkdownCode(value) {
108
+ return value.replace(/`/g, "\\`");
109
+ }
@@ -1 +1,2 @@
1
1
  export * from "./generate-jsdoc.js";
2
+ export * from "./generate-docs.js";
@@ -15,3 +15,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./generate-jsdoc.js"), exports);
18
+ __exportStar(require("./generate-docs.js"), exports);
@@ -1,4 +1,4 @@
1
1
  export { makeValidate, makeAssert, type ValidateFn, type AssertFn } from "./runtime/validate.js";
2
2
  export { defineMap, defineMappingPolicy, mapperHelpers, makeMapper, source, transform, type DefinedMap, type Mapper, type MapperOptions, type MapperMetadata, type MapRule, type MapSpec, type MappingPolicy, type MappingPolicyMode, type PathOf, } from "./runtime/mapper.js";
3
- export { default as vitePlugin } from "./transformer/vite-plugin.js";
3
+ export { default as vitePlugin, type RuntypexVitePluginOptions } from "./transformer/vite-plugin.js";
4
4
  export { default as tsTransformer } from "./transformer/ts-transformer.js";
@@ -1,3 +1,3 @@
1
1
  export { default } from "./ts-transformer.js";
2
2
  export { default as tsTransformer } from "./ts-transformer.js";
3
- export { default as vitePlugin } from "./vite-plugin.js";
3
+ export { default as vitePlugin, type RuntypexVitePluginOptions } from "./vite-plugin.js";
@@ -1,4 +1,9 @@
1
1
  import type { Plugin } from "vite";
2
+ import { type RuntypexDocsOptions } from "../generator/generate-docs.js";
3
+ export type RuntypexVitePluginOptions = {
4
+ removeInProd?: boolean;
5
+ docs?: RuntypexDocsOptions;
6
+ };
2
7
  /**
3
8
  * 🧩 vitePluginRuntypex
4
9
  * A Vite plugin that performs build-time type → runtime validation transformation.
@@ -13,6 +18,4 @@ import type { Plugin } from "vite";
13
18
  * - Optional: remove validation code in production (`removeInProd`)
14
19
  * - Compatible with Rollup / Webpack (via Vite plugin API)
15
20
  */
16
- export default function vitePluginRuntypex(options?: {
17
- removeInProd?: boolean;
18
- }): Plugin;
21
+ export default function vitePluginRuntypex(options?: RuntypexVitePluginOptions): Plugin;
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.default = vitePluginRuntypex;
7
7
  const typescript_1 = __importDefault(require("typescript"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const generate_docs_js_1 = require("../generator/generate-docs.js");
9
11
  const ts_transformer_js_1 = __importDefault(require("./ts-transformer.js"));
10
12
  /**
11
13
  * 🧩 vitePluginRuntypex
@@ -23,9 +25,24 @@ const ts_transformer_js_1 = __importDefault(require("./ts-transformer.js"));
23
25
  */
24
26
  function vitePluginRuntypex(options) {
25
27
  const removeInProd = !!options?.removeInProd;
28
+ let root = process.cwd();
26
29
  return {
27
30
  name: "vite-plugin-runtypex",
28
31
  enforce: "pre",
32
+ configResolved(config) {
33
+ root = config.root;
34
+ },
35
+ buildStart() {
36
+ if (!options?.docs)
37
+ return;
38
+ const { program } = _createProgramForRoot(root);
39
+ for (const file of (0, generate_docs_js_1.generateDocsFromProgram)({ program, rootDir: root, docs: options.docs })) {
40
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(file.fileName), { recursive: true });
41
+ if (typescript_1.default.sys.fileExists(file.fileName) && typescript_1.default.sys.readFile(file.fileName) === file.content)
42
+ continue;
43
+ node_fs_1.default.writeFileSync(file.fileName, file.content);
44
+ }
45
+ },
29
46
  transform(code, id) {
30
47
  const isTS = id.endsWith(".ts") || id.endsWith(".tsx");
31
48
  const isTargetFunction = /make(?:Validate|Assert|Mapper)</.test(code);
@@ -46,7 +63,10 @@ function vitePluginRuntypex(options) {
46
63
  // ① createProgram & TypeChecker
47
64
  // ──────────────────────────────────────────────
48
65
  function _createProgramFor(file) {
49
- const tsconfig = _findNearestTsconfig(node_path_1.default.dirname(file));
66
+ return _createProgramForRoot(node_path_1.default.dirname(file));
67
+ }
68
+ function _createProgramForRoot(root) {
69
+ const tsconfig = _findNearestTsconfig(root);
50
70
  const cfg = typescript_1.default.readConfigFile(tsconfig, typescript_1.default.sys.readFile);
51
71
  if (cfg.error)
52
72
  throw new Error(typescript_1.default.flattenDiagnosticMessageText(cfg.error.messageText, "\n"));
@@ -0,0 +1,18 @@
1
+ import ts from "typescript";
2
+ export type RuntypexDocsOptions = boolean | {
3
+ include?: string | string[];
4
+ exclude?: string | string[];
5
+ sourceSuffix?: string;
6
+ generatedFileName?: string;
7
+ outDir?: "near-source";
8
+ policyMode?: "warn" | "error";
9
+ };
10
+ export type GeneratedDocsFile = {
11
+ fileName: string;
12
+ content: string;
13
+ };
14
+ export declare function generateDocsFromProgram(params: {
15
+ program: ts.Program;
16
+ rootDir: string;
17
+ docs: RuntypexDocsOptions;
18
+ }): GeneratedDocsFile[];
@@ -0,0 +1,196 @@
1
+ import path from "node:path";
2
+ import ts from "typescript";
3
+ import { generateJSDocFromSpec } from "./generate-jsdoc.js";
4
+ export function generateDocsFromProgram(params) {
5
+ const options = _normalizeDocsOptions(params.docs);
6
+ if (!options)
7
+ return [];
8
+ const checker = params.program.getTypeChecker();
9
+ const groups = new Map();
10
+ const sourceFiles = params.program
11
+ .getSourceFiles()
12
+ .filter((sourceFile) => !sourceFile.isDeclarationFile && _isIncluded(sourceFile.fileName, params.rootDir, options))
13
+ .sort((a, b) => _toPosix(a.fileName).localeCompare(_toPosix(b.fileName)));
14
+ for (const sourceFile of sourceFiles) {
15
+ for (const doc of _findMapperDocs(checker, sourceFile, options)) {
16
+ const fileName = path.join(path.dirname(sourceFile.fileName), options.generatedFileName);
17
+ const docs = groups.get(fileName) ?? [];
18
+ docs.push(doc);
19
+ groups.set(fileName, docs);
20
+ }
21
+ }
22
+ return Array.from(groups.entries())
23
+ .sort(([a], [b]) => _toPosix(a).localeCompare(_toPosix(b)))
24
+ .map(([fileName, docs]) => ({
25
+ fileName,
26
+ content: _generateFileContent(checker, fileName, docs, options),
27
+ }));
28
+ }
29
+ function _normalizeDocsOptions(options) {
30
+ if (!options)
31
+ return null;
32
+ const object = typeof options === "object" ? options : {};
33
+ if (object.outDir && object.outDir !== "near-source") {
34
+ throw new Error('[runtypex/docs] docs.outDir currently supports only "near-source".');
35
+ }
36
+ const generatedFileName = object.generatedFileName ?? "runtypex.generated.ts";
37
+ return {
38
+ include: _array(object.include ?? ["**/*.mapper.ts", "**/*.mapper.tsx"]),
39
+ exclude: [..._array(object.exclude), `**/${generatedFileName}`],
40
+ sourceSuffix: object.sourceSuffix ?? "Source",
41
+ generatedFileName,
42
+ policyMode: object.policyMode ?? "warn",
43
+ };
44
+ }
45
+ function _isIncluded(fileName, rootDir, options) {
46
+ return (_matchesAny(fileName, rootDir, options.include) &&
47
+ !_matchesAny(fileName, rootDir, options.exclude));
48
+ }
49
+ function _findMapperDocs(checker, sourceFile, options) {
50
+ const docs = [];
51
+ const visit = (node) => {
52
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
53
+ const map = _readDefineMapCall(node.initializer);
54
+ if (map) {
55
+ const domainType = checker.getTypeFromTypeNode(map.domainTypeNode);
56
+ const domainName = _typeName(checker, domainType, map.domainTypeNode, sourceFile);
57
+ const generatedName = _generatedName(domainName, options.sourceSuffix);
58
+ if (generatedName) {
59
+ docs.push({
60
+ generatedName,
61
+ dtoType: checker.getTypeFromTypeNode(map.dtoTypeNode),
62
+ domainType,
63
+ specNode: map.specNode,
64
+ });
65
+ }
66
+ else {
67
+ _handleConventionIssue(`[runtypex/docs] Skipping ${node.name.text}: ${_generatedNameIssue(domainName, options.sourceSuffix)}.`, options.policyMode);
68
+ }
69
+ }
70
+ }
71
+ node.forEachChild(visit);
72
+ };
73
+ visit(sourceFile);
74
+ return docs;
75
+ }
76
+ function _readDefineMapCall(node) {
77
+ const specCall = _skip(node);
78
+ if (!ts.isCallExpression(specCall) || !specCall.arguments[0])
79
+ return null;
80
+ const factoryCall = _skip(specCall.expression);
81
+ if (!ts.isCallExpression(factoryCall) || factoryCall.typeArguments?.length !== 2)
82
+ return null;
83
+ if (!_isDefineMapExpression(_skip(factoryCall.expression)))
84
+ return null;
85
+ return {
86
+ dtoTypeNode: factoryCall.typeArguments[0],
87
+ domainTypeNode: factoryCall.typeArguments[1],
88
+ specNode: specCall,
89
+ };
90
+ }
91
+ function _isDefineMapExpression(node) {
92
+ return ((ts.isIdentifier(node) && node.text === "defineMap") ||
93
+ (ts.isPropertyAccessExpression(node) && node.name.text === "defineMap"));
94
+ }
95
+ function _generateFileContent(checker, fileName, docs, options) {
96
+ const names = new Set();
97
+ const interfaces = [];
98
+ for (const doc of docs) {
99
+ if (names.has(doc.generatedName)) {
100
+ throw new Error(`[runtypex/docs] Generated interface "${doc.generatedName}" conflicts in ${fileName}.`);
101
+ }
102
+ names.add(doc.generatedName);
103
+ interfaces.push(generateJSDocFromSpec({
104
+ checker,
105
+ dtoType: doc.dtoType,
106
+ domainType: doc.domainType,
107
+ specNode: doc.specNode,
108
+ options: { name: doc.generatedName, policyMode: options.policyMode },
109
+ }));
110
+ }
111
+ return `${interfaces.join("\n\n")}\n`;
112
+ }
113
+ function _generatedName(domainName, sourceSuffix) {
114
+ if (!sourceSuffix)
115
+ return domainName;
116
+ if (!domainName.endsWith(sourceSuffix))
117
+ return null;
118
+ const generatedName = domainName.slice(0, -sourceSuffix.length);
119
+ return generatedName || null;
120
+ }
121
+ function _generatedNameIssue(domainName, sourceSuffix) {
122
+ if (!sourceSuffix)
123
+ return `domain type "${domainName}" does not produce a generated interface name`;
124
+ if (!domainName.endsWith(sourceSuffix)) {
125
+ return `domain type "${domainName}" does not end with "${sourceSuffix}"`;
126
+ }
127
+ return `domain type "${domainName}" would produce an empty generated interface name`;
128
+ }
129
+ function _typeName(checker, type, typeNode, sourceFile) {
130
+ return (type.aliasSymbol?.name ??
131
+ type.symbol?.name ??
132
+ checker.typeToString(type, typeNode) ??
133
+ typeNode.getText(sourceFile));
134
+ }
135
+ function _handleConventionIssue(message, mode) {
136
+ if (mode === "error")
137
+ throw new Error(message);
138
+ console.warn(message);
139
+ }
140
+ function _matchesAny(fileName, rootDir, patterns) {
141
+ const absolute = _toPosix(path.resolve(fileName));
142
+ const relative = _toPosix(path.relative(rootDir, fileName));
143
+ return patterns.some((pattern) => {
144
+ const normalized = _toPosix(pattern);
145
+ const target = path.isAbsolute(pattern) ? absolute : relative;
146
+ return _globToRegExp(normalized).test(target);
147
+ });
148
+ }
149
+ function _globToRegExp(pattern) {
150
+ let regex = "^";
151
+ for (let i = 0; i < pattern.length; i += 1) {
152
+ const char = pattern[i];
153
+ const next = pattern[i + 1];
154
+ if (char === "*") {
155
+ if (next === "*") {
156
+ const after = pattern[i + 2];
157
+ if (after === "/") {
158
+ regex += "(?:.*/)?";
159
+ i += 2;
160
+ }
161
+ else {
162
+ regex += ".*";
163
+ i += 1;
164
+ }
165
+ }
166
+ else {
167
+ regex += "[^/]*";
168
+ }
169
+ continue;
170
+ }
171
+ if (char === "?") {
172
+ regex += "[^/]";
173
+ continue;
174
+ }
175
+ regex += _escapeRegex(char);
176
+ }
177
+ return new RegExp(`${regex}$`);
178
+ }
179
+ function _escapeRegex(value) {
180
+ return /[\\^$+?.()|[\]{}]/.test(value) ? `\\${value}` : value;
181
+ }
182
+ function _skip(node) {
183
+ let expr = node;
184
+ while (ts.isParenthesizedExpression(expr) || ts.isAsExpression(expr) || ts.isTypeAssertionExpression(expr)) {
185
+ expr = expr.expression;
186
+ }
187
+ return expr;
188
+ }
189
+ function _array(value) {
190
+ if (value === undefined)
191
+ return [];
192
+ return Array.isArray(value) ? value : [value];
193
+ }
194
+ function _toPosix(value) {
195
+ return value.replace(/\\/g, "/");
196
+ }
@@ -24,14 +24,14 @@ export function generateJSDocFromSpec(params) {
24
24
  _pushJSDocText(lines, _escapeComment(description));
25
25
  lines.push(" *");
26
26
  }
27
- _pushJSDocField(lines, "DTO", `${dtoName}.${rule.from}`);
27
+ _pushJSDocBulletField(lines, "DTO", _formatCodeSpan(`${dtoName}.${rule.from}`));
28
28
  if (rule.dtoDescription) {
29
- _pushJSDocText(lines, _escapeComment(rule.dtoDescription), " ");
29
+ _pushJSDocBulletField(lines, "DTO description", _escapeComment(rule.dtoDescription));
30
30
  }
31
- _pushJSDocField(lines, "DTO type", dtoPathType ? checker.typeToString(dtoPathType) : "unknown");
31
+ _pushJSDocBulletField(lines, "DTO type", _formatCodeSpan(dtoPathType ? checker.typeToString(dtoPathType) : "unknown"));
32
32
  if (rule.db)
33
- _pushJSDocField(lines, "DB", _escapeComment(rule.db));
34
- _pushJSDocField(lines, "Domain type", checker.typeToString(domainType));
33
+ _pushJSDocBulletField(lines, "Origin", _formatCodeSpan(rule.db));
34
+ _pushJSDocBulletField(lines, "Domain type", _formatCodeSpan(checker.typeToString(domainType)));
35
35
  lines.push(" */");
36
36
  lines.push(` ${_propertyName(prop.name)}${optional}: ${checker.typeToString(domainType)};`);
37
37
  lines.push("");
@@ -69,8 +69,8 @@ function _getDomainDescription(checker, prop) {
69
69
  const description = ts.displayPartsToString(prop.getDocumentationComment(checker)).trim();
70
70
  return description || null;
71
71
  }
72
- function _pushJSDocField(lines, label, value) {
73
- _pushJSDocText(lines, `${label}: ${value}`, "", " ".repeat(label.length + 2));
72
+ function _pushJSDocBulletField(lines, label, value) {
73
+ _pushJSDocText(lines, `- ${label}: ${value}`, "", " ");
74
74
  }
75
75
  function _pushJSDocText(lines, text, firstIndent = "", continuationIndent = firstIndent) {
76
76
  for (const line of _wrapJSDocText(text, firstIndent, continuationIndent)) {
@@ -95,3 +95,9 @@ function _wrapJSDocText(text, firstIndent, continuationIndent) {
95
95
  lines.push(current.trimEnd());
96
96
  return lines;
97
97
  }
98
+ function _formatCodeSpan(value) {
99
+ return `\`${_escapeMarkdownCode(_escapeComment(value))}\``;
100
+ }
101
+ function _escapeMarkdownCode(value) {
102
+ return value.replace(/`/g, "\\`");
103
+ }
@@ -1 +1,2 @@
1
1
  export * from "./generate-jsdoc.js";
2
+ export * from "./generate-docs.js";
@@ -1 +1,2 @@
1
1
  export * from "./generate-jsdoc.js";
2
+ export * from "./generate-docs.js";
@@ -1,4 +1,4 @@
1
1
  export { makeValidate, makeAssert, type ValidateFn, type AssertFn } from "./runtime/validate.js";
2
2
  export { defineMap, defineMappingPolicy, mapperHelpers, makeMapper, source, transform, type DefinedMap, type Mapper, type MapperOptions, type MapperMetadata, type MapRule, type MapSpec, type MappingPolicy, type MappingPolicyMode, type PathOf, } from "./runtime/mapper.js";
3
- export { default as vitePlugin } from "./transformer/vite-plugin.js";
3
+ export { default as vitePlugin, type RuntypexVitePluginOptions } from "./transformer/vite-plugin.js";
4
4
  export { default as tsTransformer } from "./transformer/ts-transformer.js";
@@ -1,3 +1,3 @@
1
1
  export { default } from "./ts-transformer.js";
2
2
  export { default as tsTransformer } from "./ts-transformer.js";
3
- export { default as vitePlugin } from "./vite-plugin.js";
3
+ export { default as vitePlugin, type RuntypexVitePluginOptions } from "./vite-plugin.js";
@@ -1,4 +1,9 @@
1
1
  import type { Plugin } from "vite";
2
+ import { type RuntypexDocsOptions } from "../generator/generate-docs.js";
3
+ export type RuntypexVitePluginOptions = {
4
+ removeInProd?: boolean;
5
+ docs?: RuntypexDocsOptions;
6
+ };
2
7
  /**
3
8
  * 🧩 vitePluginRuntypex
4
9
  * A Vite plugin that performs build-time type → runtime validation transformation.
@@ -13,6 +18,4 @@ import type { Plugin } from "vite";
13
18
  * - Optional: remove validation code in production (`removeInProd`)
14
19
  * - Compatible with Rollup / Webpack (via Vite plugin API)
15
20
  */
16
- export default function vitePluginRuntypex(options?: {
17
- removeInProd?: boolean;
18
- }): Plugin;
21
+ export default function vitePluginRuntypex(options?: RuntypexVitePluginOptions): Plugin;
@@ -1,5 +1,7 @@
1
1
  import ts from "typescript";
2
2
  import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { generateDocsFromProgram } from "../generator/generate-docs.js";
3
5
  import tsTransformer from "./ts-transformer.js";
4
6
  /**
5
7
  * 🧩 vitePluginRuntypex
@@ -17,9 +19,24 @@ import tsTransformer from "./ts-transformer.js";
17
19
  */
18
20
  export default function vitePluginRuntypex(options) {
19
21
  const removeInProd = !!options?.removeInProd;
22
+ let root = process.cwd();
20
23
  return {
21
24
  name: "vite-plugin-runtypex",
22
25
  enforce: "pre",
26
+ configResolved(config) {
27
+ root = config.root;
28
+ },
29
+ buildStart() {
30
+ if (!options?.docs)
31
+ return;
32
+ const { program } = _createProgramForRoot(root);
33
+ for (const file of generateDocsFromProgram({ program, rootDir: root, docs: options.docs })) {
34
+ fs.mkdirSync(path.dirname(file.fileName), { recursive: true });
35
+ if (ts.sys.fileExists(file.fileName) && ts.sys.readFile(file.fileName) === file.content)
36
+ continue;
37
+ fs.writeFileSync(file.fileName, file.content);
38
+ }
39
+ },
23
40
  transform(code, id) {
24
41
  const isTS = id.endsWith(".ts") || id.endsWith(".tsx");
25
42
  const isTargetFunction = /make(?:Validate|Assert|Mapper)</.test(code);
@@ -40,7 +57,10 @@ export default function vitePluginRuntypex(options) {
40
57
  // ① createProgram & TypeChecker
41
58
  // ──────────────────────────────────────────────
42
59
  function _createProgramFor(file) {
43
- const tsconfig = _findNearestTsconfig(path.dirname(file));
60
+ return _createProgramForRoot(path.dirname(file));
61
+ }
62
+ function _createProgramForRoot(root) {
63
+ const tsconfig = _findNearestTsconfig(root);
44
64
  const cfg = ts.readConfigFile(tsconfig, ts.sys.readFile);
45
65
  if (cfg.error)
46
66
  throw new Error(ts.flattenDiagnosticMessageText(cfg.error.messageText, "\n"));
@@ -25,6 +25,45 @@ When a target call is found, the plugin creates a TypeScript program for the
25
25
  nearest `tsconfig.json`, runs the transformer, and returns the transformed code
26
26
  to Vite.
27
27
 
28
+ The Vite plugin can also generate mapper documentation by convention:
29
+
30
+ ```ts
31
+ export default defineConfig({
32
+ plugins: [
33
+ runtypex({
34
+ docs: {
35
+ include: "src/features/**/*.mapper.ts",
36
+ },
37
+ }),
38
+ ],
39
+ });
40
+ ```
41
+
42
+ For each included mapper file, runtypex finds declarations like:
43
+
44
+ ```ts
45
+ export const addressMap = defineMap<
46
+ SearchAddressDto,
47
+ SearchAddressDomainSource
48
+ >()({
49
+ id: source("RESULT.ID"),
50
+ });
51
+ ```
52
+
53
+ It generates `SearchAddressDomain` by removing the `Source` suffix and writes
54
+ all generated interfaces for the same folder to `runtypex.generated.ts`.
55
+
56
+ Docs options:
57
+
58
+ | Option | Default | Description |
59
+ | --- | --- | --- |
60
+ | `include` | `**/*.mapper.ts`, `**/*.mapper.tsx` | Mapper files to scan, relative to the Vite root. |
61
+ | `exclude` | generated file name | Files to skip. |
62
+ | `sourceSuffix` | `Source` | Domain type suffix removed for the generated interface name. |
63
+ | `generatedFileName` | `runtypex.generated.ts` | File written next to each mapper file. |
64
+ | `outDir` | `near-source` | Currently only near-source generation is supported. |
65
+ | `policyMode` | `warn` | Use `error` to fail when a mapper violates docs conventions. |
66
+
28
67
  ## ts-loader
29
68
 
30
69
  ```js
@@ -4,7 +4,28 @@ JSDoc generation creates interface documentation from mapper metadata. It helps
4
4
  editors show where a domain field came from and which DTO or database field it
5
5
  represents.
6
6
 
7
- ## API
7
+ ## Vite Convention
8
+
9
+ ```ts
10
+ import { defineConfig } from "vite";
11
+ import { vitePlugin as runtypex } from "runtypex";
12
+
13
+ export default defineConfig({
14
+ plugins: [
15
+ runtypex({
16
+ docs: {
17
+ include: "src/features/**/*.mapper.ts",
18
+ },
19
+ }),
20
+ ],
21
+ });
22
+ ```
23
+
24
+ The plugin finds `defineMap<TDto, TDomainSource>()(...)` calls in included mapper
25
+ files, removes the `Source` suffix from `TDomainSource`, and writes generated
26
+ interfaces to `runtypex.generated.ts` next to the mapper file.
27
+
28
+ ## Manual API
8
29
 
9
30
  ```ts
10
31
  import { generateJSDocFromSpec } from "runtypex/generator";
@@ -17,8 +38,8 @@ const source = generateJSDocFromSpec({
17
38
  });
18
39
  ```
19
40
 
20
- This API is intended for build tooling that already has access to the TypeScript
21
- program, checker, DTO type, domain type, and mapper spec node.
41
+ This lower-level API is intended for build tooling that already has access to
42
+ the TypeScript program, checker, DTO type, domain type, and mapper spec node.
22
43
 
23
44
  ## Metadata Sources
24
45
 
@@ -45,8 +66,8 @@ Field meanings:
45
66
  | Field | Meaning |
46
67
  | --- | --- |
47
68
  | Domain property JSDoc | Domain field description. Usually used as the first JSDoc sentence. |
48
- | `dtoDescription` | Optional explanation shown below the DTO path line. |
49
- | `db` | Optional database table and column reference. |
69
+ | `dtoDescription` | Optional explanation shown as the `DTO description` bullet. |
70
+ | `db` | Optional origin field shown as the `Origin` bullet. |
50
71
 
51
72
  For older mapper specs, `description` is still read as a fallback when the
52
73
  domain property has no JSDoc. New code should prefer domain property JSDoc so
@@ -74,11 +95,11 @@ the generated documentation can look like this:
74
95
  /**
75
96
  * User id
76
97
  *
77
- * DTO: UserDto.user_id
78
- * Identifier returned by the user API.
79
- * DTO type: string
80
- * DB: users.user_id
81
- * Domain type: string
98
+ * - DTO: `UserDto.user_id`
99
+ * - DTO description: Identifier returned by the user API.
100
+ * - DTO type: `string`
101
+ * - Origin: `users.user_id`
102
+ * - Domain type: `string`
82
103
  */
83
104
  id: string;
84
105
  ```
@@ -95,7 +116,7 @@ generateJSDocFromSpec({
95
116
  domainType,
96
117
  specNode,
97
118
  options: {
98
- policy,
119
+ mappingPolicy: policy,
99
120
  policyMode: "error",
100
121
  },
101
122
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runtypex",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Runtime type guards compiled from TypeScript types.",
5
5
  "license": "MIT",
6
6
  "author": "KumJungMin",