@bitovi/vybit 0.9.0 → 0.9.1
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/overlay/dist/overlay.js +5 -0
- package/package.json +7 -2
- package/panel/dist/assets/{DesignMode-CZvwfLMn.js → DesignMode-D6tjMRjM.js} +1 -1
- package/panel/dist/assets/index-D5VCKvC6.js +62 -0
- package/panel/dist/index.html +1 -1
- package/server/app.ts +24 -0
- package/server/ghost-cache.ts +154 -0
- package/server/websocket.ts +3 -0
- package/shared/types.ts +21 -1
- package/storybook-addon/index.js +1 -0
- package/storybook-addon/manager.tsx +33 -0
- package/storybook-addon/package.json +10 -0
- package/storybook-addon/preset.js +10 -0
- package/storybook-addon/preview.ts +29 -0
- package/panel/dist/assets/index-CzGpZV08.js +0 -62
package/panel/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
font-size: 12px;
|
|
17
17
|
}
|
|
18
18
|
</style>
|
|
19
|
-
<script type="module" crossorigin src="/panel/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/panel/assets/index-D5VCKvC6.js"></script>
|
|
20
20
|
<link rel="stylesheet" crossorigin href="/panel/assets/index-BdUOBofL.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
package/server/app.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { createProxyMiddleware } from "http-proxy-middleware";
|
|
|
9
9
|
import { getByStatus, getQueueUpdate, clearAll } from "./queue.js";
|
|
10
10
|
import { resolveTailwindConfig, generateCssForClasses, getTailwindVersion } from "./tailwind.js";
|
|
11
11
|
import { loadStoryArgTypes } from "./storybook.js";
|
|
12
|
+
import { loadCache, getAllCachedGhosts, setCachedGhost, invalidateAll as invalidateGhostCache } from "./ghost-cache.js";
|
|
12
13
|
import type { PatchStatus } from "../shared/types.js";
|
|
13
14
|
|
|
14
15
|
const VALID_STATUSES = new Set<string>(['staged', 'committed', 'implementing', 'implemented', 'error']);
|
|
@@ -17,6 +18,9 @@ export function createApp(packageRoot: string, storybookUrl: string | null = nul
|
|
|
17
18
|
const app = express();
|
|
18
19
|
app.use(cors());
|
|
19
20
|
|
|
21
|
+
// Load ghost cache from disk on startup
|
|
22
|
+
loadCache();
|
|
23
|
+
|
|
20
24
|
app.get("/overlay.js", (_req, res) => {
|
|
21
25
|
const overlayPath = path.join(packageRoot, "overlay", "dist", "overlay.js");
|
|
22
26
|
res.sendFile(overlayPath, (err) => {
|
|
@@ -84,6 +88,26 @@ export function createApp(packageRoot: string, storybookUrl: string | null = nul
|
|
|
84
88
|
res.json({ ok: true });
|
|
85
89
|
});
|
|
86
90
|
|
|
91
|
+
// --- Ghost cache REST endpoints ---
|
|
92
|
+
app.get('/api/ghost-cache', (_req, res) => {
|
|
93
|
+
res.json(getAllCachedGhosts());
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
app.post('/api/ghost-cache', express.json({ limit: '1mb' }), (req, res) => {
|
|
97
|
+
const { storyId, args, ghostHtml, hostStyles, storyBackground, componentName, componentPath } = req.body;
|
|
98
|
+
if (!storyId || typeof ghostHtml !== 'string') {
|
|
99
|
+
res.status(400).json({ error: 'storyId and ghostHtml are required' });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setCachedGhost({ storyId, args, ghostHtml, hostStyles: hostStyles ?? {}, storyBackground, componentName: componentName ?? '', componentPath });
|
|
103
|
+
res.json({ ok: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
app.delete('/api/ghost-cache', (_req, res) => {
|
|
107
|
+
invalidateGhostCache();
|
|
108
|
+
res.json({ ok: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
87
111
|
// --- Storybook proxy ---
|
|
88
112
|
if (storybookUrl) {
|
|
89
113
|
// Vite's dev server serves assets at many root-absolute path prefixes:
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
|
|
5
|
+
export interface GhostCacheEntry {
|
|
6
|
+
storyId: string;
|
|
7
|
+
argsHash: string;
|
|
8
|
+
ghostHtml: string;
|
|
9
|
+
hostStyles: Record<string, string>;
|
|
10
|
+
storyBackground?: string;
|
|
11
|
+
componentName: string;
|
|
12
|
+
componentPath?: string;
|
|
13
|
+
extractedAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface CacheFile {
|
|
17
|
+
entries: GhostCacheEntry[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_ENTRIES = 200;
|
|
21
|
+
const MAX_TOTAL_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
22
|
+
|
|
23
|
+
const cache = new Map<string, GhostCacheEntry>();
|
|
24
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
25
|
+
let cacheDir: string | null = null;
|
|
26
|
+
let cacheFilePath: string | null = null;
|
|
27
|
+
|
|
28
|
+
function getCacheDir(): string {
|
|
29
|
+
if (!cacheDir) {
|
|
30
|
+
cacheDir = path.join(process.cwd(), "node_modules", ".cache", "vybit");
|
|
31
|
+
}
|
|
32
|
+
return cacheDir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getCacheFilePath(): string {
|
|
36
|
+
if (!cacheFilePath) {
|
|
37
|
+
cacheFilePath = path.join(getCacheDir(), "ghost-cache.json");
|
|
38
|
+
}
|
|
39
|
+
return cacheFilePath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function computeArgsHash(args?: Record<string, unknown>): string {
|
|
43
|
+
if (!args || Object.keys(args).length === 0) return "";
|
|
44
|
+
const sorted = JSON.stringify(args, Object.keys(args).sort());
|
|
45
|
+
return crypto.createHash("sha1").update(sorted).digest("hex").slice(0, 12);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cacheKey(storyId: string, argsHash: string): string {
|
|
49
|
+
return argsHash ? `${storyId}::${argsHash}` : storyId;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Load the cache from disk into memory. Safe to call multiple times. */
|
|
53
|
+
export function loadCache(): void {
|
|
54
|
+
cache.clear();
|
|
55
|
+
const filePath = getCacheFilePath();
|
|
56
|
+
try {
|
|
57
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
58
|
+
const data: CacheFile = JSON.parse(raw);
|
|
59
|
+
if (Array.isArray(data.entries)) {
|
|
60
|
+
for (const entry of data.entries) {
|
|
61
|
+
cache.set(cacheKey(entry.storyId, entry.argsHash), entry);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// File doesn't exist or is corrupt — start fresh
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Write the cache to disk. Creates the directory if needed. */
|
|
70
|
+
function flushToDisk(): void {
|
|
71
|
+
const filePath = getCacheFilePath();
|
|
72
|
+
const dir = getCacheDir();
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
const data: CacheFile = { entries: Array.from(cache.values()) };
|
|
76
|
+
fs.writeFileSync(filePath, JSON.stringify(data), "utf-8");
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error("[ghost-cache] Failed to write cache:", err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function scheduleFlush(): void {
|
|
83
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
84
|
+
flushTimer = setTimeout(() => {
|
|
85
|
+
flushTimer = null;
|
|
86
|
+
flushToDisk();
|
|
87
|
+
}, 500);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Evict oldest entries until we're under the size and count limits. */
|
|
91
|
+
function evictIfNeeded(): void {
|
|
92
|
+
// Evict by count
|
|
93
|
+
while (cache.size > MAX_ENTRIES) {
|
|
94
|
+
let oldestKey: string | null = null;
|
|
95
|
+
let oldestTime = Infinity;
|
|
96
|
+
for (const [key, entry] of cache) {
|
|
97
|
+
if (entry.extractedAt < oldestTime) {
|
|
98
|
+
oldestTime = entry.extractedAt;
|
|
99
|
+
oldestKey = key;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (oldestKey) cache.delete(oldestKey);
|
|
103
|
+
else break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Evict by total size
|
|
107
|
+
let totalBytes = () => {
|
|
108
|
+
let sum = 0;
|
|
109
|
+
for (const entry of cache.values()) sum += entry.ghostHtml.length;
|
|
110
|
+
return sum;
|
|
111
|
+
};
|
|
112
|
+
while (totalBytes() > MAX_TOTAL_BYTES && cache.size > 0) {
|
|
113
|
+
let oldestKey: string | null = null;
|
|
114
|
+
let oldestTime = Infinity;
|
|
115
|
+
for (const [key, entry] of cache) {
|
|
116
|
+
if (entry.extractedAt < oldestTime) {
|
|
117
|
+
oldestTime = entry.extractedAt;
|
|
118
|
+
oldestKey = key;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (oldestKey) cache.delete(oldestKey);
|
|
122
|
+
else break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getCachedGhost(storyId: string, argsHash: string): GhostCacheEntry | null {
|
|
127
|
+
return cache.get(cacheKey(storyId, argsHash)) ?? null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function setCachedGhost(entry: Omit<GhostCacheEntry, "argsHash" | "extractedAt"> & { args?: Record<string, unknown> }): void {
|
|
131
|
+
const argsHash = computeArgsHash(entry.args);
|
|
132
|
+
const full: GhostCacheEntry = {
|
|
133
|
+
storyId: entry.storyId,
|
|
134
|
+
argsHash,
|
|
135
|
+
ghostHtml: entry.ghostHtml,
|
|
136
|
+
hostStyles: entry.hostStyles,
|
|
137
|
+
storyBackground: entry.storyBackground,
|
|
138
|
+
componentName: entry.componentName,
|
|
139
|
+
componentPath: entry.componentPath,
|
|
140
|
+
extractedAt: Date.now(),
|
|
141
|
+
};
|
|
142
|
+
cache.set(cacheKey(full.storyId, argsHash), full);
|
|
143
|
+
evictIfNeeded();
|
|
144
|
+
scheduleFlush();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getAllCachedGhosts(): GhostCacheEntry[] {
|
|
148
|
+
return Array.from(cache.values());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function invalidateAll(): void {
|
|
152
|
+
cache.clear();
|
|
153
|
+
scheduleFlush();
|
|
154
|
+
}
|
package/server/websocket.ts
CHANGED
|
@@ -119,6 +119,9 @@ export function setupWebSocket(httpServer: Server): WebSocketDeps {
|
|
|
119
119
|
console.error(`[ws] Design patch staged: ${patch.id}`);
|
|
120
120
|
} else if (msg.type === "DESIGN_CLOSE") {
|
|
121
121
|
broadcastTo("overlay", { type: "DESIGN_CLOSE" }, ws);
|
|
122
|
+
} else if (msg.type === "RESET_SELECTION") {
|
|
123
|
+
broadcastTo("panel", { type: "RESET_SELECTION" }, ws);
|
|
124
|
+
console.error(`[ws] Reset selection broadcast to panels`);
|
|
122
125
|
} else if (msg.type === "COMPONENT_DROPPED") {
|
|
123
126
|
const patch = addPatch({ ...msg.patch, kind: msg.patch.kind ?? 'component-drop' });
|
|
124
127
|
console.error(`[ws] Component-drop patch staged: #${patch.id}`);
|
package/shared/types.ts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
// Shared types for the PATCH protocol.
|
|
2
2
|
// Imported by overlay (esbuild), panel (Vite), and server (tsx).
|
|
3
3
|
|
|
4
|
+
/** Cached ghost HTML + host styles for instant component preview placeholders. */
|
|
5
|
+
export interface GhostCacheEntry {
|
|
6
|
+
storyId: string;
|
|
7
|
+
argsHash: string;
|
|
8
|
+
ghostHtml: string;
|
|
9
|
+
hostStyles: Record<string, string>;
|
|
10
|
+
storyBackground?: string;
|
|
11
|
+
componentName: string;
|
|
12
|
+
componentPath?: string;
|
|
13
|
+
extractedAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
export type ContainerName = 'modal' | 'popover' | 'sidebar' | 'popup';
|
|
5
17
|
|
|
6
18
|
/** A component placed on the Fabric.js design canvas */
|
|
@@ -311,6 +323,11 @@ export interface ClosePanelMessage {
|
|
|
311
323
|
type: 'CLOSE_PANEL';
|
|
312
324
|
}
|
|
313
325
|
|
|
326
|
+
/** Overlay → Server: story changed in Storybook, clear panel selection */
|
|
327
|
+
export interface ResetSelectionMessage {
|
|
328
|
+
type: 'RESET_SELECTION';
|
|
329
|
+
}
|
|
330
|
+
|
|
314
331
|
// ---------------------------------------------------------------------------
|
|
315
332
|
// Component arm-and-place messages
|
|
316
333
|
// ---------------------------------------------------------------------------
|
|
@@ -361,7 +378,7 @@ export type PanelToOverlay =
|
|
|
361
378
|
| ClosePanelMessage
|
|
362
379
|
| ComponentArmMessage
|
|
363
380
|
| ComponentDisarmMessage;
|
|
364
|
-
export type OverlayToServer = PatchStagedMessage | ComponentDroppedMessage;
|
|
381
|
+
export type OverlayToServer = PatchStagedMessage | ComponentDroppedMessage | ResetSelectionMessage;
|
|
365
382
|
export type PanelToServer = PatchCommitMessage | MessageStageMessage;
|
|
366
383
|
export type ClientToServer =
|
|
367
384
|
| RegisterMessage
|
|
@@ -371,10 +388,12 @@ export type ClientToServer =
|
|
|
371
388
|
| DesignSubmitMessage
|
|
372
389
|
| DesignCloseMessage
|
|
373
390
|
| ComponentDroppedMessage
|
|
391
|
+
| ResetSelectionMessage
|
|
374
392
|
| PingMessage;
|
|
375
393
|
export type ServerToClient =
|
|
376
394
|
| PongMessage
|
|
377
395
|
| QueueUpdateMessage
|
|
396
|
+
| ResetSelectionMessage
|
|
378
397
|
| PatchUpdateMessage
|
|
379
398
|
| PatchImplementingMessage
|
|
380
399
|
| PatchImplementedMessage
|
|
@@ -405,5 +424,6 @@ export type AnyMessage =
|
|
|
405
424
|
| ComponentDisarmMessage
|
|
406
425
|
| ComponentDisarmedMessage
|
|
407
426
|
| ComponentDroppedMessage
|
|
427
|
+
| ResetSelectionMessage
|
|
408
428
|
| PingMessage
|
|
409
429
|
| PongMessage;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('./preset');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { addons, types } from '@storybook/manager-api';
|
|
3
|
+
import { AddonPanel } from '@storybook/components';
|
|
4
|
+
|
|
5
|
+
const ADDON_ID = 'vybit';
|
|
6
|
+
const PANEL_ID = `${ADDON_ID}/panel`;
|
|
7
|
+
|
|
8
|
+
addons.register(ADDON_ID, (api) => {
|
|
9
|
+
addons.add(PANEL_ID, {
|
|
10
|
+
type: types.PANEL,
|
|
11
|
+
title: 'Vybit',
|
|
12
|
+
paramKey: 'vybit',
|
|
13
|
+
render: ({ active }) => {
|
|
14
|
+
const serverUrl =
|
|
15
|
+
api.getCurrentParameter<{ serverUrl?: string }>('vybit')?.serverUrl
|
|
16
|
+
?? 'http://localhost:3333';
|
|
17
|
+
|
|
18
|
+
if (active) {
|
|
19
|
+
api.togglePanelPosition('right');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<AddonPanel active={active ?? false}>
|
|
24
|
+
<iframe
|
|
25
|
+
src={`${serverUrl}/panel/`}
|
|
26
|
+
style={{ width: '100%', height: '100%', border: 'none' }}
|
|
27
|
+
title="Vybit Panel"
|
|
28
|
+
/>
|
|
29
|
+
</AddonPanel>
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { addons } from '@storybook/preview-api';
|
|
2
|
+
|
|
3
|
+
let injected = false;
|
|
4
|
+
|
|
5
|
+
export const decorators = [
|
|
6
|
+
(StoryFn: any, context: any) => {
|
|
7
|
+
const serverUrl =
|
|
8
|
+
context.parameters?.vybit?.serverUrl ?? 'http://localhost:3333';
|
|
9
|
+
|
|
10
|
+
if (!injected) {
|
|
11
|
+
const script = document.createElement('script');
|
|
12
|
+
script.src = `${serverUrl}/overlay.js`;
|
|
13
|
+
document.head.appendChild(script);
|
|
14
|
+
injected = true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return StoryFn();
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const channel = addons.getChannel();
|
|
22
|
+
let lastStoryId: string | undefined;
|
|
23
|
+
|
|
24
|
+
channel.on('storyRendered', (storyId?: string) => {
|
|
25
|
+
// Only reset selection on actual story navigation, not HMR updates
|
|
26
|
+
if (storyId && storyId === lastStoryId) return;
|
|
27
|
+
lastStoryId = storyId;
|
|
28
|
+
window.postMessage({ type: 'STORYBOOK_STORY_RENDERED' }, '*');
|
|
29
|
+
});
|