proto-plugin 0.1.8 → 0.1.10
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.
- package/README.md +24 -12
- package/package.json +3 -1
- package/scripts/ensure-component-library-page.mjs +89 -0
- package/scripts/link-source.sh +2 -0
- package/src/app.ts +1 -0
- package/src/components/shell/prototype-plugin-update-popup.tsx +47 -35
- package/src/config/create-prototype-host-app.tsx +12 -1
- package/src/config/prototype-site-layout-client.tsx +5 -4
- package/src/lib/prototypes/proto-plugin-semver.ts +18 -0
- package/src/lib/prototypes/proto-plugin-version.ts +65 -2
- package/src/lib/prototypes/upgrade-proto-plugin-prompt.ts +6 -0
- package/src/lib/prototypes/use-proto-plugin-version-check.ts +38 -2
- package/src/lib/prototypes/use-prototype-tool-theme.tsx +36 -0
- package/src/server/proto-plugin-version-route.ts +11 -40
package/README.md
CHANGED
|
@@ -56,7 +56,17 @@ export default page.default;
|
|
|
56
56
|
export const generateStaticParams = page.generateStaticParams;
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
4. **
|
|
59
|
+
4. **Component library** — `src/app/component-library/page.tsx` (created automatically on `pnpm install` if missing; or run `pnpm --dir node_modules/proto-plugin setup-host`):
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { createPrototypeComponentLibraryPage } from "proto-plugin";
|
|
63
|
+
|
|
64
|
+
export default createPrototypeComponentLibraryPage();
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Pass `children` once you've synced UI from source. Until then, the page shows an empty state with a copyable agent prompt.
|
|
68
|
+
|
|
69
|
+
5. **Next.js config** — transpile the package (use a relative import so CSS is not loaded during config evaluation):
|
|
60
70
|
|
|
61
71
|
```ts
|
|
62
72
|
import { withPrototype } from "./packages/prototype/src/config/with-prototype";
|
|
@@ -64,7 +74,7 @@ import { withPrototype } from "./packages/prototype/src/config/with-prototype";
|
|
|
64
74
|
export default withPrototype({ nextConfig: { /* … */ } });
|
|
65
75
|
```
|
|
66
76
|
|
|
67
|
-
|
|
77
|
+
6. **Tailwind** — scan prototype package components from the host `globals.css`. Register semantic `--color-*` tokens as self-referential vars so utilities like `text-muted-foreground` resolve from `[data-prototype-root]` at runtime — **do not** map `--color-foreground` / `--color-background` to product `:root` tokens (that breaks gallery contrast):
|
|
68
78
|
|
|
69
79
|
```css
|
|
70
80
|
@import "tailwindcss";
|
|
@@ -79,7 +89,7 @@ export default withPrototype({ nextConfig: { /* … */ } });
|
|
|
79
89
|
|
|
80
90
|
Product tokens use separate names (e.g. `--product-background`) scoped to `[data-prototype-screenshot]`.
|
|
81
91
|
|
|
82
|
-
|
|
92
|
+
7. **Root layout + tool themes** — wrap routes with `createPrototypeSiteLayout()` and import package chrome CSS (dark/light themes live here, not in host `globals.css`):
|
|
83
93
|
|
|
84
94
|
```tsx
|
|
85
95
|
import { createPrototypeSiteLayout } from "proto-plugin";
|
|
@@ -89,7 +99,7 @@ import "./globals.css";
|
|
|
89
99
|
const PrototypeSiteLayout = createPrototypeSiteLayout();
|
|
90
100
|
```
|
|
91
101
|
|
|
92
|
-
|
|
102
|
+
8. **tsconfig paths** (optional, for tool internal `@prototype/*` alias when developing the package):
|
|
93
103
|
|
|
94
104
|
```json
|
|
95
105
|
{
|
|
@@ -102,7 +112,7 @@ const PrototypeSiteLayout = createPrototypeSiteLayout();
|
|
|
102
112
|
}
|
|
103
113
|
```
|
|
104
114
|
|
|
105
|
-
|
|
115
|
+
9. **Dev server port** — run the host on port **1985** so it stays separate from a typical source app on 3000. In the host `package.json`:
|
|
106
116
|
|
|
107
117
|
```json
|
|
108
118
|
{
|
|
@@ -120,6 +130,7 @@ Open [http://localhost:1985](http://localhost:1985) when developing.
|
|
|
120
130
|
| --- | --- |
|
|
121
131
|
| `definePrototypeConfig` | Register prototypes (sets global config as a side effect) |
|
|
122
132
|
| `createPrototypePage` | Factory for `/prototypes/[slug]` page + `generateStaticParams` |
|
|
133
|
+
| `createPrototypeComponentLibraryPage` | Factory for `/component-library` (empty state until host passes `children`) |
|
|
123
134
|
| `createPrototypeRegistry` | Server helpers: `getAllPrototypes`, `getPrototype`, … |
|
|
124
135
|
| `PrototypeComponent`, `PrototypeControl` | Comment-anchorable UI wrappers |
|
|
125
136
|
| `PrototypeShell` | Review chrome (comments sidebar, screenshot capture) |
|
|
@@ -261,16 +272,17 @@ Follow the package README (`node_modules/proto-plugin/README.md`) and implement
|
|
|
261
272
|
1. **`prototype.config.ts`** at the repo root — start with an empty `prototypes: []` array using `definePrototypeConfig` from `proto-plugin`.
|
|
262
273
|
2. **Gallery home** — `src/app/page.tsx` via `createPrototypeGalleryPage(prototypeConfig)`.
|
|
263
274
|
3. **Prototype route** — `src/app/prototypes/[slug]/page.tsx` via `createPrototypePage(prototypeConfig)` (export `default` + `generateStaticParams`).
|
|
264
|
-
4. **
|
|
275
|
+
4. **Component library** — `src/app/component-library/page.tsx` via `createPrototypeComponentLibraryPage()` (auto-scaffolded on `pnpm install` if missing; shows empty state + copyable populate prompt until synced UI is wired as `children`).
|
|
276
|
+
5. **Root layout** — wrap children with `createPrototypeSiteLayout()` from `proto-plugin` in `src/app/layout.tsx`, and import the tool chrome theme:
|
|
265
277
|
|
|
266
278
|
```tsx
|
|
267
279
|
import { createPrototypeSiteLayout } from "proto-plugin";
|
|
268
280
|
import "proto-plugin/styles/globals.css";
|
|
269
281
|
import "./globals.css";
|
|
270
282
|
```
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
283
|
+
6. **API catch-all** — `src/app/api/[...path]/route.ts` using `createPrototypeApiRoute(prototypeConfig)` from `proto-plugin/server`; export `GET`, `POST`, and `PUT` handlers that call `dispatch`.
|
|
284
|
+
7. **`next.config.ts`** — wrap with `withPrototype` from `proto-plugin/with-prototype` and ensure `proto-plugin` is transpiled.
|
|
285
|
+
8. **`src/app/globals.css`** — Tailwind v4 + product tokens synced from source. **Do not** put tool chrome dark/light mode here (no `prefers-color-scheme`, no hardcoded gallery colors). Scan package components:
|
|
274
286
|
|
|
275
287
|
```css
|
|
276
288
|
@import "tailwindcss";
|
|
@@ -279,7 +291,7 @@ Follow the package README (`node_modules/proto-plugin/README.md`) and implement
|
|
|
279
291
|
|
|
280
292
|
Tool themes live in `proto-plugin/styles/globals.css` (import in layout). The package toggles dark/light on `[data-prototype-root]` via `data-prototype-comment-theme`.
|
|
281
293
|
|
|
282
|
-
|
|
294
|
+
9. **`package.json` scripts** — set dev to port 1985 and forward CLI commands to the installed package:
|
|
283
295
|
|
|
284
296
|
```json
|
|
285
297
|
{
|
|
@@ -292,9 +304,9 @@ Follow the package README (`node_modules/proto-plugin/README.md`) and implement
|
|
|
292
304
|
}
|
|
293
305
|
```
|
|
294
306
|
|
|
295
|
-
|
|
307
|
+
10. **`.env.example` and `.env.local`** — document and set `SOURCE_PATH` to the source app path I provided. Add comment/screenshot storage vars only if I asked for them in step 5 — the app should run without them, but comments won't persist.
|
|
296
308
|
|
|
297
|
-
Run `pnpm dev` and confirm [http://localhost:1985](http://localhost:1985) shows the empty gallery and the app builds cleanly before continuing.
|
|
309
|
+
Run `pnpm dev` and confirm [http://localhost:1985](http://localhost:1985) shows the empty gallery, `/component-library` renders (empty state is fine), and the app builds cleanly before continuing.
|
|
298
310
|
|
|
299
311
|
## 3. Link the source app
|
|
300
312
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proto-plugin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org"
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
"./styles/globals.css": "./src/styles/globals.css"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
+
"postinstall": "node scripts/ensure-component-library-page.mjs",
|
|
35
|
+
"setup-host": "node scripts/ensure-component-library-page.mjs",
|
|
34
36
|
"verify:prototype-ids": "node scripts/verify-prototype-component-ids.mjs",
|
|
35
37
|
"verify:prototype-preview-states": "node scripts/verify-prototype-preview-states.mjs",
|
|
36
38
|
"link-source": "./scripts/link-source.sh",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { resolveHostRoot } from "./lib/resolve-host-root.mjs";
|
|
6
|
+
|
|
7
|
+
const PAGE_CONTENT = `import { createPrototypeComponentLibraryPage } from "proto-plugin";
|
|
8
|
+
|
|
9
|
+
// Scaffolded by proto-plugin — pass \`children\` once you've synced UI from source.
|
|
10
|
+
export default createPrototypeComponentLibraryPage();
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
function resolveAppDir(hostRoot) {
|
|
14
|
+
const srcApp = path.join(hostRoot, "src", "app");
|
|
15
|
+
if (fs.existsSync(srcApp)) {
|
|
16
|
+
return srcApp;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const app = path.join(hostRoot, "app");
|
|
20
|
+
if (fs.existsSync(app)) {
|
|
21
|
+
return app;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isPrototypeHost(hostRoot) {
|
|
28
|
+
if (fs.existsSync(path.join(hostRoot, "prototype.config.ts"))) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const packageJsonPath = path.join(hostRoot, "package.json");
|
|
33
|
+
if (!fs.existsSync(packageJsonPath)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
39
|
+
const deps = {
|
|
40
|
+
...packageJson.dependencies,
|
|
41
|
+
...packageJson.devDependencies,
|
|
42
|
+
};
|
|
43
|
+
return Boolean(deps["proto-plugin"]);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ensureComponentLibraryPage(hostRoot) {
|
|
50
|
+
if (!isPrototypeHost(hostRoot)) {
|
|
51
|
+
return { created: false, reason: "not-a-prototype-host" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const appDir = resolveAppDir(hostRoot);
|
|
55
|
+
if (!appDir) {
|
|
56
|
+
return { created: false, reason: "missing-app-dir" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const pageDir = path.join(appDir, "component-library");
|
|
60
|
+
const pagePath = path.join(pageDir, "page.tsx");
|
|
61
|
+
|
|
62
|
+
if (fs.existsSync(pagePath)) {
|
|
63
|
+
return { created: false, reason: "already-exists", pagePath };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fs.mkdirSync(pageDir, { recursive: true });
|
|
67
|
+
fs.writeFileSync(pagePath, PAGE_CONTENT, "utf8");
|
|
68
|
+
|
|
69
|
+
return { created: true, pagePath };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function main() {
|
|
73
|
+
const toolRoot = path.resolve(
|
|
74
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
75
|
+
"..",
|
|
76
|
+
);
|
|
77
|
+
const hostRoot = resolveHostRoot(toolRoot);
|
|
78
|
+
const result = ensureComponentLibraryPage(hostRoot);
|
|
79
|
+
|
|
80
|
+
if (result.created) {
|
|
81
|
+
console.log(
|
|
82
|
+
`proto-plugin: created component library route at ${path.relative(hostRoot, result.pagePath)}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (import.meta.url === new URL(process.argv[1], "file:").href) {
|
|
88
|
+
main();
|
|
89
|
+
}
|
package/scripts/link-source.sh
CHANGED
package/src/app.ts
CHANGED
|
@@ -2,3 +2,4 @@ import "server-only";
|
|
|
2
2
|
|
|
3
3
|
export { createPrototypeGalleryPage } from "./config/create-prototype-gallery-page";
|
|
4
4
|
export { createPrototypeCatchAllPage } from "./config/create-prototype-host-app";
|
|
5
|
+
export { createPrototypeComponentLibraryPage } from "./config/create-prototype-component-library-page";
|
|
@@ -3,72 +3,84 @@
|
|
|
3
3
|
import { Button } from "@prototype/components/ui/button";
|
|
4
4
|
import { buildUpgradeProtoPluginCopyText } from "@prototype/lib/prototypes/upgrade-proto-plugin-prompt";
|
|
5
5
|
import { useProtoPluginVersionCheck } from "@prototype/lib/prototypes/use-proto-plugin-version-check";
|
|
6
|
+
import { useActivePrototypeToolTheme } from "@prototype/lib/prototypes/use-prototype-tool-theme";
|
|
6
7
|
import useCopyToClipboard from "@prototype/lib/use-copy-to-clipboard";
|
|
7
8
|
import { cn } from "@prototype/lib/utils";
|
|
8
9
|
import { X } from "lucide-react";
|
|
10
|
+
import { useEffect, useState } from "react";
|
|
11
|
+
import { createPortal } from "react-dom";
|
|
9
12
|
import { toast } from "sonner";
|
|
10
13
|
|
|
11
14
|
export function PrototypePluginUpdatePopup() {
|
|
12
15
|
const { status, shouldShowPopup, dismiss } = useProtoPluginVersionCheck();
|
|
13
16
|
const { copy, icon, isCopied } = useCopyToClipboard();
|
|
17
|
+
const theme = useActivePrototypeToolTheme();
|
|
18
|
+
const [mounted, setMounted] = useState(false);
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
setMounted(true);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
if (!shouldShowPopup || !status?.installed || !status.latest || !mounted) {
|
|
16
25
|
return null;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
const
|
|
28
|
+
const handleCopyPrompt = async () => {
|
|
20
29
|
try {
|
|
21
30
|
await copy(
|
|
22
31
|
buildUpgradeProtoPluginCopyText({
|
|
23
|
-
installed: status.installed
|
|
24
|
-
latest: status.latest
|
|
32
|
+
installed: status.installed,
|
|
33
|
+
latest: status.latest,
|
|
25
34
|
}),
|
|
26
35
|
);
|
|
27
36
|
} catch {
|
|
28
37
|
toast.error("Copy error", {
|
|
29
|
-
description: "Failed to copy
|
|
38
|
+
description: "Failed to copy update prompt",
|
|
30
39
|
});
|
|
31
40
|
}
|
|
32
41
|
};
|
|
33
42
|
|
|
34
|
-
return (
|
|
43
|
+
return createPortal(
|
|
35
44
|
<div
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
className=
|
|
39
|
-
"border-border bg-card ring-border pointer-events-auto fixed bottom-4 left-4 z-[1050] flex w-[min(20rem,calc(100vw-2rem))] flex-col gap-3 rounded-xl border p-4 shadow-lg ring-[0.5px]",
|
|
40
|
-
)}
|
|
45
|
+
data-prototype-root
|
|
46
|
+
data-prototype-comment-theme={theme}
|
|
47
|
+
className="pointer-events-none fixed bottom-4 right-4 z-[1050] !bg-transparent"
|
|
41
48
|
>
|
|
42
|
-
<div
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
<div
|
|
50
|
+
role="status"
|
|
51
|
+
aria-live="polite"
|
|
52
|
+
className={cn(
|
|
53
|
+
"pointer-events-auto relative flex w-[15rem] flex-col gap-3 rounded-lg bg-[var(--tool-chrome-surface)] p-4 shadow-md ring-[0.5px] ring-[var(--tool-chrome-border)]",
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
49
56
|
<button
|
|
50
57
|
type="button"
|
|
51
58
|
aria-label="Dismiss update notification"
|
|
52
|
-
className="
|
|
59
|
+
className="absolute right-2 top-2 rounded p-1 text-[var(--tool-chrome-text-muted)] transition-colors duration-200 ease hover:text-[var(--tool-chrome-text-heading)]"
|
|
53
60
|
onClick={dismiss}
|
|
54
61
|
>
|
|
55
|
-
<X size={
|
|
62
|
+
<X size={12} />
|
|
56
63
|
</button>
|
|
57
|
-
</div>
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
<p className="pr-4 text-xs leading-relaxed text-[var(--tool-chrome-text-muted)]">
|
|
66
|
+
A new version of Proto is available, copy the prompt below into your agent to update
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
<Button
|
|
70
|
+
type="button"
|
|
71
|
+
variant="chrome"
|
|
72
|
+
size="sm"
|
|
73
|
+
className="h-7 w-fit cursor-pointer gap-1 px-2 text-[11px]"
|
|
74
|
+
onClick={() => {
|
|
75
|
+
void handleCopyPrompt();
|
|
76
|
+
}}
|
|
77
|
+
aria-label="Copy update prompt"
|
|
78
|
+
>
|
|
79
|
+
{isCopied ? "Copied update prompt" : "Copy update prompt"}
|
|
80
|
+
{icon}
|
|
81
|
+
</Button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>,
|
|
84
|
+
document.body,
|
|
73
85
|
);
|
|
74
86
|
}
|
|
@@ -10,9 +10,12 @@ import type {
|
|
|
10
10
|
PrototypeExtraRoute,
|
|
11
11
|
} from "@prototype/lib/prototypes/prototype-config-types";
|
|
12
12
|
|
|
13
|
+
import { createPrototypeComponentLibraryPage } from "./create-prototype-component-library-page";
|
|
13
14
|
import { createPrototypePage } from "./create-prototype-page";
|
|
14
15
|
import { createPrototypeStateMapPage } from "./create-prototype-state-map-page";
|
|
15
16
|
|
|
17
|
+
const ComponentLibraryPage = createPrototypeComponentLibraryPage();
|
|
18
|
+
|
|
16
19
|
function matchExtraRoute(
|
|
17
20
|
path: string[],
|
|
18
21
|
pattern: string[],
|
|
@@ -61,7 +64,11 @@ export function createPrototypeCatchAllPage(config: PrototypeConfig) {
|
|
|
61
64
|
path: ["prototypes", slug, "states"],
|
|
62
65
|
}));
|
|
63
66
|
|
|
64
|
-
return [
|
|
67
|
+
return [
|
|
68
|
+
{ path: ["component-library"] },
|
|
69
|
+
...prototypePaths,
|
|
70
|
+
...stateMapPaths,
|
|
71
|
+
];
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
async function CatchAllPage({
|
|
@@ -71,6 +78,10 @@ export function createPrototypeCatchAllPage(config: PrototypeConfig) {
|
|
|
71
78
|
}) {
|
|
72
79
|
const { path = [] } = await params;
|
|
73
80
|
|
|
81
|
+
if (path.length === 1 && path[0] === "component-library") {
|
|
82
|
+
return <ComponentLibraryPage />;
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
if (path.length === 2 && path[0] === "prototypes") {
|
|
75
86
|
const slug = path[1]!;
|
|
76
87
|
return prototypePage.default({ params: Promise.resolve({ slug }) });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { PrototypeProvider } from "@prototype/components/prototype-provider";
|
|
3
4
|
import { PrototypePluginUpdatePopup } from "@prototype/components/shell/prototype-plugin-update-popup";
|
|
4
5
|
import { usePathname } from "next/navigation";
|
|
5
6
|
import type { ComponentType, ReactNode } from "react";
|
|
@@ -29,7 +30,7 @@ export function PrototypeSiteLayoutClient({
|
|
|
29
30
|
|
|
30
31
|
if (isGalleryRoute) {
|
|
31
32
|
return (
|
|
32
|
-
<
|
|
33
|
+
<PrototypeProvider className="bg-[var(--bg-main)] flex h-svh min-h-svh w-full flex-col overflow-hidden">
|
|
33
34
|
<GalleryShell
|
|
34
35
|
sourceDirectoryName={sourceDirectoryName}
|
|
35
36
|
sourcePath={sourcePath}
|
|
@@ -38,15 +39,15 @@ export function PrototypeSiteLayoutClient({
|
|
|
38
39
|
</GalleryShell>
|
|
39
40
|
<PrototypePluginUpdatePopup />
|
|
40
41
|
{toaster}
|
|
41
|
-
</
|
|
42
|
+
</PrototypeProvider>
|
|
42
43
|
);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
return (
|
|
46
|
-
<
|
|
47
|
+
<PrototypeProvider className="bg-[var(--bg-ground)] flex h-svh overflow-hidden">
|
|
47
48
|
{children}
|
|
48
49
|
<PrototypePluginUpdatePopup />
|
|
49
50
|
{toaster}
|
|
50
|
-
</
|
|
51
|
+
</PrototypeProvider>
|
|
51
52
|
);
|
|
52
53
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function isNewerProtoPluginVersion(latest: string, installed: string): boolean {
|
|
2
|
+
const latestParts = parseSemver(latest);
|
|
3
|
+
const installedParts = parseSemver(installed);
|
|
4
|
+
if (!latestParts || !installedParts) return false;
|
|
5
|
+
|
|
6
|
+
for (let index = 0; index < 3; index += 1) {
|
|
7
|
+
if (latestParts[index]! > installedParts[index]!) return true;
|
|
8
|
+
if (latestParts[index]! < installedParts[index]!) return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseSemver(version: string): [number, number, number] | null {
|
|
15
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
|
|
16
|
+
if (!match) return null;
|
|
17
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
18
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isNewerProtoPluginVersion } from "@prototype/lib/prototypes/proto-plugin-semver";
|
|
2
|
+
|
|
1
3
|
export type ProtoPluginVersionStatus = {
|
|
2
4
|
installed: string | null;
|
|
3
5
|
latest: string | null;
|
|
@@ -8,12 +10,73 @@ export type ProtoPluginVersionStatus = {
|
|
|
8
10
|
|
|
9
11
|
export const PROTOTYPE_PLUGIN_VERSION_PATH = "/api/proto-plugin/version";
|
|
10
12
|
|
|
13
|
+
const NPM_LATEST_URL = "https://registry.npmjs.org/proto-plugin/latest";
|
|
14
|
+
|
|
15
|
+
type NpmLatestResponse = {
|
|
16
|
+
version?: string;
|
|
17
|
+
repository?: { url?: string };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function normalizeRepositoryUrl(url: string | undefined): string | undefined {
|
|
21
|
+
return url?.replace(/^git\+/, "").replace(/\.git$/, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fetchLatestFromNpmRegistry(): Promise<{
|
|
25
|
+
latest: string | null;
|
|
26
|
+
repositoryUrl?: string;
|
|
27
|
+
}> {
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(NPM_LATEST_URL, { cache: "no-store" });
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
return { latest: null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const data = (await response.json()) as NpmLatestResponse;
|
|
35
|
+
return {
|
|
36
|
+
latest: data.version ?? null,
|
|
37
|
+
repositoryUrl: normalizeRepositoryUrl(data.repository?.url),
|
|
38
|
+
};
|
|
39
|
+
} catch {
|
|
40
|
+
return { latest: null };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Client-side npm check when the server route is stale (older plugin releases cached npm for 1h). */
|
|
45
|
+
async function reconcileWithNpmRegistry(
|
|
46
|
+
status: ProtoPluginVersionStatus,
|
|
47
|
+
): Promise<ProtoPluginVersionStatus> {
|
|
48
|
+
if (status.isWorkspaceLink || !status.installed) {
|
|
49
|
+
return status;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
status.updateAvailable &&
|
|
54
|
+
status.latest &&
|
|
55
|
+
isNewerProtoPluginVersion(status.latest, status.installed)
|
|
56
|
+
) {
|
|
57
|
+
return status;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { latest, repositoryUrl } = await fetchLatestFromNpmRegistry();
|
|
61
|
+
if (!latest || !isNewerProtoPluginVersion(latest, status.installed)) {
|
|
62
|
+
return status;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
...status,
|
|
67
|
+
latest,
|
|
68
|
+
updateAvailable: true,
|
|
69
|
+
repositoryUrl: repositoryUrl ?? status.repositoryUrl,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
11
73
|
export async function fetchProtoPluginVersionStatus(): Promise<ProtoPluginVersionStatus> {
|
|
12
|
-
const response = await fetch(PROTOTYPE_PLUGIN_VERSION_PATH);
|
|
74
|
+
const response = await fetch(PROTOTYPE_PLUGIN_VERSION_PATH, { cache: "no-store" });
|
|
13
75
|
|
|
14
76
|
if (!response.ok) {
|
|
15
77
|
throw new Error("Failed to check proto-plugin version.");
|
|
16
78
|
}
|
|
17
79
|
|
|
18
|
-
|
|
80
|
+
const status = (await response.json()) as ProtoPluginVersionStatus;
|
|
81
|
+
return reconcileWithNpmRegistry(status);
|
|
19
82
|
}
|
|
@@ -38,3 +38,9 @@ export function buildUpgradeProtoPluginCopyText(
|
|
|
38
38
|
): string {
|
|
39
39
|
return buildUpgradeProtoPluginPrompt(options);
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
export function buildUpgradeProtoPluginCommand({
|
|
43
|
+
latest,
|
|
44
|
+
}: Pick<UpgradeProtoPluginPromptOptions, "latest">): string {
|
|
45
|
+
return `pnpm add proto-plugin@${latest}`;
|
|
46
|
+
}
|
|
@@ -8,12 +8,20 @@ import { usePersistedLocalString } from "@prototype/lib/prototypes/use-persisted
|
|
|
8
8
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
9
9
|
|
|
10
10
|
const DISMISSED_VERSION_KEY = "prototype-review:global:dismissed-plugin-version";
|
|
11
|
+
const PREVIEW_PLUGIN_UPDATE_PARAM = "previewPluginUpdate";
|
|
12
|
+
|
|
13
|
+
function readPreviewPluginUpdateParam(): boolean {
|
|
14
|
+
if (process.env.NODE_ENV !== "development") return false;
|
|
15
|
+
if (typeof window === "undefined") return false;
|
|
16
|
+
return new URLSearchParams(window.location.search).has(PREVIEW_PLUGIN_UPDATE_PARAM);
|
|
17
|
+
}
|
|
11
18
|
|
|
12
19
|
export function useProtoPluginVersionCheck() {
|
|
13
20
|
const [status, setStatus] = useState<ProtoPluginVersionStatus | null>(null);
|
|
14
21
|
const [isLoading, setIsLoading] = useState(true);
|
|
15
22
|
const { value: dismissedVersion, updateValue: setDismissedVersion } =
|
|
16
23
|
usePersistedLocalString(DISMISSED_VERSION_KEY, "");
|
|
24
|
+
const forcePreview = readPreviewPluginUpdateParam();
|
|
17
25
|
|
|
18
26
|
useEffect(() => {
|
|
19
27
|
let cancelled = false;
|
|
@@ -46,7 +54,35 @@ export function useProtoPluginVersionCheck() {
|
|
|
46
54
|
}
|
|
47
55
|
}, [setDismissedVersion, status?.latest]);
|
|
48
56
|
|
|
57
|
+
const previewStatus = useMemo<ProtoPluginVersionStatus | null>(() => {
|
|
58
|
+
if (!forcePreview) return null;
|
|
59
|
+
|
|
60
|
+
const installed = status?.installed ?? "0.1.7";
|
|
61
|
+
let latest = status?.latest ?? "0.1.9";
|
|
62
|
+
|
|
63
|
+
if (latest === installed) {
|
|
64
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(installed);
|
|
65
|
+
latest = match
|
|
66
|
+
? `${match[1]}.${match[2]}.${Number(match[3]) + 1}`
|
|
67
|
+
: "0.1.9";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
installed,
|
|
72
|
+
latest,
|
|
73
|
+
updateAvailable: true,
|
|
74
|
+
isWorkspaceLink: false,
|
|
75
|
+
repositoryUrl: status?.repositoryUrl,
|
|
76
|
+
};
|
|
77
|
+
}, [forcePreview, status]);
|
|
78
|
+
|
|
79
|
+
const effectiveStatus = previewStatus ?? status;
|
|
80
|
+
|
|
49
81
|
const shouldShowPopup = useMemo(() => {
|
|
82
|
+
if (forcePreview) {
|
|
83
|
+
return Boolean(effectiveStatus?.installed && effectiveStatus.latest);
|
|
84
|
+
}
|
|
85
|
+
|
|
50
86
|
if (isLoading || !status?.updateAvailable || !status.latest || !status.installed) {
|
|
51
87
|
return false;
|
|
52
88
|
}
|
|
@@ -56,10 +92,10 @@ export function useProtoPluginVersionCheck() {
|
|
|
56
92
|
}
|
|
57
93
|
|
|
58
94
|
return dismissedVersion !== status.latest;
|
|
59
|
-
}, [dismissedVersion, isLoading, status]);
|
|
95
|
+
}, [dismissedVersion, effectiveStatus, forcePreview, isLoading, status]);
|
|
60
96
|
|
|
61
97
|
return {
|
|
62
|
-
status,
|
|
98
|
+
status: effectiveStatus,
|
|
63
99
|
shouldShowPopup,
|
|
64
100
|
dismiss,
|
|
65
101
|
isLoading,
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
createContext,
|
|
8
8
|
useCallback,
|
|
9
9
|
useContext,
|
|
10
|
+
useEffect,
|
|
10
11
|
useMemo,
|
|
12
|
+
useState,
|
|
11
13
|
type ReactNode,
|
|
12
14
|
} from "react";
|
|
13
15
|
|
|
@@ -113,3 +115,37 @@ export function usePrototypeToolTheme(): PrototypeToolThemeContextValue {
|
|
|
113
115
|
|
|
114
116
|
return context;
|
|
115
117
|
}
|
|
118
|
+
|
|
119
|
+
function readActivePrototypeToolTheme(): PrototypeToolTheme {
|
|
120
|
+
if (typeof document === "undefined") return DEFAULT_THEME;
|
|
121
|
+
|
|
122
|
+
const roots = document.querySelectorAll<HTMLElement>("[data-prototype-root]");
|
|
123
|
+
const activeRoot = roots[roots.length - 1] ?? roots[0];
|
|
124
|
+
return normalizeTheme(activeRoot?.getAttribute("data-prototype-comment-theme") ?? DEFAULT_THEME);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Syncs with the innermost tool theme root (e.g. portaled UI outside nested providers). */
|
|
128
|
+
export function useActivePrototypeToolTheme(): PrototypeToolTheme {
|
|
129
|
+
const { theme: contextTheme } = usePrototypeToolTheme();
|
|
130
|
+
const [theme, setTheme] = useState<PrototypeToolTheme>(contextTheme);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const syncTheme = () => {
|
|
134
|
+
setTheme(readActivePrototypeToolTheme());
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
syncTheme();
|
|
138
|
+
|
|
139
|
+
const observer = new MutationObserver(syncTheme);
|
|
140
|
+
for (const root of document.querySelectorAll("[data-prototype-root]")) {
|
|
141
|
+
observer.observe(root, {
|
|
142
|
+
attributes: true,
|
|
143
|
+
attributeFilter: ["data-prototype-comment-theme"],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return () => observer.disconnect();
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
return theme;
|
|
151
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
|
|
4
|
+
import { isNewerProtoPluginVersion } from "@prototype/lib/prototypes/proto-plugin-semver";
|
|
4
5
|
import { NextResponse } from "next/server";
|
|
5
6
|
|
|
6
7
|
const PACKAGE_NAME = "proto-plugin";
|
|
7
8
|
const NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
8
|
-
const CACHE_TTL_MS = 60 * 60 * 1000;
|
|
9
9
|
|
|
10
10
|
export type ProtoPluginVersionStatus = {
|
|
11
11
|
installed: string | null;
|
|
@@ -20,28 +20,6 @@ type NpmLatestResponse = {
|
|
|
20
20
|
repository?: { url?: string };
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
let cachedLatest: { version: string; repositoryUrl?: string; fetchedAt: number } | null =
|
|
24
|
-
null;
|
|
25
|
-
|
|
26
|
-
function parseSemver(version: string): [number, number, number] | null {
|
|
27
|
-
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
|
|
28
|
-
if (!match) return null;
|
|
29
|
-
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function isNewerVersion(latest: string, installed: string): boolean {
|
|
33
|
-
const latestParts = parseSemver(latest);
|
|
34
|
-
const installedParts = parseSemver(installed);
|
|
35
|
-
if (!latestParts || !installedParts) return false;
|
|
36
|
-
|
|
37
|
-
for (let index = 0; index < 3; index += 1) {
|
|
38
|
-
if (latestParts[index]! > installedParts[index]!) return true;
|
|
39
|
-
if (latestParts[index]! < installedParts[index]!) return false;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
23
|
function readIsWorkspaceDependency(cwd: string): boolean {
|
|
46
24
|
try {
|
|
47
25
|
const hostPkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as {
|
|
@@ -76,18 +54,9 @@ async function fetchLatestFromNpm(): Promise<{
|
|
|
76
54
|
version: string | null;
|
|
77
55
|
repositoryUrl?: string;
|
|
78
56
|
}> {
|
|
79
|
-
const now = Date.now();
|
|
80
|
-
|
|
81
|
-
if (cachedLatest && now - cachedLatest.fetchedAt < CACHE_TTL_MS) {
|
|
82
|
-
return {
|
|
83
|
-
version: cachedLatest.version,
|
|
84
|
-
repositoryUrl: cachedLatest.repositoryUrl,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
57
|
try {
|
|
89
58
|
const response = await fetch(NPM_LATEST_URL, {
|
|
90
|
-
|
|
59
|
+
cache: "no-store",
|
|
91
60
|
});
|
|
92
61
|
|
|
93
62
|
if (!response.ok) {
|
|
@@ -98,10 +67,6 @@ async function fetchLatestFromNpm(): Promise<{
|
|
|
98
67
|
const version = data.version ?? null;
|
|
99
68
|
const repositoryUrl = data.repository?.url?.replace(/^git\+/, "").replace(/\.git$/, "");
|
|
100
69
|
|
|
101
|
-
if (version) {
|
|
102
|
-
cachedLatest = { version, repositoryUrl, fetchedAt: now };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
70
|
return { version, repositoryUrl };
|
|
106
71
|
} catch {
|
|
107
72
|
return { version: null };
|
|
@@ -119,13 +84,17 @@ export async function GET() {
|
|
|
119
84
|
updateAvailable: false,
|
|
120
85
|
isWorkspaceLink: true,
|
|
121
86
|
};
|
|
122
|
-
return NextResponse.json(status
|
|
87
|
+
return NextResponse.json(status, {
|
|
88
|
+
headers: { "Cache-Control": "no-store, max-age=0" },
|
|
89
|
+
});
|
|
123
90
|
}
|
|
124
91
|
|
|
125
92
|
const { version: latest, repositoryUrl } = await fetchLatestFromNpm();
|
|
126
93
|
|
|
127
94
|
const updateAvailable =
|
|
128
|
-
installed != null &&
|
|
95
|
+
installed != null &&
|
|
96
|
+
latest != null &&
|
|
97
|
+
isNewerProtoPluginVersion(latest, installed);
|
|
129
98
|
|
|
130
99
|
const status: ProtoPluginVersionStatus = {
|
|
131
100
|
installed,
|
|
@@ -135,5 +104,7 @@ export async function GET() {
|
|
|
135
104
|
repositoryUrl,
|
|
136
105
|
};
|
|
137
106
|
|
|
138
|
-
return NextResponse.json(status
|
|
107
|
+
return NextResponse.json(status, {
|
|
108
|
+
headers: { "Cache-Control": "no-store, max-age=0" },
|
|
109
|
+
});
|
|
139
110
|
}
|