diff options
27 files changed, 277 insertions, 193 deletions
diff --git a/.changeset/forty-coins-attend.md b/.changeset/forty-coins-attend.md new file mode 100644 index 000000000..467e520fd --- /dev/null +++ b/.changeset/forty-coins-attend.md @@ -0,0 +1,16 @@ +--- +"astro": minor +--- + +Implement RFC [#0017](https://github.com/withastro/rfcs/blob/main/proposals/0017-markdown-content-redesign.md) + +- New Markdown API +- New `Astro.glob()` API +- **BREAKING CHANGE:** Removed `Astro.fetchContent()` (replaced by `Astro.glob()`) + +```diff +// v0.25 +- let allPosts = Astro.fetchContent('./posts/*.md'); +// v0.26+ ++ let allPosts = await Astro.glob('./posts/*.md'); +``` diff --git a/examples/blog-multiple-authors/src/components/PostPreview.astro b/examples/blog-multiple-authors/src/components/PostPreview.astro index 81e80ba6c..5a9808348 100644 --- a/examples/blog-multiple-authors/src/components/PostPreview.astro +++ b/examples/blog-multiple-authors/src/components/PostPreview.astro @@ -4,6 +4,7 @@ export interface Props { author: string; } const { post, author } = Astro.props; +const { frontmatter } = post; function formatDate(date) { return new Date(date).toUTCString().replace(/(\d\d\d\d) .*/, '$1'); // remove everything after YYYY @@ -12,12 +13,12 @@ function formatDate(date) { <article class="post"> <div class="data"> - <h2>{post.title}</h2> - <a class="author" href={`/authors/${post.author}`}>{author.name}</a> - <time class="date" datetime={post.date}>{formatDate(post.date)}</time> + <h2>{frontmatter.title}</h2> + <a class="author" href={`/authors/${frontmatter.author}`}>{author.name}</a> + <time class="date" datetime={frontmatter.date}>{formatDate(frontmatter.date)}</time> <p class="description"> - {post.description} - <a class="link" href={post.url} aria-label={`Read ${post.title}`}>Read</a> + {frontmatter.description} + <a class="link" href={post.url} aria-label={`Read ${frontmatter.title}`}>Read</a> </p> </div> </article> diff --git a/examples/blog-multiple-authors/src/pages/authors/[author].astro b/examples/blog-multiple-authors/src/pages/authors/[author].astro index 21aab27a5..c2ba49d39 100644 --- a/examples/blog-multiple-authors/src/pages/authors/[author].astro +++ b/examples/blog-multiple-authors/src/pages/authors/[author].astro @@ -2,36 +2,28 @@ import MainHead from '../../components/MainHead.astro'; import Nav from '../../components/Nav.astro'; import PostPreview from '../../components/PostPreview.astro'; -import Pagination from '../../components/Pagination.astro'; import authorData from '../../data/authors.json'; -export function getStaticPaths() { - const allPosts = Astro.fetchContent<MarkdownFrontmatter>('../post/*.md'); - let allAuthorsUnique = [...new Set(allPosts.map((p) => p.author))]; +export async function getStaticPaths() { + const allPosts = await Astro.glob('../post/*.md'); + let allAuthorsUnique = [...new Set(allPosts.map((p) => p.frontmatter.author))]; return allAuthorsUnique.map((author) => ({ params: { author }, props: { allPosts } })); } -interface MarkdownFrontmatter { - date: number; - description: string; - title: string; - author: string; -} - const { allPosts } = Astro.props; const { params, canonicalURL } = Astro.request; const title = 'Don’s Blog'; const description = 'An example blog on Astro'; /** filter posts by author, sort by date */ -const posts = allPosts.filter((post) => post.author === params.author).sort((a, b) => new Date(b.date).valueOf() - new Date(a.date).valueOf()); -const author = authorData[posts[0].author]; +const posts = allPosts.filter((post) => post.frontmatter.author === params.author).sort((a, b) => new Date(b.frontmatter.date).valueOf() - new Date(a.frontmatter.date).valueOf()); +const author = authorData[posts[0].frontmatter.author]; --- <html lang="en"> <head> <title>{title}</title> - <MainHead {title} {description} image={posts[0].image} canonicalURL={canonicalURL.toString()} /> + <MainHead {title} {description} image={posts[0].frontmatter.image} canonicalURL={canonicalURL.toString()} /> <style lang="scss"> .title { diff --git a/examples/blog-multiple-authors/src/pages/index.astro b/examples/blog-multiple-authors/src/pages/index.astro index 8ad01c190..518424b99 100644 --- a/examples/blog-multiple-authors/src/pages/index.astro +++ b/examples/blog-multiple-authors/src/pages/index.astro @@ -6,12 +6,6 @@ import PostPreview from '../components/PostPreview.astro'; import Pagination from '../components/Pagination.astro'; import authorData from '../data/authors.json'; -interface MarkdownFrontmatter { - date: number; - image: string; - author: string; -} - // Component Script: // You can write any JavaScript/TypeScript that you'd like here. // It will run during the build, but never in the browser. @@ -21,10 +15,9 @@ let description = 'An example blog on Astro'; let canonicalURL = Astro.request.canonicalURL; // Data Fetching: List all Markdown posts in the repo. -let allPosts = Astro.fetchContent<MarkdownFrontmatter>('./post/*.md'); -allPosts.sort((a, b) => new Date(b.date).valueOf() - new Date(a.date).valueOf()); +let allPosts = await Astro.glob('./post/*.md'); +allPosts.sort((a, b) => new Date(b.frontmatter.date).valueOf() - new Date(a.frontmatter.date).valueOf()); let firstPage = allPosts.slice(0, 2); - // Full Astro Component Syntax: // https://docs.astro.build/core-concepts/astro-components/ --- @@ -32,14 +25,14 @@ let firstPage = allPosts.slice(0, 2); <html lang="en"> <head> <title>{title}</title> - <MainHead {title} {description} image={allPosts[0].image} {canonicalURL} /> + <MainHead {title} {description} image={allPosts[0].frontmatter.image} {canonicalURL} /> </head> <body> <Nav {title} /> <main class="wrapper"> - {allPosts.map((post) => <PostPreview post={post} author={authorData[post.author]} />)} + {allPosts.map((post) => <PostPreview post={post} author={authorData[post.frontmatter.author]} />)} </main> <footer> diff --git a/examples/blog-multiple-authors/src/pages/posts/[...page].astro b/examples/blog-multiple-authors/src/pages/posts/[...page].astro index d0f95ce5b..da9b06fc5 100644 --- a/examples/blog-multiple-authors/src/pages/posts/[...page].astro +++ b/examples/blog-multiple-authors/src/pages/posts/[...page].astro @@ -6,8 +6,8 @@ import Pagination from '../../components/Pagination.astro'; import authorData from '../../data/authors.json'; export async function getStaticPaths({ paginate, rss }) { - const allPosts = Astro.fetchContent<MarkdownFrontmatter>('../post/*.md'); - const sortedPosts = allPosts.sort((a, b) => new Date(b.date).valueOf() - new Date(a.date).valueOf()); + const allPosts = await Astro.glob('../post/*.md'); + const sortedPosts = allPosts.sort((a, b) => new Date(b.frontmatter.date).valueOf() - new Date(a.frontmatter.date).valueOf()); // Generate an RSS feed from this collection of posts. // NOTE: This is disabled by default, since it requires `buildOptions.site` to be set in your "astro.config.mjs" file. @@ -31,21 +31,13 @@ export async function getStaticPaths({ paginate, rss }) { let title = 'Don’s Blog'; let description = 'An example blog on Astro'; let canonicalURL = Astro.request.canonicalURL; - -// collection -interface MarkdownFrontmatter { - date: number; - description: string; - title: string; -} - const { page } = Astro.props; --- <html lang="en"> <head> <title>{title}</title> - <MainHead {title} {description} image={page.data[0].image} canonicalURL={canonicalURL.toString()} prev={page.url.prev} next={page.url.next} /> + <MainHead {title} {description} image={page.data[0].frontmatter.image} canonicalURL={canonicalURL.toString()} prev={page.url.prev} next={page.url.next} /> <style lang="scss"> .title { @@ -70,7 +62,7 @@ const { page } = Astro.props; <main class="wrapper"> <h2 class="title">All Posts</h2> <small class="count">{page.start + 1}–{page.end + 1} of {page.total}</small> - {page.data.map((post) => <PostPreview post={post} author={authorData[post.author]} />)} + {page.data.map((post) => <PostPreview post={post} author={authorData[post.frontmatter.author]} />)} </main> <footer> diff --git a/examples/blog/src/components/BlogPostPreview.astro b/examples/blog/src/components/BlogPostPreview.astro index 4841d3a65..f935ff8b2 100644 --- a/examples/blog/src/components/BlogPostPreview.astro +++ b/examples/blog/src/components/BlogPostPreview.astro @@ -8,10 +8,10 @@ const { post } = Astro.props; <article class="post-preview"> <header> - <p class="publish-date">{post.publishDate}</p> - <a href={post.url}><h1 class="title">{post.title}</h1></a> + <p class="publish-date">{post.frontmatter.publishDate}</p> + <a href={post.url}><h1 class="title">{post.frontmatter.title}</h1></a> </header> - <p>{post.description}</p> + <p>{post.frontmatter.description}</p> <a href={post.url}>Read more</a> </article> diff --git a/examples/blog/src/pages/index.astro b/examples/blog/src/pages/index.astro index c7bc3ea32..1e1264533 100644 --- a/examples/blog/src/pages/index.astro +++ b/examples/blog/src/pages/index.astro @@ -4,10 +4,6 @@ import BaseHead from '../components/BaseHead.astro'; import BlogHeader from '../components/BlogHeader.astro'; import BlogPostPreview from '../components/BlogPostPreview.astro'; -interface MarkdownFrontmatter { - publishDate: number; -} - // Component Script: // You can write any JavaScript/TypeScript that you'd like here. // It will run during the build, but never in the browser. @@ -18,8 +14,8 @@ let permalink = 'https://example.com/'; // Data Fetching: List all Markdown posts in the repo. -let allPosts = await Astro.fetchContent('./posts/*.md'); -allPosts = allPosts.sort((a, b) => new Date(b.publishDate).valueOf() - new Date(a.publishDate).valueOf()); +let allPosts = await Astro.glob('./posts/*.md'); +allPosts = allPosts.sort((a, b) => new Date(b.frontmatter.publishDate).valueOf() - new Date(a.frontmatter.publishDate).valueOf()); // Full Astro Component Syntax: // https://docs.astro.build/core-concepts/astro-components/ diff --git a/examples/portfolio/src/components/PortfolioPreview/index.jsx b/examples/portfolio/src/components/PortfolioPreview/index.jsx index 6957e5884..4f1627604 100644 --- a/examples/portfolio/src/components/PortfolioPreview/index.jsx +++ b/examples/portfolio/src/components/PortfolioPreview/index.jsx @@ -2,16 +2,17 @@ import { h } from 'preact'; import Styles from './styles.module.scss'; function PortfolioPreview({ project }) { + const { frontmatter } = project; return ( <div className={Styles.card}> - <div className={Styles.titleCard} style={`background-image:url(${project.img})`}> - <h1 className={Styles.title}>{project.title}</h1> + <div className={Styles.titleCard} style={`background-image:url(${frontmatter.img})`}> + <h1 className={Styles.title}>{frontmatter.title}</h1> </div> <div className="pa3"> - <p className={`${Styles.desc} mt0 mb2`}>{project.description}</p> + <p className={`${Styles.desc} mt0 mb2`}>{frontmatter.description}</p> <div className={Styles.tags}> Tagged: - {project.tags.map((t) => ( + {frontmatter.tags.map((t) => ( <div className={Styles.tag} data-tag={t}> {t} </div> diff --git a/examples/portfolio/src/pages/index.astro b/examples/portfolio/src/pages/index.astro index ce11119b5..d8a9efcc2 100644 --- a/examples/portfolio/src/pages/index.astro +++ b/examples/portfolio/src/pages/index.astro @@ -7,7 +7,7 @@ import Footer from '../components/Footer/index.jsx'; import PortfolioPreview from '../components/PortfolioPreview/index.jsx'; // Data Fetching: List all Markdown posts in the repo. -const projects = Astro.fetchContent('./project/**/*.md'); +const projects = await Astro.glob('./project/**/*.md'); const featuredProject = projects[0]; // Full Astro Component Syntax: diff --git a/examples/portfolio/src/pages/projects.astro b/examples/portfolio/src/pages/projects.astro index 991c254bc..1aa05e07f 100644 --- a/examples/portfolio/src/pages/projects.astro +++ b/examples/portfolio/src/pages/projects.astro @@ -4,13 +4,9 @@ import Footer from '../components/Footer/index.jsx'; import Nav from '../components/Nav/index.jsx'; import PortfolioPreview from '../components/PortfolioPreview/index.jsx'; -interface MarkdownFrontmatter { - publishDate: number; -} - -const projects = Astro.fetchContent<MarkdownFrontmatter>('./project/**/*.md') - .filter(({ publishDate }) => !!publishDate) - .sort((a, b) => new Date(b.publishDate).valueOf() - new Date(a.publishDate).valueOf()); +const projects = (await Astro.glob('./project/**/*.md')) + .filter(({ frontmatter }) => !!frontmatter.publishDate) + .sort((a, b) => new Date(b.frontmatter.publishDate).valueOf() - new Date(a.frontmatter.publishDate).valueOf()); --- <html lang="en"> diff --git a/packages/astro/env.d.ts b/packages/astro/env.d.ts index 88a4bcce3..ebb416dd2 100644 --- a/packages/astro/env.d.ts +++ b/packages/astro/env.d.ts @@ -1,6 +1,6 @@ /// <reference types="vite/client" /> -type Astro = import('./dist/types/@types/astro').AstroGlobal; +type Astro = import('astro').AstroGlobal; // We duplicate the description here because editors won't show the JSDoc comment from the imported type (but will for its properties, ex: Astro.request will show the AstroGlobal.request description) /** diff --git a/packages/astro/package.json b/packages/astro/package.json index 9e0a508bf..579642a1d 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -85,6 +85,7 @@ "@proload/core": "^0.2.2", "@proload/plugin-tsm": "^0.1.1", "@web/parse5-utils": "^1.3.0", + "ast-types": "^0.14.2", "boxen": "^6.2.1", "ci-info": "^3.3.0", "common-ancestor-path": "^1.0.1", @@ -112,6 +113,7 @@ "preferred-pm": "^3.0.3", "prismjs": "^1.27.0", "prompts": "^2.4.2", + "recast": "^0.20.5", "rehype-slug": "^5.0.1", "resolve": "^1.22.0", "rollup": "^2.70.1", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 6e3638e52..c82d2d7d7 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1,7 +1,7 @@ import type { AddressInfo } from 'net'; import type * as babel from '@babel/core'; import type * as vite from 'vite'; -import type { z } from 'zod'; +import { z } from 'zod'; import type { AstroConfigSchema } from '../core/config'; import type { AstroComponentFactory, Metadata } from '../runtime/server'; import type { AstroRequest } from '../core/render/request'; @@ -60,11 +60,15 @@ export interface AstroGlobal extends AstroGlobalPartial { } export interface AstroGlobalPartial { - fetchContent<T = any>(globStr: string): Promise<FetchContentResult<T>[]>; /** * @deprecated since version 0.24. See the {@link https://astro.build/deprecated/resolve upgrade guide} for more details. */ resolve: (path: string) => string; + /** @deprecated Use `Astro.glob()` instead. */ + fetchContent(globStr: string): Promise<any[]>; + glob(globStr: `${any}.astro`): Promise<ComponentInstance[]>; + glob<T extends Record<string, any>>(globStr: `${any}.md`): Promise<MarkdownInstance<T>[]>; + glob<T extends Record<string, any>>(globStr: string): Promise<T[]>; site: URL; } @@ -508,20 +512,13 @@ export interface ComponentInstance { getStaticPaths?: (options: GetStaticPathsOptions) => GetStaticPathsResult; } -/** - * Astro.fetchContent() result - * Docs: https://docs.astro.build/reference/api-reference/#astrofetchcontent - */ -export type FetchContentResult<T> = FetchContentResultBase & T; - -export type FetchContentResultBase = { - astro: { - headers: string[]; - source: string; - html: string; - }; - url: string; -}; +export interface MarkdownInstance<T extends Record<string, any>> { + frontmatter: T; + file: string; + url: string | undefined; + Content: AstroComponentFactory; + getHeaders(): Promise<{ depth: number, slug: string, text: string }[]>; +} export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: string | null) => void>; diff --git a/packages/astro/src/core/render/dev/css.ts b/packages/astro/src/core/render/dev/css.ts index 82141c5cb..baded71a9 100644 --- a/packages/astro/src/core/render/dev/css.ts +++ b/packages/astro/src/core/render/dev/css.ts @@ -29,10 +29,15 @@ export function getStylesForURL(filePath: URL, viteServer: vite.ViteDevServer): : // Otherwise, you are following an import in the module import tree. // You are safe to use getModuleById() here because Vite has already // resolved the correct `id` for you, by creating the import you followed here. - new Set([viteServer.moduleGraph.getModuleById(id)!]); + new Set([viteServer.moduleGraph.getModuleById(id)]); // Collect all imported modules for the module(s). for (const entry of moduleEntriesForId) { + // Handle this in case an module entries weren't found for ID + // This seems possible with some virtual IDs (ex: `astro:markdown/*.md`) + if (!entry) { + continue; + } if (id === entry.id) { scanned.add(id); for (const importedModule of entry.importedModules) { diff --git a/packages/astro/src/runtime/server/index.ts b/packages/astro/src/runtime/server/index.ts index d977219ac..25d41f541 100644 --- a/packages/astro/src/runtime/server/index.ts +++ b/packages/astro/src/runtime/server/index.ts @@ -277,34 +277,25 @@ If you're still stuck, please open an issue on GitHub or join us at https://astr } /** Create the Astro.fetchContent() runtime function. */ -function createFetchContentFn(url: URL, site: URL) { - let sitePathname = site.pathname; - const fetchContent = (importMetaGlobResult: Record<string, any>) => { - let allEntries = [...Object.entries(importMetaGlobResult)]; +function createDeprecatedFetchContentFn() { + return () => { + throw new Error('Deprecated: Astro.fetchContent() has been replaced with Astro.glob().'); + }; +} + +/** Create the Astro.glob() runtime function. */ +function createAstroGlobFn() { + const globHandler = (importMetaGlobResult: Record<string, any>, globValue: () => any) => { + let allEntries = [...Object.values(importMetaGlobResult)]; if (allEntries.length === 0) { - throw new Error(`[${url.pathname}] Astro.fetchContent() no matches found.`); + throw new Error(`Astro.glob(${JSON.stringify(globValue())}) - no matches found.`); } - return allEntries - .map(([spec, mod]) => { - // Only return Markdown files for now. - if (!mod.frontmatter) { - return; - } - const urlSpec = new URL(spec, url).pathname; - return { - ...mod.frontmatter, - Content: mod.default, - content: mod.metadata, - file: new URL(spec, url), - url: urlSpec.includes('/pages/') ? urlSpec.replace(/^.*\/pages\//, sitePathname).replace(/(\/index)?\.md$/, '') : undefined, - }; - }) - .filter(Boolean); + // Map over the `import()` promises, calling to load them. + return Promise.all(allEntries.map(fn => fn())); }; - // This has to be cast because the type of fetchContent is the type of the function - // that receives the import.meta.glob result, but the user is using it as - // another type. - return fetchContent as unknown as AstroGlobalPartial['fetchContent']; + // Cast the return type because the argument that the user sees (string) is different from the argument + // that the runtime sees post-compiler (Record<string, Module>). + return globHandler as unknown as AstroGlobalPartial['glob']; } // This is used to create the top-level Astro global; the one that you can use @@ -313,10 +304,10 @@ export function createAstro(filePathname: string, _site: string, projectRootStr: const site = new URL(_site); const url = new URL(filePathname, site); const projectRoot = new URL(projectRootStr); - const fetchContent = createFetchContentFn(url, site); return { site, - fetchContent, + fetchContent: createDeprecatedFetchContentFn(), + glob: createAstroGlobFn(), // INVESTIGATE is there a use-case for multi args? resolve(...segments: string[]) { let resolved = segments.reduce((u, segment) => new URL(segment, u), url).pathname; diff --git a/packages/astro/src/vite-plugin-astro-postprocess/index.ts b/packages/astro/src/vite-plugin-astro-postprocess/index.ts index fc40f9891..c0e3ad551 100644 --- a/packages/astro/src/vite-plugin-astro-postprocess/index.ts +++ b/packages/astro/src/vite-plugin-astro-postprocess/index.ts @@ -1,10 +1,12 @@ -import type * as t from '@babel/types'; +import { parse as babelParser } from '@babel/parser'; +import type { ArrowFunctionExpressionKind, CallExpressionKind, StringLiteralKind } from 'ast-types/gen/kinds'; +import type { NodePath } from 'ast-types/lib/node-path'; +import { parse, print, types, visit } from "recast"; import type { Plugin } from 'vite'; import type { AstroConfig } from '../@types/astro'; -import * as babelTraverse from '@babel/traverse'; -import * as babel from '@babel/core'; - +// Check for `Astro.glob()`. Be very forgiving of whitespace. False positives are okay. +const ASTRO_GLOB_REGEX = /Astro2?\s*\.\s*glob\s*\(/; interface AstroPluginOptions { config: AstroConfig; } @@ -21,55 +23,56 @@ export default function astro({ config }: AstroPluginOptions): Plugin { return null; } - // Optimization: only run on a probably match - // Open this up if need for post-pass extends past fetchContent - if (!code.includes('fetchContent')) { + // Optimization: Detect usage with a quick string match. + // Only perform the transform if this function is found + if (!ASTRO_GLOB_REGEX.test(code)) { return null; } - // Handle the second-pass JS AST Traversal - const result = await babel.transformAsync(code, { - sourceType: 'module', - sourceMaps: true, - plugins: [ - () => { - return { - visitor: { - StringLiteral(path: babelTraverse.NodePath<t.StringLiteral>) { - if ( - path.parent.type !== 'CallExpression' || - path.parent.callee.type !== 'MemberExpression' || - !validAstroGlobalNames.has((path.parent.callee.object as any).name) || - (path.parent.callee.property as any).name !== 'fetchContent' - ) { - return; - } - const { value } = path.node; - if (/[a-z]\:\/\//.test(value)) { - return; - } - path.replaceWith({ - type: 'CallExpression', - callee: { - type: 'MemberExpression', - object: { type: 'MetaProperty', meta: { type: 'Identifier', name: 'import' }, property: { type: 'Identifier', name: 'meta' } }, - property: { type: 'Identifier', name: 'globEager' }, - computed: false, - }, - arguments: [path.node], - } as any); - }, - }, - }; - }, - ], + const ast = parse(code, { + // We need to use the babel parser because `import.meta.hot` is not + // supported by esprima (default parser). In the future, we should + // experiment with other parsers if Babel is too slow or heavy. + parser: { parse: babelParser }, }); - // Undocumented baby behavior, but possible according to Babel types. - if (!result || !result.code) { - return null; - } + visit(ast, { + visitCallExpression: function (path) { + // Filter out anything that isn't `Astro.glob()` or `Astro2.glob()` + if ( + !types.namedTypes.MemberExpression.check(path.node.callee) || + !types.namedTypes.Identifier.check(path.node.callee.property) || + !(path.node.callee.property.name === 'glob') || + !types.namedTypes.Identifier.check(path.node.callee.object) || + !(path.node.callee.object.name === 'Astro' || path.node.callee.object.name === 'Astro2') + ) { + this.traverse(path); + return; + } + + // Wrap the `Astro.glob()` argument with `import.meta.glob`. + const argsPath = path.get('arguments', 0) as NodePath; + const args = argsPath.value; + argsPath.replace({ + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { type: 'MetaProperty', meta: { type: 'Identifier', name: 'import' }, property: { type: 'Identifier', name: 'meta' } }, + property: { type: 'Identifier', name: 'glob' }, + computed: false, + }, + arguments: [args], + } as CallExpressionKind, + { + type: 'ArrowFunctionExpression', + body: args, + params: [] + } as ArrowFunctionExpressionKind); + return false; + }, + }); + const result = print(ast); return { code: result.code, map: result.map }; }, }; diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index c3c75b93d..ebe578783 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -2,14 +2,20 @@ import { transform } from '@astrojs/compiler'; import ancestor from 'common-ancestor-path'; import esbuild from 'esbuild'; import fs from 'fs'; +import matter from 'gray-matter'; +import { fileURLToPath } from 'url'; import type { Plugin } from 'vite'; import type { AstroConfig } from '../@types/astro'; import { PAGE_SSR_SCRIPT_ID } from '../vite-plugin-scripts/index.js'; +import { virtualModuleId as pagesVirtualModuleId } from '../core/build/vite-plugin-pages.js'; interface AstroPluginOptions { config: AstroConfig; } +const VIRTUAL_MODULE_ID_PREFIX = 'astro:markdown'; +const VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID_PREFIX; + // TODO: Clean up some of the shared logic between this Markdown plugin and the Astro plugin. // Both end up connecting a `load()` hook to the Astro compiler, and share some copy-paste // logic in how that is done. @@ -23,14 +29,88 @@ export default function markdown({ config }: AstroPluginOptions): Plugin { return filename; } + // Weird Vite behavior: Vite seems to use a fake "index.html" importer when you + // have `enforce: pre`. This can probably be removed once the vite issue is fixed. + // see: https://github.com/vitejs/vite/issues/5981 + const fakeRootImporter = fileURLToPath(new URL('index.html', config.projectRoot)); + function isRootImport(importer: string | undefined) { + if (!importer) { + return true; + } + if (importer === fakeRootImporter) { + return true; + } + if (importer === '\0' + pagesVirtualModuleId) { + return true; + } + return false; + } + return { name: 'astro:markdown', - enforce: 'pre', // run transforms before other plugins can + enforce: 'pre', + async resolveId(id, importer, options) { + // Resolve virtual modules as-is. + if (id.startsWith(VIRTUAL_MODULE_ID)) { + return id; + } + // Resolve any .md files with the `?content` cache buster. This should only come from + // an already-resolved JS module wrapper. Needed to prevent infinite loops in Vite. + // Unclear if this is expected or if cache busting is just working around a Vite bug. + if (id.endsWith('.md?content')) { + const resolvedId = await this.resolve(id, importer, { skipSelf: true, ...options }); + return resolvedId?.id.replace('?content', ''); + } + // If the markdown file is imported from another file via ESM, resolve a JS representation + // that defers the markdown -> HTML rendering until it is needed. This is especially useful + // when fetching and then filtering many markdown files, like with import.meta.glob() or Astro.glob(). + // Otherwise, resolve directly to the actual component. + if (id.endsWith('.md') && !isRootImport(importer)) { + const resolvedId = await this.resolve(id, importer, { skipSelf: true, ...options }); + if (resolvedId) { + return VIRTUAL_MODULE_ID + resolvedId.id; + } + } + // In all other cases, we do nothing and rely on normal Vite resolution. + return undefined; + }, async load(id) { + // A markdown file has been imported via ESM! + // Return the file's JS representation, including all Markdown + // frontmatter and a deferred `import() of the compiled markdown content. + if (id.startsWith(VIRTUAL_MODULE_ID)) { + const sitePathname = config.buildOptions.site ? new URL(config.buildOptions.site).pathname : '/'; + const fileId = id.substring(VIRTUAL_MODULE_ID.length); + const fileUrl = fileId.includes('/pages/') ? fileId.replace(/^.*\/pages\//, sitePathname).replace(/(\/index)?\.md$/, '') : undefined; + const source = await fs.promises.readFile(fileId, 'utf8'); + const { data: frontmatter } = matter(source); + return { + code: ` + // Static + export const frontmatter = ${JSON.stringify(frontmatter)}; + export const file = ${JSON.stringify(fileId)}; + export const url = ${JSON.stringify(fileUrl)}; + + // Deferred + export default async function load() { + return (await import(${JSON.stringify(fileId + '?content')})); + }; + export function Content(...args) { + return load().then((m) => m.default(...args)) + } + Content.isAstroComponentFactory = true; + export function getHeaders() { + return load().then((m) => m.metadata.headers) + };`, + map: null, + }; + } + + // A markdown file is being rendered! This markdown file was either imported + // directly as a page in Vite, or it was a deferred render from a JS module. + // This returns the compiled markdown -> astro component that renders to HTML. if (id.endsWith('.md')) { const source = await fs.promises.readFile(id, 'utf8'); - - // Transform from `.md` to valid `.astro` let render = config.markdownOptions.render; let renderOpts = {}; if (Array.isArray(render)) { @@ -40,8 +120,6 @@ export default function markdown({ config }: AstroPluginOptions): Plugin { if (typeof render === 'string') { ({ default: render } = await import(render)); } - let renderResult = await render(source, renderOpts); - let { frontmatter, metadata, code: astroResult } = renderResult; const filename = normalizeFilename(id); const fileUrl = new URL(`file://${filename}`); @@ -49,6 +127,9 @@ export default function markdown({ config }: AstroPluginOptions): Plugin { const hasInjectedScript = isPage && config._ctx.scripts.some((s) => s.stage === 'page-ssr'); // Extract special frontmatter keys + const { data: frontmatter, content: markdownContent } = matter(source); + let renderResult = await render(markdownContent, renderOpts); + let { code: astroResult, metadata } = renderResult; const { layout = '', components = '', setup = '', ...content } = frontmatter; content.astro = metadata; const prelude = `--- @@ -83,8 +164,7 @@ export const frontmatter = ${JSON.stringify(content)}; ${tsResult}`; // Compile from `.ts` to `.js` - const { code, map } = await esbuild.transform(tsResult, { loader: 'ts', sourcemap: 'inline', sourcefile: id }); - + const { code } = await esbuild.transform(tsResult, { loader: 'ts', sourcemap: false, sourcefile: id }); return { code, map: null, diff --git a/packages/astro/test/astro-global.test.js b/packages/astro/test/astro-global.test.js index d46c994ec..eda1ce438 100644 --- a/packages/astro/test/astro-global.test.js +++ b/packages/astro/test/astro-global.test.js @@ -48,7 +48,7 @@ describe('Astro.*', () => { expect($('#site').attr('href')).to.equal('https://mysite.dev/blog/'); }); - it('Astro.fetchContent() returns the correct "url" property, including buildOptions.site subpath', async () => { + it('Astro.glob() correctly returns an array of all posts', async () => { const html = await fixture.readFile('/posts/1/index.html'); const $ = cheerio.load(html); expect($('.post-url').attr('href')).to.equal('/blog/post/post-2'); diff --git a/packages/astro/test/fixtures/astro-global/src/pages/posts/[page].astro b/packages/astro/test/fixtures/astro-global/src/pages/posts/[page].astro index e684161e6..1cd1c6881 100644 --- a/packages/astro/test/fixtures/astro-global/src/pages/posts/[page].astro +++ b/packages/astro/test/fixtures/astro-global/src/pages/posts/[page].astro @@ -1,6 +1,6 @@ --- -export function getStaticPaths({paginate}) { - const data = Astro.fetchContent('../post/*.md'); +export async function getStaticPaths({paginate}) { + const data = await Astro.glob('../post/*.md'); return paginate(data, {pageSize: 1}); } const { page } = Astro.props; @@ -15,7 +15,7 @@ const { params, canonicalURL} = Astro.request; <body> {page.data.map((data) => ( <div> - <h1>{data.title}</h1> + <h1>{data.frontmatter.title}</h1> <a class="post-url" href={data.url}>Read</a> </div> ))} diff --git a/packages/astro/test/fixtures/astro-pagination/src/pages/posts/[slug]/[page].astro b/packages/astro/test/fixtures/astro-pagination/src/pages/posts/[slug]/[page].astro index b3dc4be87..1b701517f 100644 --- a/packages/astro/test/fixtures/astro-pagination/src/pages/posts/[slug]/[page].astro +++ b/packages/astro/test/fixtures/astro-pagination/src/pages/posts/[slug]/[page].astro @@ -1,8 +1,8 @@ --- -export function getStaticPaths({paginate}) { - const allPosts = Astro.fetchContent('../../post/*.md'); +export async function getStaticPaths({paginate}) { + const allPosts = await Astro.glob('../../post/*.md'); return ['red', 'blue'].map((filter) => { - const filteredPosts = allPosts.filter((post) => post.tag === filter); + const filteredPosts = allPosts.filter((post) => post.frontmatter.tag === filter); return paginate(filteredPosts, { params: { slug: filter }, props: { filter }, diff --git a/packages/astro/test/fixtures/astro-pagination/src/pages/posts/named-root-page/[page].astro b/packages/astro/test/fixtures/astro-pagination/src/pages/posts/named-root-page/[page].astro index d70f0673c..fef4cc887 100644 --- a/packages/astro/test/fixtures/astro-pagination/src/pages/posts/named-root-page/[page].astro +++ b/packages/astro/test/fixtures/astro-pagination/src/pages/posts/named-root-page/[page].astro @@ -1,6 +1,6 @@ --- export async function getStaticPaths({paginate}) { - const data = Astro.fetchContent('../../post/*.md'); + const data = await Astro.glob('../../post/*.md'); return paginate(data, {pageSize: 1}); } const { page } = Astro.props; diff --git a/packages/astro/test/fixtures/astro-pagination/src/pages/posts/optional-root-page/[...page].astro b/packages/astro/test/fixtures/astro-pagination/src/pages/posts/optional-root-page/[...page].astro index d70f0673c..fef4cc887 100644 --- a/packages/astro/test/fixtures/astro-pagination/src/pages/posts/optional-root-page/[...page].astro +++ b/packages/astro/test/fixtures/astro-pagination/src/pages/posts/optional-root-page/[...page].astro @@ -1,6 +1,6 @@ --- export async function getStaticPaths({paginate}) { - const data = Astro.fetchContent('../../post/*.md'); + const data = await Astro.glob('../../post/*.md'); return paginate(data, {pageSize: 1}); } const { page } = Astro.props; diff --git a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episodes/[...page].astro b/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episodes/[...page].astro index 0c0c676ee..3732c4ba3 100644 --- a/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episodes/[...page].astro +++ b/packages/astro/test/fixtures/astro-sitemap-rss/src/pages/episodes/[...page].astro @@ -1,6 +1,6 @@ --- -export function getStaticPaths({paginate, rss}) { - const episodes = Astro.fetchContent('../episode/*.md').sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate)); +export async function getStaticPaths({paginate, rss}) { + const episodes = (await Astro.glob('../episode/*.md')).sort((a, b) => new Date(b.frontmatter.pubDate) - new Date(a.frontmatter.pubDate)); rss({ title: 'MF Doomcast', description: 'The podcast about the things you find on a picnic, or at a picnic table', @@ -11,13 +11,13 @@ export function getStaticPaths({paginate, rss}) { customData: `<language>en-us</language>` + `<itunes:author>MF Doom</itunes:author>`, items: episodes.map((episode) => ({ - title: episode.title, + title: episode.frontmatter.title, link: episode.url, - description: episode.description, - pubDate: episode.pubDate + 'Z', - customData: `<itunes:episodeType>${episode.type}</itunes:episodeType>` + - `<itunes:duration>${episode.duration}</itunes:duration>` + - `<itunes:explicit>${episode.explicit || false}</itunes:explicit>`, + description: episode.frontmatter.description, + pubDate: episode.frontmatter.pubDate + 'Z', + customData: `<itunes:episodeType>${episode.frontmatter.type}</itunes:episodeType>` + + `<itunes:duration>${episode.frontmatter.duration}</itunes:duration>` + + `<itunes:explicit>${episode.frontmatter.explicit || false}</itunes:explicit>`, })), dest: '/custom/feed.xml', }); @@ -31,13 +31,13 @@ export function getStaticPaths({paginate, rss}) { customData: `<language>en-us</language>` + `<itunes:author>MF Doom</itunes:author>`, items: episodes.map((episode) => ({ - title: episode.title, + title: episode.frontmatter.title, link: `https://example.com${episode.url}/`, - description: episode.description, - pubDate: episode.pubDate + 'Z', - customData: `<itunes:episodeType>${episode.type}</itunes:episodeType>` + - `<itunes:duration>${episode.duration}</itunes:duration>` + - `<itunes:explicit>${episode.explicit || false}</itunes:explicit>`, + description: episode.frontmatter.description, + pubDate: episode.frontmatter.pubDate + 'Z', + customData: `<itunes:episodeType>${episode.frontmatter.type}</itunes:episodeType>` + + `<itunes:duration>${episode.frontmatter.duration}</itunes:duration>` + + `<itunes:explicit>${episode.frontmatter.explicit || false}</itunes:explicit>`, })), dest: '/custom/feed-pregenerated-urls.xml', }); @@ -53,6 +53,6 @@ const { page } = Astro.props; <link rel="alternate" type="application/rss+2.0" href="/rss.xml" /> </head> <body> - {page.data.map((ep) => (<li>{ep.title}</li>))} + {page.data.map((ep) => (<li>{ep.frontmatter.title}</li>))} </body> </html> diff --git a/packages/astro/test/fixtures/debug-component/src/pages/posts/[slug].astro b/packages/astro/test/fixtures/debug-component/src/pages/posts/[slug].astro index 8b6ae00e9..ed85be913 100644 --- a/packages/astro/test/fixtures/debug-component/src/pages/posts/[slug].astro +++ b/packages/astro/test/fixtures/debug-component/src/pages/posts/[slug].astro @@ -3,15 +3,13 @@ import Debug from 'astro/debug'; // all the content that should be generated export async function getStaticPaths() { - const data = Astro.fetchContent('../../data/posts/*.md') + const data = await Astro.glob('../../data/posts/*.md') - const allArticles = data.map(({ astro, file, url, ...article }) => { + const allArticles = data.map((article) => { return { - params: { slug: article.slug }, + params: { slug: article.frontmatter.slug }, props: { article: article, - content: astro.html, - md: astro.source, } } }) diff --git a/packages/astro/test/fixtures/static build/src/pages/index.astro b/packages/astro/test/fixtures/static build/src/pages/index.astro index b1bd2067e..763046d0a 100644 --- a/packages/astro/test/fixtures/static build/src/pages/index.astro +++ b/packages/astro/test/fixtures/static build/src/pages/index.astro @@ -2,7 +2,7 @@ import MainHead from '../components/MainHead.astro'; import Nav from '../components/Nav/index.jsx'; import { test as ssrConfigTest } from '@test/static-build-pkg'; -let allPosts = await Astro.fetchContent('./posts/*.md'); +let allPosts = await Astro.glob('./posts/*.md'); --- <html> <head> diff --git a/packages/astro/test/static-build.test.js b/packages/astro/test/static-build.test.js index 89860505e..47707ea27 100644 --- a/packages/astro/test/static-build.test.js +++ b/packages/astro/test/static-build.test.js @@ -21,7 +21,7 @@ describe('Static build', () => { expect(html).to.be.a('string'); }); - it('can build pages using fetchContent', async () => { + it('can build pages using Astro.glob()', async () => { const html = await fixture.readFile('/index.html'); const $ = cheerioLoad(html); const link = $('.posts a'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e547cbb7..e46db9db6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -473,6 +473,7 @@ importers: '@types/send': ^0.17.1 '@types/yargs-parser': ^21.0.0 '@web/parse5-utils': ^1.3.0 + ast-types: ^0.14.2 astro-scripts: workspace:* boxen: ^6.2.1 chai: ^4.3.6 @@ -504,6 +505,7 @@ importers: preferred-pm: ^3.0.3 prismjs: ^1.27.0 prompts: ^2.4.2 + recast: ^0.20.5 rehype-slug: ^5.0.1 resolve: ^1.22.0 rollup: ^2.70.1 @@ -536,6 +538,7 @@ importers: '@proload/core': 0.2.2 '@proload/plugin-tsm': 0.1.1_@proload+core@0.2.2 '@web/parse5-utils': 1.3.0 + ast-types: 0.14.2 boxen: 6.2.1 ci-info: 3.3.0 common-ancestor-path: 1.0.1 @@ -563,6 +566,7 @@ importers: preferred-pm: 3.0.3 prismjs: 1.27.0 prompts: 2.4.2 + recast: 0.20.5 rehype-slug: 5.0.1 resolve: 1.22.0 rollup: 2.70.1 @@ -4615,6 +4619,13 @@ packages: tslib: 2.3.1 dev: true + /ast-types/0.14.2: + resolution: {integrity: sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==} + engines: {node: '>=4'} + dependencies: + tslib: 2.3.1 + dev: false + /async/0.9.2: resolution: {integrity: sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=} dev: true @@ -8807,6 +8818,16 @@ packages: dependencies: picomatch: 2.3.1 + /recast/0.20.5: + resolution: {integrity: sha512-E5qICoPoNL4yU0H0NoBDntNB0Q5oMSNh9usFctYniLBluTthi3RsQVBXIJNbApOlvSwW/RGxIuokPcAc59J5fQ==} + engines: {node: '>= 4'} + dependencies: + ast-types: 0.14.2 + esprima: 4.0.1 + source-map: 0.6.1 + tslib: 2.3.1 + dev: false + /redent/3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} |