@tailorkit/vite 0.0.0-stage → 0.1.0-beta.32

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.
@@ -0,0 +1,454 @@
1
+ import { createRequire } from "node:module";
2
+ import { z } from "zod";
3
+ import { execFile } from "node:child_process";
4
+ import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { promisify } from "node:util";
8
+ import { existsSync, watch } from "node:fs";
9
+ import { parseSync } from "vite";
10
+ //#region src/builder/upload-manifest.ts
11
+ const tailorkitUploadManifestSchema = z.object({
12
+ assets: z.object({
13
+ client: z.literal("client/client.js"),
14
+ logos: z.object({
15
+ dark: z.string().regex(/^client\/logo-dark\.(?:png|svg|webp)$/u).optional(),
16
+ light: z.string().regex(/^client\/logo-light\.(?:png|svg|webp)$/u).optional()
17
+ }).optional(),
18
+ server: z.literal("server/server.js").optional()
19
+ }),
20
+ version: z.literal(1),
21
+ views: z.array(z.object({
22
+ disabled: z.literal(true).optional(),
23
+ instances: z.literal(true).optional(),
24
+ path: z.string().startsWith("/").max(1024),
25
+ slot: z.string().min(1).max(255)
26
+ })).max(1e3).optional()
27
+ });
28
+ const createTailorKitUploadManifest = (logos, server = false, views) => {
29
+ const assets = { client: "client/client.js" };
30
+ if (server) assets.server = "server/server.js";
31
+ if (logos && Object.keys(logos).length > 0) assets.logos = logos;
32
+ return tailorkitUploadManifestSchema.parse({
33
+ assets,
34
+ version: 1,
35
+ views
36
+ });
37
+ };
38
+ //#endregion
39
+ //#region src/builder/database-paths.ts
40
+ const appDatabasePaths = (root, migrations) => ({
41
+ migrations: path.resolve(root, migrations ?? "./src/db/migrations"),
42
+ schema: path.join(root, "src/db/schema.ts")
43
+ });
44
+ //#endregion
45
+ //#region src/builder/drizzle.ts
46
+ /** Use the app's Drizzle version with explicit flags; no Drizzle config file is needed. */
47
+ const drizzleGenerateArguments = async (root, output, name) => {
48
+ const require = createRequire(path.join(root, "package.json"));
49
+ for (const directory of require.resolve.paths("drizzle-kit") ?? []) {
50
+ const cli = path.join(directory, "drizzle-kit/bin.cjs");
51
+ if (await access(cli).then(() => true).catch(() => false)) return [
52
+ "--preserve-symlinks-main",
53
+ cli,
54
+ "generate",
55
+ "--dialect",
56
+ "sqlite",
57
+ "--schema",
58
+ appDatabasePaths(root).schema,
59
+ "--out",
60
+ output,
61
+ ...name === void 0 ? [] : ["--name", name]
62
+ ];
63
+ }
64
+ throw new Error("Install drizzle-kit and drizzle-orm as dev dependencies, then run tailorkit db generate.");
65
+ };
66
+ //#endregion
67
+ //#region src/builder/env.ts
68
+ /** The builder forwards the app developer’s environment to build tools. */
69
+ const getBuildEnvironment = () => process.env;
70
+ //#endregion
71
+ //#region src/builder/fs-errors.ts
72
+ const isMissingFile = (cause) => cause !== null && typeof cause === "object" && "code" in cause && cause.code === "ENOENT";
73
+ //#endregion
74
+ //#region src/builder/schema-check.ts
75
+ const isObjectValue$1 = (value) => typeof value === "object" && value !== null;
76
+ const run = promisify(execFile);
77
+ const snapshot = async (directory) => {
78
+ const latest = (await readdir(directory, { withFileTypes: true }).catch((error) => {
79
+ if (isMissingFile(error)) return [];
80
+ throw error;
81
+ })).filter((entry) => entry.isDirectory()).map((entry) => entry.name).toSorted().at(-1);
82
+ if (!(latest !== void 0 && latest !== null && latest !== "")) return;
83
+ const filename = path.join(directory, latest, "snapshot.json");
84
+ const parsed = z.object({
85
+ ddl: z.array(z.unknown()),
86
+ dialect: z.literal("sqlite"),
87
+ version: z.literal("7")
88
+ }).safeParse(JSON.parse(await readFile(filename, "utf-8")));
89
+ if (!parsed.success) throw new Error(`Unsupported app schema snapshot: ${filename}. Use Drizzle Kit v1 SQLite migrations.`);
90
+ return parsed.data;
91
+ };
92
+ const canonical = (value) => {
93
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
94
+ if (isObjectValue$1(value)) return `{${Object.entries(value).toSorted(([left], [right]) => left.localeCompare(right)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
95
+ return JSON.stringify(value);
96
+ };
97
+ /** Generate a fresh snapshot with Drizzle in disposable storage, leaving committed history unchanged. */
98
+ const checkAppSchema = async (root, migrationsDirectory) => {
99
+ const { schema } = appDatabasePaths(root);
100
+ try {
101
+ await access(schema);
102
+ } catch (error) {
103
+ if (isMissingFile(error)) return;
104
+ throw error;
105
+ }
106
+ const temporary = await mkdtemp(path.join(tmpdir(), "tailorkit-schema-check-"));
107
+ try {
108
+ const { stdout } = await run(process.execPath, await drizzleGenerateArguments(root, temporary), {
109
+ cwd: root,
110
+ env: {
111
+ ...getBuildEnvironment(),
112
+ CI: "true"
113
+ },
114
+ maxBuffer: 1048576,
115
+ timeout: 3e4
116
+ }).catch((error) => {
117
+ throw new Error(`Could not check the app database schema with Drizzle Kit. Check ${path.relative(root, schema)} and your drizzle-kit/drizzle-orm dependencies.`, { cause: error });
118
+ });
119
+ const current = await snapshot(temporary);
120
+ if (!current && !stdout.includes("No schema changes")) throw new Error(`Drizzle Kit did not produce a schema snapshot. Check ${path.relative(root, schema)} and run tailorkit db generate.`);
121
+ const saved = await snapshot(migrationsDirectory);
122
+ const expected = (current?.ddl ?? []).map(canonical).toSorted();
123
+ const actual = (saved?.ddl ?? []).map(canonical).toSorted();
124
+ if (canonical(expected) !== canonical(actual)) throw new Error(`App database migrations are missing or out of sync with ${path.relative(root, schema)}. Run tailorkit db generate, commit the generated files in ${path.relative(root, migrationsDirectory)}, then build again.`);
125
+ } finally {
126
+ await rm(temporary, {
127
+ force: true,
128
+ recursive: true
129
+ });
130
+ }
131
+ };
132
+ //#endregion
133
+ //#region src/builder/define-view.ts
134
+ const clientImports = /* @__PURE__ */ new Set([
135
+ "tailorkit/client",
136
+ "tailorkit/app",
137
+ "tailorkit/app/client",
138
+ "@tailorkit/app/client",
139
+ "@tailorkit/app"
140
+ ]);
141
+ const createScope = (parent, functionScope = false) => ({
142
+ bindings: /* @__PURE__ */ new Map(),
143
+ functionScope,
144
+ parent
145
+ });
146
+ const bindingNames = (node) => {
147
+ if (!node) return [];
148
+ switch (node.type) {
149
+ case "Identifier": return [node.name];
150
+ case "RestElement": return bindingNames(node.argument);
151
+ case "AssignmentPattern": return bindingNames(node.left);
152
+ case "ArrayPattern": return node.elements.flatMap(bindingNames);
153
+ case "ObjectPattern": return node.properties.flatMap((property) => bindingNames(property.type === "Property" ? property.value : property.argument));
154
+ case "TSParameterProperty": return bindingNames(node.parameter);
155
+ default: return [];
156
+ }
157
+ };
158
+ const bind = (node, scope) => {
159
+ for (const name of bindingNames(node)) scope.bindings.set(name, "local");
160
+ };
161
+ const classScope = (node, parent) => {
162
+ if (node.type === "ClassDeclaration") bind(node.id, parent);
163
+ const scope = createScope(parent);
164
+ bind(node.id, scope);
165
+ return scope;
166
+ };
167
+ const resolve = (scope, name) => {
168
+ for (let current = scope; current; current = current.parent) if (current.bindings.has(name)) return current.bindings.get(name);
169
+ };
170
+ const childNodes = (node) => Object.values(node).flatMap((value) => {
171
+ return (Array.isArray(value) ? value : [value]).filter((child) => child !== void 0 && child !== null && child !== false && child !== "" && child !== 0 && !Number.isNaN(child) && child !== 0n && typeof child === "object" && "type" in child);
172
+ });
173
+ const bindImports = (node, scope) => {
174
+ const sdk = node.importKind !== "type" && clientImports.has(node.source.value);
175
+ for (const specifier of node.specifiers) {
176
+ let binding = "local";
177
+ if (sdk && specifier.type === "ImportNamespaceSpecifier") binding = "namespace";
178
+ else if (sdk && specifier.type === "ImportSpecifier" && specifier.importKind !== "type" && specifier.imported.type === "Identifier" && specifier.imported.name === "defineView") binding = "view";
179
+ scope.bindings.set(specifier.local.name, binding);
180
+ }
181
+ };
182
+ /** Resolve SDK import bindings, including declarations hoisted past a call. */
183
+ const callScopes = (program) => {
184
+ const scopes = /* @__PURE__ */ new WeakMap();
185
+ const bindVariables = (node, scope) => {
186
+ let target = scope;
187
+ if (node.kind === "var") while (!target.functionScope && target.parent) target = target.parent;
188
+ for (const declaration of node.declarations) bind(declaration.id, target);
189
+ };
190
+ const visit = (node, initialScope) => {
191
+ let scope = initialScope;
192
+ scopes.set(node, scope);
193
+ switch (node.type) {
194
+ case "ImportDeclaration":
195
+ bindImports(node, scope);
196
+ return;
197
+ case "VariableDeclaration":
198
+ bindVariables(node, scope);
199
+ break;
200
+ case "FunctionDeclaration":
201
+ case "FunctionExpression":
202
+ case "ArrowFunctionExpression":
203
+ visitFunction(node, scope);
204
+ return;
205
+ case "ClassDeclaration":
206
+ case "ClassExpression":
207
+ scope = classScope(node, scope);
208
+ break;
209
+ case "CatchClause":
210
+ scope = createScope(scope);
211
+ bind(node.param, scope);
212
+ break;
213
+ case "SwitchStatement":
214
+ visit(node.discriminant, scope);
215
+ scope = createScope(scope);
216
+ for (const branch of node.cases) visit(branch, scope);
217
+ return;
218
+ case "BlockStatement":
219
+ case "ForStatement":
220
+ case "ForInStatement":
221
+ case "ForOfStatement":
222
+ scope = createScope(scope);
223
+ break;
224
+ case "StaticBlock":
225
+ case "TSModuleBlock":
226
+ scope = createScope(scope, true);
227
+ break;
228
+ case "TSEnumDeclaration":
229
+ case "TSModuleDeclaration": bind(node.id, scope);
230
+ }
231
+ for (const child of childNodes(node)) visit(child, scope);
232
+ };
233
+ const visitFunction = (node, scope) => {
234
+ if (node.type === "FunctionDeclaration") bind(node.id, scope);
235
+ const parameters = createScope(scope, true);
236
+ if (node.type !== "ArrowFunctionExpression") bind(node.id, parameters);
237
+ for (const parameter of node.params) bind(parameter, parameters);
238
+ for (const parameter of node.params) visit(parameter, parameters);
239
+ if (node.body) visit(node.body, createScope(parameters, true));
240
+ };
241
+ visit(program, createScope(void 0, true));
242
+ return scopes;
243
+ };
244
+ /** Recognize the same SDK calls in route inference and instance extraction. */
245
+ const createDefineViewMatcher = (program) => {
246
+ const scopes = callScopes(program);
247
+ return (node) => {
248
+ if (node.type !== "CallExpression") return false;
249
+ const { callee } = node;
250
+ const scope = scopes.get(node);
251
+ if (callee.type === "Identifier") return resolve(scope, callee.name) === "view";
252
+ if (callee.type !== "MemberExpression" || callee.object.type !== "Identifier" || resolve(scope, callee.object.name) !== "namespace") return false;
253
+ return callee.computed ? callee.property.type === "Literal" && callee.property.value === "defineView" : callee.property.type === "Identifier" && callee.property.name === "defineView";
254
+ };
255
+ };
256
+ //#endregion
257
+ //#region src/builder/file-routes.ts
258
+ const isObjectValue = (value) => typeof value === "object" && value !== null;
259
+ const clientRuntimeImport = "tailorkit:file-client-runtime";
260
+ const routePath = (name, filename) => {
261
+ const segments = name.split(/[./]/u);
262
+ if (segments.some((segment) => !segment || segment === "..")) throw new Error(`${filename} has an empty route segment. Use route.nested-route.view.tsx.`);
263
+ return `/${segments.join("/")}`;
264
+ };
265
+ const fileViewLocation = (filename, root) => {
266
+ const relative = path.relative(path.join(root, "src/slots"), filename).split(path.sep);
267
+ if (relative.length < 2 || relative[0] === ".." || !filename.endsWith(".view.tsx")) return;
268
+ const [slot, ...routeSegments] = relative;
269
+ if (!(slot !== void 0 && slot !== null && slot !== "")) return;
270
+ const basename = routeSegments.join("/").slice(0, -9);
271
+ return {
272
+ path: routePath(basename, filename),
273
+ slot
274
+ };
275
+ };
276
+ /** Fill missing defineView options before instance extraction, on both build sides. */
277
+ const inferFileView = (initialSource, filename, root) => {
278
+ let source = initialSource;
279
+ const location = fileViewLocation(filename, root);
280
+ if (!location) return source;
281
+ const { slot, path: viewPath } = location;
282
+ const parsed = parseSync(filename, source);
283
+ if (parsed.errors.length) throw new Error(`${filename}: ${parsed.errors[0]?.message}`);
284
+ const isDefineViewCall = createDefineViewMatcher(parsed.program);
285
+ const edits = [];
286
+ const visit = (node) => {
287
+ if (isDefineViewCall(node)) {
288
+ const [options] = node.arguments;
289
+ if (options?.type !== "ObjectExpression") throw new Error(`${filename}: file views require defineView with an inline options object.`);
290
+ const properties = new Set(options.properties.flatMap((property) => {
291
+ if (property.type !== "Property" || property.computed) return [];
292
+ if (property.key.type === "Identifier") return [property.key.name];
293
+ if (property.key.type === "Literal") return [String(property.key.value)];
294
+ return [];
295
+ }));
296
+ const defaults = [properties.has("slot") ? "" : `slot: ${JSON.stringify(slot)},`, properties.has("view") ? "" : `view: ${JSON.stringify(viewPath)},`].join("");
297
+ if (defaults) edits.push({
298
+ position: options.start + 1,
299
+ text: defaults
300
+ });
301
+ }
302
+ for (const value of Object.values(node)) {
303
+ const children = Array.isArray(value) ? value : [value];
304
+ for (const child of children) if (isObjectValue(child) && Boolean(child) && "type" in child) visit(child);
305
+ }
306
+ };
307
+ visit(parsed.program);
308
+ for (const edit of edits.toSorted((a, b) => b.position - a.position)) source = source.slice(0, edit.position) + edit.text + source.slice(edit.position);
309
+ return source;
310
+ };
311
+ const discoverFileRoutes = async (root) => {
312
+ const views = [];
313
+ const roots = [];
314
+ const layouts = [];
315
+ const slotsDir = path.join(root, "src/slots");
316
+ const scan = async (directory, slot, prefix = "") => {
317
+ const entries = await (async () => {
318
+ try {
319
+ return await readdir(directory, { withFileTypes: true });
320
+ } catch (error) {
321
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
322
+ throw error;
323
+ }
324
+ })();
325
+ for (const entry of entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
326
+ const filename = path.join(directory, entry.name);
327
+ if (entry.isDirectory()) await scan(filename, slot ?? entry.name, slot !== void 0 && slot !== null && slot !== "" ? `${prefix}${entry.name}/` : "");
328
+ else if (entry.isFile() && slot !== void 0 && slot !== null && slot !== "") {
329
+ if (entry.name.endsWith(".view.tsx")) {
330
+ fileViewLocation(filename, root);
331
+ views.push({
332
+ filename,
333
+ slot
334
+ });
335
+ } else if (entry.name === "root.tsx" && !prefix) roots.push({
336
+ filename,
337
+ slot
338
+ });
339
+ else if (entry.name === "layout.tsx" || entry.name.endsWith(".layout.tsx")) {
340
+ const name = entry.name === "layout.tsx" ? prefix.replace(/\/$/u, "") : prefix + entry.name.slice(0, -11);
341
+ layouts.push({
342
+ filename,
343
+ path: name ? routePath(name, filename) : "/",
344
+ slot
345
+ });
346
+ }
347
+ }
348
+ }
349
+ };
350
+ await scan(slotsDir);
351
+ const rootFile = path.join(root, "src/root.tsx");
352
+ return {
353
+ layouts,
354
+ root: existsSync(rootFile) ? { filename: rootFile } : void 0,
355
+ roots,
356
+ views
357
+ };
358
+ };
359
+ /** Root, layout, view and binding sources to check before deployment. */
360
+ const getClientSourceFiles = async (root) => {
361
+ const discovered = await discoverFileRoutes(root);
362
+ const bindings = path.join(root, "src/tailorkit.gen.ts");
363
+ return [
364
+ ...discovered.root ? [discovered.root.filename] : [],
365
+ ...discovered.roots.map((module) => module.filename),
366
+ ...discovered.layouts.map((module) => module.filename),
367
+ ...discovered.views.map((module) => module.filename),
368
+ ...existsSync(bindings) ? [bindings] : []
369
+ ];
370
+ };
371
+ const fileClientSource = async (root) => {
372
+ const discovered = await discoverFileRoutes(root);
373
+ const imports = [`import { createFileClient } from ${JSON.stringify(clientRuntimeImport)};`];
374
+ let index = 0;
375
+ const module = (entry) => {
376
+ const binding = `module${(index += 1) - 1}`;
377
+ imports.push(`import ${binding} from ${JSON.stringify(entry.filename)};`);
378
+ return `{ filename: ${JSON.stringify(path.relative(root, entry.filename))}, definition: ${binding}`;
379
+ };
380
+ const rootModule = discovered.root ? `${module(discovered.root)} }` : "undefined";
381
+ const roots = discovered.roots.map((entry) => `${module(entry)}, slot: ${JSON.stringify(entry.slot)} }`);
382
+ const layouts = discovered.layouts.map((entry) => `${module(entry)}, slot: ${JSON.stringify(entry.slot)}, path: ${JSON.stringify(entry.path)} }`);
383
+ const views = discovered.views.map((entry) => `${module(entry)}, slot: ${JSON.stringify(entry.slot)} }`);
384
+ return `${imports.join("\n")}\nexport default createFileClient({ root: ${rootModule}, roots: [${roots.join(",")}], layouts: [${layouts.join(",")}], views: [${views.join(",")}] });`;
385
+ };
386
+ /** A generated entry in build output lets the bundler track imports normally.
387
+ * Directory watching also covers routes that did not exist at the first build. */
388
+ const createFileRouteEntry = async (root, output, watching) => {
389
+ let source = await fileClientSource(root);
390
+ const temporaryRoot = path.join(output, "tmp");
391
+ await mkdir(temporaryRoot, { recursive: true });
392
+ const directory = await mkdtemp(path.join(temporaryRoot, "client-"));
393
+ const entry = path.join(directory, "entry.ts");
394
+ await writeFile(entry, source);
395
+ let pending = Promise.resolve();
396
+ let closed = false;
397
+ let timer;
398
+ const refresh = async () => {
399
+ if (closed) return;
400
+ let next;
401
+ try {
402
+ next = await fileClientSource(root);
403
+ } catch (error) {
404
+ next = `throw new Error(${JSON.stringify(error instanceof Error ? error.message : String(error))});`;
405
+ }
406
+ if (next === source) return;
407
+ source = next;
408
+ await writeFile(entry, next);
409
+ };
410
+ const watcher = watching ? watch(path.join(root, "src"), { recursive: true }, () => {
411
+ if (timer) clearTimeout(timer);
412
+ timer = setTimeout(() => {
413
+ pending = (async () => {
414
+ await pending;
415
+ await refresh();
416
+ })();
417
+ }, 25);
418
+ }) : void 0;
419
+ return {
420
+ close: async () => {
421
+ closed = true;
422
+ if (timer) clearTimeout(timer);
423
+ watcher?.close();
424
+ try {
425
+ await pending;
426
+ } finally {
427
+ await rm(directory, {
428
+ force: true,
429
+ recursive: true
430
+ });
431
+ }
432
+ },
433
+ entry
434
+ };
435
+ };
436
+ const fileRoutesPlugin = (root) => ({
437
+ enforce: "pre",
438
+ name: "tailorkit-file-routes",
439
+ async resolveId(id) {
440
+ if (id !== clientRuntimeImport) return;
441
+ const importer = path.join(root(), "src/root.tsx");
442
+ return await (await this.resolve("tailorkit/client", importer, { skipSelf: true }) ?? this.resolve("@tailorkit/app/client", importer, { skipSelf: true }));
443
+ },
444
+ transform: (source, filename) => {
445
+ if (!filename.endsWith(".view.tsx")) return;
446
+ const code = inferFileView(source, filename, root());
447
+ return code === source ? void 0 : {
448
+ code,
449
+ map: null
450
+ };
451
+ }
452
+ });
453
+ //#endregion
454
+ export { createDefineViewMatcher as a, getBuildEnvironment as c, createTailorKitUploadManifest as d, tailorkitUploadManifestSchema as f, inferFileView as i, drizzleGenerateArguments as l, fileRoutesPlugin as n, checkAppSchema as o, getClientSourceFiles as r, isMissingFile as s, createFileRouteEntry as t, appDatabasePaths as u };
@@ -0,0 +1,9 @@
1
+ import { Plugin } from "vite";
2
+ //#region src/index.d.ts
3
+ export interface TailorKitPluginOptions {
4
+ /** Config file path relative to vite.config.ts. Defaults to ./tailorkit.config.ts. */
5
+ configPath?: string;
6
+ }
7
+ /** Build the app client, private backend, migrations and deployment manifest. */
8
+ export declare const tailorkit: (options?: TailorKitPluginOptions) => Plugin[];
9
+ //#endregion