@usejourney/codegen 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 femoral <me@femoral.dev>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,63 @@
1
+ type HttpMethod = "get" | "post" | "put" | "patch" | "delete" | "head" | "options";
2
+ interface Operation {
3
+ readonly method: HttpMethod;
4
+ readonly path: string;
5
+ readonly operationId: string;
6
+ }
7
+ interface OpenApiDocument {
8
+ readonly openapi?: string;
9
+ readonly swagger?: string;
10
+ readonly paths?: Record<string, Record<string, {
11
+ operationId?: string;
12
+ } | unknown> | undefined>;
13
+ }
14
+
15
+ declare function collectOperations(doc: OpenApiDocument): Operation[];
16
+ declare function renderEndpointsFile(operations: Operation[]): string;
17
+
18
+ interface PrefixLintFinding {
19
+ majority: {
20
+ prefix: string;
21
+ count: number;
22
+ };
23
+ minority: {
24
+ prefix: string;
25
+ count: number;
26
+ };
27
+ message: string;
28
+ }
29
+ /**
30
+ * Flags specs where one or two operations sit under a top-level prefix that
31
+ * the rest of the API doesn't share. Catches the `/api/v1/foo` typo when 30
32
+ * other ops live under `/v1/...` — common when an OpenAPI is hand-assembled.
33
+ *
34
+ * Heuristic: take the first path segment of every operation, bucket counts.
35
+ * If the dominant bucket holds ≥80% and the smallest other bucket holds
36
+ * ≤20% on a corpus of ≥5 operations, return a finding. Multi-prefix specs
37
+ * with a balanced split fall through silently.
38
+ */
39
+ declare function findPrefixOutliers(operations: ReadonlyArray<Operation>): PrefixLintFinding | null;
40
+
41
+ declare function loadSpec(specPath: string): Promise<OpenApiDocument>;
42
+
43
+ /**
44
+ * Derive a stable JS identifier for an operation. When `operationId` is a valid
45
+ * identifier we trust it verbatim — users chose that name intentionally. Otherwise
46
+ * derive from method + path, e.g. `GET /v1/accounts/{id}` → `getV1AccountsById`.
47
+ */
48
+ declare function operationName(method: string, path: string, operationId: string | undefined): string;
49
+
50
+ interface GenerateOptions {
51
+ /** Path to the OpenAPI spec file (YAML or JSON). */
52
+ specPath: string;
53
+ /** Directory where `endpoints.ts` and `models.ts` are written. */
54
+ outDir: string;
55
+ }
56
+ interface GenerateResult {
57
+ modelsPath: string;
58
+ endpointsPath: string;
59
+ operationCount: number;
60
+ }
61
+ declare function generate(opts: GenerateOptions): Promise<GenerateResult>;
62
+
63
+ export { type GenerateOptions, type GenerateResult, type Operation, type PrefixLintFinding, collectOperations, findPrefixOutliers, generate, loadSpec, operationName, renderEndpointsFile };
package/dist/index.js ADDED
@@ -0,0 +1,158 @@
1
+ // src/index.ts
2
+ import { mkdir, writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import openapiTS, { astToString } from "openapi-typescript";
5
+
6
+ // src/names.ts
7
+ var INVALID = /[^a-zA-Z0-9]+/g;
8
+ var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
9
+ function pascalCaseParts(input) {
10
+ return input.split(INVALID).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
11
+ }
12
+ function operationName(method, path, operationId) {
13
+ if (operationId && IDENT_RE.test(operationId)) return operationId;
14
+ const cleanedPath = path.replace(/\{([^}]+)\}/g, "By_$1").replace(/\//g, "_");
15
+ const pascal = pascalCaseParts(`${method}_${cleanedPath}`);
16
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
17
+ }
18
+ function uniqueName(base, taken) {
19
+ if (!taken.has(base)) {
20
+ taken.add(base);
21
+ return base;
22
+ }
23
+ let i = 2;
24
+ while (taken.has(`${base}${i}`)) i += 1;
25
+ const next = `${base}${i}`;
26
+ taken.add(next);
27
+ return next;
28
+ }
29
+
30
+ // src/types.ts
31
+ var HTTP_METHODS = [
32
+ "get",
33
+ "post",
34
+ "put",
35
+ "patch",
36
+ "delete",
37
+ "head",
38
+ "options"
39
+ ];
40
+
41
+ // src/emit-endpoints.ts
42
+ function collectOperations(doc) {
43
+ const paths = doc.paths ?? {};
44
+ const taken = /* @__PURE__ */ new Set();
45
+ const out = [];
46
+ for (const [path, pathItem] of Object.entries(paths)) {
47
+ if (!pathItem || typeof pathItem !== "object") continue;
48
+ for (const method of HTTP_METHODS) {
49
+ const op = pathItem[method];
50
+ if (!op || typeof op !== "object") continue;
51
+ const rawId = op.operationId;
52
+ const id = uniqueName(
53
+ operationName(method, path, typeof rawId === "string" ? rawId : void 0),
54
+ taken
55
+ );
56
+ out.push({ method, path, operationId: id });
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ var HEADER = `// AUTO-GENERATED BY @usejourney/codegen \u2014 do not edit by hand.
62
+ import type { EndpointRef } from "@usejourney/core";
63
+ import type { paths } from "./models.js";
64
+
65
+ type JsonResponse<P extends keyof paths, M extends keyof paths[P]> =
66
+ paths[P][M] extends { responses: infer R }
67
+ ? R extends { 200: { content: { "application/json": infer T } } } ? T
68
+ : R extends { 201: { content: { "application/json": infer T } } } ? T
69
+ : R extends { 202: { content: { "application/json": infer T } } } ? T
70
+ : unknown
71
+ : unknown;
72
+ `;
73
+ function renderEndpointsFile(operations) {
74
+ const entries = operations.map((op) => {
75
+ const methodUc = op.method.toUpperCase();
76
+ return ` ${op.operationId}: { method: "${methodUc}", path: "${op.path}", operationId: "${op.operationId}" } as unknown as EndpointRef<JsonResponse<"${op.path}", "${op.method}">>,`;
77
+ });
78
+ return [
79
+ HEADER,
80
+ "export const endpoints = {",
81
+ ...entries,
82
+ "} as const;",
83
+ "",
84
+ "export type EndpointName = keyof typeof endpoints;",
85
+ ""
86
+ ].join("\n");
87
+ }
88
+
89
+ // src/lint.ts
90
+ function findPrefixOutliers(operations) {
91
+ if (operations.length < 5) return null;
92
+ const buckets = /* @__PURE__ */ new Map();
93
+ for (const op of operations) {
94
+ const first = firstSegment(op.path);
95
+ buckets.set(first, (buckets.get(first) ?? 0) + 1);
96
+ }
97
+ if (buckets.size < 2) return null;
98
+ const sorted = [...buckets.entries()].sort((a, b) => b[1] - a[1]);
99
+ const total = operations.length;
100
+ const [majPrefix, majCount] = sorted[0];
101
+ const [minPrefix, minCount] = sorted[sorted.length - 1];
102
+ if (majCount / total < 0.8) return null;
103
+ if (minCount / total > 0.2) return null;
104
+ return {
105
+ majority: { prefix: `/${majPrefix}`, count: majCount },
106
+ minority: { prefix: `/${minPrefix}`, count: minCount },
107
+ message: `journey: warning \u2014 ${minCount} operation(s) use prefix '/${minPrefix}' while ${majCount} use '/${majPrefix}'`
108
+ };
109
+ }
110
+ function firstSegment(path) {
111
+ const trimmed = path.replace(/^\/+/, "");
112
+ const idx = trimmed.indexOf("/");
113
+ return idx === -1 ? trimmed : trimmed.slice(0, idx);
114
+ }
115
+
116
+ // src/parse.ts
117
+ import { readFile } from "fs/promises";
118
+ import { extname } from "path";
119
+ import yaml from "js-yaml";
120
+ async function loadSpec(specPath) {
121
+ const raw = await readFile(specPath, "utf8");
122
+ const ext = extname(specPath).toLowerCase();
123
+ const doc = ext === ".json" ? JSON.parse(raw) : yaml.load(raw);
124
+ if (!doc || typeof doc !== "object") {
125
+ throw new Error(`Spec at ${specPath} did not parse to an object`);
126
+ }
127
+ const typed = doc;
128
+ if (!typed.openapi && !typed.swagger) {
129
+ throw new Error(`Spec at ${specPath} is missing "openapi"/"swagger" field`);
130
+ }
131
+ return typed;
132
+ }
133
+
134
+ // src/index.ts
135
+ async function generate(opts) {
136
+ const doc = await loadSpec(opts.specPath);
137
+ const operations = collectOperations(doc);
138
+ const outlier = findPrefixOutliers(operations);
139
+ if (outlier) console.warn(outlier.message);
140
+ const ast = await openapiTS(doc);
141
+ const modelsSource = `// AUTO-GENERATED BY @usejourney/codegen \u2014 do not edit by hand.
142
+ ${astToString(ast)}`;
143
+ const endpointsSource = renderEndpointsFile(operations);
144
+ await mkdir(opts.outDir, { recursive: true });
145
+ const modelsPath = join(opts.outDir, "models.ts");
146
+ const endpointsPath = join(opts.outDir, "endpoints.ts");
147
+ await writeFile(modelsPath, modelsSource, "utf8");
148
+ await writeFile(endpointsPath, endpointsSource, "utf8");
149
+ return { modelsPath, endpointsPath, operationCount: operations.length };
150
+ }
151
+ export {
152
+ collectOperations,
153
+ findPrefixOutliers,
154
+ generate,
155
+ loadSpec,
156
+ operationName,
157
+ renderEndpointsFile
158
+ };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@usejourney/codegen",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/femoral/journey.git",
8
+ "directory": "packages/codegen"
9
+ },
10
+ "homepage": "https://github.com/femoral/journey#readme",
11
+ "bugs": "https://github.com/femoral/journey/issues",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "js-yaml": "^4.1.1",
29
+ "openapi-typescript": "^7.13.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/js-yaml": "^4.0.9"
33
+ },
34
+ "scripts": {
35
+ "build": "tsup src/index.ts --format esm --dts --clean",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "vitest run --passWithNoTests"
38
+ }
39
+ }