chore(web): add bundle analysis gate
This commit is contained in:
parent
2bf4b079ea
commit
6b896949d4
@ -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": {
|
||||
|
||||
143
web/scripts/analyze-build.mjs
Normal file
143
web/scripts/analyze-build.mjs
Normal file
@ -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;
|
||||
}
|
||||
@ -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 (
|
||||
<Card style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<strong style={{ fontSize: "var(--nl-fs-md)" }}>Result</strong>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss result"
|
||||
style={{ padding: 4 }}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
fontSize: "var(--nl-fs-sm)",
|
||||
maxHeight: 400,
|
||||
overflowY: "auto",
|
||||
padding: "var(--nl-space-3)",
|
||||
borderRadius: "var(--nl-radius-md)",
|
||||
backgroundColor: "var(--nl-bg-secondary)",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{result.content}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
|
||||
{onSaveAsNote && (
|
||||
<Button
|
||||
onClick={() => onSaveAsNote(result.content)}
|
||||
aria-label="Save as new note"
|
||||
style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-1)" }}
|
||||
>
|
||||
<FilePlus size={14} /> Save as Note
|
||||
</Button>
|
||||
)}
|
||||
{onApplyToNote && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onApplyToNote(result.content)}
|
||||
aria-label="Apply to current note"
|
||||
style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-1)" }}
|
||||
>
|
||||
<Save size={14} /> Apply to Note
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleCopy()}
|
||||
aria-label="Copy result to clipboard"
|
||||
style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-1)" }}
|
||||
>
|
||||
{copied ? <CheckCircle size={14} /> : <Copy size={14} />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onDismiss} aria-label="Discard result">
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Metadata footer */}
|
||||
{(result.model || result.usage) && (
|
||||
<div style={{ fontSize: "var(--nl-fs-xs)", color: "var(--nl-text-secondary)", display: "flex", gap: "var(--nl-space-3)" }}>
|
||||
{result.model && <span>Model: {result.model}</span>}
|
||||
{result.usage && <span>{result.usage.totalTokens} tokens</span>}
|
||||
{result.approvalState && <span>Status: {result.approvalState}</span>}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -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(<RunPromptModal template={baseTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("Summarize")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders close button with aria-label", () => {
|
||||
render(<RunPromptModal template={baseTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
|
||||
expect(screen.getByLabelText("Close modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders run button with aria-label", () => {
|
||||
render(<RunPromptModal template={baseTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
|
||||
expect(screen.getByLabelText("Run prompt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom instructions textarea", () => {
|
||||
render(<RunPromptModal template={baseTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
|
||||
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(<RunPromptModal template={multiTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
|
||||
expect(screen.getByLabelText("Additional note IDs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show additional note IDs input for text templates", () => {
|
||||
render(<RunPromptModal template={baseTemplate} noteId="n1" workspaceId="ws1" onClose={onClose} onResult={onResult} />);
|
||||
expect(screen.queryByLabelText("Additional note IDs")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -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<typeof runPrompt>[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 (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
backgroundColor: "var(--nl-overlay-scrim)",
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Run: ${template.name}`}
|
||||
>
|
||||
<Card
|
||||
padding="lg"
|
||||
style={{
|
||||
width: "min(90vw, 520px)",
|
||||
display: "grid",
|
||||
gap: "var(--nl-space-4)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<strong>{template.name}</strong>
|
||||
<Button variant="secondary" size="sm" onClick={onClose} aria-label="Close modal" style={{ padding: 4 }}>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>
|
||||
{template.description}
|
||||
</p>
|
||||
|
||||
{/* Inline prompt override */}
|
||||
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
|
||||
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Custom instructions (optional)</span>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={3}
|
||||
value={inlinePrompt}
|
||||
onChange={(e) => setInlinePrompt(e.target.value)}
|
||||
placeholder="Add specific instructions to override or extend the template..."
|
||||
aria-label="Custom instructions"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Multi-note selector */}
|
||||
{isMultiNote && (
|
||||
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
|
||||
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Additional note IDs (comma-separated)</span>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={additionalNoteIds}
|
||||
onChange={(e) => setAdditionalNoteIds(e.target.value)}
|
||||
placeholder="note-id-1, note-id-2"
|
||||
aria-label="Additional note IDs"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Dry run toggle */}
|
||||
<label style={{ display: "flex", gap: "var(--nl-space-2)", alignItems: "center", fontSize: "var(--nl-fs-sm)" }}>
|
||||
<input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} />
|
||||
Dry run (preview without saving)
|
||||
</label>
|
||||
|
||||
{/* Info badges */}
|
||||
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
|
||||
<Badge>{template.inputType}</Badge>
|
||||
<Badge>{template.outputType}</Badge>
|
||||
<Badge>{template.category}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Run button */}
|
||||
<Button
|
||||
disabled={running}
|
||||
loading={running}
|
||||
onClick={() => void handleRun()}
|
||||
aria-label={running ? "Running prompt..." : "Run prompt"}
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "var(--nl-space-2)" }}
|
||||
>
|
||||
{!running ? <Play size={16} /> : null}
|
||||
Run
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user