diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index a10ad51262..b048de6486 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -3298,6 +3298,11 @@ export interface SSRLoadedRenderer extends Pick; + context?: Record; +} + export type HookParameters< Hook extends keyof AstroIntegration['hooks'], Fn = AstroIntegration['hooks'][Hook], @@ -3341,6 +3346,7 @@ declare global { server: vite.ViteDevServer; logger: AstroIntegrationLogger; toolbar: ReturnType; + refreshContent?: (options: RefreshContentOptions) => Promise; }) => void | Promise; 'astro:server:start': (options: { address: AddressInfo; diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts index 4861f3f61a..8eecb7e71f 100644 --- a/packages/astro/src/content/content-layer.ts +++ b/packages/astro/src/content/content-layer.ts @@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { FSWatcher } from 'vite'; import xxhash from 'xxhash-wasm'; -import type { AstroSettings } from '../@types/astro.js'; +import type { AstroSettings, RefreshContentOptions } from '../@types/astro.js'; import { AstroUserError } from '../core/errors/errors.js'; import type { Logger } from '../core/logger/core.js'; import { @@ -44,12 +44,6 @@ export class ContentLayer { this.#watcher = watcher; } - /** - * Whether the content layer is currently loading content - */ - get loading() { - return this.#loading; - } /** * Watch for changes to the content config and trigger a sync when it changes. @@ -71,22 +65,6 @@ export class ContentLayer { this.#unsubscribe?.(); } - /** - * 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 - * perform an incremental update. After the data is loaded, the data store is written to disk. - */ - async sync() { - if (this.#loading) { - return; - } - this.#loading = true; - try { - await this.#doSync(); - } finally { - this.#loading = false; - } - } async #getGenerateDigest() { if (this.#generateDigest) { @@ -108,10 +86,12 @@ export class ContentLayer { collectionName, loaderName = 'content', parseData, + refreshContextData, }: { collectionName: string; loaderName: string; parseData: LoaderContext['parseData']; + refreshContextData?: Record; }): Promise { return { collection: collectionName, @@ -122,10 +102,17 @@ export class ContentLayer { parseData, generateDigest: await this.#getGenerateDigest(), watcher: this.#watcher, + refreshContextData }; } - async #doSync() { + /** + * 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 + * perform an incremental update. After the data is loaded, the data store is written to disk. + */ + async sync(options?: RefreshContentOptions) { + const contentConfig = globalContentConfigObserver.get(); const logger = this.#logger.forkIntegrationLogger('content'); if (contentConfig?.status !== 'loaded') { @@ -171,6 +158,14 @@ export class ContentLayer { } } + // If loaders are specified, only sync the specified loaders + if ( + options?.loaders && + (typeof collection.loader !== 'object' || !options.loaders.includes(collection.loader.name)) + ) { + return; + } + const collectionWithResolvedSchema = { ...collection, schema }; const parseData: LoaderContext['parseData'] = async ({ id, data, filePath = '' }) => { @@ -204,6 +199,7 @@ export class ContentLayer { collectionName: name, parseData, loaderName: collection.loader.name, + refreshContextData: options?.context }); if (typeof collection.loader === 'function') { diff --git a/packages/astro/src/content/loaders/types.ts b/packages/astro/src/content/loaders/types.ts index 26be0495a2..8b0e340748 100644 --- a/packages/astro/src/content/loaders/types.ts +++ b/packages/astro/src/content/loaders/types.ts @@ -31,6 +31,9 @@ export interface LoaderContext { /** When running in dev, this is a filesystem watcher that can be used to trigger updates */ watcher?: FSWatcher; + + /** If the loader has been triggered by an integration, this may optionally contain extra data set by that integration */ + refreshContextData?: Record; } export interface Loader { diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index c0b9604335..d1c0ca0ffe 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -12,6 +12,7 @@ import type { ContentEntryType, DataEntryType, HookParameters, + RefreshContentOptions, RouteData, RouteOptions, } from '../@types/astro.js'; @@ -22,6 +23,7 @@ import { mergeConfig } from '../core/config/index.js'; import type { AstroIntegrationLogger, Logger } from '../core/logger/core.js'; import { isServerLikeOutput } from '../core/util.js'; import { validateSupportedFeatures } from './features-validation.js'; +import { globalContentLayer } from '../content/content-layer.js'; async function withTakingALongTimeMsg({ name, @@ -370,6 +372,14 @@ export async function runHookServerSetup({ server: ViteDevServer; logger: Logger; }) { + let refreshContent: undefined | ((options: RefreshContentOptions) => Promise); + if (config.experimental?.contentLayer) { + refreshContent = async (options: RefreshContentOptions) => { + const contentLayer = await globalContentLayer.get(); + await contentLayer.sync(options); + }; + } + for (const integration of config.integrations) { if (integration?.hooks?.['astro:server:setup']) { await withTakingALongTimeMsg({ @@ -379,6 +389,7 @@ export async function runHookServerSetup({ server, logger: getLogger(integration, logger), toolbar: getToolbarServerCommunicationHelpers(server), + refreshContent, }), logger, }); diff --git a/packages/astro/test/fixtures/content-layer/astro.config.mjs b/packages/astro/test/fixtures/content-layer/astro.config.mjs index 3266e5e8c0..3548ef7e29 100644 --- a/packages/astro/test/fixtures/content-layer/astro.config.mjs +++ b/packages/astro/test/fixtures/content-layer/astro.config.mjs @@ -3,7 +3,38 @@ import { defineConfig } from 'astro/config'; import { fileURLToPath } from 'node:url'; export default defineConfig({ - integrations: [mdx()], + integrations: [mdx(), { + name: '@astrojs/my-integration', + hooks: { + 'astro:server:setup': async ({ server, refreshContent }) => { + server.middlewares.use('/_refresh', async (req, res) => { + if(req.method !== 'POST') { + res.statusCode = 405 + res.end('Method Not Allowed'); + return + } + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + req.on('end', async () => { + try { + const webhookBody = JSON.parse(body); + await refreshContent({ + context: { webhookBody }, + loaders: ['increment-loader'] + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: 'Content refreshed successfully' })); + } catch (error) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to refresh content' })); + } + }); + }); + } + } +}], vite: { resolve: { alias: {