-
+ const isDevelopment = process.env.NODE_ENV === "development";
-
Persona
+ return (
+
+
+
+
+
+ P
+
+ Persona
+
+ Portfolio starter kit
+
-
- Your portfolio will appear here once built.
-
- Run the setup script to get started.
-
+
+
+
For people with more personality than a template
+
+ Build a portfolio that feels like you.
+
+
+ Persona gives your coding agent the context and guardrails to design a personal site—not paste your name into the same developer portfolio everyone has.
+
-
-
- ./bin/setup.sh
-
-
+
+
+ {isDevelopment ? "Open local setup" : "Use this template"}
+
+
+ ./setup.sh
+
+
+
Your profile and materials stay in your local repository.
+
-
- Or configure manually →
-
+
+ {steps.map(([number, title, description]) => (
+
+ {number}
+
+
{title}
+
{description}
+
+
+ ))}
+
+
);
diff --git a/src/lib/local-setup.test.ts b/src/lib/local-setup.test.ts
new file mode 100644
index 0000000..70c6b67
--- /dev/null
+++ b/src/lib/local-setup.test.ts
@@ -0,0 +1,26 @@
+import { NextRequest } from "next/server";
+import { describe, expect, it } from "vitest";
+import { isLocalSetupRequest } from "./local-setup";
+
+describe("isLocalSetupRequest", () => {
+ it("allows same-origin localhost requests outside production", () => {
+ const request = new NextRequest("http://127.0.0.1:3000/api/read-config", {
+ headers: { origin: "http://127.0.0.1:3000" },
+ });
+ expect(isLocalSetupRequest(request, "development")).toBe(true);
+ });
+
+ it("rejects remote hosts and cross-origin requests", () => {
+ const remote = new NextRequest("https://portfolio.example/api/read-config");
+ const crossOrigin = new NextRequest("http://127.0.0.1:3000/api/read-config", {
+ headers: { origin: "https://evil.example" },
+ });
+ expect(isLocalSetupRequest(remote, "development")).toBe(false);
+ expect(isLocalSetupRequest(crossOrigin, "development")).toBe(false);
+ });
+
+ it("rejects every request in production", () => {
+ const request = new NextRequest("http://127.0.0.1:3000/api/read-config");
+ expect(isLocalSetupRequest(request, "production")).toBe(false);
+ });
+});
diff --git a/src/lib/local-setup.ts b/src/lib/local-setup.ts
new file mode 100644
index 0000000..1ed2d6f
--- /dev/null
+++ b/src/lib/local-setup.ts
@@ -0,0 +1,31 @@
+import type { NextRequest } from "next/server";
+
+const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
+
+export function isLocalSetupRequest(
+ request: NextRequest,
+ environment = process.env.NODE_ENV,
+): boolean {
+ if (environment === "production") return false;
+
+ const requestUrl = new URL(request.url);
+ if (!LOCAL_HOSTS.has(requestUrl.hostname)) return false;
+
+ const origin = request.headers.get("origin");
+ if (!origin) return true;
+
+ try {
+ const originUrl = new URL(origin);
+ return (
+ LOCAL_HOSTS.has(originUrl.hostname) &&
+ originUrl.protocol === requestUrl.protocol &&
+ originUrl.port === requestUrl.port
+ );
+ } catch {
+ return false;
+ }
+}
+
+export const LOCAL_ONLY_RESPONSE = {
+ error: "This setup endpoint is only available from the local development server.",
+};
diff --git a/src/lib/material-upload.test.ts b/src/lib/material-upload.test.ts
new file mode 100644
index 0000000..c570da9
--- /dev/null
+++ b/src/lib/material-upload.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import {
+ MAX_UPLOAD_BYTES,
+ sanitizeMaterialName,
+ validateMaterialUpload,
+} from "./material-upload";
+
+describe("sanitizeMaterialName", () => {
+ it("removes directories and unsafe characters", () => {
+ expect(sanitizeMaterialName("../My Resume (final).pdf")).toBe("My_Resume__final_.pdf");
+ });
+
+ it("rejects empty path names", () => {
+ expect(() => sanitizeMaterialName("..")).toThrow("Invalid filename");
+ });
+});
+
+describe("validateMaterialUpload", () => {
+ it("accepts an allowed document", () => {
+ const file = new File(["resume"], "resume.pdf", { type: "application/pdf" });
+ expect(validateMaterialUpload(file, "documents")).toBe("resume.pdf");
+ });
+
+ it("rejects executable and SVG uploads", () => {
+ const executable = new File(["bad"], "resume.js");
+ const svg = new File(["
"], "portrait.svg", { type: "image/svg+xml" });
+ expect(() => validateMaterialUpload(executable, "documents")).toThrow();
+ expect(() => validateMaterialUpload(svg, "images")).toThrow();
+ });
+
+ it("rejects oversized files", () => {
+ const file = { name: "resume.pdf", size: MAX_UPLOAD_BYTES + 1 } as File;
+ expect(() => validateMaterialUpload(file, "documents")).toThrow("10 MB");
+ });
+});
diff --git a/src/lib/material-upload.ts b/src/lib/material-upload.ts
new file mode 100644
index 0000000..04eb95d
--- /dev/null
+++ b/src/lib/material-upload.ts
@@ -0,0 +1,32 @@
+import { basename, extname } from "path";
+
+export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
+
+const ALLOWED_EXTENSIONS = {
+ documents: new Set([".pdf", ".txt", ".md", ".doc", ".docx"]),
+ images: new Set([".jpg", ".jpeg", ".png", ".webp", ".gif"]),
+} as const;
+
+export type MaterialFolder = keyof typeof ALLOWED_EXTENSIONS;
+
+export function sanitizeMaterialName(name: string): string {
+ const clean = basename(name).replace(/[^a-zA-Z0-9._-]/g, "_");
+ if (!clean || clean === "." || clean === "..") {
+ throw new Error("Invalid filename");
+ }
+ return clean;
+}
+
+export function validateMaterialUpload(file: File, folder: MaterialFolder): string {
+ if (file.size <= 0 || file.size > MAX_UPLOAD_BYTES) {
+ throw new Error("Files must be between 1 byte and 10 MB");
+ }
+
+ const safeName = sanitizeMaterialName(file.name);
+ const extension = extname(safeName).toLowerCase();
+ if (!ALLOWED_EXTENSIONS[folder].has(extension)) {
+ throw new Error(`Unsupported ${folder} file type`);
+ }
+
+ return safeName;
+}
diff --git a/src/lib/profile-config.test.ts b/src/lib/profile-config.test.ts
new file mode 100644
index 0000000..e0411a0
--- /dev/null
+++ b/src/lib/profile-config.test.ts
@@ -0,0 +1,88 @@
+import * as yaml from "js-yaml";
+import { describe, expect, it } from "vitest";
+import {
+ generateProfileYaml,
+ parseDesignAttributes,
+ type InspirationArchetype,
+ type ProfileConfig,
+} from "./profile-config";
+
+const config: ProfileConfig = {
+ name: 'Ava "AJ" Jones',
+ email: "ava@example.com",
+ github: "https://github.com/ava",
+ linkedin: "",
+ twitter: "",
+ website: "",
+ cli: "codex",
+ sections: { hero: true, projects: true, blog: false },
+ design: {
+ creativity: 7,
+ simplicity: 8,
+ playfulness: 4,
+ animation: 3,
+ color_intensity: 5,
+ notes: "",
+ },
+ content: { tone: "conversational", length: "concise", focus: "projects" },
+ ai: { quality_bar: 8, research_depth: 6, copy_creativity: 5 },
+ notes: "First line\nSecond: line",
+};
+
+const archetypes: InspirationArchetype[] = [
+ {
+ id: 1,
+ name: "Minimal",
+ description: "Quiet",
+ color: "from-black to-white",
+ examples: [
+ {
+ name: "Example",
+ url: "https://example.com",
+ screenshot: "/example.png",
+ design: {
+ typography: "Inter, 14-18px, 500 weight",
+ colors: "Black #000000, white #ffffff, blue #5e6ad2",
+ layout: "Centered, 1200px max",
+ spacing: "80-120px, 40px padding",
+ motion: "Subtle fades, 200ms",
+ details: "1px border, rounded cards",
+ },
+ },
+ ],
+ },
+];
+
+describe("generateProfileYaml", () => {
+ it("round-trips user text and enabled sections safely", () => {
+ const output = generateProfileYaml(config, [], archetypes);
+ const parsed = yaml.load(output) as Record
;
+
+ expect(parsed.name).toBe(config.name);
+ expect(parsed.notes).toBe(config.notes);
+ expect(parsed.sections).toEqual(["hero", "projects"]);
+ });
+
+ it("includes only selected design inspirations", () => {
+ const output = generateProfileYaml(config, ["https://example.com"], archetypes);
+ const parsed = yaml.load(output) as { design_inspirations: Array<{ name: string }> };
+
+ expect(parsed.design_inspirations).toEqual([
+ expect.objectContaining({ name: "Example" }),
+ ]);
+ });
+});
+
+describe("parseDesignAttributes", () => {
+ it("turns design descriptions into stable values", () => {
+ expect(parseDesignAttributes(archetypes[0].examples[0].design)).toEqual(
+ expect.objectContaining({
+ fontFamily: "Inter",
+ fontSize: 16,
+ fontWeight: 500,
+ maxWidth: 1200,
+ sectionSpacing: 100,
+ }),
+ );
+ });
+});
diff --git a/src/lib/profile-config.ts b/src/lib/profile-config.ts
new file mode 100644
index 0000000..d842375
--- /dev/null
+++ b/src/lib/profile-config.ts
@@ -0,0 +1,157 @@
+import * as yaml from "js-yaml";
+
+export type DesignDescription = {
+ typography: string;
+ colors: string;
+ layout: string;
+ spacing: string;
+ motion: string;
+ details: string;
+};
+
+type InspirationExample = {
+ name: string;
+ url: string;
+ screenshot: string;
+ design: DesignDescription;
+};
+
+export type InspirationArchetype = {
+ id: number;
+ name: string;
+ description: string;
+ color: string;
+ examples: InspirationExample[];
+};
+
+export type ProfileConfig = {
+ name: string;
+ email: string;
+ github: string;
+ linkedin: string;
+ twitter: string;
+ website: string;
+ cli: string;
+ sections: Record;
+ design: {
+ creativity: number;
+ simplicity: number;
+ playfulness: number;
+ animation: number;
+ color_intensity: number;
+ notes: string;
+ };
+ content: {
+ tone: string;
+ length: string;
+ focus: string;
+ };
+ ai: {
+ quality_bar: number;
+ research_depth: number;
+ copy_creativity: number;
+ };
+ notes: string;
+};
+
+export function parseDesignAttributes(design: DesignDescription) {
+ const fontMatch = design.typography.match(/([A-Z][a-z]+(?:\s[A-Z][a-z]+)*)/);
+ const sizeMatch = design.typography.match(/(\d+)(?:-(\d+))?px/);
+ const weightMatch = design.typography.match(/(\d{3})\s*weight/);
+ const trackingMatch = design.typography.match(/([-\d.]+)em\s*tracking/);
+ const hexMatches = design.colors.match(/#[0-9A-Fa-f]{3,6}/g) ?? [];
+ const widthMatch = design.layout.match(/(\d+)px/);
+ const spacingMatch = design.spacing.match(/(\d+)(?:-(\d+))?px/);
+ const paddingMatch = design.spacing.match(/(\d+)px\s*padding/);
+ const durationMatch = design.motion.match(/(\d+)ms/);
+ const borderMatch = design.details.match(/(\d+)px\s*border/);
+
+ return {
+ fontFamily: fontMatch?.[1] ?? "Inter",
+ fontSize: sizeMatch
+ ? sizeMatch[2]
+ ? Math.round((Number(sizeMatch[1]) + Number(sizeMatch[2])) / 2)
+ : Number(sizeMatch[1])
+ : 16,
+ fontWeight: weightMatch ? Number(weightMatch[1]) : 400,
+ letterSpacing: trackingMatch ? `${trackingMatch[1]}em` : "normal",
+ colorBg: hexMatches[0] ?? "#000000",
+ colorText: hexMatches[1] ?? "#ffffff",
+ colorAccent: hexMatches[2] ?? hexMatches[0] ?? "#5e6ad2",
+ maxWidth: widthMatch ? Number(widthMatch[1]) : 1200,
+ alignment: design.layout.toLowerCase().includes("centered")
+ ? "centered"
+ : design.layout.toLowerCase().includes("full")
+ ? "full-width"
+ : "left",
+ sectionSpacing: spacingMatch
+ ? spacingMatch[2]
+ ? Math.round((Number(spacingMatch[1]) + Number(spacingMatch[2])) / 2)
+ : Number(spacingMatch[1])
+ : 100,
+ padding: paddingMatch ? Number(paddingMatch[1]) : 40,
+ motionDuration: durationMatch ? Number(durationMatch[1]) : 200,
+ motionStyle: design.motion.toLowerCase().includes("slide")
+ ? "slide"
+ : design.motion.toLowerCase().includes("snap")
+ ? "snap"
+ : "fade",
+ borderWidth: design.details.toLowerCase().includes("no border")
+ ? 0
+ : borderMatch
+ ? Number(borderMatch[1])
+ : 1,
+ borderRadius:
+ design.details.toLowerCase().includes("sharp") ||
+ design.details.toLowerCase().includes("clean edges")
+ ? 0
+ : design.details.toLowerCase().includes("rounded")
+ ? 8
+ : 0,
+ };
+}
+
+export function generateProfileYaml(
+ config: ProfileConfig,
+ selectedExamples: string[],
+ archetypes: InspirationArchetype[],
+): string {
+ const examples = archetypes.flatMap((archetype) => archetype.examples);
+ const designInspirations = selectedExamples.flatMap((url) => {
+ const example = examples.find((item) => item.url === url);
+ if (!example) return [];
+ return [
+ {
+ name: example.name,
+ url: example.url,
+ attributes: parseDesignAttributes(example.design),
+ descriptions: example.design,
+ },
+ ];
+ });
+
+ const output = {
+ name: config.name || "Your Name",
+ email: config.email,
+ github: config.github,
+ linkedin: config.linkedin,
+ twitter: config.twitter,
+ website: config.website,
+ cli: config.cli,
+ sections: Object.entries(config.sections)
+ .filter(([, enabled]) => enabled)
+ .map(([section]) => section),
+ design: config.design,
+ ...(designInspirations.length > 0
+ ? { design_inspirations: designInspirations }
+ : {}),
+ content: config.content,
+ ai: config.ai,
+ notes: config.notes,
+ };
+
+ return yaml.dump(output, {
+ noRefs: true,
+ lineWidth: 100,
+ });
+}