Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: watch for content layer changes #11371

Merged
merged 2 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
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
31 changes: 31 additions & 0 deletions packages/astro/src/content/data-store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import { promises as fs, type PathLike, existsSync } from 'fs';

const SAVE_DEBOUNCE_MS = 500;
export class DataStore {
#collections = new Map<string, Map<string, any>>();

#file?: PathLike;

#saveTimeout: NodeJS.Timeout | undefined;

#dirty = false;

constructor() {
this.#collections = new Map();
}
Expand All @@ -23,15 +32,18 @@ export class DataStore {
const collection = this.#collections.get(collectionName) ?? new Map();
collection.set(String(key), value);
this.#collections.set(collectionName, collection);
this.#saveToDiskDebounced();
}
delete(collectionName: string, key: string) {
const collection = this.#collections.get(collectionName);
if (collection) {
collection.delete(String(key));
this.#saveToDiskDebounced();
}
}
clear(collectionName: string) {
this.#collections.delete(collectionName);
this.#saveToDiskDebounced();
}

has(collectionName: string, key: string) {
Expand All @@ -50,6 +62,20 @@ export class DataStore {
return this.#collections;
}

#saveToDiskDebounced = () => {
this.#dirty = true;
// Only save to disk if it has already been saved once
if (this.#file) {
if (this.#saveTimeout) {
clearTimeout(this.#saveTimeout);
}
this.#saveTimeout = setTimeout(() => {
this.#saveTimeout = undefined;
this.writeToDisk(this.#file!);
}, SAVE_DEBOUNCE_MS);
}
};

scopedStore(collectionName: string): ScopedDataStore {
return {
get: (key: string) => this.get(collectionName, key),
Expand All @@ -76,8 +102,13 @@ export class DataStore {
}

async writeToDisk(filePath: PathLike) {
if (!this.#dirty) {
return;
}
try {
await fs.writeFile(filePath, this.toString());
this.#file = filePath;
this.#dirty = false;
} catch {
throw new Error(`Failed to save data store to disk`);
}
Expand Down
90 changes: 52 additions & 38 deletions packages/astro/src/content/file.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fileURLToPath } from 'url';
import type { Loader } from './loaders.js';
import type { Loader, LoaderContext } from './loaders.js';
import { promises as fs, existsSync } from 'fs';

/**
Expand All @@ -12,54 +12,68 @@ export function file(fileName: string): Loader {
// TODO: AstroError
throw new Error('Glob patterns are not supported in `file` loader. Use `glob` loader instead.');
}

async function syncData(filePath: string, { logger, parseData, store }: LoaderContext) {
let json: Array<Record<string, unknown>>;

try {
const data = await fs.readFile(filePath, 'utf-8');
json = JSON.parse(data);
} catch (error: any) {
logger.error(`Error reading data from ${fileName}`);
logger.debug(error.message);
return;
}

if (Array.isArray(json)) {
if (json.length === 0) {
logger.warn(`No items found in ${fileName}`);
}
logger.debug(`Found ${json.length} item array in ${fileName}`);
store.clear();
for (const rawItem of json) {
const id = (rawItem.id ?? rawItem.slug)?.toString();
if (!id) {
logger.error(`Item in ${fileName} is missing an id or slug field.`);
continue;
}
const item = await parseData({ id, data: rawItem, filePath });
store.set(id, item);
}
} else if (typeof json === 'object') {
const entries = Object.entries<Record<string, unknown>>(json);
logger.debug(`Found object with ${entries.length} entries in ${fileName}`);
store.clear();
for (const [id, rawItem] of entries) {
const item = await parseData({ id, data: rawItem, filePath });
store.set(id, item);
}
} else {
logger.error(`Invalid data in ${fileName}. Must be an array or object.`);
}
}

return {
name: 'file-loader',
load: async ({ store, logger, settings, parseData }) => {
load: async (options) => {
const { settings, logger, watcher } = options;
const contentDir = new URL('./content/', settings.config.srcDir);
logger.debug(`Loading data from ${fileName}`)
logger.debug(`Loading data from ${fileName}`);
const url = new URL(fileName, contentDir);
if (!existsSync(url)) {
logger.error(`File not found: ${fileName}`);
return;
}

let json: Array<Record<string, unknown>>;

try {
const data = await fs.readFile(url, 'utf-8');
json = JSON.parse(data);
} catch (error: any) {
logger.error(`Error reading data from ${fileName}`);
logger.debug(error.message);
return;
}

const filePath = fileURLToPath(url);

if (Array.isArray(json)) {
if (json.length === 0) {
logger.warn(`No items found in ${fileName}`);
}
logger.debug(`Found ${json.length} item array in ${fileName}`);
for (const rawItem of json) {
const id = (rawItem.id ?? rawItem.slug)?.toString();
if (!id) {
logger.error(`Item in ${fileName} is missing an id or slug field.`);
continue;
}
const item = await parseData({ id, data: rawItem, filePath });
store.set(id, item);
}
} else if (typeof json === 'object') {
const entries = Object.entries<Record<string, unknown>>(json);
logger.debug(`Found object with ${entries.length} entries in ${fileName}`);
for (const [id, rawItem] of entries) {
const item = await parseData({ id, data: rawItem, filePath });
store.set(id, item);
await syncData(filePath, options);

watcher?.on('change', async (changedPath) => {
if (changedPath === filePath) {
logger.info(`Reloading data from ${fileName}`);
await syncData(filePath, options);
}
} else {
logger.error(`Invalid data in ${fileName}. Must be an array or object.`);
}
});
},
};
}
15 changes: 14 additions & 1 deletion packages/astro/src/content/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DataStore, globalDataStore, type MetaStore, type ScopedDataStore } from
import { getEntryData, globalContentConfigObserver } from './utils.js';
import { promises as fs, existsSync } from 'fs';
import { DATA_STORE_FILE } from './consts.js';
import type { FSWatcher } from 'vite';

export interface ParseDataOptions {
/** The ID of the entry. Unique per collection */
Expand All @@ -30,6 +31,9 @@ export interface LoaderContext {
parseData<T extends Record<string, unknown> = Record<string, unknown>>(
props: ParseDataOptions
): T;

/** When running in dev, this is a filesystem watcher that can be used to trigger updates */
watcher?: FSWatcher;
}

export interface Loader {
Expand All @@ -42,6 +46,13 @@ export interface Loader {
render?: (entry: any) => any;
}

export interface SyncContentLayerOptions {
store?: DataStore;
settings: AstroSettings;
logger: Logger;
watcher?: FSWatcher;
}

/**
* Run the `load()` method of each collection's loader, which will load the data and save it in the data store.
* The loader itself is responsible for deciding whether this will clear and reload the full collection, or
Expand All @@ -51,7 +62,8 @@ export async function syncContentLayer({
settings,
logger: globalLogger,
store,
}: { settings: AstroSettings; logger: Logger; store?: DataStore }) {
watcher,
}: SyncContentLayerOptions) {
const logger = globalLogger.forkIntegrationLogger('content');
logger.info('Syncing content');
if (!store) {
Expand Down Expand Up @@ -112,6 +124,7 @@ export async function syncContentLayer({
logger: globalLogger.forkIntegrationLogger(collection.loader.name ?? 'content'),
settings,
parseData,
watcher,
});
})
);
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const collectionConfigParser = z.union([
logger: z.any(),
settings: z.any(),
parseData: z.any(),
watcher: z.any().optional(),
}),
],
z.unknown()
Expand Down
25 changes: 25 additions & 0 deletions packages/astro/src/content/vite-plugin-content-virtual-mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,31 @@ export function astroContentVirtualModPlugin({
return code.replaceAll(RESOLVED_VIRTUAL_MODULE_ID, `${prefix}content/entry.mjs`);
}
},

configureServer(server) {
const dataStorePath = fileURLToPath(dataStoreFile);
// Watch for changes to the data store file
if (Array.isArray(server.watcher.options.ignored)) {
// The data store file is in node_modules, so is ignored by default,
// so we need to un-ignore it.
server.watcher.options.ignored.push(`!${dataStorePath}`);
}
server.watcher.add(dataStorePath);

server.watcher.on('change', (changedPath) => {
// If the datastore file changes, invalidate the virtual module
if (changedPath === dataStorePath) {
const module = server.moduleGraph.getModuleById(RESOLVED_DATA_STORE_VIRTUAL_ID);
if (module) {
server.moduleGraph.invalidateModule(module);
}
server.ws.send({
type: 'full-reload',
path: '*',
});
}
});
},
};
}

Expand Down
6 changes: 5 additions & 1 deletion packages/astro/src/core/dev/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ export default async function dev(inlineConfig: AstroInlineConfig): Promise<DevS

await attachContentServerListeners(restart.container);

await syncContentLayer({ settings: restart.container.settings, logger: logger });
await syncContentLayer({
settings: restart.container.settings,
logger,
watcher: restart.container.viteServer.watcher,
});

logger.info(null, green('watching for file changes...'));

Expand Down
Loading
Loading