181 lines
4.5 KiB
TypeScript
181 lines
4.5 KiB
TypeScript
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();
|
|
}
|
|
},
|
|
};
|
|
}
|