prototype-prd-vite-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "prototype-prd-vite-plugin-basic-example",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite"
7
+ },
8
+ "devDependencies": {
9
+ "prototype-prd-vite-plugin": "file:../..",
10
+ "vite": ">=5.0.0"
11
+ }
12
+ }
@@ -0,0 +1 @@
1
+ import "./style.css";
@@ -0,0 +1,45 @@
1
+ body {
2
+ margin: 0;
3
+ min-height: 100vh;
4
+ background:
5
+ radial-gradient(circle at 20% 10%, rgba(219, 143, 62, 0.26), transparent 28%),
6
+ linear-gradient(135deg, #f7ead7, #dbe7dd);
7
+ color: #1f1c17;
8
+ display: grid;
9
+ place-items: center;
10
+ font-family: Georgia, "Times New Roman", serif;
11
+ }
12
+
13
+ .hero {
14
+ width: min(720px, calc(100vw - 40px));
15
+ background: rgba(255, 250, 240, 0.78);
16
+ border: 1px solid rgba(91, 65, 33, 0.18);
17
+ border-radius: 28px;
18
+ box-shadow: 0 30px 90px rgba(48, 35, 18, 0.18);
19
+ padding: 56px;
20
+ }
21
+
22
+ .eyebrow {
23
+ font-family: "Avenir Next", "Gill Sans", sans-serif;
24
+ font-size: 12px;
25
+ font-weight: 800;
26
+ letter-spacing: 0.16em;
27
+ text-transform: uppercase;
28
+ }
29
+
30
+ h1 {
31
+ font-size: clamp(42px, 8vw, 84px);
32
+ line-height: 0.92;
33
+ margin: 0;
34
+ }
35
+
36
+ button {
37
+ background: #1f1c17;
38
+ border: 0;
39
+ border-radius: 999px;
40
+ color: #fff7e7;
41
+ cursor: pointer;
42
+ font-weight: 800;
43
+ margin-top: 24px;
44
+ padding: 14px 18px;
45
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "vite";
2
+ import prototypePrd from "prototype-prd-vite-plugin";
3
+
4
+ export default defineConfig({
5
+ plugins: [
6
+ prototypePrd({
7
+ defaultTitle: "Checkout Flow PRD"
8
+ })
9
+ ]
10
+ });
package/index.js ADDED
@@ -0,0 +1,83 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+
5
+ import { normalizeOptions } from "./src/config.js";
6
+ import { createPrototypePrdMiddleware } from "./src/middleware.js";
7
+
8
+ const PACKAGE_ROOT = path.dirname(fileURLToPath(import.meta.url));
9
+ const CLIENT_PREFIX = "/__prototype_prd__/client/";
10
+
11
+ export default function prototypePrd(userOptions = {}) {
12
+ let resolvedConfig = {
13
+ command: "serve",
14
+ root: process.cwd()
15
+ };
16
+ let options = normalizeOptions(userOptions, "serve");
17
+
18
+ return {
19
+ name: "prototype-prd-vite-plugin",
20
+ apply: "serve",
21
+
22
+ configResolved(config) {
23
+ resolvedConfig = config;
24
+ options = normalizeOptions(userOptions, config.command);
25
+ },
26
+
27
+ configureServer(server) {
28
+ if (!options.enabled) {
29
+ return;
30
+ }
31
+
32
+ server.middlewares.use(createClientAssetMiddleware());
33
+ server.middlewares.use(
34
+ createPrototypePrdMiddleware({
35
+ root: resolvedConfig.root,
36
+ options
37
+ })
38
+ );
39
+ },
40
+
41
+ transformIndexHtml(html) {
42
+ if (!options.enabled) {
43
+ return html;
44
+ }
45
+
46
+ const script = `<script type="module" src="${CLIENT_PREFIX}overlay.js"></script>`;
47
+ return html.includes("</body>")
48
+ ? html.replace("</body>", `${script}</body>`)
49
+ : `${html}${script}`;
50
+ }
51
+ };
52
+ }
53
+
54
+ export { prototypePrd };
55
+
56
+ function createClientAssetMiddleware() {
57
+ return async function clientAssetMiddleware(req, res, next) {
58
+ const url = new URL(req.url ?? "/", "http://localhost");
59
+
60
+ if (!url.pathname.startsWith(CLIENT_PREFIX)) {
61
+ next();
62
+ return;
63
+ }
64
+
65
+ const assetName = path.basename(url.pathname);
66
+ if (!["overlay.js", "overlay.css"].includes(assetName)) {
67
+ res.statusCode = 404;
68
+ res.end("Not found");
69
+ return;
70
+ }
71
+
72
+ const filePath = path.join(PACKAGE_ROOT, "client", assetName);
73
+ const content = await readFile(filePath, "utf8");
74
+
75
+ res.setHeader(
76
+ "content-type",
77
+ assetName.endsWith(".css")
78
+ ? "text/css; charset=utf-8"
79
+ : "text/javascript; charset=utf-8"
80
+ );
81
+ res.end(content);
82
+ };
83
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "prototype-prd-vite-plugin",
3
+ "version": "0.1.0",
4
+ "description": "Local-first PRD workbench overlay for Vite prototype projects.",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/dzye/prototype-prd-vite-plugin.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/dzye/prototype-prd-vite-plugin/issues"
13
+ },
14
+ "homepage": "https://github.com/dzye/prototype-prd-vite-plugin#readme",
15
+ "exports": {
16
+ ".": "./index.js"
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "src",
21
+ "client",
22
+ "examples/basic/index.html",
23
+ "examples/basic/package.json",
24
+ "examples/basic/package-lock.json",
25
+ "examples/basic/src",
26
+ "examples/basic/vite.config.js",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "scripts": {
31
+ "test": "node --test",
32
+ "pack:dry": "npm pack --dry-run"
33
+ },
34
+ "keywords": [
35
+ "vite",
36
+ "plugin",
37
+ "prd",
38
+ "prototype",
39
+ "markdown",
40
+ "openai"
41
+ ],
42
+ "author": "",
43
+ "license": "MIT",
44
+ "peerDependencies": {
45
+ "vite": ">=5.0.0"
46
+ },
47
+ "engines": {
48
+ "node": ">=18.18.0"
49
+ }
50
+ }
package/src/config.js ADDED
@@ -0,0 +1,27 @@
1
+ const DEFAULT_OPTIONS = {
2
+ draftDir: ".prototype-prd",
3
+ draftFile: "current.md",
4
+ exportDir: "docs/prd",
5
+ defaultTitle: "Product Requirements Document",
6
+ ai: {
7
+ enabled: true,
8
+ trigger: "manual",
9
+ model: "gpt-4.1-mini",
10
+ baseURL: "https://api.openai.com/v1"
11
+ }
12
+ };
13
+
14
+ export function normalizeOptions(userOptions = {}, command = "serve") {
15
+ const ai = {
16
+ ...DEFAULT_OPTIONS.ai,
17
+ ...(userOptions.ai ?? {}),
18
+ trigger: "manual"
19
+ };
20
+
21
+ return {
22
+ ...DEFAULT_OPTIONS,
23
+ ...userOptions,
24
+ enabled: userOptions.enabled ?? command === "serve",
25
+ ai
26
+ };
27
+ }
package/src/files.js ADDED
@@ -0,0 +1,74 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { slugify } from "./paths.js";
6
+
7
+ const STARTER_SECTIONS = [
8
+ "Background",
9
+ "Goals",
10
+ "Non-Goals",
11
+ "Users",
12
+ "User Stories",
13
+ "Functional Requirements",
14
+ "UX Notes",
15
+ "Data And State",
16
+ "Edge Cases",
17
+ "Acceptance Criteria"
18
+ ];
19
+
20
+ export function getStarterMarkdown(title = "Product Requirements Document") {
21
+ const safeTitle = String(title || "Product Requirements Document").trim();
22
+ const sections = STARTER_SECTIONS.map((section) => `## ${section}\n`).join("\n");
23
+
24
+ return `# ${safeTitle}\n\n${sections}`;
25
+ }
26
+
27
+ export async function readDraft(storagePaths, title) {
28
+ try {
29
+ const markdown = await readFile(storagePaths.draftPath, "utf8");
30
+ return { exists: true, markdown, path: storagePaths.draftPath };
31
+ } catch (error) {
32
+ if (error?.code !== "ENOENT") {
33
+ throw error;
34
+ }
35
+
36
+ return {
37
+ exists: false,
38
+ markdown: getStarterMarkdown(title),
39
+ path: storagePaths.draftPath
40
+ };
41
+ }
42
+ }
43
+
44
+ export async function saveDraft(storagePaths, markdown) {
45
+ await mkdir(path.dirname(storagePaths.draftPath), { recursive: true });
46
+ await writeFile(storagePaths.draftPath, String(markdown ?? ""), "utf8");
47
+
48
+ return { path: storagePaths.draftPath };
49
+ }
50
+
51
+ export async function exportPrd(
52
+ storagePaths,
53
+ { markdown, title, date = new Date().toISOString().slice(0, 10), overwrite = false }
54
+ ) {
55
+ await mkdir(storagePaths.exportDirPath, { recursive: true });
56
+
57
+ const fileName = `${date}-${slugify(title)}.md`;
58
+ const targetPath = path.join(storagePaths.exportDirPath, fileName);
59
+
60
+ if (!overwrite) {
61
+ try {
62
+ await access(targetPath, fsConstants.F_OK);
63
+ throw new Error(`Export file "${fileName}" already exists.`);
64
+ } catch (error) {
65
+ if (error?.code !== "ENOENT") {
66
+ throw error;
67
+ }
68
+ }
69
+ }
70
+
71
+ await writeFile(targetPath, String(markdown ?? ""), "utf8");
72
+
73
+ return { fileName, path: targetPath };
74
+ }
package/src/http.js ADDED
@@ -0,0 +1,25 @@
1
+ export async function readJson(req) {
2
+ const chunks = [];
3
+
4
+ for await (const chunk of req) {
5
+ chunks.push(Buffer.from(chunk));
6
+ }
7
+
8
+ const raw = Buffer.concat(chunks).toString("utf8");
9
+ return raw ? JSON.parse(raw) : {};
10
+ }
11
+
12
+ export function sendJson(res, statusCode, payload) {
13
+ res.statusCode = statusCode;
14
+ res.setHeader("content-type", "application/json; charset=utf-8");
15
+ res.end(JSON.stringify(payload));
16
+ }
17
+
18
+ export function sendError(res, statusCode, code, message) {
19
+ sendJson(res, statusCode, {
20
+ error: {
21
+ code,
22
+ message
23
+ }
24
+ });
25
+ }
@@ -0,0 +1,112 @@
1
+ import { normalizeOptions } from "./config.js";
2
+ import { exportPrd, readDraft, saveDraft } from "./files.js";
3
+ import { readJson, sendError, sendJson } from "./http.js";
4
+ import { generatePrdMarkdown } from "./openai.js";
5
+ import { resolveStoragePaths } from "./paths.js";
6
+
7
+ const PREFIX = "/__prototype_prd__";
8
+
9
+ export function createPrototypePrdMiddleware({
10
+ root,
11
+ options = {},
12
+ env = process.env,
13
+ generate = generatePrdMarkdown
14
+ }) {
15
+ const normalized = normalizeOptions(options, "serve");
16
+ const storagePaths = resolveStoragePaths(root, normalized);
17
+
18
+ return async function prototypePrdMiddleware(req, res, next) {
19
+ const url = new URL(req.url ?? "/", "http://localhost");
20
+
21
+ if (!url.pathname.startsWith(PREFIX)) {
22
+ next();
23
+ return;
24
+ }
25
+
26
+ try {
27
+ if (req.method === "GET" && url.pathname === `${PREFIX}/state`) {
28
+ sendJson(res, 200, {
29
+ title: normalized.defaultTitle,
30
+ draftPath: relativeForClient(storagePaths.root, storagePaths.draftPath),
31
+ exportDir: relativeForClient(storagePaths.root, storagePaths.exportDirPath),
32
+ ai: {
33
+ enabled: normalized.ai.enabled,
34
+ trigger: "manual",
35
+ configured: Boolean(env.OPENAI_API_KEY)
36
+ }
37
+ });
38
+ return;
39
+ }
40
+
41
+ if (req.method === "GET" && url.pathname === `${PREFIX}/draft`) {
42
+ sendJson(res, 200, await readDraft(storagePaths, normalized.defaultTitle));
43
+ return;
44
+ }
45
+
46
+ if (req.method === "PUT" && url.pathname === `${PREFIX}/draft`) {
47
+ const body = await readJson(req);
48
+ sendJson(res, 200, await saveDraft(storagePaths, body.markdown));
49
+ return;
50
+ }
51
+
52
+ if (req.method === "POST" && url.pathname === `${PREFIX}/export`) {
53
+ const body = await readJson(req);
54
+ sendJson(
55
+ res,
56
+ 200,
57
+ await exportPrd(storagePaths, {
58
+ markdown: body.markdown,
59
+ title: body.title || normalized.defaultTitle,
60
+ date: body.date,
61
+ overwrite: Boolean(body.overwrite)
62
+ })
63
+ );
64
+ return;
65
+ }
66
+
67
+ if (req.method === "POST" && url.pathname === `${PREFIX}/ai/generate`) {
68
+ if (!normalized.ai.enabled) {
69
+ sendError(res, 400, "AI_DISABLED", "AI generation is disabled.");
70
+ return;
71
+ }
72
+
73
+ if (!env.OPENAI_API_KEY) {
74
+ sendError(
75
+ res,
76
+ 400,
77
+ "OPENAI_API_KEY_MISSING",
78
+ "Set OPENAI_API_KEY in your local environment to use AI generation."
79
+ );
80
+ return;
81
+ }
82
+
83
+ const body = await readJson(req);
84
+ const markdown = await generate({
85
+ apiKey: env.OPENAI_API_KEY,
86
+ baseURL: normalized.ai.baseURL,
87
+ model: normalized.ai.model,
88
+ markdown: body.markdown,
89
+ instruction: body.instruction,
90
+ pageUrl: body.pageUrl,
91
+ notes: body.notes
92
+ });
93
+
94
+ sendJson(res, 200, { markdown });
95
+ return;
96
+ }
97
+
98
+ sendError(res, 404, "NOT_FOUND", "Prototype PRD endpoint not found.");
99
+ } catch (error) {
100
+ sendError(
101
+ res,
102
+ error?.message?.includes("already exists") ? 409 : 500,
103
+ "PROTOTYPE_PRD_ERROR",
104
+ error?.message || "Prototype PRD request failed."
105
+ );
106
+ }
107
+ };
108
+ }
109
+
110
+ function relativeForClient(root, targetPath) {
111
+ return targetPath.replace(`${root}/`, "");
112
+ }
package/src/openai.js ADDED
@@ -0,0 +1,78 @@
1
+ const INSTRUCTIONS = `You are a senior product manager helping write a concise product requirements document.
2
+ Return only Markdown. Preserve useful existing sections and improve the PRD structure when needed.
3
+ Use these sections when applicable: Background, Goals, Non-Goals, Users, User Stories, Functional Requirements, UX Notes, Data And State, Edge Cases, Acceptance Criteria.`;
4
+
5
+ export async function generatePrdMarkdown({
6
+ apiKey,
7
+ baseURL = "https://api.openai.com/v1",
8
+ model = "gpt-4.1-mini",
9
+ markdown = "",
10
+ instruction = "",
11
+ pageUrl = "",
12
+ notes = "",
13
+ fetchImpl = globalThis.fetch
14
+ }) {
15
+ if (!apiKey) {
16
+ throw new Error("OPENAI_API_KEY is required.");
17
+ }
18
+
19
+ if (typeof fetchImpl !== "function") {
20
+ throw new Error("A fetch implementation is required for AI generation.");
21
+ }
22
+
23
+ const response = await fetchImpl(`${baseURL.replace(/\/+$/, "")}/responses`, {
24
+ method: "POST",
25
+ headers: {
26
+ authorization: `Bearer ${apiKey}`,
27
+ "content-type": "application/json"
28
+ },
29
+ body: JSON.stringify({
30
+ model,
31
+ instructions: INSTRUCTIONS,
32
+ input: buildInput({ markdown, instruction, pageUrl, notes })
33
+ })
34
+ });
35
+
36
+ const payload = await response.json();
37
+
38
+ if (!response.ok) {
39
+ const message =
40
+ payload?.error?.message || `Request failed with status ${response.status}`;
41
+ throw new Error(`OpenAI API request failed: ${message}`);
42
+ }
43
+
44
+ const text = extractResponseText(payload);
45
+ if (!text) {
46
+ throw new Error("OpenAI API response did not contain generated text.");
47
+ }
48
+
49
+ return text;
50
+ }
51
+
52
+ export function extractResponseText(payload) {
53
+ if (typeof payload?.output_text === "string") {
54
+ return payload.output_text;
55
+ }
56
+
57
+ if (!Array.isArray(payload?.output)) {
58
+ return "";
59
+ }
60
+
61
+ return payload.output
62
+ .flatMap((item) => item?.content ?? [])
63
+ .filter((content) => content?.type === "output_text" && content.text)
64
+ .map((content) => content.text)
65
+ .join("\n")
66
+ .trim();
67
+ }
68
+
69
+ function buildInput({ markdown, instruction, pageUrl, notes }) {
70
+ return [
71
+ `User instruction:\n${instruction || "Generate or improve this PRD."}`,
72
+ pageUrl ? `Current prototype URL:\n${pageUrl}` : "",
73
+ notes ? `Product notes:\n${notes}` : "",
74
+ `Current PRD Markdown:\n${markdown || "(empty draft)"}`
75
+ ]
76
+ .filter(Boolean)
77
+ .join("\n\n---\n\n");
78
+ }
package/src/paths.js ADDED
@@ -0,0 +1,39 @@
1
+ import path from "node:path";
2
+
3
+ export function resolveInsideRoot(root, targetPath) {
4
+ const resolvedRoot = path.resolve(root);
5
+ const resolvedTarget = path.resolve(resolvedRoot, targetPath);
6
+ const relative = path.relative(resolvedRoot, resolvedTarget);
7
+
8
+ if (
9
+ relative === ".." ||
10
+ relative.startsWith(`..${path.sep}`) ||
11
+ path.isAbsolute(relative)
12
+ ) {
13
+ throw new Error(`Path "${targetPath}" escapes the project root.`);
14
+ }
15
+
16
+ return resolvedTarget;
17
+ }
18
+
19
+ export function resolveStoragePaths(root, options) {
20
+ return {
21
+ root: path.resolve(root),
22
+ draftPath: resolveInsideRoot(
23
+ root,
24
+ path.join(options.draftDir, options.draftFile)
25
+ ),
26
+ draftDirPath: resolveInsideRoot(root, options.draftDir),
27
+ exportDirPath: resolveInsideRoot(root, options.exportDir)
28
+ };
29
+ }
30
+
31
+ export function slugify(value) {
32
+ const slug = String(value ?? "")
33
+ .trim()
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9]+/g, "-")
36
+ .replace(/^-+|-+$/g, "");
37
+
38
+ return slug || "prd";
39
+ }