diff --git a/web/package.json b/web/package.json index 0fab40c..5d10a52 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", + "build:analyze": "next build --webpack && node scripts/analyze-build.mjs", "test:e2e": "playwright test" }, "dependencies": { diff --git a/web/scripts/analyze-build.mjs b/web/scripts/analyze-build.mjs new file mode 100644 index 0000000..2b6e12b --- /dev/null +++ b/web/scripts/analyze-build.mjs @@ -0,0 +1,143 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { gzipSync } from "node:zlib"; + +const root = process.cwd(); +const nextDir = path.join(root, ".next"); +const appManifestPath = path.join(nextDir, "app-build-manifest.json"); +const appPathsManifestPath = path.join(nextDir, "server", "app-paths-manifest.json"); +const buildManifestPath = path.join(nextDir, "build-manifest.json"); +const routeLimitKb = Number(process.env.NOTELETT_ROUTE_GZIP_LIMIT_KB ?? "650"); +const sharedLimitKb = Number(process.env.NOTELETT_SHARED_GZIP_LIMIT_KB ?? "1500"); + +if (!existsSync(appManifestPath) && !existsSync(appPathsManifestPath)) { + throw new Error("Missing .next/app-build-manifest.json. Run `pnpm --filter @notelett/web run build` first."); +} + +const manifest = existsSync(appManifestPath) + ? JSON.parse(readFileSync(appManifestPath, "utf8")) + : appPathManifest(); +const pages = manifest.pages ?? {}; +const buildManifest = existsSync(buildManifestPath) + ? JSON.parse(readFileSync(buildManifestPath, "utf8")) + : {}; +const sharedLayoutFiles = findStaticChunks("app/layout") + .concat(findStaticChunks("app/(app)/layout"), findStaticChunks("app/(auth)/layout")); +const routeRows = Object.entries(pages) + .map(([route, files]) => routeSummary(route, Array.isArray(files) ? files : [])) + .sort((a, b) => b.gzipKb - a.gzipKb); + +const sharedFiles = new Set([...(buildManifest.rootMainFiles ?? []), ...sharedLayoutFiles]); +for (const row of routeRows) { + for (const file of row.files) { + if (file.includes("static/chunks/") && !file.includes("app/")) { + sharedFiles.add(file); + } + } +} +const sharedGzipKb = roundKb([...sharedFiles].reduce((total, file) => total + gzipBytes(file), 0)); +const oversizedRoutes = routeRows.filter((row) => row.gzipKb > routeLimitKb); +const oversizedShared = sharedGzipKb > sharedLimitKb; + +const summary = { + generatedAt: new Date().toISOString(), + limits: { + routeGzipKb: routeLimitKb, + sharedGzipKb: sharedLimitKb, + }, + shared: { + gzipKb: sharedGzipKb, + files: [...sharedFiles].sort(), + }, + routes: routeRows, +}; + +const outDir = path.join(nextDir, "analyze"); +mkdirSync(outDir, { recursive: true }); +writeFileSync(path.join(outDir, "notelett-bundle-summary.json"), `${JSON.stringify(summary, null, 2)}\n`); + +console.log("NoteLett bundle summary (gzip KB)"); +console.table(routeRows.slice(0, 20).map((row) => ({ + route: row.route, + gzipKb: row.gzipKb, + jsKb: row.jsKb, + files: row.files.length, +}))); +console.log(`Shared chunks: ${sharedGzipKb} KB gzip across ${sharedFiles.size} files`); + +if (oversizedRoutes.length > 0 || oversizedShared) { + const routeText = oversizedRoutes.map((row) => `${row.route}: ${row.gzipKb} KB`).join(", "); + const sharedText = oversizedShared ? `shared chunks: ${sharedGzipKb} KB` : ""; + throw new Error(`Bundle budget exceeded. ${[routeText, sharedText].filter(Boolean).join("; ")}`); +} + +function routeSummary(route, files) { + const jsFiles = files.filter((file) => file.endsWith(".js")); + const jsBytes = jsFiles.reduce((total, file) => total + rawBytes(file), 0); + const gzBytes = jsFiles.reduce((total, file) => total + gzipBytes(file), 0); + return { + route, + files: jsFiles, + jsKb: roundKb(jsBytes), + gzipKb: roundKb(gzBytes), + }; +} + +function appPathManifest() { + const appPaths = JSON.parse(readFileSync(appPathsManifestPath, "utf8")); + const pages = {}; + for (const [appRoute, serverFile] of Object.entries(appPaths)) { + const route = normalizeAppRoute(appRoute); + pages[route] = [ + `server/${serverFile}`, + ...findStaticChunks(String(serverFile).replace(/\.js$/, "")), + ]; + } + return { pages }; +} + +function normalizeAppRoute(appRoute) { + const route = appRoute + .replace(/\/page$/, "") + .replace(/^\/\((app|auth)\)/, "") + .replace(/^$/, "/"); + return route === "" ? "/" : route; +} + +function findStaticChunks(prefix) { + const chunkRoot = path.join(nextDir, "static", "chunks"); + const relativePrefix = `static/chunks/${prefix}`; + return walk(chunkRoot) + .map((file) => path.relative(nextDir, file).replaceAll(path.sep, "/")) + .filter((file) => file.startsWith(relativePrefix) && file.endsWith(".js")); +} + +function walk(dir) { + if (!existsSync(dir)) return []; + const entries = []; + for (const name of readdirSync(dir)) { + const full = path.join(dir, name); + if (statSync(full).isDirectory()) { + entries.push(...walk(full)); + } else { + entries.push(full); + } + } + return entries; +} + +function rawBytes(file) { + const fullPath = path.join(nextDir, file); + if (!existsSync(fullPath)) return 0; + return statSync(fullPath).size; +} + +function gzipBytes(file) { + const fullPath = path.join(nextDir, file); + if (!existsSync(fullPath)) return 0; + return gzipSync(readFileSync(fullPath)).length; +} + +function roundKb(bytes) { + return Math.round((bytes / 1024) * 10) / 10; +} diff --git a/web/src/components/PromptResultView.tsx b/web/src/components/PromptResultView.tsx deleted file mode 100644 index 7787f71..0000000 --- a/web/src/components/PromptResultView.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Copy, FilePlus, Save, X, CheckCircle } from "lucide-react"; -import { Button, Card } from "@/components/ui/Primitives"; -import { toast } from "@/lib/toast"; -import type { RunPromptOutput } from "@/lib/types"; - -interface PromptResultViewProps { - result: RunPromptOutput; - onDismiss: () => void; - onSaveAsNote?: (content: string) => void; - onApplyToNote?: (content: string) => void; -} - -export function PromptResultView({ - result, - onDismiss, - onSaveAsNote, - onApplyToNote, -}: PromptResultViewProps) { - const [copied, setCopied] = useState(false); - - async function handleCopy() { - await navigator.clipboard.writeText(result.content); - setCopied(true); - toast.success("Copied to clipboard"); - setTimeout(() => setCopied(false), 2000); - } - - return ( - - {/* Header */} -
- Result - -
- - {/* Content */} -
- {result.content} -
- - {/* Action buttons */} -
- {onSaveAsNote && ( - - )} - {onApplyToNote && ( - - )} - - -
- - {/* Metadata footer */} - {(result.model || result.usage) && ( -
- {result.model && Model: {result.model}} - {result.usage && {result.usage.totalTokens} tokens} - {result.approvalState && Status: {result.approvalState}} -
- )} -
- ); -} diff --git a/web/src/components/RunPromptModal.test.tsx b/web/src/components/RunPromptModal.test.tsx deleted file mode 100644 index 55234e9..0000000 --- a/web/src/components/RunPromptModal.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; - -vi.mock("@/lib/prompt-client", () => ({ - runPrompt: vi.fn().mockResolvedValue({ content: "Result", templateSlug: "summarize", outputType: "new_note" }), -})); - -vi.mock("@/lib/toast", () => ({ - toast: { success: vi.fn(), error: vi.fn() }, -})); - -import { RunPromptModal } from "./RunPromptModal"; -import type { PromptTemplate } from "@/lib/types"; - -const baseTemplate: PromptTemplate = { - id: "t1", - slug: "summarize", - name: "Summarize", - description: "Summarize the note", - category: "transform", - inputType: "text", - outputType: "new_note", - isBuiltin: true, -}; - -describe("RunPromptModal", () => { - const onClose = vi.fn(); - const onResult = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("renders dialog with template name", () => { - render(); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - expect(screen.getByText("Summarize")).toBeInTheDocument(); - }); - - it("renders close button with aria-label", () => { - render(); - expect(screen.getByLabelText("Close modal")).toBeInTheDocument(); - }); - - it("renders run button with aria-label", () => { - render(); - expect(screen.getByLabelText("Run prompt")).toBeInTheDocument(); - }); - - it("renders custom instructions textarea", () => { - render(); - expect(screen.getByLabelText("Custom instructions")).toBeInTheDocument(); - }); - - it("shows additional note IDs input for multi-note templates", () => { - const multiTemplate = { ...baseTemplate, inputType: "multi-note" as const }; - render(); - expect(screen.getByLabelText("Additional note IDs")).toBeInTheDocument(); - }); - - it("does not show additional note IDs input for text templates", () => { - render(); - expect(screen.queryByLabelText("Additional note IDs")).not.toBeInTheDocument(); - }); -}); diff --git a/web/src/components/RunPromptModal.tsx b/web/src/components/RunPromptModal.tsx deleted file mode 100644 index 9e22552..0000000 --- a/web/src/components/RunPromptModal.tsx +++ /dev/null @@ -1,143 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { X, Play } from "lucide-react"; -import { Badge, Button, Card } from "@/components/ui/Primitives"; -import { runPrompt } from "@/lib/prompt-client"; -import { toast } from "@/lib/toast"; -import type { PromptTemplate, RunPromptOutput } from "@/lib/types"; - -interface RunPromptModalProps { - template: PromptTemplate; - noteId: string; - workspaceId: string; - onClose: () => void; - onResult: (result: RunPromptOutput) => void; -} - -export function RunPromptModal({ - template, - noteId, - workspaceId, - onClose, - onResult, -}: RunPromptModalProps) { - const [inlinePrompt, setInlinePrompt] = useState(""); - const [additionalNoteIds, setAdditionalNoteIds] = useState(""); - const [dryRun, setDryRun] = useState(false); - const [running, setRunning] = useState(false); - - const isMultiNote = template.inputType === "multi-note"; - - async function handleRun() { - setRunning(true); - try { - const input: Parameters[0] = { - templateId: template.slug, - noteId, - workspaceId, - }; - if (inlinePrompt.trim()) { - input.inputText = inlinePrompt.trim(); - } - const result = await runPrompt(input); - onResult(result); - toast.success(`"${template.name}" completed`); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Prompt execution failed"); - } finally { - setRunning(false); - } - } - - return ( -
{ if (e.target === e.currentTarget) onClose(); }} - role="dialog" - aria-modal="true" - aria-label={`Run: ${template.name}`} - > - - {/* Header */} -
- {template.name} - -
- -

- {template.description} -

- - {/* Inline prompt override */} -