diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 199250d21..b2521491c 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -978,12 +978,27 @@ export interface AstroConfig extends z.output { integrations: AstroIntegration[]; } +export interface ContentEntryType { + extensions: string[]; + getEntryInfo(params: { fileUrl: URL }): Promise<{ + data: Record; + /** + * Used for error hints to point to correct line and location + * Should be the untouched data as read from the file, + * including newlines + */ + rawData: string; + body: string; + slug: string; + }>; +} + export interface AstroSettings { config: AstroConfig; - adapter: AstroAdapter | undefined; injectedRoutes: InjectedRoute[]; pageExtensions: string[]; + contentEntryTypes: ContentEntryType[]; renderers: AstroRenderer[]; scripts: { stage: InjectedScriptStage; diff --git a/packages/astro/src/content/consts.ts b/packages/astro/src/content/consts.ts index 9b052ff8a..472ca0d88 100644 --- a/packages/astro/src/content/consts.ts +++ b/packages/astro/src/content/consts.ts @@ -1,5 +1,4 @@ -/** TODO as const*/ -export const defaultContentFileExts = ['.md', '.mdx']; +export const defaultContentEntryExts = ['.md', '.mdx'] as const; export const PROPAGATED_ASSET_FLAG = 'astroPropagatedAssets'; export const CONTENT_FLAG = 'astroContent'; export const VIRTUAL_MODULE_ID = 'astro:content'; diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 1d43df77a..91d14af5c 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -8,12 +8,13 @@ import type { AstroSettings } from '../@types/astro.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; import { info, LogOptions, warn } from '../core/logger/core.js'; import { isRelativePath } from '../core/path.js'; -import { CONTENT_TYPES_FILE, defaultContentFileExts } from './consts.js'; +import { CONTENT_TYPES_FILE } from './consts.js'; import { ContentConfig, ContentObservable, ContentPaths, EntryInfo, + getContentEntryExts, getContentPaths, getEntryInfo, getEntrySlug, @@ -22,7 +23,6 @@ import { NoCollectionError, parseFrontmatter, } from './utils.js'; -import { contentEntryTypes } from './~dream.js'; type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'; type RawContentEvent = { name: ChokidarEvent; entry: string }; @@ -58,10 +58,7 @@ export async function createContentTypesGenerator({ }: CreateContentGeneratorParams) { const contentTypes: ContentTypes = {}; const contentPaths = getContentPaths(settings.config, fs); - const contentFileExts = [ - ...defaultContentFileExts, - ...contentEntryTypes.map((t) => t.extensions).flat(), - ]; + const contentEntryExts = getContentEntryExts(settings); let events: EventWithOptions[] = []; let debounceTimeout: NodeJS.Timeout | undefined; @@ -126,7 +123,7 @@ export async function createContentTypesGenerator({ } return { shouldGenerateTypes: true }; } - const fileType = getEntryType(fileURLToPath(event.entry), contentPaths, contentFileExts); + const fileType = getEntryType(fileURLToPath(event.entry), contentPaths, contentEntryExts); if (fileType === 'ignored') { return { shouldGenerateTypes: false }; } diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index 8798ec5b9..2eaba6103 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -7,8 +7,7 @@ import { ErrorPayload as ViteErrorPayload, normalizePath, ViteDevServer } from ' import { z } from 'zod'; import { AstroConfig, AstroSettings } from '../@types/astro.js'; import { AstroError, AstroErrorData } from '../core/errors/index.js'; -import { appendForwardSlash } from '../core/path.js'; -import { contentFileExts, CONTENT_TYPES_FILE } from './consts.js'; +import { CONTENT_TYPES_FILE, defaultContentEntryExts } from './consts.js'; export const collectionConfigParser = z.object({ schema: z.any().optional(), @@ -119,6 +118,14 @@ export async function getEntryData( return data; } +export function getContentEntryExts(settings: Pick) { + return [ + // TODO: roll defaults into settings + ...defaultContentEntryExts, + ...settings.contentEntryTypes.map((t) => t.extensions).flat(), + ]; +} + export class NoCollectionError extends Error {} export function getEntryInfo( diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts index 8021258b9..cda8aff64 100644 --- a/packages/astro/src/content/vite-plugin-content-assets.ts +++ b/packages/astro/src/content/vite-plugin-content-assets.ts @@ -1,6 +1,7 @@ import npath from 'node:path'; import { pathToFileURL } from 'url'; import type { Plugin } from 'vite'; +import { AstroSettings } from '../@types/astro.js'; import { moduleIsTopLevelPage, walkParentInfos } from '../core/build/graph.js'; import { BuildInternals, getPageDataByViteID } from '../core/build/internal.js'; import { AstroBuildPlugin } from '../core/build/plugin.js'; @@ -11,23 +12,30 @@ import { prependForwardSlash } from '../core/path.js'; import { getStylesForURL } from '../core/render/dev/css.js'; import { getScriptsForURL } from '../core/render/dev/scripts.js'; import { - defaultContentFileExts, LINKS_PLACEHOLDER, PROPAGATED_ASSET_FLAG, SCRIPTS_PLACEHOLDER, STYLES_PLACEHOLDER, } from './consts.js'; +import { getContentEntryExts } from './utils.js'; -function isPropagatedAsset(viteId: string): boolean { +function isPropagatedAsset(viteId: string, contentEntryExts: string[]): boolean { const url = new URL(viteId, 'file://'); return ( url.searchParams.has(PROPAGATED_ASSET_FLAG) && - defaultContentFileExts.some((ext) => url.pathname.endsWith(ext)) + contentEntryExts.some((ext) => url.pathname.endsWith(ext)) ); } -export function astroContentAssetPropagationPlugin({ mode }: { mode: string }): Plugin { +export function astroContentAssetPropagationPlugin({ + mode, + settings, +}: { + mode: string; + settings: AstroSettings; +}): Plugin { let devModuleLoader: ModuleLoader; + const contentEntryExts = getContentEntryExts(settings); return { name: 'astro:content-asset-propagation', enforce: 'pre', @@ -37,7 +45,7 @@ export function astroContentAssetPropagationPlugin({ mode }: { mode: string }): } }, load(id) { - if (isPropagatedAsset(id)) { + if (isPropagatedAsset(id, contentEntryExts)) { const basePath = id.split('?')[0]; const code = ` export async function getMod() { @@ -52,7 +60,7 @@ export function astroContentAssetPropagationPlugin({ mode }: { mode: string }): }, async transform(code, id, options) { if (!options?.ssr) return; - if (devModuleLoader && isPropagatedAsset(id)) { + if (devModuleLoader && isPropagatedAsset(id, contentEntryExts)) { const basePath = id.split('?')[0]; if (!devModuleLoader.getModuleById(basePath)?.ssrModule) { await devModuleLoader.import(basePath); diff --git a/packages/astro/src/content/vite-plugin-content-imports.ts b/packages/astro/src/content/vite-plugin-content-imports.ts index ddbfecffd..f36829c19 100644 --- a/packages/astro/src/content/vite-plugin-content-imports.ts +++ b/packages/astro/src/content/vite-plugin-content-imports.ts @@ -1,4 +1,3 @@ -import { contentEntryTypes } from './~dream.js'; import * as devalue from 'devalue'; import type fsMod from 'node:fs'; import { pathToFileURL } from 'url'; @@ -7,9 +6,10 @@ import { AstroSettings } from '../@types/astro.js'; import { AstroErrorData } from '../core/errors/errors-data.js'; import { AstroError } from '../core/errors/errors.js'; import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js'; -import { defaultContentFileExts, CONTENT_FLAG } from './consts.js'; +import { CONTENT_FLAG } from './consts.js'; import { ContentConfig, + getContentEntryExts, getContentPaths, getEntryData, getEntryInfo, @@ -19,9 +19,9 @@ import { parseFrontmatter, } from './utils.js'; -function isContentFlagImport(viteId: string) { - const { searchParams } = new URL(viteId, 'file://'); - return searchParams.has(CONTENT_FLAG); +function isContentFlagImport(viteId: string, contentEntryExts: string[]) { + const { searchParams, pathname } = new URL(viteId, 'file://'); + return searchParams.has(CONTENT_FLAG) && contentEntryExts.some((ext) => pathname.endsWith(ext)); } export function astroContentImportPlugin({ @@ -32,16 +32,13 @@ export function astroContentImportPlugin({ settings: AstroSettings; }): Plugin { const contentPaths = getContentPaths(settings.config, fs); - const contentFileExts = [ - ...defaultContentFileExts, - ...contentEntryTypes.map((t) => t.extensions).flat(), - ]; + const contentEntryExts = getContentEntryExts(settings); return { name: 'astro:content-imports', async load(id) { const { fileId } = getFileInfo(id, settings.config); - if (isContentFlagImport(id)) { + if (isContentFlagImport(id, contentEntryExts)) { const observable = globalContentConfigObserver.get(); // Content config should be loaded before this plugin is used @@ -74,7 +71,7 @@ export function astroContentImportPlugin({ }); } const rawContents = await fs.promises.readFile(fileId, 'utf-8'); - const contentEntryType = contentEntryTypes.find((entryType) => + const contentEntryType = settings.contentEntryTypes.find((entryType) => entryType.extensions.some((ext) => fileId.endsWith(ext)) ); let body: string, @@ -129,11 +126,11 @@ export const _internal = { viteServer.watcher.on('all', async (event, entry) => { if ( ['add', 'unlink', 'change'].includes(event) && - getEntryType(entry, contentPaths, contentFileExts) === 'config' + getEntryType(entry, contentPaths, contentEntryExts) === 'config' ) { // Content modules depend on config, so we need to invalidate them. for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) { - if (isContentFlagImport(modUrl)) { + if (isContentFlagImport(modUrl, contentEntryExts)) { const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl); if (mod) { viteServer.moduleGraph.invalidateModule(mod); @@ -144,7 +141,7 @@ export const _internal = { }); }, async transform(code, id) { - if (isContentFlagImport(id)) { + if (isContentFlagImport(id, contentEntryExts)) { // Escape before Rollup internal transform. // Base on MUCH trial-and-error, inspired by MDX integration 2-step transform. return { code: escapeViteEnvReferences(code) }; diff --git a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts index f09821449..ab69304b7 100644 --- a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts +++ b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts @@ -4,9 +4,8 @@ import type { Plugin } from 'vite'; import { normalizePath } from 'vite'; import type { AstroSettings } from '../@types/astro.js'; import { appendForwardSlash, prependForwardSlash } from '../core/path.js'; -import { defaultContentFileExts, VIRTUAL_MODULE_ID } from './consts.js'; -import { getContentPaths } from './utils.js'; -import { contentEntryTypes } from './~dream.js'; +import { VIRTUAL_MODULE_ID } from './consts.js'; +import { getContentEntryExts, getContentPaths } from './utils.js'; interface AstroContentVirtualModPluginParams { settings: AstroSettings; @@ -23,12 +22,9 @@ export function astroContentVirtualModPlugin({ ) ) ); - const contentFileExts = [ - ...defaultContentFileExts, - ...contentEntryTypes.map((t) => t.extensions).flat(), - ]; + const contentEntryExts = getContentEntryExts(settings); - const entryGlob = `${relContentDir}**/*{${contentFileExts.join(',')}}`; + const entryGlob = `${relContentDir}**/*{${contentEntryExts.join(',')}}`; const virtualModContents = fsMod .readFileSync(contentPaths.virtualModTemplate, 'utf-8') .replace('@@CONTENT_DIR@@', relContentDir) diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts index 58c81b0f8..736f964fb 100644 --- a/packages/astro/src/core/config/settings.ts +++ b/packages/astro/src/core/config/settings.ts @@ -16,6 +16,8 @@ export function createBaseSettings(config: AstroConfig): AstroSettings { adapter: undefined, injectedRoutes: [], pageExtensions: ['.astro', '.html', ...SUPPORTED_MARKDOWN_FILE_EXTENSIONS], + /** TODO: default Markdown entry type */ + contentEntryTypes: [], renderers: [jsxRenderer], scripts: [], watchFiles: [], diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 385b8a9f0..8e8fc3907 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -114,7 +114,7 @@ export async function createVite( astroInjectEnvTsPlugin({ settings, logging, fs }), astroContentVirtualModPlugin({ settings }), astroContentImportPlugin({ fs, settings }), - astroContentAssetPropagationPlugin({ mode }), + astroContentAssetPropagationPlugin({ mode, settings }), ], publicDir: fileURLToPath(settings.config.publicDir), root: fileURLToPath(settings.config.root), diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index 4c59783c0..0c69355c9 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -8,6 +8,7 @@ import { AstroRenderer, AstroSettings, BuildConfig, + ContentEntryType, HookParameters, RouteData, } from '../@types/astro.js'; @@ -100,11 +101,22 @@ export async function runHookConfigSetup({ const exts = (input.flat(Infinity) as string[]).map((ext) => `.${ext.replace(/^\./, '')}`); updatedSettings.pageExtensions.push(...exts); } + + // Semi-private `addContentEntryType` hook + function addContentEntryType(contentEntryType: ContentEntryType) { + updatedSettings.contentEntryTypes.push(contentEntryType); + } + Object.defineProperty(hooks, 'addPageExtension', { value: addPageExtension, writable: false, enumerable: false, }); + Object.defineProperty(hooks, 'addContentEntryType', { + value: addContentEntryType, + writable: false, + enumerable: false, + }); await withTakingALongTimeMsg({ name: integration.name, hookResult: integration.hooks['astro:config:setup'](hooks), diff --git a/packages/integrations/markdoc/src/index.ts b/packages/integrations/markdoc/src/index.ts index 2ea2e862c..e8f4f2778 100644 --- a/packages/integrations/markdoc/src/index.ts +++ b/packages/integrations/markdoc/src/index.ts @@ -1,13 +1,47 @@ import type { AstroIntegration } from 'astro'; import type { InlineConfig } from 'vite'; import _Markdoc from '@markdoc/markdoc'; +import fs from 'node:fs'; +import { parseFrontmatter } from './utils.js'; +import { fileURLToPath } from 'node:url'; + +const contentEntryType = { + extensions: ['.mdoc'], + async getEntryInfo({ fileUrl }: { fileUrl: URL }) { + const rawContents = await fs.promises.readFile(fileUrl, 'utf-8'); + const parsed = parseFrontmatter(rawContents, fileURLToPath(fileUrl)); + return { + data: parsed.data, + body: parsed.content, + slug: parsed.data.slug, + rawData: parsed.matter, + }; + }, + async render({ entry }: { entry: any }) { + function getParsed() { + return Markdoc.parse(entry.body); + } + async function getTransformed(inlineConfig: any) { + let config = inlineConfig; + // TODO: load config file + // if (!config) { + // try { + // const importedConfig = await import('./markdoc.config.ts'); + // config = importedConfig.default.transform; + // } catch {} + // } + return Markdoc.transform(getParsed(), config); + } + return { getParsed, getTransformed }; + }, +}; export default function markdoc(partialOptions: {} = {}): AstroIntegration { return { name: '@astrojs/markdoc', hooks: { - 'astro:config:setup': async ({ updateConfig, config, addPageExtension, command }: any) => { - addPageExtension('.mdoc'); + 'astro:config:setup': async ({ updateConfig, config, addContentEntryType, command }: any) => { + addContentEntryType(contentEntryType); console.log('Markdoc working!'); const markdocConfigUrl = new URL('./markdoc.config.ts', config.srcDir); diff --git a/packages/integrations/markdoc/src/utils.ts b/packages/integrations/markdoc/src/utils.ts new file mode 100644 index 000000000..abcebbf8b --- /dev/null +++ b/packages/integrations/markdoc/src/utils.ts @@ -0,0 +1,25 @@ +import matter from 'gray-matter'; +import type { ErrorPayload as ViteErrorPayload } from 'vite'; + +/** + * Match YAML exception handling from Astro core errors + * @see 'astro/src/core/errors.ts' + */ +export function parseFrontmatter(fileContents: string, filePath: string) { + try { + // `matter` is empty string on cache results + // clear cache to prevent this + (matter as any).clearCache(); + return matter(fileContents); + } catch (e: any) { + if (e.name === 'YAMLException') { + const err: Error & ViteErrorPayload['err'] = e; + err.id = filePath; + err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column }; + err.message = e.reason; + throw err; + } else { + throw e; + } + } +}