-
- TanStack Start renders the pages, Hono serves the API and Drizzle talks
- to PostgreSQL. Email, storage, search, captcha, AI and cron are adapters
- you pick in config.
+
+ The four in the middle are the core: TanStack Start renders the pages,
+ Hono serves the API and Drizzle talks to PostgreSQL. Everything around
+ them is an adapter you pick in config.
Explore the docs
diff --git a/apps/web/src/site/home/sections/logos/integration-marks.ts b/apps/web/src/site/home/sections/logos/integration-marks.ts
index 49fd823813..91e9f69452 100644
--- a/apps/web/src/site/home/sections/logos/integration-marks.ts
+++ b/apps/web/src/site/home/sections/logos/integration-marks.ts
@@ -16,4 +16,5 @@ export const INTEGRATION_MARKS = {
'M12.001,4.8c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 C13.666,10.618,15.027,12,18.001,12c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C16.337,6.182,14.976,4.8,12.001,4.8z M6.001,12c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 c1.177,1.194,2.538,2.576,5.512,2.576c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C10.337,13.382,8.976,12,6.001,12z',
tanstack:
'M12 0c6.627 0 9.166 4.102 9.166 12S18.626 24 12 24s-9.166-4.096-9.166-12c0-7.898 2.54-12 9.166-12m3.031 17.485c-.861 0-1.33.234-1.708.423-.327.164-.582.292-1.148.292-.567 0-.822-.128-1.148-.292-.378-.189-.848-.423-1.71-.423-.86 0-1.33.234-1.708.423-.327.164-.581.292-1.148.292v1.251c.862 0 1.331-.234 1.709-.423.326-.163.581-.292 1.148-.292s.821.129 1.148.292c.378.189.847.423 1.709.423.861 0 1.33-.234 1.709-.423.326-.163.58-.292 1.147-.292s.822.129 1.148.292c.378.189.848.423 1.71.423v-1.25c-.565 0-.822-.13-1.149-.293-.377-.189-.847-.423-1.709-.423m.41-12.536c.65-.586 0-1.648-.813-1.328-.45.18-.873.438-1.251.779a4.2 4.2 0 0 0-1.202 1.94 4.2 4.2 0 0 0-1.203-1.94 4.3 4.3 0 0 0-1.25-.779c-.814-.32-1.463.742-.814 1.328l2.385 2.153a4.86 4.86 0 0 0-2.731-.839c-.552 0-1.082.09-1.58.26-.836.284-.604 1.532.275 1.532h3.326a4.2 4.2 0 0 0-2.012.988 4 4 0 0 0-.9 1.165c-.403.776.588 1.529 1.237.948l2.686-2.42-.2 6.656c0 .08-.047.158-.107.223a5 5 0 0 1-.257-.123c-.378-.189-.848-.423-1.71-.423-.861 0-1.33.234-1.708.423-.327.164-.581.292-1.148.292v1.251c.861 0 1.33-.234 1.709-.423.326-.164.58-.292 1.148-.292.566 0 .821.128 1.148.292.377.189.847.423 1.708.423.862 0 1.332-.234 1.71-.423.326-.164.58-.292 1.147-.292s.822.128 1.148.292c.378.189.848.423 1.71.423v-1.25c-.565 0-.822-.13-1.149-.293v-.005c-.378-.19-.847-.424-1.709-.424-.861 0-1.33.235-1.709.424-.097.045-.189.094-.283.131a.34.34 0 0 1-.12-.232l-.2-6.69 2.722 2.457c.65.587 1.64-.166 1.236-.948a4.11 4.11 0 0 0-2.911-2.153h3.326c.882 0 1.108-1.245.275-1.531a4.88 4.88 0 0 0-4.311.578l2.385-2.152z',
+ vercel: 'm12 3.686 9.6 16.628H2.4Z',
} as const
diff --git a/packages/vitnode/scripts/cli/builder/app-build.ts b/packages/vitnode/scripts/cli/builder/app-build.ts
index e47a54e594..e600375a20 100644
--- a/packages/vitnode/scripts/cli/builder/app-build.ts
+++ b/packages/vitnode/scripts/cli/builder/app-build.ts
@@ -5,6 +5,7 @@ import type { OutputCapture } from "../ui/capture-output";
import type { TaskHandle, Ui } from "../ui/ui";
import type { EnvironmentInfo } from "./collector";
import type { MeasuredFile } from "./output-files";
+import type { BuildRoute } from "./routes";
import { isPluginPackage } from "../plugins/discover";
import { importFromProject, readPackageJson } from "../project/packages";
@@ -13,12 +14,13 @@ import { createBuildCollector } from "./collector";
import { describeBuildError } from "./error-context";
import { measureFiles } from "./measure";
import { createPackageOwner } from "./package-owner";
+import { htmlPathsUnder, routeReportApiOf } from "./routes";
/** The slice of Vite's JavaScript API a build needs. */
export interface ViteBuildApi {
createBuilder: (config: InlineConfig) => Promise<{
buildApp: () => Promise;
- config: { plugins: readonly { name: string }[] };
+ config: { plugins: readonly { api?: unknown; name: string }[] };
}>;
}
@@ -37,6 +39,7 @@ export interface AppBuildResult {
/** Environments that wrote what ships, in build order. */
environments: string[];
files: MeasuredFile[];
+ reportRoutes: (() => BuildRoute[]) | null;
}
const ENVIRONMENT_LABELS: Record = {
@@ -110,6 +113,7 @@ export const runAppBuild = async ({
const capture = ui.verbose ? null : captureOutput();
const configTask = ui.task("Loading configuration");
+ let routeReportApi: ReturnType = null;
try {
const builder = await vite.createBuilder({
@@ -130,6 +134,8 @@ export const runAppBuild = async ({
: "Configuration loaded",
);
+ routeReportApi = routeReportApiOf(builder.config.plugins);
+
await builder.buildApp();
} catch (error) {
configTask.fail();
@@ -166,9 +172,17 @@ export const runAppBuild = async ({
),
);
+ const publicDir = shipped.find(
+ environment => environment.consumer === "client",
+ )?.outDir;
+
return {
bundlerWarnings,
environments: shipped.map(environment => environment.name),
files,
+ reportRoutes:
+ routeReportApi === null || publicDir == null
+ ? null
+ : () => routeReportApi.routeReport(htmlPathsUnder(publicDir)),
};
};
diff --git a/packages/vitnode/scripts/cli/builder/report.ts b/packages/vitnode/scripts/cli/builder/report.ts
index 92073d54ed..fedb66e5ca 100644
--- a/packages/vitnode/scripts/cli/builder/report.ts
+++ b/packages/vitnode/scripts/cli/builder/report.ts
@@ -1,6 +1,7 @@
import type { Ui } from "../ui/ui";
import type { ChunkAnalysis } from "./analysis";
import type { FileCategory, MeasuredFile } from "./output-files";
+import type { BuildRoute, RouteMode } from "./routes";
import type { SizeSeverity } from "./size-severity";
import type { SnapshotComparison } from "./snapshot";
import type { BuildWarning } from "./warnings";
@@ -266,3 +267,119 @@ export const renderWarnings = (ui: Ui, warnings: readonly BuildWarning[]) => {
ui.note("Size warnings are recommendations - they never fail a build.");
};
+
+const ROUTE_CHILD_LIMIT = 6;
+
+const ADMIN_PREFIX = "/admin";
+
+const isAdminRoute = (route: BuildRoute) =>
+ route.path === ADMIN_PREFIX || route.path.startsWith(`${ADMIN_PREFIX}/`);
+
+const routeMark = (ui: Ui, mode: RouteMode) => {
+ switch (mode) {
+ case "dynamic":
+ return ui.colors.muted(ui.symbols.fn);
+ case "partial":
+ return ui.symbols.dot;
+ case "static":
+ return ui.colors.success(ui.symbols.pending);
+ }
+};
+
+const ROUTE_LEGEND: { description: string; label: string; mode: RouteMode }[] =
+ [
+ {
+ description: "prerendered as static HTML",
+ label: "Static",
+ mode: "static",
+ },
+ {
+ description: "some paths prerendered, the rest rendered on demand",
+ label: "Partial",
+ mode: "partial",
+ },
+ {
+ description: "rendered on demand",
+ label: "Dynamic",
+ mode: "dynamic",
+ },
+ ];
+
+interface RouteLine {
+ children: string[];
+ detail?: string;
+ mode: RouteMode;
+ path: string;
+}
+
+const routeLines = (ui: Ui, routes: readonly BuildRoute[]): RouteLine[] => {
+ const collapsed = ui.verbose
+ ? []
+ : routes.filter(route => isAdminRoute(route) && route.mode === "dynamic");
+ const lines: RouteLine[] = routes
+ .filter(route => !collapsed.includes(route))
+ .map(route => ({
+ children:
+ route.staticPaths.length === 1 && route.staticPaths[0] === route.path
+ ? []
+ : route.staticPaths,
+ mode: route.mode,
+ path: route.path,
+ }));
+
+ if (collapsed.length > 0) {
+ lines.push({
+ children: [],
+ detail: `${plural(collapsed.length, "AdminCP route")} (--verbose lists all)`,
+ mode: "dynamic",
+ path: `${ADMIN_PREFIX}/*`,
+ });
+ }
+
+ return lines.sort((a, b) => (a.path < b.path ? -1 : 1));
+};
+
+export const renderRoutes = (ui: Ui, routes: readonly BuildRoute[]) => {
+ if (routes.length === 0) return;
+
+ const { bar, corner, tee } = ui.symbols;
+ const lines = routeLines(ui, routes);
+
+ ui.section("Routes");
+
+ lines.forEach((line, index) => {
+ const isLast = index === lines.length - 1;
+ const detail =
+ line.detail === undefined ? "" : ` ${ui.colors.muted(line.detail)}`;
+ ui.line(
+ ` ${ui.colors.muted(isLast ? corner : tee)} ${routeMark(ui, line.mode)} ${line.path}${detail}`,
+ );
+
+ const shown = ui.verbose
+ ? line.children
+ : line.children.slice(0, ROUTE_CHILD_LIMIT);
+ const hidden = line.children.length - shown.length;
+ const rail = isLast ? " " : bar;
+ const children = [
+ ...shown,
+ ...(hidden > 0 ? [`… ${plural(hidden, "more path")}`] : []),
+ ];
+
+ children.forEach((child, childIndex) => {
+ const branch = childIndex === children.length - 1 ? corner : tee;
+ ui.line(
+ ` ${ui.colors.muted(rail)} ${ui.colors.muted(branch)} ${ui.colors.muted(child)}`,
+ );
+ });
+ });
+
+ const used = new Set(lines.map(line => line.mode));
+ const legend = ROUTE_LEGEND.filter(entry => used.has(entry.mode));
+ const labelWidth = Math.max(...legend.map(entry => entry.label.length));
+ ui.line();
+ legend.forEach(entry => {
+ ui.line(
+ ` ${routeMark(ui, entry.mode)} ${padEnd(entry.label, labelWidth)} ${ui.colors.muted(entry.description)}`,
+ );
+ });
+};
diff --git a/packages/vitnode/scripts/cli/builder/routes.test.ts b/packages/vitnode/scripts/cli/builder/routes.test.ts
new file mode 100644
index 0000000000..ecb9d415e3
--- /dev/null
+++ b/packages/vitnode/scripts/cli/builder/routes.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { routeReportApiOf, urlPathOfHtmlFile } from "./routes";
+
+describe("urlPathOfHtmlFile", () => {
+ it("serves an index file at its directory", () => {
+ expect(urlPathOfHtmlFile("index.html")).toBe("/");
+ expect(urlPathOfHtmlFile("pl/solutions/index.html")).toBe("/pl/solutions");
+ });
+
+ it("serves any other file at its name", () => {
+ expect(urlPathOfHtmlFile("404.html")).toBe("/404");
+ });
+});
+
+describe("routeReportApiOf", () => {
+ it("finds the report on VitNode's routes plugin", () => {
+ const routeReport = () => [];
+
+ expect(
+ routeReportApiOf([
+ { name: "nitro:main" },
+ { api: { routeReport }, name: "vitnode:plugin-routes" },
+ ])?.routeReport,
+ ).toBe(routeReport);
+ });
+
+ it("answers null for a plugin without the report", () => {
+ expect(routeReportApiOf([{ name: "vitnode:plugin-routes" }])).toBeNull();
+ });
+});
diff --git a/packages/vitnode/scripts/cli/builder/routes.ts b/packages/vitnode/scripts/cli/builder/routes.ts
new file mode 100644
index 0000000000..80f461efe3
--- /dev/null
+++ b/packages/vitnode/scripts/cli/builder/routes.ts
@@ -0,0 +1,57 @@
+import { existsSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+
+export type RouteMode = "dynamic" | "partial" | "static";
+
+export interface BuildRoute {
+ mode: RouteMode;
+ path: string;
+ staticPaths: string[];
+}
+
+interface RouteReportApi {
+ routeReport: (htmlPaths: readonly string[]) => BuildRoute[];
+}
+
+const ROUTES_PLUGIN = "vitnode:plugin-routes";
+
+const isRouteReportApi = (api: unknown): api is RouteReportApi =>
+ typeof api === "object" &&
+ api !== null &&
+ typeof (api as { routeReport?: unknown }).routeReport === "function";
+
+export const routeReportApiOf = (
+ plugins: readonly { api?: unknown; name: string }[],
+): null | RouteReportApi => {
+ const plugin = plugins.find(candidate => candidate.name === ROUTES_PLUGIN);
+
+ return plugin !== undefined && isRouteReportApi(plugin.api)
+ ? plugin.api
+ : null;
+};
+
+const htmlFilesUnder = (directory: string, prefix = ""): string[] =>
+ readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
+ if (entry.name.startsWith(".")) return [];
+
+ const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
+ if (entry.isDirectory()) {
+ return htmlFilesUnder(join(directory, entry.name), relative);
+ }
+
+ return entry.name.endsWith(".html") ? [relative] : [];
+ });
+
+export const urlPathOfHtmlFile = (file: string): string => {
+ if (file === "index.html") return "/";
+ if (file.endsWith("/index.html")) {
+ return `/${file.slice(0, -"/index.html".length)}`;
+ }
+
+ return `/${file.slice(0, -".html".length)}`;
+};
+
+export const htmlPathsUnder = (directory: string): string[] =>
+ existsSync(directory)
+ ? htmlFilesUnder(directory).map(urlPathOfHtmlFile).sort()
+ : [];
diff --git a/packages/vitnode/scripts/cli/commands/build.test.ts b/packages/vitnode/scripts/cli/commands/build.test.ts
index 1478af6fd6..84d2685c2b 100644
--- a/packages/vitnode/scripts/cli/commands/build.test.ts
+++ b/packages/vitnode/scripts/cli/commands/build.test.ts
@@ -13,6 +13,7 @@ import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ViteBuildApi } from "../builder/app-build";
+import type { BuildRoute } from "../builder/routes";
import { snapshotPathFor } from "../builder/snapshot";
import { RuntimeError } from "../errors";
@@ -67,7 +68,13 @@ const call = (
*/
const fakeVite = (
environments: FakeEnvironment[],
- { fail }: { fail?: Error } = {},
+ {
+ fail,
+ routeReport,
+ }: {
+ fail?: Error;
+ routeReport?: (htmlPaths: readonly string[]) => BuildRoute[];
+ } = {},
): ViteBuildApi & { configs: InlineConfig[] } => {
const configs: InlineConfig[] = [];
@@ -123,7 +130,14 @@ const fakeVite = (
await call(plugin, "closeBundle", context);
}
},
- config: { plugins: [{ name: "vitnode:plugin-routes" }] },
+ config: {
+ plugins: [
+ {
+ api: routeReport === undefined ? undefined : { routeReport },
+ name: "vitnode:plugin-routes",
+ },
+ ],
+ },
};
},
};
@@ -447,3 +461,125 @@ describe("a failed build", () => {
expect(failure.details).toEqual(["Something odd"]);
});
});
+
+describe("vitnode build route summary", () => {
+ const writeHtml = (...files: string[]) => {
+ files.forEach(file => {
+ const path = join(root, ".output/public", file);
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, "");
+ });
+ };
+
+ const ROUTES: BuildRoute[] = [
+ { mode: "static", path: "/", staticPaths: ["/", "/pl"] },
+ {
+ mode: "partial",
+ path: "/solutions/:slug",
+ staticPaths: ["/solutions/help-center"],
+ },
+ { mode: "dynamic", path: "/login", staticPaths: [] },
+ { mode: "dynamic", path: "/admin/core", staticPaths: [] },
+ { mode: "dynamic", path: "/admin/core/users", staticPaths: [] },
+ ];
+
+ it("asks the routes plugin about every HTML file the build wrote", async () => {
+ writeHtml(
+ "index.html",
+ "pl/index.html",
+ "solutions/help-center/index.html",
+ );
+ let received: readonly string[] = [];
+
+ await build(
+ fakeVite(appBuild({ admin: 1000, index: 1000 }), {
+ routeReport: htmlPaths => {
+ received = htmlPaths;
+
+ return ROUTES;
+ },
+ }),
+ );
+
+ expect(received).toEqual(["/", "/pl", "/solutions/help-center"]);
+ });
+
+ it("ends with every route marked by how it is served", async () => {
+ const { runtime } = await build(
+ fakeVite(appBuild({ admin: 1000, index: 1000 }), {
+ routeReport: () => ROUTES,
+ }),
+ );
+ const output = runtime.output();
+
+ expect(output).toContain("Routes");
+ expect(output).toContain("├ ○ /\n");
+ expect(output).toContain("│ └ /pl");
+ expect(output).toContain("● /solutions/:slug");
+ expect(output).toContain("ƒ /login");
+ expect(output).toContain("ƒ /admin/* 2 AdminCP routes");
+ expect(output).not.toContain("/admin/core/users");
+ expect(output).toMatch(/○ {2}Static\s+prerendered as static HTML/);
+ expect(output.indexOf("Routes")).toBeGreaterThan(
+ output.indexOf("Client JS"),
+ );
+ });
+
+ it("lists every AdminCP route with --verbose", async () => {
+ const { runtime } = await build(
+ fakeVite(appBuild({ admin: 1000, index: 1000 }), {
+ routeReport: () => ROUTES,
+ }),
+ { verbose: true },
+ );
+
+ expect(runtime.output()).toContain("ƒ /admin/core/users");
+ });
+
+ it("skips the section rather than fail a finished build", async () => {
+ const { code, runtime } = await build(
+ fakeVite(appBuild({ admin: 1000, index: 1000 }), {
+ routeReport: () => {
+ throw new Error("no i18n configuration");
+ },
+ }),
+ );
+
+ expect(code).toBe(0);
+ expect(runtime.output()).toContain(
+ "Route summary skipped: no i18n configuration",
+ );
+ });
+
+ it("leaves the section out for an app VitNode does not route", async () => {
+ const { runtime } = await build(
+ fakeVite(appBuild({ admin: 1000, index: 1000 })),
+ );
+
+ expect(runtime.output()).not.toContain("Routes");
+ });
+});
+
+describe("vitnode build start hint", () => {
+ it("names the start script with the package manager the app uses", async () => {
+ writeFileSync(
+ join(root, "package.json"),
+ JSON.stringify({ name: "web", scripts: { start: "vitnode start" } }),
+ );
+
+ const { runtime } = await build(
+ fakeVite(appBuild({ admin: 1000, index: 1000 })),
+ { env: { npm_config_user_agent: "pnpm/10.18.0 npm/? node/v24.15.0" } },
+ );
+
+ expect(runtime.output()).toContain("Run pnpm start to serve it.");
+ });
+
+ it("falls back to vitnode start for an app without a start script", async () => {
+ const { runtime } = await build(
+ fakeVite(appBuild({ admin: 1000, index: 1000 })),
+ );
+
+ expect(runtime.output()).toContain("Run vitnode start to serve it.");
+ });
+});
diff --git a/packages/vitnode/scripts/cli/commands/build.ts b/packages/vitnode/scripts/cli/commands/build.ts
index 42564f6040..680197c0eb 100644
--- a/packages/vitnode/scripts/cli/commands/build.ts
+++ b/packages/vitnode/scripts/cli/commands/build.ts
@@ -1,7 +1,8 @@
import type { AppBuildOptions } from "../builder/app-build";
import type { StepRunner } from "../builder/compiler-build";
import type { MeasuredFile } from "../builder/output-files";
-import type { CommandContext, OutputOptions } from "../context";
+import type { BuildRoute } from "../builder/routes";
+import type { CommandContext, Env, OutputOptions } from "../context";
import type { Project } from "../project/project";
import type { Ui } from "../ui/ui";
@@ -19,6 +20,7 @@ import {
renderAnalysis,
renderComparison,
renderOutput,
+ renderRoutes,
renderWarnings,
} from "../builder/report";
import {
@@ -31,6 +33,8 @@ import {
import { collectWarnings } from "../builder/warnings";
import { errorMessage, EXIT_CODE } from "../errors";
import { isPluginPackage } from "../plugins/discover";
+import { detectPackageManager } from "../project/package-manager";
+import { readPackageJson } from "../project/packages";
import { detectProject } from "../project/project";
import { detectRuntime } from "../project/runtime";
import { formatDuration, plural } from "../ui/format";
@@ -88,7 +92,12 @@ const reportAppBuild = (
{
analyze,
bundlerWarnings,
- }: { analyze: boolean; bundlerWarnings: readonly string[] },
+ reportRoutes,
+ }: {
+ analyze: boolean;
+ bundlerWarnings: readonly string[];
+ reportRoutes: (() => readonly BuildRoute[]) | null;
+ },
) => {
const analyses = analyze
? safely(ui, "Bundle analysis", () =>
@@ -113,6 +122,21 @@ const reportAppBuild = (
`${plural(bundlerWarnings.length, "bundler warning")} hidden - run with --verbose to see them.`,
);
}
+
+ if (reportRoutes !== null) {
+ safely(ui, "Route summary", () => {
+ renderRoutes(ui, reportRoutes());
+ });
+ }
+};
+
+const startCommand = (project: Project, env: Env): string => {
+ const packageManager = detectPackageManager(project.root, env);
+
+ return packageManager === undefined ||
+ readPackageJson(project.root)?.scripts?.start === undefined
+ ? "vitnode start"
+ : `${packageManager} start`;
};
export const runBuildCommand = async (
@@ -163,13 +187,16 @@ export const runBuildCommand = async (
reportAppBuild(ui, project, result.files, {
analyze,
bundlerWarnings: result.bundlerWarnings,
+ reportRoutes: result.reportRoutes,
});
}
ui.line();
ui.success(`Built in ${formatDuration(now() - startedAt)}`);
if (project.kind !== "package") {
- ui.note(`Run ${ui.colors.command("vitnode start")} to serve it.`);
+ ui.note(
+ `Run ${ui.colors.command(startCommand(project, env))} to serve it.`,
+ );
}
ui.line();
diff --git a/packages/vitnode/scripts/cli/project/package-manager.test.ts b/packages/vitnode/scripts/cli/project/package-manager.test.ts
new file mode 100644
index 0000000000..0712a7fd6e
--- /dev/null
+++ b/packages/vitnode/scripts/cli/project/package-manager.test.ts
@@ -0,0 +1,58 @@
+// @vitest-environment node
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { detectPackageManager } from "./package-manager";
+
+let root: string;
+
+beforeEach(() => {
+ root = mkdtempSync(join(tmpdir(), "vitnode-package-manager-"));
+ mkdirSync(join(root, "apps", "web"), { recursive: true });
+ writeFileSync(join(root, "apps", "web", "package.json"), "{}");
+});
+
+afterEach(() => {
+ rmSync(root, { force: true, recursive: true });
+});
+
+const web = () => join(root, "apps", "web");
+
+describe("detectPackageManager", () => {
+ it("is the package manager that started the command", () => {
+ writeFileSync(join(root, "yarn.lock"), "");
+
+ expect(
+ detectPackageManager(web(), {
+ npm_config_user_agent: "pnpm/10.18.0 npm/? node/v24.15.0",
+ }),
+ ).toBe("pnpm");
+ });
+
+ it("is the one the workspace declares", () => {
+ writeFileSync(
+ join(root, "package.json"),
+ JSON.stringify({ packageManager: "yarn@4.9.1" }),
+ );
+
+ expect(detectPackageManager(web(), {})).toBe("yarn");
+ });
+
+ it("is the one whose lockfile the workspace has", () => {
+ writeFileSync(join(root, "bun.lock"), "");
+
+ expect(detectPackageManager(web(), {})).toBe("bun");
+ });
+
+ it("is unknown when nothing names one", () => {
+ expect(detectPackageManager(web(), {})).toBeUndefined();
+ });
+
+ it("is unknown for a package manager it cannot name a command for", () => {
+ expect(
+ detectPackageManager(web(), { npm_config_user_agent: "deno/2.5.0" }),
+ ).toBeUndefined();
+ });
+});
diff --git a/packages/vitnode/scripts/cli/project/package-manager.ts b/packages/vitnode/scripts/cli/project/package-manager.ts
new file mode 100644
index 0000000000..2943a668a3
--- /dev/null
+++ b/packages/vitnode/scripts/cli/project/package-manager.ts
@@ -0,0 +1,59 @@
+import { existsSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+
+import type { Env } from "../context";
+
+import { readPackageJson } from "./packages";
+
+export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
+
+const PACKAGE_MANAGERS: readonly PackageManager[] = [
+ "bun",
+ "npm",
+ "pnpm",
+ "yarn",
+];
+
+const LOCKFILES: readonly (readonly [string, PackageManager])[] = [
+ ["bun.lock", "bun"],
+ ["bun.lockb", "bun"],
+ ["pnpm-lock.yaml", "pnpm"],
+ ["yarn.lock", "yarn"],
+ ["package-lock.json", "npm"],
+];
+
+const packageManagerNamed = (name: string): PackageManager | undefined =>
+ PACKAGE_MANAGERS.find(manager => manager === name);
+
+export const declaredPackageManager = (
+ root: string,
+): PackageManager | undefined => {
+ let current = resolve(root);
+
+ for (;;) {
+ const declared = readPackageJson(current)?.packageManager;
+ if (declared !== undefined) {
+ return packageManagerNamed(declared.split("@")[0]);
+ }
+
+ const lockfile = LOCKFILES.find(([file]) =>
+ existsSync(join(current, file)),
+ );
+ if (lockfile !== undefined) return lockfile[1];
+
+ const parent = dirname(current);
+ if (parent === current) return undefined;
+ current = parent;
+ }
+};
+
+export const detectPackageManager = (
+ root: string,
+ env: Env,
+): PackageManager | undefined => {
+ const runningManager = env.npm_config_user_agent?.split("/")[0];
+
+ return runningManager === undefined
+ ? declaredPackageManager(root)
+ : packageManagerNamed(runningManager);
+};
diff --git a/packages/vitnode/scripts/cli/project/runtime.ts b/packages/vitnode/scripts/cli/project/runtime.ts
index d1b4d9b15b..8695ec1cab 100644
--- a/packages/vitnode/scripts/cli/project/runtime.ts
+++ b/packages/vitnode/scripts/cli/project/runtime.ts
@@ -1,9 +1,6 @@
-import { existsSync } from "node:fs";
-import { dirname, join, resolve } from "node:path";
-
import type { Env } from "../context";
-import { readPackageJson } from "./packages";
+import { declaredPackageManager } from "./package-manager";
/**
* What a standalone API app runs on.
@@ -15,9 +12,6 @@ import { readPackageJson } from "./packages";
*/
export type Runtime = "bun" | "node";
-const OTHER_LOCKFILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock"];
-const BUN_LOCKFILES = ["bun.lock", "bun.lockb"];
-
/**
* Bun when the CLI itself runs under Bun, when Bun started the script that
* runs it, or when the project (or its workspace) is a Bun project - a
@@ -32,22 +26,7 @@ export const detectRuntime = (
if (versions.bun !== undefined) return "bun";
if (env.npm_config_user_agent?.startsWith("bun/")) return "bun";
- let current = resolve(root);
- for (;;) {
- const manager = readPackageJson(current)?.packageManager;
- if (manager !== undefined)
- return manager.startsWith("bun@") ? "bun" : "node";
- if (BUN_LOCKFILES.some(file => existsSync(join(current, file)))) {
- return "bun";
- }
- if (OTHER_LOCKFILES.some(file => existsSync(join(current, file)))) {
- return "node";
- }
-
- const parent = dirname(current);
- if (parent === current) return "node";
- current = parent;
- }
+ return declaredPackageManager(root) === "bun" ? "bun" : "node";
};
/**
diff --git a/packages/vitnode/scripts/cli/ui/symbols.ts b/packages/vitnode/scripts/cli/ui/symbols.ts
index dc6b0014ac..ccba0e3872 100644
--- a/packages/vitnode/scripts/cli/ui/symbols.ts
+++ b/packages/vitnode/scripts/cli/ui/symbols.ts
@@ -7,6 +7,7 @@ export interface Symbols {
corner: string;
dot: string;
error: string;
+ fn: string;
large: string;
line: string;
pending: string;
@@ -25,6 +26,7 @@ const UNICODE: Symbols = {
corner: "└",
dot: "●",
error: "✖",
+ fn: "ƒ",
large: "▲",
line: "─",
pending: "○",
@@ -43,6 +45,7 @@ const ASCII: Symbols = {
corner: "`-",
dot: "*",
error: "x",
+ fn: "f",
large: "^",
line: "-",
pending: "o",
diff --git a/packages/vitnode/src/framework/plugin-routes/index.ts b/packages/vitnode/src/framework/plugin-routes/index.ts
index 642a509170..78a1d004e6 100644
--- a/packages/vitnode/src/framework/plugin-routes/index.ts
+++ b/packages/vitnode/src/framework/plugin-routes/index.ts
@@ -39,6 +39,7 @@ export type {
} from "./locale-route-paths.js";
export {
assertLocaleRoutePaths,
+ hostRouteSegments,
i18nFromLoadedConfig,
localeRoutePathTargets,
} from "./locale-route-paths.js";
diff --git a/packages/vitnode/src/framework/plugin-routes/locale-route-paths.ts b/packages/vitnode/src/framework/plugin-routes/locale-route-paths.ts
index e2c864380d..ad6363ae81 100644
--- a/packages/vitnode/src/framework/plugin-routes/locale-route-paths.ts
+++ b/packages/vitnode/src/framework/plugin-routes/locale-route-paths.ts
@@ -45,7 +45,9 @@ export const i18nFromLoadedConfig = (
return isLocaleRoutePathsConfig(i18n) ? i18n : undefined;
};
-const hostRouteSegments = (path: string): null | PluginRouteSegment[] => {
+export const hostRouteSegments = (
+ path: string,
+): null | PluginRouteSegment[] => {
const parts = path.split("/").filter(part => part.length > 0);
const segments = parts.map((part): PluginRouteSegment => {
if (part === "$") return { kind: "splat" };
diff --git a/packages/vitnode/src/framework/vite/plugin-routes.ts b/packages/vitnode/src/framework/vite/plugin-routes.ts
index 831d21f304..c8e8c36c8b 100644
--- a/packages/vitnode/src/framework/vite/plugin-routes.ts
+++ b/packages/vitnode/src/framework/vite/plugin-routes.ts
@@ -25,6 +25,7 @@ import type {
LocaleRoutePathsConfig,
PluginRouteCompilerSource,
} from "../plugin-routes";
+import type { RouteReportEntry, RouteReportInput } from "./route-report";
import { generateAdminNavSource } from "../admin-nav";
import { generateApiRegistrySource } from "../api-registry";
@@ -47,6 +48,7 @@ import {
} from "../plugin-routes";
import { createGenerationQueue } from "./generation-queue";
import { versionedModuleUrl } from "./module-version";
+import { buildRouteReport } from "./route-report";
const ROUTES_SUBPATH = "routes";
@@ -529,12 +531,13 @@ const discover = async (
const contentUrls = await loadPluginContentUrls(contentModules);
+ const hostRoutes = readHostRoutes(
+ appRoot,
+ hostRoutesConfigFor(appRoot, options.hostRoutesDir),
+ );
const compiled = compilePluginRoutes({
contentUrls,
- hostRoutes: readHostRoutes(
- appRoot,
- hostRoutesConfigFor(appRoot, options.hostRoutesDir),
- ),
+ hostRoutes,
i18n,
sources: loaded.map(({ source }) => source),
});
@@ -558,6 +561,8 @@ const discover = async (
specifier,
})),
contentRegistry: contentRegistry.modules,
+ hostRoutes,
+ i18n,
packageMessages,
watch,
};
@@ -575,12 +580,16 @@ const removeIfPresent = async (path: string): Promise => {
await unlink(path);
};
+type DiscoveredRoutes = Omit & {
+ i18n: RouteReportInput["i18n"] | undefined;
+};
+
/** All seven generated files, from one discovery pass. */
const writeGenerated = async (
appRoot: string,
options: VitNodePluginRoutesOptions,
onLoaded?: (watch: string[]) => void,
-): Promise => {
+): Promise => {
const paths = pathsFor(appRoot);
const {
adminNav,
@@ -589,6 +598,8 @@ const writeGenerated = async (
compiled,
contentModules,
contentRegistry,
+ hostRoutes,
+ i18n,
packageMessages,
} = await discover(appRoot, options, onLoaded);
@@ -614,29 +625,53 @@ const writeGenerated = async (
),
removeIfPresent(paths.staleManifest),
]);
+
+ return { hostRoutes, i18n, pluginRoutes: compiled.manifest };
};
+export interface VitNodePluginRoutesApi {
+ routeReport: (htmlPaths: readonly string[]) => RouteReportEntry[];
+}
+
export const vitNodePluginRoutes = (
options: VitNodePluginRoutesOptions,
-): Plugin => {
+): Plugin => {
const { appRoot } = options;
const configPath = pathsFor(appRoot).config;
const routesDir = hostRoutesConfigFor(appRoot, options.hostRoutesDir).dir;
+ let discovered: DiscoveredRoutes | undefined;
return {
+ api: {
+ routeReport: htmlPaths => {
+ if (discovered?.i18n === undefined) {
+ throw new Error(
+ `${ERROR_PREFIX} The route report needs the i18n configuration from ${relative(appRoot, configPath)}, and none was read.`,
+ );
+ }
+
+ return buildRouteReport({
+ ...discovered,
+ htmlPaths,
+ i18n: discovered.i18n,
+ });
+ },
+ },
+
config: async () => {
- await writeGenerated(appRoot, options);
+ discovered = await writeGenerated(appRoot, options);
},
configureServer: server => {
const watched = new Set([configPath]);
const queue = createGenerationQueue(
- async () =>
- writeGenerated(appRoot, options, files => {
+ async () => {
+ await writeGenerated(appRoot, options, files => {
files.forEach(file => watched.add(file));
server.watcher.add(files);
- }),
+ });
+ },
error => {
server.config.logger.error(String(error));
},
diff --git a/packages/vitnode/src/framework/vite/route-report.test.ts b/packages/vitnode/src/framework/vite/route-report.test.ts
new file mode 100644
index 0000000000..2a10d09c8f
--- /dev/null
+++ b/packages/vitnode/src/framework/vite/route-report.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, it } from "vitest";
+
+import type { PluginRoute } from "@/routing/types";
+
+import { parseRoutePath } from "@/routing/path";
+
+import { buildRouteReport } from "./route-report";
+
+const pluginRoute = (
+ path: string,
+ kind: PluginRoute["kind"] = "page",
+): PluginRoute => {
+ const parsed = parseRoutePath(path);
+ if (!parsed.ok) throw new Error(`invalid test path ${path}`);
+
+ return {
+ area: "main",
+ id: `@vitnode/core:${path}`,
+ kind,
+ messages: [],
+ parentId: null,
+ path: parsed.path,
+ pluginId: "@vitnode/core",
+ requires: null,
+ routeId: path,
+ segments: parsed.segments,
+ };
+};
+
+const hostRoute = (path: string) => ({ file: `src/routes${path}.tsx`, path });
+
+const i18n = {
+ defaultLocale: "en",
+ locales: [
+ { code: "en", name: "English" },
+ { code: "pl", name: "Polski" },
+ ],
+ routePaths: { pl: { "/discover": "/odkrywaj" } },
+};
+
+const report = (htmlPaths: string[]) =>
+ buildRouteReport({
+ hostRoutes: [
+ hostRoute("/"),
+ hostRoute("/plugins"),
+ hostRoute("/solutions/$slug"),
+ hostRoute("/api/$"),
+ ],
+ htmlPaths,
+ i18n,
+ pluginRoutes: [
+ pluginRoute("/discover"),
+ pluginRoute("/login"),
+ pluginRoute("/settings", "layout"),
+ ],
+ });
+
+const entry = (htmlPaths: string[], path: string) =>
+ report(htmlPaths).find(candidate => candidate.path === path);
+
+describe("buildRouteReport", () => {
+ it("calls a route static once every language has a file", () => {
+ expect(entry(["/plugins", "/pl/plugins"], "/plugins")).toEqual({
+ mode: "static",
+ path: "/plugins",
+ staticPaths: ["/plugins", "/pl/plugins"],
+ });
+ });
+
+ it("calls a route partly static when one language still renders on demand", () => {
+ expect(entry(["/plugins"], "/plugins")?.mode).toBe("partial");
+ });
+
+ it("files a translated path under the route it translates", () => {
+ expect(entry(["/discover", "/pl/odkrywaj"], "/discover")).toEqual({
+ mode: "static",
+ path: "/discover",
+ staticPaths: ["/discover", "/pl/odkrywaj"],
+ });
+ });
+
+ it("lists the prerendered values of a route with a parameter", () => {
+ expect(
+ entry(
+ [
+ "/pl/solutions/help-center",
+ "/pl/solutions/gaming-guild",
+ "/solutions/help-center",
+ "/solutions/gaming-guild",
+ ],
+ "/solutions/:slug",
+ ),
+ ).toEqual({
+ mode: "partial",
+ path: "/solutions/:slug",
+ staticPaths: [
+ "/solutions/gaming-guild",
+ "/pl/solutions/gaming-guild",
+ "/solutions/help-center",
+ "/pl/solutions/help-center",
+ ],
+ });
+ });
+
+ it("prefers the route with fixed segments over one with a parameter", () => {
+ expect(entry(["/plugins"], "/solutions/:slug")?.staticPaths).toEqual([]);
+ });
+
+ it("gives a file to the exact route before a catch-all below it", () => {
+ const [docs] = buildRouteReport({
+ hostRoutes: [hostRoute("/docs"), hostRoute("/docs/$")],
+ htmlPaths: ["/docs", "/pl/docs"],
+ i18n,
+ pluginRoutes: [],
+ });
+
+ expect(docs).toEqual({
+ mode: "static",
+ path: "/docs",
+ staticPaths: ["/docs", "/pl/docs"],
+ });
+ });
+
+ it("reports routes without a file as rendered on demand", () => {
+ expect(entry([], "/login")?.mode).toBe("dynamic");
+ expect(entry([], "/api/*")?.mode).toBe("dynamic");
+ });
+
+ it("leaves layouts out, since they are not pages", () => {
+ expect(entry([], "/settings")).toBeUndefined();
+ });
+
+ it("still reports a static file no route claims", () => {
+ expect(entry(["/404"], "/404")).toEqual({
+ mode: "static",
+ path: "/404",
+ staticPaths: ["/404"],
+ });
+ });
+});
diff --git a/packages/vitnode/src/framework/vite/route-report.ts b/packages/vitnode/src/framework/vite/route-report.ts
new file mode 100644
index 0000000000..63510bcb36
--- /dev/null
+++ b/packages/vitnode/src/framework/vite/route-report.ts
@@ -0,0 +1,160 @@
+import type { LocaleRouting } from "@/lib/i18n/locale-routing";
+import type { PluginRoute, PluginRouteSegment } from "@/routing/types";
+
+import { localeRoutingFromConfig } from "@/lib/i18n/locale-routing";
+import { formatRoutePath } from "@/routing/path";
+
+import type { HostRoutePath, LocaleRoutePathsConfig } from "../plugin-routes";
+
+import { hostRouteSegments } from "../plugin-routes";
+
+export type RouteRenderMode = "dynamic" | "partial" | "static";
+
+export interface RouteReportEntry {
+ mode: RouteRenderMode;
+ path: string;
+ staticPaths: string[];
+}
+
+export interface RouteReportInput {
+ hostRoutes: readonly HostRoutePath[];
+ htmlPaths: readonly string[];
+ i18n: LocaleRoutePathsConfig;
+ pluginRoutes: readonly PluginRoute[];
+}
+
+interface ReportedRoute {
+ path: string;
+ segments: PluginRouteSegment[];
+ staticPaths: Set;
+}
+
+const segmentRank = (segment: PluginRouteSegment | undefined): number => {
+ if (segment === undefined) return 2;
+
+ return { param: 1, splat: 3, static: 0 }[segment.kind];
+};
+
+const bySpecificity = (a: ReportedRoute, b: ReportedRoute): number => {
+ const length = Math.max(a.segments.length, b.segments.length);
+
+ for (let index = 0; index < length; index++) {
+ const difference =
+ segmentRank(a.segments[index]) - segmentRank(b.segments[index]);
+ if (difference !== 0) return difference;
+ }
+
+ return 0;
+};
+
+const matchesRoute = (
+ segments: readonly PluginRouteSegment[],
+ parts: readonly string[],
+): boolean => {
+ for (const [index, segment] of segments.entries()) {
+ if (segment.kind === "splat") return parts.length >= index;
+ if (index >= parts.length) return false;
+ if (
+ segment.kind === "static" &&
+ segment.value.toLowerCase() !== parts[index].toLowerCase()
+ ) {
+ return false;
+ }
+ }
+
+ return segments.length === parts.length;
+};
+
+const reportedRoutes = (
+ hostRoutes: readonly HostRoutePath[],
+ pluginRoutes: readonly PluginRoute[],
+): ReportedRoute[] => {
+ const routes = new Map();
+ const add = (segments: PluginRouteSegment[]) => {
+ const path = formatRoutePath(segments);
+ routes.set(path, { path, segments, staticPaths: new Set() });
+ };
+
+ pluginRoutes
+ .filter(route => route.kind === "page")
+ .forEach(route => {
+ add(route.segments);
+ });
+ hostRoutes.forEach(route => {
+ const segments = hostRouteSegments(route.path);
+ if (segments !== null) add(segments);
+ });
+
+ return [...routes.values()].sort(bySpecificity);
+};
+
+const modeOf = (
+ route: ReportedRoute,
+ localeRouting: LocaleRouting,
+): RouteRenderMode => {
+ if (route.staticPaths.size === 0) return "dynamic";
+ if (route.segments.some(segment => segment.kind !== "static")) {
+ return "partial";
+ }
+
+ const isEveryLocaleStatic = localeRouting.locales.every(locale =>
+ route.staticPaths.has(localeRouting.localizePathname(route.path, locale)),
+ );
+
+ return isEveryLocaleStatic ? "static" : "partial";
+};
+
+const byPageThenLocale =
+ (localeRouting: LocaleRouting) =>
+ (a: string, b: string): number => {
+ const pageA = localeRouting.deLocalizePathname(a);
+ const pageB = localeRouting.deLocalizePathname(b);
+ if (pageA !== pageB) return pageA < pageB ? -1 : 1;
+
+ const localeIndex = (path: string) =>
+ localeRouting.locales.indexOf(
+ localeRouting.extractLocaleFromPath(path) ??
+ localeRouting.defaultLocale,
+ );
+
+ return localeIndex(a) - localeIndex(b);
+ };
+
+export const buildRouteReport = ({
+ hostRoutes,
+ htmlPaths,
+ i18n,
+ pluginRoutes,
+}: RouteReportInput): RouteReportEntry[] => {
+ const localeRouting = localeRoutingFromConfig(i18n);
+ const routes = reportedRoutes(hostRoutes, pluginRoutes);
+ const unclaimed: string[] = [];
+
+ for (const htmlPath of htmlPaths) {
+ const parts = localeRouting
+ .deLocalizePathname(htmlPath)
+ .split("/")
+ .filter(part => part.length > 0);
+ const route = routes.find(candidate =>
+ matchesRoute(candidate.segments, parts),
+ );
+
+ if (route === undefined) unclaimed.push(htmlPath);
+ else route.staticPaths.add(htmlPath);
+ }
+
+ const entries: RouteReportEntry[] = [
+ ...routes.map(route => ({
+ mode: modeOf(route, localeRouting),
+ path: route.path,
+ staticPaths: [...route.staticPaths].sort(byPageThenLocale(localeRouting)),
+ })),
+ ...unclaimed.map(path => ({
+ mode: "static" as const,
+ path,
+ staticPaths: [path],
+ })),
+ ];
+
+ return entries.sort((a, b) => (a.path < b.path ? -1 : 1));
+};
diff --git a/packages/vitnode/src/lib/ai/usage-breakdown.test.ts b/packages/vitnode/src/lib/ai/usage-breakdown.test.ts
new file mode 100644
index 0000000000..07c68afad1
--- /dev/null
+++ b/packages/vitnode/src/lib/ai/usage-breakdown.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+
+import type { AiUsageSegment } from "./usage-breakdown";
+
+import { aiUsageBreakdown } from "./usage-breakdown";
+
+const action = (key: string, monthPoints: string) => ({ key, monthPoints });
+
+const keysOf = (segments: AiUsageSegment>[]) =>
+ segments.map(segment =>
+ segment.kind === "action"
+ ? segment.action.key
+ : `${String(segment.count)} other`,
+ );
+
+describe("aiUsageBreakdown", () => {
+ it("orders the features by the points they used, leaving unused ones out", () => {
+ const segments = aiUsageBreakdown([
+ action("tags", "10"),
+ action("reply", "0"),
+ action("summarize", "30"),
+ ]);
+
+ expect(keysOf(segments)).toEqual(["summarize", "tags"]);
+ expect(segments.map(segment => segment.share)).toEqual([0.75, 0.25]);
+ });
+
+ it("folds everything after the top three into one segment", () => {
+ const segments = aiUsageBreakdown([
+ action("a", "50"),
+ action("b", "20"),
+ action("c", "15"),
+ action("d", "10"),
+ action("e", "5"),
+ ]);
+
+ expect(keysOf(segments)).toEqual(["a", "b", "c", "2 other"]);
+ expect(segments.at(-1)).toMatchObject({ points: 15, share: 0.15 });
+ });
+
+ it("names a fourth feature instead of calling it one other feature", () => {
+ const segments = aiUsageBreakdown([
+ action("a", "4"),
+ action("b", "3"),
+ action("c", "2"),
+ action("d", "1"),
+ ]);
+
+ expect(keysOf(segments)).toEqual(["a", "b", "c", "d"]);
+ });
+
+ it("has nothing to show before any points were used", () => {
+ expect(aiUsageBreakdown([action("a", "0")])).toEqual([]);
+ });
+});
diff --git a/packages/vitnode/src/lib/ai/usage-breakdown.ts b/packages/vitnode/src/lib/ai/usage-breakdown.ts
new file mode 100644
index 0000000000..d7c186e68c
--- /dev/null
+++ b/packages/vitnode/src/lib/ai/usage-breakdown.ts
@@ -0,0 +1,40 @@
+export const AI_USAGE_BREAKDOWN_SIZE = 3;
+
+export type AiUsageSegment =
+ | { action: TAction; kind: "action"; points: number; share: number }
+ | { count: number; kind: "other"; points: number; share: number };
+
+export const aiUsageBreakdown = (
+ actions: readonly TAction[],
+): AiUsageSegment[] => {
+ const used = actions
+ .map(action => ({ action, points: Number(action.monthPoints) }))
+ .filter(({ points }) => points > 0)
+ .sort((a, b) => b.points - a.points);
+ const total = used.reduce((sum, { points }) => sum + points, 0);
+ const named =
+ used.length > AI_USAGE_BREAKDOWN_SIZE + 1
+ ? used.slice(0, AI_USAGE_BREAKDOWN_SIZE)
+ : used;
+ const others = used.slice(named.length);
+ const othersPoints = others.reduce((sum, { points }) => sum + points, 0);
+
+ return [
+ ...named.map(({ action, points }): AiUsageSegment => ({
+ action,
+ kind: "action",
+ points,
+ share: points / total,
+ })),
+ ...(others.length > 0
+ ? [
+ {
+ count: others.length,
+ kind: "other" as const,
+ points: othersPoints,
+ share: othersPoints / total,
+ },
+ ]
+ : []),
+ ];
+};
diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json
index 1237f1d603..aad263bb4f 100644
--- a/packages/vitnode/src/locales/en.json
+++ b/packages/vitnode/src/locales/en.json
@@ -942,7 +942,12 @@
"desc": "Your avatar and the details on your account.",
"profileTitle": "Profile",
"accountTitle": "Account",
- "edit": "Edit",
+ "editProfile": "Edit profile",
+ "editProfileDesc": "Change your personal details and time zone in one go.",
+ "name": "Name",
+ "realName": "Real name",
+ "realNameShown": "Shown on your profile",
+ "realNameHidden": "Hidden",
"personalTitle": "Personal Information",
"personalDesc": "Tell the community who you are. Every field is optional.",
"nickname": "Nickname",
@@ -958,16 +963,14 @@
"headlineDesc": "One line about you, up to {max} characters.",
"notSet": "Not set",
"save": "Save changes",
- "saved": "Personal information updated.",
- "savedDesc": "Your profile shows the new details right away.",
+ "saved": "Profile updated",
+ "savedDesc": "Your changes show up right away.",
"regionTitle": "Region",
"regionDesc": "Digest emails and dates follow this time zone.",
"timeZone": "Time zone",
"timeZoneDesc": "Search by city, like Warsaw or New York.",
"timeZoneAuto": "Automatic - from your language",
- "timeZoneUseDevice": "Use this device's time zone ({timeZone})",
- "timeZoneSaved": "Time zone saved",
- "timeZoneSavedDesc": "Digests and dates now follow {timeZone}."
+ "timeZoneUseDevice": "Use this device's time zone ({timeZone})"
},
"devices": {
"title": "Devices",
@@ -1311,7 +1314,14 @@
"used_of": "{used} of {total} points used",
"renews": "Renews {date}",
"unlimited": "Unlimited points",
- "unlimited_used": "{used} points used this month",
+ "unlimited_desc": "No monthly cap. Your usage count starts over on {date}.",
+ "this_month": "pts this month",
+ "breakdown": {
+ "label": "Where your points went this month",
+ "other": "{count, plural, one {# other feature} other {# other features}}",
+ "points": "{points} pts",
+ "empty": "Nothing used this month yet. Once you run an AI feature, you'll see where your points go."
+ },
"none": "No AI points this month"
},
"features": {
diff --git a/packages/vitnode/src/tanstack/settings/overview.tsx b/packages/vitnode/src/tanstack/settings/overview.tsx
index 9994760f6a..b7797d1331 100644
--- a/packages/vitnode/src/tanstack/settings/overview.tsx
+++ b/packages/vitnode/src/tanstack/settings/overview.tsx
@@ -4,12 +4,18 @@ import {
useSuspenseQuery,
} from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
+import { cn } from "cn";
+import { ChevronRightIcon } from "lucide-react";
import React from "react";
import { useTranslations } from "use-intl";
import type { UpdatePersonalInformation } from "@/views/auth/settings/overview/personal-update";
import type { UpdateTimeZone } from "@/views/auth/settings/overview/time-zone-update";
+import {
+ AiPointsBreakdown,
+ AiUnlimitedPointsSummary,
+} from "@/views/auth/settings/ai/ai-points-breakdown";
import { AiPointsContent } from "@/views/auth/settings/ai/ai-usage-content";
import {
aiUsageQueryOptions,
@@ -43,15 +49,27 @@ const OverviewAiPoints = ({ userId }: { userId: number }) => {
return (
-
-
-
+ {usage.points.total === null ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : (
+
+
+
+ )}
{t("details")}
+
diff --git a/packages/vitnode/src/views/auth/settings/ai/ai-points-breakdown.tsx b/packages/vitnode/src/views/auth/settings/ai/ai-points-breakdown.tsx
new file mode 100644
index 0000000000..0a809f2c9d
--- /dev/null
+++ b/packages/vitnode/src/views/auth/settings/ai/ai-points-breakdown.tsx
@@ -0,0 +1,120 @@
+import { cn } from "cn";
+import { InfinityIcon } from "lucide-react";
+import { useFormatter, useLocale, useTranslations } from "use-intl";
+
+import {
+ type AiActionTranslate,
+ translateAiActionText,
+} from "@/lib/ai/action-text";
+import { formatAiPoints } from "@/lib/ai/format-points";
+import { aiUsageBreakdown } from "@/lib/ai/usage-breakdown";
+
+import type { AiUsage, AiUsageAction } from "./ai-usage-query";
+
+const SEGMENT_TONES = [
+ "bg-primary",
+ "bg-primary/65",
+ "bg-primary/40",
+ "bg-primary/20",
+] as const;
+
+export const AiUnlimitedPointsSummary = ({ usage }: { usage: AiUsage }) => {
+ const t = useTranslations("core.auth.settings.ai.points");
+ const locale = useLocale();
+ const format = useFormatter();
+
+ return (
+
+
+
+
+
+ {t("unlimited")}
+
+ {t("unlimited_desc", {
+ date: format.dateTime(new Date(usage.resetsAt), {
+ day: "numeric",
+ month: "long",
+ }),
+ })}
+
+
+
+
+ {formatAiPoints(usage.points.used, locale)}
+
+ {t("this_month")}
+
+
+ );
+};
+
+export const AiPointsBreakdown = ({
+ actions,
+}: {
+ actions: AiUsageAction[];
+}) => {
+ const t = useTranslations("core.auth.settings.ai.points.breakdown");
+ const tAll = useTranslations() as unknown as AiActionTranslate;
+ const locale = useLocale();
+ const format = useFormatter();
+ const segments = aiUsageBreakdown(actions).map((segment, index) => ({
+ ...segment,
+ key: segment.kind === "action" ? segment.action.key : "other",
+ label:
+ segment.kind === "action"
+ ? translateAiActionText(tAll, segment.action.title)
+ : t("other", { count: segment.count }),
+ tone: SEGMENT_TONES[index],
+ }));
+
+ if (segments.length === 0) {
+ return (
+
+ {t("empty")}
+
+ );
+ }
+
+ return (
+
+
+ {segments.map(segment => (
+
+ ))}
+
+
+ {segments.map(segment => (
+
+
+ {segment.label}
+
+ {t("points", {
+ points: formatAiPoints(String(segment.points), locale),
+ })}
+
+
+ {format.number(segment.share, {
+ maximumFractionDigits: 0,
+ style: "percent",
+ })}
+
+
+ ))}
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx
index 7722246515..fe0d08ff89 100644
--- a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx
+++ b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx
@@ -1,5 +1,11 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from "@testing-library/react";
import { IntlProvider } from "use-intl";
import { describe, expect, it } from "vitest";
@@ -125,6 +131,50 @@ describe("AiUsageContent", () => {
).toBeTruthy();
});
+ it("breaks an unlimited allowance down by the features that used it", () => {
+ const base = usage().actions[0];
+ renderUsage(
+ usage({
+ actions: [
+ { ...base, key: "translate", monthPoints: "4", title: "Translate" },
+ base,
+ { ...base, key: "tags", monthPoints: "0", title: "Suggest tags" },
+ ],
+ points: { available: null, reserved: "0", total: null, used: "16.5" },
+ }),
+ );
+
+ const breakdown = screen.getByRole("list", {
+ name: "core.auth.settings.ai.points.breakdown.label",
+ });
+ const rows = within(breakdown).getAllByRole("listitem");
+
+ expect(rows.map(row => row.textContent)).toEqual([
+ expect.stringContaining("Generate excerpt"),
+ expect.stringContaining("Translate"),
+ ]);
+ expect(rows[0].textContent).toContain("76%");
+ expect(within(breakdown).queryByText("Suggest tags")).toBeNull();
+ });
+
+ it("says nothing was used yet instead of drawing an empty breakdown", () => {
+ renderUsage(
+ usage({
+ actions: [{ ...usage().actions[0], monthPoints: "0" }],
+ points: { available: null, reserved: "0", total: null, used: "0" },
+ }),
+ );
+
+ expect(
+ screen.getByText("core.auth.settings.ai.points.breakdown.empty"),
+ ).toBeTruthy();
+ expect(
+ screen.queryByRole("list", {
+ name: "core.auth.settings.ai.points.breakdown.label",
+ }),
+ ).toBeNull();
+ });
+
it("lists each feature by its title with today's use", () => {
renderUsage(usage());
diff --git a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx
index eb1035aeef..1a9a492fa1 100644
--- a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx
+++ b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.tsx
@@ -24,6 +24,10 @@ import { AI_USAGE_TONE_BAR, aiUsageTone } from "@/lib/ai/usage-tone";
import type { AiHistoryItem, AiUsage, AiUsageAction } from "./ai-usage-query";
+import {
+ AiPointsBreakdown,
+ AiUnlimitedPointsSummary,
+} from "./ai-points-breakdown";
import { aiHistoryQueryOptions } from "./ai-usage-query";
const FEATURE_RUNS_PAGE = 5;
@@ -94,12 +98,10 @@ export const AiPointsContent = ({ usage }: { usage: AiUsage }) => {
if (total === null) {
return (
-
- {t("unlimited")} {" "}
-
- {t("unlimited_used", { used: usedLabel })}
-
-
+
);
}
diff --git a/packages/vitnode/src/views/auth/settings/overview/overview.tsx b/packages/vitnode/src/views/auth/settings/overview/overview.tsx
index 697909091a..2fec7f5542 100644
--- a/packages/vitnode/src/views/auth/settings/overview/overview.tsx
+++ b/packages/vitnode/src/views/auth/settings/overview/overview.tsx
@@ -11,8 +11,8 @@ import { PageTitle } from "@/components/ui/page-title";
import { displayNameOf } from "@/lib/user-personal-information";
import { SelfUserImageDialog } from "@/views/profile/images/self-image-dialog";
-import type { PersonalInformationUser } from "./personal-content";
import type { UpdatePersonalInformation } from "./personal-update";
+import type { ProfileSummaryUser } from "./profile-summary";
import type { UpdateTimeZone } from "./time-zone-update";
import {
@@ -20,17 +20,14 @@ import {
SETTINGS_ROW_LABEL,
SettingsGroup,
} from "../settings-group";
-import { PersonalInformationContent } from "./personal-content";
-import { RealNameRow } from "./real-name-row";
-import { TimeZoneGroup } from "./time-zone-row";
+import { ProfileSheet } from "./profile-sheet";
+import { ProfileSummary } from "./profile-summary";
-export interface SettingsOverviewUser extends PersonalInformationUser {
+export interface SettingsOverviewUser extends ProfileSummaryUser {
avatarColor: string;
avatarUrl: null | string;
- name: string;
nameCode: string;
role: ProfileRole;
- timeZone: null | string;
}
export const OverviewSettingsContent = ({
@@ -67,7 +64,10 @@ export const OverviewSettingsContent = ({
subtitle={tSettings("title")}
/>
-
+
- {editor ? (
-