| import { mkdirSync, writeFileSync } from "fs"; |
| import path from "path"; |
| import glob from "fast-glob"; |
| import chokidar from "chokidar"; |
| import type { FSWatcher } from "chokidar"; |
| import type { Plugin } from "vite"; |
|
|
| const MOCKUPS_DIR = "src/components/mockups"; |
| const GENERATED_MODULE = "src/.generated/mockup-components.ts"; |
|
|
| interface DiscoveredComponent { |
| globKey: string; |
| importPath: string; |
| } |
|
|
| export function mockupPreviewPlugin(): Plugin { |
| let root = ""; |
| let currentSource = ""; |
| let watcher: FSWatcher | null = null; |
|
|
| function getMockupsAbsDir(): string { |
| return path.join(root, MOCKUPS_DIR); |
| } |
|
|
| function getGeneratedModuleAbsPath(): string { |
| return path.join(root, GENERATED_MODULE); |
| } |
|
|
| function isMockupFile(absolutePath: string): boolean { |
| const rel = path.relative(getMockupsAbsDir(), absolutePath); |
| return ( |
| !rel.startsWith("..") && !path.isAbsolute(rel) && rel.endsWith(".tsx") |
| ); |
| } |
|
|
| function isPreviewTarget(relativeToMockups: string): boolean { |
| return relativeToMockups |
| .split(path.sep) |
| .every((segment) => !segment.startsWith("_")); |
| } |
|
|
| async function discoverComponents(): Promise<Array<DiscoveredComponent>> { |
| const files = await glob(`${MOCKUPS_DIR}/**/*.tsx`, { |
| cwd: root, |
| ignore: ["**/_*/**", "**/_*.tsx"], |
| }); |
|
|
| return files.map((f) => ({ |
| globKey: "./" + f.slice("src/".length), |
| importPath: path.posix.relative("src/.generated", f), |
| })); |
| } |
|
|
| function generateSource(components: Array<DiscoveredComponent>): string { |
| const entries = components |
| .map( |
| (c) => |
| ` ${JSON.stringify(c.globKey)}: () => import(${JSON.stringify(c.importPath)})`, |
| ) |
| .join(",\n"); |
|
|
| return [ |
| "// This file is auto-generated by mockupPreviewPlugin.ts.", |
| "type ModuleMap = Record<string, () => Promise<Record<string, unknown>>>;", |
| "export const modules: ModuleMap = {", |
| entries, |
| "};", |
| "", |
| ].join("\n"); |
| } |
|
|
| function shouldAutoRescan(pathname: string): boolean { |
| return ( |
| pathname.includes("/components/mockups/") || |
| pathname.includes("/.generated/mockup-components") |
| ); |
| } |
|
|
| let refreshInFlight = false; |
| let refreshQueued = false; |
|
|
| async function refresh(): Promise<boolean> { |
| if (refreshInFlight) { |
| refreshQueued = true; |
| return false; |
| } |
|
|
| refreshInFlight = true; |
| let changed = false; |
| try { |
| const components = await discoverComponents(); |
| const newSource = generateSource(components); |
| if (newSource !== currentSource) { |
| currentSource = newSource; |
| const generatedModuleAbsPath = getGeneratedModuleAbsPath(); |
| mkdirSync(path.dirname(generatedModuleAbsPath), { recursive: true }); |
| writeFileSync(generatedModuleAbsPath, currentSource); |
| changed = true; |
| } |
| } finally { |
| refreshInFlight = false; |
| } |
|
|
| if (refreshQueued) { |
| refreshQueued = false; |
| const followUp = await refresh(); |
| return changed || followUp; |
| } |
|
|
| return changed; |
| } |
|
|
| async function onFileAddedOrRemoved(): Promise<void> { |
| await refresh(); |
| } |
|
|
| return { |
| name: "mockup-preview", |
| enforce: "pre", |
|
|
| configResolved(config) { |
| root = config.root; |
| }, |
|
|
| async buildStart() { |
| await refresh(); |
| }, |
|
|
| async configureServer(viteServer) { |
| await refresh(); |
|
|
| const mockupsAbsDir = getMockupsAbsDir(); |
| mkdirSync(mockupsAbsDir, { recursive: true }); |
|
|
| watcher = chokidar.watch(mockupsAbsDir, { |
| ignoreInitial: true, |
| awaitWriteFinish: { |
| stabilityThreshold: 100, |
| pollInterval: 50, |
| }, |
| }); |
|
|
| watcher.on("add", (file) => { |
| if ( |
| isMockupFile(file) && |
| isPreviewTarget(path.relative(mockupsAbsDir, file)) |
| ) { |
| void onFileAddedOrRemoved(); |
| } |
| }); |
|
|
| watcher.on("unlink", (file) => { |
| if (isMockupFile(file)) { |
| void onFileAddedOrRemoved(); |
| } |
| }); |
|
|
| viteServer.middlewares.use((req, res, next) => { |
| const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1"); |
| const pathname = requestUrl.pathname; |
| const originalEnd = res.end.bind(res); |
|
|
| res.end = ((...args: Parameters<typeof originalEnd>) => { |
| if (res.statusCode === 404 && shouldAutoRescan(pathname)) { |
| void refresh(); |
| } |
| return originalEnd(...args); |
| }) as typeof res.end; |
|
|
| next(); |
| }); |
| }, |
|
|
| async closeWatcher() { |
| if (watcher) { |
| await watcher.close(); |
| } |
| }, |
| }; |
| } |
|
|