Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 107 additions & 16 deletions cloud/vite-plugins/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@
* ```
*/

import type { Plugin } from "vite";
import type { Plugin, ViteDevServer } from "vite";
import fs from "node:fs";
import fsp from "node:fs/promises";
import { glob } from "glob";
import path from "node:path";

import {
CONTENT_TYPES,
type ContentType,
Expand Down Expand Up @@ -55,6 +56,23 @@ export function viteContent(options: ViteContentOptions): Plugin {
const contentDir = path.resolve(options.contentDir);
const processor = new ContentProcessor(contentDir, true);
let isBuild = false;
let serverInstance: ViteDevServer | null = null;

// Debounced processing for HMR - prevents rapid-fire reprocessing
// when multiple files change in quick succession (e.g., git operations, autosave)
const debouncedProcessAndInvalidate = debounce(async () => {
await processor.processAllContent();

// Invalidate the virtual meta module after processing
if (serverInstance) {
const metaModule = serverInstance.moduleGraph.getModuleById(
RESOLVED_VIRTUAL_MODULE_ID,
);
if (metaModule) {
serverInstance.moduleGraph.invalidateModule(metaModule);
}
}
}, 100);

return {
name: "vite-plugin-content",
Expand All @@ -64,7 +82,8 @@ export function viteContent(options: ViteContentOptions): Plugin {
isBuild = command === "build";
},

async configureServer() {
async configureServer(server) {
serverInstance = server;
// Scan content directory on startup
await processor.processAllContent();
},
Expand All @@ -91,22 +110,24 @@ export function viteContent(options: ViteContentOptions): Plugin {
}
},

// Enable HMR for content meta updates
async handleHotUpdate({ file, server }) {
if (file.endsWith(".mdx") && file.startsWith(contentDir)) {
console.log(`[content] Updating meta for ${file}`);

// todo(sebastian): debounce?
await processor.processAllContent();
// Watch for file additions and deletions
watchChange(id, { event }) {
if (!processor.isRelevantMdxFile(id)) {
return;
}
if (event === "create" || event === "delete") {
console.log(`[content] File ${event}: ${id}`);
void debouncedProcessAndInvalidate();
}
},

// Invalidate the virtual meta module so it regenerates
const metaModule = server.moduleGraph.getModuleById(
RESOLVED_VIRTUAL_MODULE_ID,
);
if (metaModule) {
server.moduleGraph.invalidateModule(metaModule);
}
// Enable HMR for content meta updates (modifications)
async handleHotUpdate({ file }) {
if (!processor.isRelevantMdxFile(file)) {
return;
}
console.log(`[content] Updating meta for ${file}`);
await debouncedProcessAndInvalidate();
},
};
}
Expand Down Expand Up @@ -161,12 +182,28 @@ export default class ContentProcessor {
this.verbose = verbose;
}

/**
* Check if a file path is a relevant MDX file in the content directory
*/
isRelevantMdxFile(filePath: string): boolean {
return filePath.endsWith(".mdx") && filePath.startsWith(this.contentDir);
}

/**
* Process all content types
*/
async processAllContent(): Promise<void> {
if (this.verbose) console.log("[content] Processing all content...");

// Clear existing metadata to prevent accumulation on HMR updates
this.metadata = {
blog: [],
docs: [],
policy: [],
dev: [],
};
this.errors = [];

// Process each content type
for (const contentType of SOURCE_CONTENT_TYPES) {
await this.processContentType(contentType);
Expand Down Expand Up @@ -542,3 +579,57 @@ export default class ContentProcessor {
}
}
}

/**
* Creates a debounced version of an async function.
* Multiple calls within the delay period will only execute once after the delay.
* If processing is already running when the timer fires, it will queue another run
* after the current one completes.
*/
function debounce<T extends (...args: unknown[]) => Promise<void>>(
fn: T,
ms: number,
): (...args: Parameters<T>) => Promise<void> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let isProcessing = false;
let queuedExecution: { args: Parameters<T>; resolve: () => void } | null =
null;

const execute = async (args: Parameters<T>): Promise<void> => {
if (isProcessing) {
// Return a promise that will resolve when this queued work actually completes
return new Promise<void>((resolve) => {
// If there's already a queued execution, replace args (debounce behavior)
queuedExecution = { args, resolve };
});
}

isProcessing = true;
try {
await fn(...args);
} finally {
isProcessing = false;
timeoutId = null;

// Process queued execution
if (queuedExecution) {
const { args: queuedArgs, resolve } = queuedExecution;
queuedExecution = null;
await fn(...queuedArgs);
resolve();
}
}
};

return (...args: Parameters<T>): Promise<void> => {
if (timeoutId) clearTimeout(timeoutId);

const promise = new Promise<void>((resolve) => {
timeoutId = setTimeout(() => {
void execute(args).then(resolve);
}, ms);
});

return promise;
};
}
Loading