runtypex 0.2.0 → 0.2.2

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,23 +96,23 @@ interface UserDto {
78
96
  status: "ACTIVE" | "INACTIVE";
79
97
  }
80
98
 
81
- interface User {
99
+ interface UserSource {
100
+ /** User id */
82
101
  id: string;
83
102
  displayName: string;
84
103
  isActive: boolean;
85
104
  }
86
105
 
87
- const userMap = defineMap<UserDto, User>()({
106
+ const userMap = defineMap<UserDto, UserSource>()({
88
107
  id: source("user_id", {
89
108
  db: "users.user_id",
90
- description: "User id",
91
109
  dtoDescription: "User identifier from the user DTO.",
92
110
  }),
93
111
  displayName: source("profile.name"),
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?
@@ -9,6 +9,7 @@ export type MapRuleInfo = {
9
9
  key: string;
10
10
  from: string;
11
11
  db?: string;
12
+ /** @deprecated Prefer domain property JSDoc for domain field descriptions. */
12
13
  description?: string;
13
14
  dtoDescription?: string;
14
15
  };
@@ -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
+ }
@@ -7,6 +7,7 @@ exports.generateJSDocFromSpec = generateJSDocFromSpec;
7
7
  const typescript_1 = __importDefault(require("typescript"));
8
8
  const path_js_1 = require("../core/path.js");
9
9
  const emitMapperFromSpec_js_1 = require("../core/emitMapperFromSpec.js");
10
+ const JSDOC_CONTENT_WIDTH = 76;
10
11
  function generateJSDocFromSpec(params) {
11
12
  const checker = params.checker;
12
13
  const dtoName = params.dtoType.symbol?.name ?? "Dto";
@@ -22,18 +23,21 @@ function generateJSDocFromSpec(params) {
22
23
  const declaration = prop.valueDeclaration ?? prop.declarations?.[0];
23
24
  const domainType = declaration ? checker.getTypeOfSymbolAtLocation(prop, declaration) : checker.getAnyType();
24
25
  const dtoPathType = _getTypeAtPath(checker, params.dtoType, rule.from);
26
+ const description = _getDomainDescription(checker, prop) ?? rule.description;
25
27
  const optional = (prop.getFlags() & typescript_1.default.SymbolFlags.Optional) !== 0 ? "?" : "";
26
28
  lines.push(" /**");
27
- if (rule.description) {
28
- lines.push(` * ${_escapeComment(rule.description)}`);
29
+ if (description) {
30
+ _pushJSDocText(lines, _escapeComment(description));
29
31
  lines.push(" *");
30
32
  }
31
- const dtoDescription = rule.dtoDescription ? ` ${_escapeComment(rule.dtoDescription)}` : "";
32
- lines.push(` * DTO: ${dtoName}.${rule.from}${dtoDescription}`);
33
- lines.push(` * DTO type: ${dtoPathType ? checker.typeToString(dtoPathType) : "unknown"}`);
33
+ _pushJSDocField(lines, "DTO", `${dtoName}.${rule.from}`);
34
+ if (rule.dtoDescription) {
35
+ _pushJSDocText(lines, _escapeComment(rule.dtoDescription), " ");
36
+ }
37
+ _pushJSDocField(lines, "DTO type", dtoPathType ? checker.typeToString(dtoPathType) : "unknown");
34
38
  if (rule.db)
35
- lines.push(` * DB: ${_escapeComment(rule.db)}`);
36
- lines.push(` * Domain type: ${checker.typeToString(domainType)}`);
39
+ _pushJSDocField(lines, "DB", _escapeComment(rule.db));
40
+ _pushJSDocField(lines, "Domain type", checker.typeToString(domainType));
37
41
  lines.push(" */");
38
42
  lines.push(` ${_propertyName(prop.name)}${optional}: ${checker.typeToString(domainType)};`);
39
43
  lines.push("");
@@ -67,3 +71,33 @@ function _propertyName(name) {
67
71
  function _escapeComment(value) {
68
72
  return value.replace(/\*\//g, "* /");
69
73
  }
74
+ function _getDomainDescription(checker, prop) {
75
+ const description = typescript_1.default.displayPartsToString(prop.getDocumentationComment(checker)).trim();
76
+ return description || null;
77
+ }
78
+ function _pushJSDocField(lines, label, value) {
79
+ _pushJSDocText(lines, `${label}: ${value}`, "", " ".repeat(label.length + 2));
80
+ }
81
+ function _pushJSDocText(lines, text, firstIndent = "", continuationIndent = firstIndent) {
82
+ for (const line of _wrapJSDocText(text, firstIndent, continuationIndent)) {
83
+ lines.push(` * ${line}`);
84
+ }
85
+ }
86
+ function _wrapJSDocText(text, firstIndent, continuationIndent) {
87
+ const words = text.replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
88
+ if (!words.length)
89
+ return [firstIndent.trimEnd()];
90
+ const lines = [];
91
+ let current = firstIndent;
92
+ for (const word of words) {
93
+ const candidate = current.trim().length ? `${current} ${word}` : `${current}${word}`;
94
+ if (candidate.length <= JSDOC_CONTENT_WIDTH || !current.trim().length) {
95
+ current = candidate;
96
+ continue;
97
+ }
98
+ lines.push(current.trimEnd());
99
+ current = `${continuationIndent}${word}`;
100
+ }
101
+ lines.push(current.trimEnd());
102
+ return lines;
103
+ }
@@ -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";
@@ -5,6 +5,7 @@ export type PathOf<T> = T extends Primitive ? never : T extends readonly (infer
5
5
  export type Mapper<TDto, TDomain> = (dto: TDto) => TDomain;
6
6
  export type MapperMetadata<TValue = never> = {
7
7
  db?: string;
8
+ /** @deprecated Prefer domain property JSDoc for domain field descriptions. */
8
9
  description?: string;
9
10
  dtoDescription?: string;
10
11
  default?: TValue;
@@ -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"));
@@ -9,6 +9,7 @@ export type MapRuleInfo = {
9
9
  key: string;
10
10
  from: string;
11
11
  db?: string;
12
+ /** @deprecated Prefer domain property JSDoc for domain field descriptions. */
12
13
  description?: string;
13
14
  dtoDescription?: string;
14
15
  };
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import ts from "typescript";
2
2
  import { parsePath } from "../core/path.js";
3
3
  import { findMapPolicyViolations, handleMapPolicyViolations, readMapRules } from "../core/emitMapperFromSpec.js";
4
+ const JSDOC_CONTENT_WIDTH = 76;
4
5
  export function generateJSDocFromSpec(params) {
5
6
  const checker = params.checker;
6
7
  const dtoName = params.dtoType.symbol?.name ?? "Dto";
@@ -16,18 +17,21 @@ export function generateJSDocFromSpec(params) {
16
17
  const declaration = prop.valueDeclaration ?? prop.declarations?.[0];
17
18
  const domainType = declaration ? checker.getTypeOfSymbolAtLocation(prop, declaration) : checker.getAnyType();
18
19
  const dtoPathType = _getTypeAtPath(checker, params.dtoType, rule.from);
20
+ const description = _getDomainDescription(checker, prop) ?? rule.description;
19
21
  const optional = (prop.getFlags() & ts.SymbolFlags.Optional) !== 0 ? "?" : "";
20
22
  lines.push(" /**");
21
- if (rule.description) {
22
- lines.push(` * ${_escapeComment(rule.description)}`);
23
+ if (description) {
24
+ _pushJSDocText(lines, _escapeComment(description));
23
25
  lines.push(" *");
24
26
  }
25
- const dtoDescription = rule.dtoDescription ? ` ${_escapeComment(rule.dtoDescription)}` : "";
26
- lines.push(` * DTO: ${dtoName}.${rule.from}${dtoDescription}`);
27
- lines.push(` * DTO type: ${dtoPathType ? checker.typeToString(dtoPathType) : "unknown"}`);
27
+ _pushJSDocField(lines, "DTO", `${dtoName}.${rule.from}`);
28
+ if (rule.dtoDescription) {
29
+ _pushJSDocText(lines, _escapeComment(rule.dtoDescription), " ");
30
+ }
31
+ _pushJSDocField(lines, "DTO type", dtoPathType ? checker.typeToString(dtoPathType) : "unknown");
28
32
  if (rule.db)
29
- lines.push(` * DB: ${_escapeComment(rule.db)}`);
30
- lines.push(` * Domain type: ${checker.typeToString(domainType)}`);
33
+ _pushJSDocField(lines, "DB", _escapeComment(rule.db));
34
+ _pushJSDocField(lines, "Domain type", checker.typeToString(domainType));
31
35
  lines.push(" */");
32
36
  lines.push(` ${_propertyName(prop.name)}${optional}: ${checker.typeToString(domainType)};`);
33
37
  lines.push("");
@@ -61,3 +65,33 @@ function _propertyName(name) {
61
65
  function _escapeComment(value) {
62
66
  return value.replace(/\*\//g, "* /");
63
67
  }
68
+ function _getDomainDescription(checker, prop) {
69
+ const description = ts.displayPartsToString(prop.getDocumentationComment(checker)).trim();
70
+ return description || null;
71
+ }
72
+ function _pushJSDocField(lines, label, value) {
73
+ _pushJSDocText(lines, `${label}: ${value}`, "", " ".repeat(label.length + 2));
74
+ }
75
+ function _pushJSDocText(lines, text, firstIndent = "", continuationIndent = firstIndent) {
76
+ for (const line of _wrapJSDocText(text, firstIndent, continuationIndent)) {
77
+ lines.push(` * ${line}`);
78
+ }
79
+ }
80
+ function _wrapJSDocText(text, firstIndent, continuationIndent) {
81
+ const words = text.replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
82
+ if (!words.length)
83
+ return [firstIndent.trimEnd()];
84
+ const lines = [];
85
+ let current = firstIndent;
86
+ for (const word of words) {
87
+ const candidate = current.trim().length ? `${current} ${word}` : `${current}${word}`;
88
+ if (candidate.length <= JSDOC_CONTENT_WIDTH || !current.trim().length) {
89
+ current = candidate;
90
+ continue;
91
+ }
92
+ lines.push(current.trimEnd());
93
+ current = `${continuationIndent}${word}`;
94
+ }
95
+ lines.push(current.trimEnd());
96
+ return lines;
97
+ }
@@ -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";
@@ -5,6 +5,7 @@ export type PathOf<T> = T extends Primitive ? never : T extends readonly (infer
5
5
  export type Mapper<TDto, TDomain> = (dto: TDto) => TDomain;
6
6
  export type MapperMetadata<TValue = never> = {
7
7
  db?: string;
8
+ /** @deprecated Prefer domain property JSDoc for domain field descriptions. */
8
9
  description?: string;
9
10
  dtoDescription?: string;
10
11
  default?: TValue;
@@ -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,17 +38,25 @@ 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
- ## Metadata Fields
44
+ ## Metadata Sources
24
45
 
25
- Mapper metadata can include:
46
+ Domain field descriptions should live on the domain type:
47
+
48
+ ```ts
49
+ interface User {
50
+ /** User id */
51
+ id: string;
52
+ }
53
+ ```
54
+
55
+ Mapper metadata can include source-specific details:
26
56
 
27
57
  ```ts
28
58
  source("user_id", {
29
59
  db: "users.user_id",
30
- description: "User id",
31
60
  dtoDescription: "Identifier returned by the user API.",
32
61
  });
33
62
  ```
@@ -36,18 +65,26 @@ Field meanings:
36
65
 
37
66
  | Field | Meaning |
38
67
  | --- | --- |
39
- | `description` | Domain field description. Usually used as the first JSDoc sentence. |
40
- | `dtoDescription` | Optional explanation attached to the DTO path line. |
68
+ | Domain property JSDoc | Domain field description. Usually used as the first JSDoc sentence. |
69
+ | `dtoDescription` | Optional explanation shown below the DTO path line. |
41
70
  | `db` | Optional database table and column reference. |
42
71
 
72
+ For older mapper specs, `description` is still read as a fallback when the
73
+ domain property has no JSDoc. New code should prefer domain property JSDoc so
74
+ the domain description is not duplicated per DTO mapping.
75
+
43
76
  ## Generated Output
44
77
 
45
- Given this domain field:
78
+ Given this domain field and mapping:
46
79
 
47
80
  ```ts
81
+ interface User {
82
+ /** User id */
83
+ id: string;
84
+ }
85
+
48
86
  id: source("user_id", {
49
87
  db: "users.user_id",
50
- description: "User id",
51
88
  dtoDescription: "Identifier returned by the user API.",
52
89
  });
53
90
  ```
@@ -58,7 +95,8 @@ the generated documentation can look like this:
58
95
  /**
59
96
  * User id
60
97
  *
61
- * DTO: UserDto.user_id Identifier returned by the user API.
98
+ * DTO: UserDto.user_id
99
+ * Identifier returned by the user API.
62
100
  * DTO type: string
63
101
  * DB: users.user_id
64
102
  * Domain type: string
@@ -78,7 +116,7 @@ generateJSDocFromSpec({
78
116
  domainType,
79
117
  specNode,
80
118
  options: {
81
- policy,
119
+ mappingPolicy: policy,
82
120
  policyMode: "error",
83
121
  },
84
122
  });
package/docs/mapper.md CHANGED
@@ -77,13 +77,13 @@ Mapping rules can include metadata:
77
77
  ```ts
78
78
  id: source("user_id", {
79
79
  db: "users.user_id",
80
- description: "User id",
81
80
  dtoDescription: "Identifier returned by the user API.",
82
81
  });
83
82
  ```
84
83
 
85
84
  Metadata is not required for mapping. It is mainly used by JSDoc generation and
86
- documentation tooling.
85
+ documentation tooling. Keep domain field descriptions on the domain type JSDoc;
86
+ mapper metadata should describe DTO/database-specific details.
87
87
 
88
88
  ## Typed Helpers
89
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runtypex",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Runtime type guards compiled from TypeScript types.",
5
5
  "license": "MIT",
6
6
  "author": "KumJungMin",