@designtools/next-plugin 0.1.2 → 0.1.3

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,231 @@
1
+ /**
2
+ * Preview route generator for component isolation.
3
+ *
4
+ * Writes a catch-all preview page into the target app's app directory
5
+ * that can render any component with arbitrary props via postMessage.
6
+ * Uses Next.js file-system routing — no custom server needed.
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+
12
+ const PREVIEW_DIR = "designtools-preview";
13
+
14
+ /** Generate the preview route files in the target app's app directory. */
15
+ export function generatePreviewRoute(appDir: string): void {
16
+ const projectRoot = path.dirname(appDir);
17
+ const previewDir = path.join(appDir, PREVIEW_DIR);
18
+
19
+ // Discover component files so we can generate static imports
20
+ const componentPaths = discoverComponentFiles(projectRoot);
21
+
22
+ // Create directory
23
+ fs.mkdirSync(previewDir, { recursive: true });
24
+
25
+ // Write layout — minimal shell, no app chrome
26
+ fs.writeFileSync(
27
+ path.join(previewDir, "layout.tsx"),
28
+ getLayoutTemplate(),
29
+ "utf-8"
30
+ );
31
+
32
+ // Write page — client component that renders previews via postMessage
33
+ fs.writeFileSync(
34
+ path.join(previewDir, "page.tsx"),
35
+ getPageTemplate(componentPaths),
36
+ "utf-8"
37
+ );
38
+
39
+ // Ensure designtools-preview is gitignored
40
+ ensureGitignore(projectRoot);
41
+ }
42
+
43
+ /** Clean up generated preview files. */
44
+ export function cleanupPreviewRoute(appDir: string): void {
45
+ const previewDir = path.join(appDir, PREVIEW_DIR);
46
+ try {
47
+ fs.rmSync(previewDir, { recursive: true, force: true });
48
+ // Directory itself is removed by rmSync above
49
+ } catch {
50
+ // ignore
51
+ }
52
+ }
53
+
54
+ function ensureGitignore(projectRoot: string): void {
55
+ const gitignorePath = path.join(projectRoot, ".gitignore");
56
+ const entry = "app/designtools-preview";
57
+
58
+ try {
59
+ const existing = fs.existsSync(gitignorePath)
60
+ ? fs.readFileSync(gitignorePath, "utf-8")
61
+ : "";
62
+ if (!existing.includes(entry)) {
63
+ fs.appendFileSync(gitignorePath, `\n# Generated by @designtools/next-plugin\n${entry}\n`);
64
+ }
65
+ } catch {
66
+ // ignore if we can't write gitignore
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Scan the project for component .tsx files in known directories.
72
+ * Returns paths like "components/ui/button" (no extension).
73
+ */
74
+ function discoverComponentFiles(projectRoot: string): string[] {
75
+ const dirs = ["components/ui", "src/components/ui"];
76
+ for (const dir of dirs) {
77
+ const fullDir = path.join(projectRoot, dir);
78
+ if (fs.existsSync(fullDir)) {
79
+ const files = fs.readdirSync(fullDir);
80
+ return files
81
+ .filter((f) => f.endsWith(".tsx") || f.endsWith(".ts") || f.endsWith(".jsx"))
82
+ .map((f) => `${dir}/${f.replace(/\.(tsx|ts|jsx|js)$/, "")}`);
83
+ }
84
+ }
85
+ return [];
86
+ }
87
+
88
+ function getLayoutTemplate(): string {
89
+ return `// Auto-generated by @designtools/next-plugin — do not edit
90
+ export default function PreviewLayout({ children }: { children: React.ReactNode }) {
91
+ return (
92
+ <div style={{ padding: 32, background: "var(--background, #fff)", minHeight: "100vh" }}>
93
+ {children}
94
+ </div>
95
+ );
96
+ }
97
+ `;
98
+ }
99
+
100
+ function getPageTemplate(componentPaths: string[]): string {
101
+ // Generate static import map entries — webpack can analyze these
102
+ const registryEntries = componentPaths
103
+ .map((p) => ` "${p}": () => import("@/${p}"),`)
104
+ .join("\n");
105
+
106
+ return `// Auto-generated by @designtools/next-plugin — do not edit
107
+ "use client";
108
+
109
+ import { useState, useEffect, useCallback, createElement } from "react";
110
+
111
+ /* Static import registry — webpack can analyze these imports */
112
+ const COMPONENT_REGISTRY: Record<string, () => Promise<any>> = {
113
+ ${registryEntries}
114
+ };
115
+
116
+ interface Combination {
117
+ label: string;
118
+ props: Record<string, string>;
119
+ }
120
+
121
+ interface RenderMsg {
122
+ type: "tool:renderPreview";
123
+ componentPath: string;
124
+ exportName: string;
125
+ combinations: Combination[];
126
+ defaultChildren: string;
127
+ }
128
+
129
+ export default function PreviewPage() {
130
+ const [Component, setComponent] = useState<React.ComponentType<any> | null>(null);
131
+ const [combinations, setCombinations] = useState<Combination[]>([]);
132
+ const [defaultChildren, setDefaultChildren] = useState("");
133
+ const [error, setError] = useState<string | null>(null);
134
+
135
+ const handleMessage = useCallback(async (e: MessageEvent) => {
136
+ const msg = e.data;
137
+ if (msg?.type !== "tool:renderPreview") return;
138
+
139
+ const { componentPath, exportName, combinations: combos, defaultChildren: children } = msg as RenderMsg;
140
+
141
+ try {
142
+ setError(null);
143
+ setCombinations(combos);
144
+ setDefaultChildren(children || exportName);
145
+
146
+ const loader = COMPONENT_REGISTRY[componentPath];
147
+ if (!loader) {
148
+ setError(\`Component "\${componentPath}" not found in registry. Available: \${Object.keys(COMPONENT_REGISTRY).join(", ")}\`);
149
+ return;
150
+ }
151
+
152
+ const mod = await loader();
153
+ const Comp = mod[exportName] || mod.default;
154
+ if (!Comp) {
155
+ setError(\`Export "\${exportName}" not found in \${componentPath}\`);
156
+ return;
157
+ }
158
+
159
+ setComponent(() => Comp);
160
+
161
+ // Notify editor that preview is ready
162
+ window.parent.postMessage(
163
+ { type: "tool:previewReady", cellCount: combos.length },
164
+ "*"
165
+ );
166
+ } catch (err: any) {
167
+ setError(\`Failed to load component: \${err.message}\`);
168
+ }
169
+ }, []);
170
+
171
+ useEffect(() => {
172
+ window.addEventListener("message", handleMessage);
173
+ // Signal readiness to the editor
174
+ window.parent.postMessage({ type: "tool:injectedReady" }, "*");
175
+ return () => window.removeEventListener("message", handleMessage);
176
+ }, [handleMessage]);
177
+
178
+ if (error) {
179
+ return (
180
+ <div style={{ padding: 32, color: "#ef4444", fontFamily: "monospace", fontSize: 14 }}>
181
+ {error}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ if (!Component) {
187
+ return (
188
+ <div style={{ padding: 32, color: "#888", fontFamily: "system-ui", fontSize: 14 }}>
189
+ Waiting for component…
190
+ </div>
191
+ );
192
+ }
193
+
194
+ return (
195
+ <div style={{ fontFamily: "system-ui" }}>
196
+ <div style={{
197
+ display: "grid",
198
+ gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
199
+ gap: 24,
200
+ }}>
201
+ {combinations.map((combo, i) => (
202
+ <div key={i} style={{ display: "flex", flexDirection: "column", gap: 8 }}>
203
+ <div style={{
204
+ fontSize: 11,
205
+ fontWeight: 600,
206
+ color: "#888",
207
+ textTransform: "uppercase",
208
+ letterSpacing: "0.05em",
209
+ }}>
210
+ {combo.label}
211
+ </div>
212
+ <div style={{
213
+ padding: 16,
214
+ border: "1px solid #e5e7eb",
215
+ borderRadius: 8,
216
+ display: "flex",
217
+ alignItems: "center",
218
+ justifyContent: "center",
219
+ minHeight: 64,
220
+ background: "#fff",
221
+ }}>
222
+ {createElement(Component, combo.props, defaultChildren)}
223
+ </div>
224
+ </div>
225
+ ))}
226
+ </div>
227
+ </div>
228
+ );
229
+ }
230
+ `;
231
+ }