frappebun 0.0.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 +72 -0
- package/package.json +59 -0
- package/src/api/auth.ts +76 -0
- package/src/api/index.ts +10 -0
- package/src/api/resource.ts +177 -0
- package/src/api/route.ts +301 -0
- package/src/app/index.ts +6 -0
- package/src/app/loader.ts +218 -0
- package/src/auth/auth.ts +247 -0
- package/src/auth/index.ts +2 -0
- package/src/cli/args.ts +40 -0
- package/src/cli/bin.ts +12 -0
- package/src/cli/commands/add-api.ts +32 -0
- package/src/cli/commands/add-doctype.ts +43 -0
- package/src/cli/commands/add-page.ts +33 -0
- package/src/cli/commands/add-user.ts +96 -0
- package/src/cli/commands/dev.ts +71 -0
- package/src/cli/commands/drop-site.ts +27 -0
- package/src/cli/commands/init.ts +98 -0
- package/src/cli/commands/migrate.ts +110 -0
- package/src/cli/commands/new-site.ts +61 -0
- package/src/cli/commands/routes.ts +56 -0
- package/src/cli/commands/use.ts +30 -0
- package/src/cli/index.ts +73 -0
- package/src/cli/log.ts +13 -0
- package/src/cli/scaffold/templates.ts +189 -0
- package/src/context.ts +162 -0
- package/src/core/doctype/migration/migration.ts +17 -0
- package/src/core/doctype/role/role.ts +7 -0
- package/src/core/doctype/session/session.ts +16 -0
- package/src/core/doctype/user/user.controller.ts +11 -0
- package/src/core/doctype/user/user.ts +22 -0
- package/src/core/doctype/user_role/user_role.ts +9 -0
- package/src/core/doctypes.ts +25 -0
- package/src/core/index.ts +1 -0
- package/src/database/database.ts +359 -0
- package/src/database/filters.ts +131 -0
- package/src/database/index.ts +30 -0
- package/src/database/query-builder.ts +1118 -0
- package/src/database/schema.ts +188 -0
- package/src/doctype/define.ts +45 -0
- package/src/doctype/discovery.ts +57 -0
- package/src/doctype/field.ts +160 -0
- package/src/doctype/index.ts +20 -0
- package/src/doctype/layout.ts +62 -0
- package/src/doctype/query-builder-stub.ts +16 -0
- package/src/doctype/registry.ts +106 -0
- package/src/doctype/types.ts +407 -0
- package/src/document/document.ts +593 -0
- package/src/document/index.ts +6 -0
- package/src/document/naming.ts +56 -0
- package/src/errors.ts +53 -0
- package/src/frappe.d.ts +128 -0
- package/src/globals.ts +72 -0
- package/src/index.ts +112 -0
- package/src/migrations/index.ts +11 -0
- package/src/migrations/runner.ts +256 -0
- package/src/permissions/index.ts +265 -0
- package/src/response.ts +100 -0
- package/src/server.ts +210 -0
- package/src/site.ts +126 -0
- package/src/ssr/handler.ts +56 -0
- package/src/ssr/index.ts +11 -0
- package/src/ssr/page-loader.ts +200 -0
- package/src/ssr/renderer.ts +94 -0
- package/src/ssr/use-context.ts +41 -0
package/src/api/route.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `route()` — marks an export as an HTTP endpoint with access-control metadata.
|
|
3
|
+
*
|
|
4
|
+
* ─── Two URL conventions ────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* 1. CONVENTION — no `path:` → URL derived from file path + export name
|
|
7
|
+
*
|
|
8
|
+
* // api/reports.ts
|
|
9
|
+
* export const getOutstanding = route(async ({ customer }) => { ... })
|
|
10
|
+
* // → GET /api/myapp/reports/get-outstanding?customer=CUST-001
|
|
11
|
+
*
|
|
12
|
+
* 2. EXPLICIT — `path:` is the full URL relative to /api/{appname}/
|
|
13
|
+
*
|
|
14
|
+
* // any file
|
|
15
|
+
* export const getProduct = route({
|
|
16
|
+
* path: "/products/:id",
|
|
17
|
+
* method: "GET",
|
|
18
|
+
* async handler({ id }: { id: string }) { ... },
|
|
19
|
+
* })
|
|
20
|
+
* // → GET /api/myapp/products/:id
|
|
21
|
+
*
|
|
22
|
+
* export const getProductAction = route({
|
|
23
|
+
* path: "/products/:id/details/:action",
|
|
24
|
+
* method: "GET",
|
|
25
|
+
* async handler({ id, action }: { id: string; action: string }) { ... },
|
|
26
|
+
* })
|
|
27
|
+
* // → GET /api/myapp/products/:id/details/:action
|
|
28
|
+
*
|
|
29
|
+
* When `path:` is set the file's location has no effect on the URL.
|
|
30
|
+
* Path params (`:name` segments) are merged with query-string / body params;
|
|
31
|
+
* path params win on collision.
|
|
32
|
+
*
|
|
33
|
+
* ─── Return types ────────────────────────────────────────────────────────────
|
|
34
|
+
*
|
|
35
|
+
* - Plain value → wrapped in `{ data: <value> }` with status 200
|
|
36
|
+
* - `Response` → passed through as-is (full control over status / headers)
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { contextStore } from "../context"
|
|
40
|
+
import type { FrappeContext } from "../context"
|
|
41
|
+
import { response as _response } from "../response"
|
|
42
|
+
|
|
43
|
+
export type AuthMode = "required" | "public" | "guest-only"
|
|
44
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"
|
|
45
|
+
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
47
|
+
export type RouteHandler = (params: any) => Promise<unknown> | unknown
|
|
48
|
+
|
|
49
|
+
export interface RouteConfig {
|
|
50
|
+
handler: RouteHandler
|
|
51
|
+
/**
|
|
52
|
+
* App-relative URL pattern, starting with `/`.
|
|
53
|
+
* Params are declared as `:name` segments and received in the handler.
|
|
54
|
+
*
|
|
55
|
+
* Examples:
|
|
56
|
+
* "/products/:id" → /api/{app}/products/:id
|
|
57
|
+
* "/products/:id/details/:action" → /api/{app}/products/:id/details/:action
|
|
58
|
+
* "/orders/:orderId/items/:itemId" → /api/{app}/orders/:orderId/items/:itemId
|
|
59
|
+
*
|
|
60
|
+
* When omitted the URL is derived from the file path + export name convention.
|
|
61
|
+
*/
|
|
62
|
+
path?: string
|
|
63
|
+
auth?: AuthMode
|
|
64
|
+
method?: HttpMethod
|
|
65
|
+
roles?: string[]
|
|
66
|
+
rateLimit?: { limit: number; window: number }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RouteDefinition {
|
|
70
|
+
__isRoute: true
|
|
71
|
+
config: RouteConfig
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── route() factory ──────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Infer HTTP method from an export name:
|
|
78
|
+
* get / list / fetch / search / check / is / has → GET
|
|
79
|
+
* everything else → POST
|
|
80
|
+
*/
|
|
81
|
+
export function inferMethod(exportName: string): HttpMethod {
|
|
82
|
+
if (!exportName) return "POST"
|
|
83
|
+
return /^(get|list|fetch|search|check|is|has)/i.test(exportName) ? "GET" : "POST"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Convert camelCase to kebab-case: `getOutstanding` → `get-outstanding` */
|
|
87
|
+
export function camelToKebab(s: string): string {
|
|
88
|
+
return s.replace(/([A-Z])/g, (_, c: string) => `-${c.toLowerCase()}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compute the full server URL path for a route definition.
|
|
93
|
+
*
|
|
94
|
+
* Strategy 1 — explicit `path:`:
|
|
95
|
+
* `appPrefix` + `def.config.path`
|
|
96
|
+
* e.g. "/api/myapp" + "/products/:id" → "/api/myapp/products/:id"
|
|
97
|
+
*
|
|
98
|
+
* Strategy 2 — convention (no `path:`):
|
|
99
|
+
* `appPrefix` / `fileSegment` / kebab(`exportName`)
|
|
100
|
+
* e.g. "/api/myapp" / "reports" / "get-outstanding" → "/api/myapp/reports/get-outstanding"
|
|
101
|
+
*
|
|
102
|
+
* @param appPrefix App namespace, e.g. `"/api/myapp"`
|
|
103
|
+
* @param def The RouteDefinition
|
|
104
|
+
* @param exportName JS export name — used for method inference and convention URL
|
|
105
|
+
* @param fileSegment File URL segment, e.g. `"reports"` for `api/reports.ts`
|
|
106
|
+
*/
|
|
107
|
+
export function resolveRoutePath(
|
|
108
|
+
appPrefix: string,
|
|
109
|
+
def: RouteDefinition,
|
|
110
|
+
exportName: string,
|
|
111
|
+
fileSegment?: string,
|
|
112
|
+
): string {
|
|
113
|
+
if (def.config.path) return `${appPrefix}${def.config.path}`
|
|
114
|
+
const parts = [appPrefix, fileSegment, camelToKebab(exportName)].filter(Boolean)
|
|
115
|
+
return parts.join("/")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Mark a function as an HTTP endpoint.
|
|
120
|
+
*
|
|
121
|
+
* route(fn) — simple form, sensible defaults
|
|
122
|
+
* route({ ... }) — full form, all options explicit
|
|
123
|
+
*/
|
|
124
|
+
export function route(fnOrConfig: RouteHandler | RouteConfig): RouteDefinition {
|
|
125
|
+
const config: RouteConfig =
|
|
126
|
+
typeof fnOrConfig === "function" ? { handler: fnOrConfig } : fnOrConfig
|
|
127
|
+
|
|
128
|
+
return { __isRoute: true, config: { auth: "required", ...config } }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function isRouteDefinition(v: unknown): v is RouteDefinition {
|
|
132
|
+
return typeof v === "object" && v !== null && (v as RouteDefinition).__isRoute === true
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── Route registry ───────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
interface RegistryEntry {
|
|
138
|
+
def: RouteDefinition
|
|
139
|
+
method: HttpMethod
|
|
140
|
+
segments: string[]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Registry of all discovered user-defined routes.
|
|
145
|
+
* Framework routes (auth, resource) are dispatched directly by the server.
|
|
146
|
+
*/
|
|
147
|
+
class RouteRegistry {
|
|
148
|
+
private fixed: RegistryEntry[] = [] // framework routes — survive clear()
|
|
149
|
+
private dynamic: RegistryEntry[] = [] // user / test routes — cleared by clear()
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Register a framework (system) route.
|
|
153
|
+
* These are never removed by clear() and have priority over user routes.
|
|
154
|
+
*/
|
|
155
|
+
registerSystem(fullPath: string, def: RouteDefinition, exportName = ""): void {
|
|
156
|
+
this.fixed.push(this._entry(fullPath, def, exportName))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Register a user-defined route.
|
|
161
|
+
* Use resolveRoutePath() to compute fullPath from a RouteDefinition.
|
|
162
|
+
*/
|
|
163
|
+
register(fullPath: string, def: RouteDefinition, exportName = ""): void {
|
|
164
|
+
this.dynamic.push(this._entry(fullPath, def, exportName))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Match an incoming request against registered patterns.
|
|
169
|
+
* Fixed (framework) routes are checked first.
|
|
170
|
+
*/
|
|
171
|
+
match(
|
|
172
|
+
pathname: string,
|
|
173
|
+
httpMethod: string,
|
|
174
|
+
): { def: RouteDefinition; params: Record<string, string> } | null {
|
|
175
|
+
const incoming = pathname.split("/").filter(Boolean)
|
|
176
|
+
const method = httpMethod.toUpperCase() as HttpMethod
|
|
177
|
+
|
|
178
|
+
for (const entry of [...this.fixed, ...this.dynamic]) {
|
|
179
|
+
if (entry.method !== method) continue
|
|
180
|
+
if (entry.segments.length !== incoming.length) continue
|
|
181
|
+
|
|
182
|
+
const params: Record<string, string> = {}
|
|
183
|
+
let ok = true
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < entry.segments.length; i++) {
|
|
186
|
+
const seg = entry.segments[i]!
|
|
187
|
+
if (seg.startsWith(":")) {
|
|
188
|
+
params[seg.slice(1)] = decodeURIComponent(incoming[i]!)
|
|
189
|
+
} else if (seg !== incoming[i]) {
|
|
190
|
+
ok = false
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (ok) return { def: entry.def, params }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Clear user-defined routes only. Framework routes are preserved. */
|
|
202
|
+
clear(): void {
|
|
203
|
+
this.dynamic = []
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Return all registered user routes as a flat list for introspection. */
|
|
207
|
+
list(): Array<{ method: HttpMethod; path: string; auth: AuthMode }> {
|
|
208
|
+
return this.dynamic.map((e) => ({
|
|
209
|
+
method: e.method,
|
|
210
|
+
path: "/" + e.segments.join("/"),
|
|
211
|
+
auth: e.def.config.auth ?? "required",
|
|
212
|
+
}))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private _entry(fullPath: string, def: RouteDefinition, exportName: string): RegistryEntry {
|
|
216
|
+
return {
|
|
217
|
+
def,
|
|
218
|
+
method: def.config.method ?? inferMethod(exportName),
|
|
219
|
+
segments: fullPath.split("/").filter(Boolean),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Singleton route registry — app route files register into this at startup. */
|
|
225
|
+
export const routeRegistry = new RouteRegistry()
|
|
226
|
+
|
|
227
|
+
// ─── invokeRoute ──────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Execute a RouteDefinition:
|
|
231
|
+
* 1. Enforce auth / role checks
|
|
232
|
+
* 2. Merge params (path > query-string > body)
|
|
233
|
+
* 3. Call handler
|
|
234
|
+
* 4. Plain return values → `{ data: ... }`; Response objects → pass through
|
|
235
|
+
*
|
|
236
|
+
* @param def The RouteDefinition to invoke
|
|
237
|
+
* @param pathParams Path parameters extracted from the URL (highest priority)
|
|
238
|
+
*/
|
|
239
|
+
export async function invokeRoute(
|
|
240
|
+
def: RouteDefinition,
|
|
241
|
+
pathParams: Record<string, unknown> = {},
|
|
242
|
+
): Promise<Response> {
|
|
243
|
+
const ctx = contextStore.getStore()
|
|
244
|
+
const authMode = def.config.auth ?? "required"
|
|
245
|
+
|
|
246
|
+
// ── Auth ────────────────────────────────────────────
|
|
247
|
+
if (authMode === "required" && (!ctx || ctx.user === "Guest")) {
|
|
248
|
+
return _response.unauthorized()
|
|
249
|
+
}
|
|
250
|
+
if (authMode === "guest-only" && ctx?.user !== "Guest") {
|
|
251
|
+
return _response.error("Already logged in", 400)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Roles ────────────────────────────────────────────
|
|
255
|
+
if (def.config.roles?.length && ctx) {
|
|
256
|
+
if (!def.config.roles.some((r) => ctx.perms.hasRole(r))) {
|
|
257
|
+
return _response.forbidden()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Params: query-string + body + path params ────────
|
|
262
|
+
const params = await mergeParams(pathParams, ctx)
|
|
263
|
+
|
|
264
|
+
// ── Execute ──────────────────────────────────────────
|
|
265
|
+
const result = await def.config.handler(params)
|
|
266
|
+
|
|
267
|
+
if (result instanceof Response) return result
|
|
268
|
+
return _response.ok(result)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─── Internal helpers ─────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
async function mergeParams(
|
|
274
|
+
pathParams: Record<string, unknown>,
|
|
275
|
+
ctx: FrappeContext | undefined,
|
|
276
|
+
): Promise<Record<string, unknown>> {
|
|
277
|
+
const req = ctx?.request
|
|
278
|
+
if (!req) return { ...pathParams }
|
|
279
|
+
|
|
280
|
+
const params: Record<string, unknown> = {}
|
|
281
|
+
|
|
282
|
+
// 1. Query-string (lowest priority)
|
|
283
|
+
for (const [k, v] of new URL(req.url).searchParams) params[k] = v
|
|
284
|
+
|
|
285
|
+
// 2. JSON body for write methods
|
|
286
|
+
const method = req.method.toUpperCase()
|
|
287
|
+
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
|
288
|
+
if ((req.headers.get("content-type") ?? "").includes("application/json")) {
|
|
289
|
+
try {
|
|
290
|
+
Object.assign(params, (await req.clone().json()) as Record<string, unknown>)
|
|
291
|
+
} catch {
|
|
292
|
+
/* non-JSON body */
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 3. Path params (highest priority)
|
|
298
|
+
Object.assign(params, pathParams)
|
|
299
|
+
|
|
300
|
+
return params
|
|
301
|
+
}
|
package/src/app/index.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App loader — discover the app module from package.json and scan
|
|
3
|
+
* its conventional directories (doctype/, api/, pages/, extensions/, ...).
|
|
4
|
+
*
|
|
5
|
+
* One app per project. The project root contains a `package.json` with a
|
|
6
|
+
* `"name"` field — that name becomes the app module directory name.
|
|
7
|
+
* Scoped names (`@scope/name`) use only the name part.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
11
|
+
import { join, basename } from "node:path"
|
|
12
|
+
|
|
13
|
+
import { isRouteDefinition, resolveRoutePath, routeRegistry } from "../api/route"
|
|
14
|
+
import type { RouteDefinition } from "../api/route"
|
|
15
|
+
import { discoverDocTypes } from "../doctype/discovery"
|
|
16
|
+
|
|
17
|
+
// ─── Types ────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface AppMetadata {
|
|
20
|
+
name: string
|
|
21
|
+
version: string
|
|
22
|
+
title: string
|
|
23
|
+
description?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LoadedApp {
|
|
27
|
+
/** Project root — directory containing package.json. */
|
|
28
|
+
readonly root: string
|
|
29
|
+
|
|
30
|
+
/** App module name — derived from package.json "name". */
|
|
31
|
+
readonly name: string
|
|
32
|
+
|
|
33
|
+
/** Absolute path to the app module directory — `<root>/<name>/`. */
|
|
34
|
+
readonly moduleDir: string
|
|
35
|
+
|
|
36
|
+
/** Metadata from package.json. */
|
|
37
|
+
readonly metadata: AppMetadata
|
|
38
|
+
|
|
39
|
+
/** Counts of what was loaded — for dev-server logging. */
|
|
40
|
+
readonly loaded: {
|
|
41
|
+
doctypes: number
|
|
42
|
+
apiRoutes: number
|
|
43
|
+
pages: number
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── package.json parsing ─────────────────────────────────
|
|
48
|
+
|
|
49
|
+
interface PackageJson {
|
|
50
|
+
name?: string
|
|
51
|
+
version?: string
|
|
52
|
+
frappe?: { title?: string; description?: string }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve the app module directory name from a package name. */
|
|
56
|
+
export function appNameFromPackage(pkgName: string): string {
|
|
57
|
+
// "@scope/hrms" → "hrms", "myapp" → "myapp"
|
|
58
|
+
return pkgName.startsWith("@") ? (pkgName.split("/")[1] ?? pkgName) : pkgName
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read and parse the project's package.json. */
|
|
62
|
+
export function readAppMetadata(root: string): AppMetadata {
|
|
63
|
+
const pkgPath = join(root, "package.json")
|
|
64
|
+
if (!existsSync(pkgPath)) {
|
|
65
|
+
throw new Error(`No package.json found at ${root}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as PackageJson
|
|
69
|
+
|
|
70
|
+
if (!pkg.name) {
|
|
71
|
+
throw new Error(`package.json at ${root} has no "name" field`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
name: appNameFromPackage(pkg.name),
|
|
76
|
+
version: pkg.version ?? "0.0.0",
|
|
77
|
+
title: pkg.frappe?.title ?? pkg.name,
|
|
78
|
+
description: pkg.frappe?.description,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Loading ──────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load the app at `root` — read its metadata, then scan all conventional
|
|
86
|
+
* directories inside the app module for DocTypes, API routes, and pages.
|
|
87
|
+
*
|
|
88
|
+
* Missing directories are silently skipped.
|
|
89
|
+
*/
|
|
90
|
+
export async function loadApp(root: string): Promise<LoadedApp> {
|
|
91
|
+
const metadata = readAppMetadata(root)
|
|
92
|
+
const moduleDir = join(root, metadata.name)
|
|
93
|
+
|
|
94
|
+
if (!existsSync(moduleDir)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`App module directory "${metadata.name}/" not found at ${root}. ` +
|
|
97
|
+
`Expected ${moduleDir}/ — it must match package.json "name".`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const doctypes = await loadDocTypes(moduleDir)
|
|
102
|
+
const apiRoutes = await loadApiRoutes(moduleDir, metadata.name)
|
|
103
|
+
const pages = countPages(moduleDir)
|
|
104
|
+
|
|
105
|
+
return { root, name: metadata.name, moduleDir, metadata, loaded: { doctypes, apiRoutes, pages } }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── DocTypes ─────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
async function loadDocTypes(moduleDir: string): Promise<number> {
|
|
111
|
+
const dir = join(moduleDir, "doctype")
|
|
112
|
+
if (!existsSync(dir)) return 0
|
|
113
|
+
await discoverDocTypes(dir)
|
|
114
|
+
return readdirSync(dir).filter((e) => statSync(join(dir, e)).isDirectory()).length
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── API routes ───────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Discover API route files under `<moduleDir>/api/` and register them.
|
|
121
|
+
*
|
|
122
|
+
* File path → URL segment:
|
|
123
|
+
* api/reports.ts → /api/<app>/reports/<kebab-export>
|
|
124
|
+
* api/webhooks/stripe.ts → /api/<app>/webhooks/stripe/<kebab-export>
|
|
125
|
+
*/
|
|
126
|
+
async function loadApiRoutes(moduleDir: string, appName: string): Promise<number> {
|
|
127
|
+
const apiDir = join(moduleDir, "api")
|
|
128
|
+
if (!existsSync(apiDir)) return 0
|
|
129
|
+
|
|
130
|
+
const appPrefix = `/api/${appName}`
|
|
131
|
+
let count = 0
|
|
132
|
+
|
|
133
|
+
for (const file of walkTsFiles(apiDir)) {
|
|
134
|
+
const fileSegment = toRouteSegment(file.relPath)
|
|
135
|
+
const mod = (await import(file.absPath)) as Record<string, unknown>
|
|
136
|
+
|
|
137
|
+
for (const [exportName, value] of Object.entries(mod)) {
|
|
138
|
+
if (!isRouteDefinition(value)) continue
|
|
139
|
+
|
|
140
|
+
// `export default route(...)` owns the file-level URL — no export-name
|
|
141
|
+
// segment appended. e.g. api/hello.ts → POST /api/myapp/hello
|
|
142
|
+
const isDefault = exportName === "default"
|
|
143
|
+
const fullPath = isDefault
|
|
144
|
+
? `${appPrefix}/${fileSegment}`
|
|
145
|
+
: resolveRoutePath(appPrefix, value as RouteDefinition, exportName, fileSegment)
|
|
146
|
+
|
|
147
|
+
routeRegistry.register(fullPath, value as RouteDefinition, isDefault ? "" : exportName)
|
|
148
|
+
count++
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return count
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── Pages (counted only — SSR loads them separately) ────
|
|
156
|
+
|
|
157
|
+
function countPages(moduleDir: string): number {
|
|
158
|
+
const pagesDir = join(moduleDir, "pages")
|
|
159
|
+
if (!existsSync(pagesDir)) return 0
|
|
160
|
+
let count = 0
|
|
161
|
+
for (const _ of walkVueFiles(pagesDir)) count++
|
|
162
|
+
return count
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Filesystem helpers ───────────────────────────────────
|
|
166
|
+
|
|
167
|
+
interface WalkedFile {
|
|
168
|
+
absPath: string
|
|
169
|
+
relPath: string // relative to walk root, no leading slash
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function* walkTsFiles(root: string, prefix = ""): Generator<WalkedFile> {
|
|
173
|
+
for (const entry of readdirSync(root)) {
|
|
174
|
+
const abs = join(root, entry)
|
|
175
|
+
const rel = prefix ? `${prefix}/${entry}` : entry
|
|
176
|
+
if (statSync(abs).isDirectory()) {
|
|
177
|
+
yield* walkTsFiles(abs, rel)
|
|
178
|
+
} else if (entry.endsWith(".ts") && !entry.endsWith(".d.ts") && !entry.endsWith(".test.ts")) {
|
|
179
|
+
yield { absPath: abs, relPath: rel }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function* walkVueFiles(root: string, prefix = ""): Generator<WalkedFile> {
|
|
185
|
+
for (const entry of readdirSync(root)) {
|
|
186
|
+
const abs = join(root, entry)
|
|
187
|
+
const rel = prefix ? `${prefix}/${entry}` : entry
|
|
188
|
+
if (statSync(abs).isDirectory()) {
|
|
189
|
+
yield* walkVueFiles(abs, rel)
|
|
190
|
+
} else if (entry.endsWith(".vue")) {
|
|
191
|
+
yield { absPath: abs, relPath: rel }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Convert an api/ relative file path to its URL segment. */
|
|
197
|
+
function toRouteSegment(relPath: string): string {
|
|
198
|
+
// "webhooks/stripe.ts" → "webhooks/stripe"
|
|
199
|
+
// "reports.ts" → "reports"
|
|
200
|
+
return relPath.replace(/\.ts$/, "")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Convenience: infer project root as the nearest ancestor containing package.json. */
|
|
204
|
+
export function findProjectRoot(start: string = process.cwd()): string {
|
|
205
|
+
let dir = start
|
|
206
|
+
while (dir !== "/" && dir !== "") {
|
|
207
|
+
if (existsSync(join(dir, "package.json"))) return dir
|
|
208
|
+
const parent = join(dir, "..")
|
|
209
|
+
if (parent === dir) break
|
|
210
|
+
dir = parent
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`Could not find a package.json starting from ${start}`)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Friendly basename — used for logging. */
|
|
216
|
+
export function appLabel(app: LoadedApp): string {
|
|
217
|
+
return basename(app.root)
|
|
218
|
+
}
|