New Markdown API (#2862)

* Implement new markdown plugin with deferred markdown rendering

* feat: switch from `getContent()` fn to `<Content />` API

* update types

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>

* update types

* Create forty-coins-attend.md

Co-authored-by: Nate Moore <nate@skypack.dev>
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
This commit is contained in:
Fred K. Schott 2022-03-28 17:16:06 -07:00 committed by GitHub
parent 7d29feace1
commit 4299ab303b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 279 additions and 195 deletions

View file

@ -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');
```

View file

@ -4,6 +4,7 @@ export interface Props {
author: string; author: string;
} }
const { post, author } = Astro.props; const { post, author } = Astro.props;
const { frontmatter } = post;
function formatDate(date) { function formatDate(date) {
return new Date(date).toUTCString().replace(/(\d\d\d\d) .*/, '$1'); // remove everything after YYYY 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"> <article class="post">
<div class="data"> <div class="data">
<h2>{post.title}</h2> <h2>{frontmatter.title}</h2>
<a class="author" href={`/authors/${post.author}`}>{author.name}</a> <a class="author" href={`/authors/${frontmatter.author}`}>{author.name}</a>
<time class="date" datetime={post.date}>{formatDate(post.date)}</time> <time class="date" datetime={frontmatter.date}>{formatDate(frontmatter.date)}</time>
<p class="description"> <p class="description">
{post.description} {frontmatter.description}
<a class="link" href={post.url} aria-label={`Read ${post.title}`}>Read</a> <a class="link" href={post.url} aria-label={`Read ${frontmatter.title}`}>Read</a>
</p> </p>
</div> </div>
</article> </article>

View file

@ -2,36 +2,28 @@
import MainHead from '../../components/MainHead.astro'; import MainHead from '../../components/MainHead.astro';
import Nav from '../../components/Nav.astro'; import Nav from '../../components/Nav.astro';
import PostPreview from '../../components/PostPreview.astro'; import PostPreview from '../../components/PostPreview.astro';
import Pagination from '../../components/Pagination.astro';
import authorData from '../../data/authors.json'; import authorData from '../../data/authors.json';
export function getStaticPaths() { export async function getStaticPaths() {
const allPosts = Astro.fetchContent<MarkdownFrontmatter>('../post/*.md'); const allPosts = await Astro.glob('../post/*.md');
let allAuthorsUnique = [...new Set(allPosts.map((p) => p.author))]; let allAuthorsUnique = [...new Set(allPosts.map((p) => p.frontmatter.author))];
return allAuthorsUnique.map((author) => ({ params: { author }, props: { allPosts } })); return allAuthorsUnique.map((author) => ({ params: { author }, props: { allPosts } }));
} }
interface MarkdownFrontmatter {
date: number;
description: string;
title: string;
author: string;
}
const { allPosts } = Astro.props; const { allPosts } = Astro.props;
const { params, canonicalURL } = Astro.request; const { params, canonicalURL } = Astro.request;
const title = 'Dons Blog'; const title = 'Dons Blog';
const description = 'An example blog on Astro'; const description = 'An example blog on Astro';
/** filter posts by author, sort by date */ /** 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 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].author]; const author = authorData[posts[0].frontmatter.author];
--- ---
<html lang="en"> <html lang="en">
<head> <head>
<title>{title}</title> <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"> <style lang="scss">
.title { .title {

View file

@ -6,12 +6,6 @@ import PostPreview from '../components/PostPreview.astro';
import Pagination from '../components/Pagination.astro'; import Pagination from '../components/Pagination.astro';
import authorData from '../data/authors.json'; import authorData from '../data/authors.json';
interface MarkdownFrontmatter {
date: number;
image: string;
author: string;
}
// Component Script: // Component Script:
// You can write any JavaScript/TypeScript that you'd like here. // You can write any JavaScript/TypeScript that you'd like here.
// It will run during the build, but never in the browser. // 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; let canonicalURL = Astro.request.canonicalURL;
// Data Fetching: List all Markdown posts in the repo. // Data Fetching: List all Markdown posts in the repo.
let allPosts = Astro.fetchContent<MarkdownFrontmatter>('./post/*.md'); let allPosts = await Astro.glob('./post/*.md');
allPosts.sort((a, b) => new Date(b.date).valueOf() - new Date(a.date).valueOf()); allPosts.sort((a, b) => new Date(b.frontmatter.date).valueOf() - new Date(a.frontmatter.date).valueOf());
let firstPage = allPosts.slice(0, 2); let firstPage = allPosts.slice(0, 2);
// Full Astro Component Syntax: // Full Astro Component Syntax:
// https://docs.astro.build/core-concepts/astro-components/ // https://docs.astro.build/core-concepts/astro-components/
--- ---
@ -32,14 +25,14 @@ let firstPage = allPosts.slice(0, 2);
<html lang="en"> <html lang="en">
<head> <head>
<title>{title}</title> <title>{title}</title>
<MainHead {title} {description} image={allPosts[0].image} {canonicalURL} /> <MainHead {title} {description} image={allPosts[0].frontmatter.image} {canonicalURL} />
</head> </head>
<body> <body>
<Nav {title} /> <Nav {title} />
<main class="wrapper"> <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> </main>
<footer> <footer>

View file

@ -6,8 +6,8 @@ import Pagination from '../../components/Pagination.astro';
import authorData from '../../data/authors.json'; import authorData from '../../data/authors.json';
export async function getStaticPaths({ paginate, rss }) { export async function getStaticPaths({ paginate, rss }) {
const allPosts = Astro.fetchContent<MarkdownFrontmatter>('../post/*.md'); const allPosts = await Astro.glob('../post/*.md');
const sortedPosts = allPosts.sort((a, b) => new Date(b.date).valueOf() - new Date(a.date).valueOf()); 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. // 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. // 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 = 'Dons Blog'; let title = 'Dons Blog';
let description = 'An example blog on Astro'; let description = 'An example blog on Astro';
let canonicalURL = Astro.request.canonicalURL; let canonicalURL = Astro.request.canonicalURL;
// collection
interface MarkdownFrontmatter {
date: number;
description: string;
title: string;
}
const { page } = Astro.props; const { page } = Astro.props;
--- ---
<html lang="en"> <html lang="en">
<head> <head>
<title>{title}</title> <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"> <style lang="scss">
.title { .title {
@ -70,7 +62,7 @@ const { page } = Astro.props;
<main class="wrapper"> <main class="wrapper">
<h2 class="title">All Posts</h2> <h2 class="title">All Posts</h2>
<small class="count">{page.start + 1}{page.end + 1} of {page.total}</small> <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> </main>
<footer> <footer>

View file

@ -8,10 +8,10 @@ const { post } = Astro.props;
<article class="post-preview"> <article class="post-preview">
<header> <header>
<p class="publish-date">{post.publishDate}</p> <p class="publish-date">{post.frontmatter.publishDate}</p>
<a href={post.url}><h1 class="title">{post.title}</h1></a> <a href={post.url}><h1 class="title">{post.frontmatter.title}</h1></a>
</header> </header>
<p>{post.description}</p> <p>{post.frontmatter.description}</p>
<a href={post.url}>Read more</a> <a href={post.url}>Read more</a>
</article> </article>

View file

@ -4,10 +4,6 @@ import BaseHead from '../components/BaseHead.astro';
import BlogHeader from '../components/BlogHeader.astro'; import BlogHeader from '../components/BlogHeader.astro';
import BlogPostPreview from '../components/BlogPostPreview.astro'; import BlogPostPreview from '../components/BlogPostPreview.astro';
interface MarkdownFrontmatter {
publishDate: number;
}
// Component Script: // Component Script:
// You can write any JavaScript/TypeScript that you'd like here. // You can write any JavaScript/TypeScript that you'd like here.
// It will run during the build, but never in the browser. // 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. // Data Fetching: List all Markdown posts in the repo.
let allPosts = await Astro.fetchContent('./posts/*.md'); let allPosts = await Astro.glob('./posts/*.md');
allPosts = allPosts.sort((a, b) => new Date(b.publishDate).valueOf() - new Date(a.publishDate).valueOf()); allPosts = allPosts.sort((a, b) => new Date(b.frontmatter.publishDate).valueOf() - new Date(a.frontmatter.publishDate).valueOf());
// Full Astro Component Syntax: // Full Astro Component Syntax:
// https://docs.astro.build/core-concepts/astro-components/ // https://docs.astro.build/core-concepts/astro-components/

View file

@ -2,16 +2,17 @@ import { h } from 'preact';
import Styles from './styles.module.scss'; import Styles from './styles.module.scss';
function PortfolioPreview({ project }) { function PortfolioPreview({ project }) {
const { frontmatter } = project;
return ( return (
<div className={Styles.card}> <div className={Styles.card}>
<div className={Styles.titleCard} style={`background-image:url(${project.img})`}> <div className={Styles.titleCard} style={`background-image:url(${frontmatter.img})`}>
<h1 className={Styles.title}>{project.title}</h1> <h1 className={Styles.title}>{frontmatter.title}</h1>
</div> </div>
<div className="pa3"> <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}> <div className={Styles.tags}>
Tagged: Tagged:
{project.tags.map((t) => ( {frontmatter.tags.map((t) => (
<div className={Styles.tag} data-tag={t}> <div className={Styles.tag} data-tag={t}>
{t} {t}
</div> </div>

View file

@ -7,7 +7,7 @@ import Footer from '../components/Footer/index.jsx';
import PortfolioPreview from '../components/PortfolioPreview/index.jsx'; import PortfolioPreview from '../components/PortfolioPreview/index.jsx';
// Data Fetching: List all Markdown posts in the repo. // 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]; const featuredProject = projects[0];
// Full Astro Component Syntax: // Full Astro Component Syntax:

View file

@ -4,13 +4,9 @@ import Footer from '../components/Footer/index.jsx';
import Nav from '../components/Nav/index.jsx'; import Nav from '../components/Nav/index.jsx';
import PortfolioPreview from '../components/PortfolioPreview/index.jsx'; import PortfolioPreview from '../components/PortfolioPreview/index.jsx';
interface MarkdownFrontmatter { const projects = (await Astro.glob('./project/**/*.md'))
publishDate: number; .filter(({ frontmatter }) => !!frontmatter.publishDate)
} .sort((a, b) => new Date(b.frontmatter.publishDate).valueOf() - new Date(a.frontmatter.publishDate).valueOf());
const projects = Astro.fetchContent<MarkdownFrontmatter>('./project/**/*.md')
.filter(({ publishDate }) => !!publishDate)
.sort((a, b) => new Date(b.publishDate).valueOf() - new Date(a.publishDate).valueOf());
--- ---
<html lang="en"> <html lang="en">

View file

@ -1,6 +1,6 @@
/// <reference types="vite/client" /> /// <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) // 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)
/** /**

View file

@ -85,6 +85,7 @@
"@proload/core": "^0.2.2", "@proload/core": "^0.2.2",
"@proload/plugin-tsm": "^0.1.1", "@proload/plugin-tsm": "^0.1.1",
"@web/parse5-utils": "^1.3.0", "@web/parse5-utils": "^1.3.0",
"ast-types": "^0.14.2",
"boxen": "^6.2.1", "boxen": "^6.2.1",
"ci-info": "^3.3.0", "ci-info": "^3.3.0",
"common-ancestor-path": "^1.0.1", "common-ancestor-path": "^1.0.1",
@ -112,6 +113,7 @@
"preferred-pm": "^3.0.3", "preferred-pm": "^3.0.3",
"prismjs": "^1.27.0", "prismjs": "^1.27.0",
"prompts": "^2.4.2", "prompts": "^2.4.2",
"recast": "^0.20.5",
"rehype-slug": "^5.0.1", "rehype-slug": "^5.0.1",
"resolve": "^1.22.0", "resolve": "^1.22.0",
"rollup": "^2.70.1", "rollup": "^2.70.1",

View file

@ -1,7 +1,7 @@
import type { AddressInfo } from 'net'; import type { AddressInfo } from 'net';
import type * as babel from '@babel/core'; import type * as babel from '@babel/core';
import type * as vite from 'vite'; import type * as vite from 'vite';
import type { z } from 'zod'; import { z } from 'zod';
import type { AstroConfigSchema } from '../core/config'; import type { AstroConfigSchema } from '../core/config';
import type { AstroComponentFactory, Metadata } from '../runtime/server'; import type { AstroComponentFactory, Metadata } from '../runtime/server';
import type { AstroRequest } from '../core/render/request'; import type { AstroRequest } from '../core/render/request';
@ -60,11 +60,15 @@ export interface AstroGlobal extends AstroGlobalPartial {
} }
export interface 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. * @deprecated since version 0.24. See the {@link https://astro.build/deprecated/resolve upgrade guide} for more details.
*/ */
resolve: (path: string) => string; 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; site: URL;
} }
@ -508,20 +512,13 @@ export interface ComponentInstance {
getStaticPaths?: (options: GetStaticPathsOptions) => GetStaticPathsResult; getStaticPaths?: (options: GetStaticPathsOptions) => GetStaticPathsResult;
} }
/** export interface MarkdownInstance<T extends Record<string, any>> {
* Astro.fetchContent() result frontmatter: T;
* Docs: https://docs.astro.build/reference/api-reference/#astrofetchcontent file: string;
*/ url: string | undefined;
export type FetchContentResult<T> = FetchContentResultBase & T; Content: AstroComponentFactory;
getHeaders(): Promise<{ depth: number, slug: string, text: string }[]>;
export type FetchContentResultBase = { }
astro: {
headers: string[];
source: string;
html: string;
};
url: string;
};
export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: string | null) => void>; export type GetHydrateCallback = () => Promise<(element: Element, innerHTML: string | null) => void>;

View file

@ -29,10 +29,15 @@ export function getStylesForURL(filePath: URL, viteServer: vite.ViteDevServer):
: // Otherwise, you are following an import in the module import tree. : // Otherwise, you are following an import in the module import tree.
// You are safe to use getModuleById() here because Vite has already // You are safe to use getModuleById() here because Vite has already
// resolved the correct `id` for you, by creating the import you followed here. // 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). // Collect all imported modules for the module(s).
for (const entry of moduleEntriesForId) { 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) { if (id === entry.id) {
scanned.add(id); scanned.add(id);
for (const importedModule of entry.importedModules) { for (const importedModule of entry.importedModules) {

View file

@ -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. */ /** Create the Astro.fetchContent() runtime function. */
function createFetchContentFn(url: URL, site: URL) { function createDeprecatedFetchContentFn() {
let sitePathname = site.pathname; return () => {
const fetchContent = (importMetaGlobResult: Record<string, any>) => { throw new Error('Deprecated: Astro.fetchContent() has been replaced with Astro.glob().');
let allEntries = [...Object.entries(importMetaGlobResult)]; };
}
/** 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) { 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 over the `import()` promises, calling to load them.
.map(([spec, mod]) => { return Promise.all(allEntries.map(fn => fn()));
// 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,
}; };
}) // Cast the return type because the argument that the user sees (string) is different from the argument
.filter(Boolean); // that the runtime sees post-compiler (Record<string, Module>).
}; return globHandler as unknown as AstroGlobalPartial['glob'];
// 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'];
} }
// This is used to create the top-level Astro global; the one that you can use // 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 site = new URL(_site);
const url = new URL(filePathname, site); const url = new URL(filePathname, site);
const projectRoot = new URL(projectRootStr); const projectRoot = new URL(projectRootStr);
const fetchContent = createFetchContentFn(url, site);
return { return {
site, site,
fetchContent, fetchContent: createDeprecatedFetchContentFn(),
glob: createAstroGlobFn(),
// INVESTIGATE is there a use-case for multi args? // INVESTIGATE is there a use-case for multi args?
resolve(...segments: string[]) { resolve(...segments: string[]) {
let resolved = segments.reduce((u, segment) => new URL(segment, u), url).pathname; let resolved = segments.reduce((u, segment) => new URL(segment, u), url).pathname;

View file

@ -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 { Plugin } from 'vite';
import type { AstroConfig } from '../@types/astro'; import type { AstroConfig } from '../@types/astro';
import * as babelTraverse from '@babel/traverse'; // Check for `Astro.glob()`. Be very forgiving of whitespace. False positives are okay.
import * as babel from '@babel/core'; const ASTRO_GLOB_REGEX = /Astro2?\s*\.\s*glob\s*\(/;
interface AstroPluginOptions { interface AstroPluginOptions {
config: AstroConfig; config: AstroConfig;
} }
@ -21,55 +23,56 @@ export default function astro({ config }: AstroPluginOptions): Plugin {
return null; return null;
} }
// Optimization: only run on a probably match // Optimization: Detect usage with a quick string match.
// Open this up if need for post-pass extends past fetchContent // Only perform the transform if this function is found
if (!code.includes('fetchContent')) { if (!ASTRO_GLOB_REGEX.test(code)) {
return null; return null;
} }
// Handle the second-pass JS AST Traversal const ast = parse(code, {
const result = await babel.transformAsync(code, { // We need to use the babel parser because `import.meta.hot` is not
sourceType: 'module', // supported by esprima (default parser). In the future, we should
sourceMaps: true, // experiment with other parsers if Babel is too slow or heavy.
plugins: [ parser: { parse: babelParser },
() => { });
return {
visitor: { visit(ast, {
StringLiteral(path: babelTraverse.NodePath<t.StringLiteral>) { visitCallExpression: function (path) {
// Filter out anything that isn't `Astro.glob()` or `Astro2.glob()`
if ( if (
path.parent.type !== 'CallExpression' || !types.namedTypes.MemberExpression.check(path.node.callee) ||
path.parent.callee.type !== 'MemberExpression' || !types.namedTypes.Identifier.check(path.node.callee.property) ||
!validAstroGlobalNames.has((path.parent.callee.object as any).name) || !(path.node.callee.property.name === 'glob') ||
(path.parent.callee.property as any).name !== 'fetchContent' !types.namedTypes.Identifier.check(path.node.callee.object) ||
!(path.node.callee.object.name === 'Astro' || path.node.callee.object.name === 'Astro2')
) { ) {
this.traverse(path);
return; return;
} }
const { value } = path.node;
if (/[a-z]\:\/\//.test(value)) { // Wrap the `Astro.glob()` argument with `import.meta.glob`.
return; const argsPath = path.get('arguments', 0) as NodePath;
} const args = argsPath.value;
path.replaceWith({ argsPath.replace({
type: 'CallExpression', type: 'CallExpression',
callee: { callee: {
type: 'MemberExpression', type: 'MemberExpression',
object: { type: 'MetaProperty', meta: { type: 'Identifier', name: 'import' }, property: { type: 'Identifier', name: 'meta' } }, object: { type: 'MetaProperty', meta: { type: 'Identifier', name: 'import' }, property: { type: 'Identifier', name: 'meta' } },
property: { type: 'Identifier', name: 'globEager' }, property: { type: 'Identifier', name: 'glob' },
computed: false, computed: false,
}, },
arguments: [path.node], arguments: [args],
} as any); } as CallExpressionKind,
{
type: 'ArrowFunctionExpression',
body: args,
params: []
} as ArrowFunctionExpressionKind);
return false;
}, },
},
};
},
],
}); });
// Undocumented baby behavior, but possible according to Babel types. const result = print(ast);
if (!result || !result.code) {
return null;
}
return { code: result.code, map: result.map }; return { code: result.code, map: result.map };
}, },
}; };

View file

@ -2,14 +2,20 @@ import { transform } from '@astrojs/compiler';
import ancestor from 'common-ancestor-path'; import ancestor from 'common-ancestor-path';
import esbuild from 'esbuild'; import esbuild from 'esbuild';
import fs from 'fs'; import fs from 'fs';
import matter from 'gray-matter';
import { fileURLToPath } from 'url';
import type { Plugin } from 'vite'; import type { Plugin } from 'vite';
import type { AstroConfig } from '../@types/astro'; import type { AstroConfig } from '../@types/astro';
import { PAGE_SSR_SCRIPT_ID } from '../vite-plugin-scripts/index.js'; import { PAGE_SSR_SCRIPT_ID } from '../vite-plugin-scripts/index.js';
import { virtualModuleId as pagesVirtualModuleId } from '../core/build/vite-plugin-pages.js';
interface AstroPluginOptions { interface AstroPluginOptions {
config: AstroConfig; 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. // 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 // Both end up connecting a `load()` hook to the Astro compiler, and share some copy-paste
// logic in how that is done. // logic in how that is done.
@ -23,14 +29,88 @@ export default function markdown({ config }: AstroPluginOptions): Plugin {
return filename; 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 { return {
name: 'astro:markdown', 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) { 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')) { if (id.endsWith('.md')) {
const source = await fs.promises.readFile(id, 'utf8'); const source = await fs.promises.readFile(id, 'utf8');
// Transform from `.md` to valid `.astro`
let render = config.markdownOptions.render; let render = config.markdownOptions.render;
let renderOpts = {}; let renderOpts = {};
if (Array.isArray(render)) { if (Array.isArray(render)) {
@ -40,8 +120,6 @@ export default function markdown({ config }: AstroPluginOptions): Plugin {
if (typeof render === 'string') { if (typeof render === 'string') {
({ default: render } = await import(render)); ({ default: render } = await import(render));
} }
let renderResult = await render(source, renderOpts);
let { frontmatter, metadata, code: astroResult } = renderResult;
const filename = normalizeFilename(id); const filename = normalizeFilename(id);
const fileUrl = new URL(`file://${filename}`); 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'); const hasInjectedScript = isPage && config._ctx.scripts.some((s) => s.stage === 'page-ssr');
// Extract special frontmatter keys // 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; const { layout = '', components = '', setup = '', ...content } = frontmatter;
content.astro = metadata; content.astro = metadata;
const prelude = `--- const prelude = `---
@ -83,8 +164,7 @@ export const frontmatter = ${JSON.stringify(content)};
${tsResult}`; ${tsResult}`;
// Compile from `.ts` to `.js` // 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 { return {
code, code,
map: null, map: null,

View file

@ -48,7 +48,7 @@ describe('Astro.*', () => {
expect($('#site').attr('href')).to.equal('https://mysite.dev/blog/'); 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 html = await fixture.readFile('/posts/1/index.html');
const $ = cheerio.load(html); const $ = cheerio.load(html);
expect($('.post-url').attr('href')).to.equal('/blog/post/post-2'); expect($('.post-url').attr('href')).to.equal('/blog/post/post-2');

View file

@ -1,6 +1,6 @@
--- ---
export function getStaticPaths({paginate}) { export async function getStaticPaths({paginate}) {
const data = Astro.fetchContent('../post/*.md'); const data = await Astro.glob('../post/*.md');
return paginate(data, {pageSize: 1}); return paginate(data, {pageSize: 1});
} }
const { page } = Astro.props; const { page } = Astro.props;
@ -15,7 +15,7 @@ const { params, canonicalURL} = Astro.request;
<body> <body>
{page.data.map((data) => ( {page.data.map((data) => (
<div> <div>
<h1>{data.title}</h1> <h1>{data.frontmatter.title}</h1>
<a class="post-url" href={data.url}>Read</a> <a class="post-url" href={data.url}>Read</a>
</div> </div>
))} ))}

View file

@ -1,8 +1,8 @@
--- ---
export function getStaticPaths({paginate}) { export async function getStaticPaths({paginate}) {
const allPosts = Astro.fetchContent('../../post/*.md'); const allPosts = await Astro.glob('../../post/*.md');
return ['red', 'blue'].map((filter) => { 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, { return paginate(filteredPosts, {
params: { slug: filter }, params: { slug: filter },
props: { filter }, props: { filter },

View file

@ -1,6 +1,6 @@
--- ---
export async function getStaticPaths({paginate}) { export async function getStaticPaths({paginate}) {
const data = Astro.fetchContent('../../post/*.md'); const data = await Astro.glob('../../post/*.md');
return paginate(data, {pageSize: 1}); return paginate(data, {pageSize: 1});
} }
const { page } = Astro.props; const { page } = Astro.props;

View file

@ -1,6 +1,6 @@
--- ---
export async function getStaticPaths({paginate}) { export async function getStaticPaths({paginate}) {
const data = Astro.fetchContent('../../post/*.md'); const data = await Astro.glob('../../post/*.md');
return paginate(data, {pageSize: 1}); return paginate(data, {pageSize: 1});
} }
const { page } = Astro.props; const { page } = Astro.props;

View file

@ -1,6 +1,6 @@
--- ---
export function getStaticPaths({paginate, rss}) { export async function getStaticPaths({paginate, rss}) {
const episodes = Astro.fetchContent('../episode/*.md').sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate)); const episodes = (await Astro.glob('../episode/*.md')).sort((a, b) => new Date(b.frontmatter.pubDate) - new Date(a.frontmatter.pubDate));
rss({ rss({
title: 'MF Doomcast', title: 'MF Doomcast',
description: 'The podcast about the things you find on a picnic, or at a picnic table', 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>` + customData: `<language>en-us</language>` +
`<itunes:author>MF Doom</itunes:author>`, `<itunes:author>MF Doom</itunes:author>`,
items: episodes.map((episode) => ({ items: episodes.map((episode) => ({
title: episode.title, title: episode.frontmatter.title,
link: episode.url, link: episode.url,
description: episode.description, description: episode.frontmatter.description,
pubDate: episode.pubDate + 'Z', pubDate: episode.frontmatter.pubDate + 'Z',
customData: `<itunes:episodeType>${episode.type}</itunes:episodeType>` + customData: `<itunes:episodeType>${episode.frontmatter.type}</itunes:episodeType>` +
`<itunes:duration>${episode.duration}</itunes:duration>` + `<itunes:duration>${episode.frontmatter.duration}</itunes:duration>` +
`<itunes:explicit>${episode.explicit || false}</itunes:explicit>`, `<itunes:explicit>${episode.frontmatter.explicit || false}</itunes:explicit>`,
})), })),
dest: '/custom/feed.xml', dest: '/custom/feed.xml',
}); });
@ -31,13 +31,13 @@ export function getStaticPaths({paginate, rss}) {
customData: `<language>en-us</language>` + customData: `<language>en-us</language>` +
`<itunes:author>MF Doom</itunes:author>`, `<itunes:author>MF Doom</itunes:author>`,
items: episodes.map((episode) => ({ items: episodes.map((episode) => ({
title: episode.title, title: episode.frontmatter.title,
link: `https://example.com${episode.url}/`, link: `https://example.com${episode.url}/`,
description: episode.description, description: episode.frontmatter.description,
pubDate: episode.pubDate + 'Z', pubDate: episode.frontmatter.pubDate + 'Z',
customData: `<itunes:episodeType>${episode.type}</itunes:episodeType>` + customData: `<itunes:episodeType>${episode.frontmatter.type}</itunes:episodeType>` +
`<itunes:duration>${episode.duration}</itunes:duration>` + `<itunes:duration>${episode.frontmatter.duration}</itunes:duration>` +
`<itunes:explicit>${episode.explicit || false}</itunes:explicit>`, `<itunes:explicit>${episode.frontmatter.explicit || false}</itunes:explicit>`,
})), })),
dest: '/custom/feed-pregenerated-urls.xml', 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" /> <link rel="alternate" type="application/rss+2.0" href="/rss.xml" />
</head> </head>
<body> <body>
{page.data.map((ep) => (<li>{ep.title}</li>))} {page.data.map((ep) => (<li>{ep.frontmatter.title}</li>))}
</body> </body>
</html> </html>

View file

@ -3,15 +3,13 @@ import Debug from 'astro/debug';
// all the content that should be generated // all the content that should be generated
export async function getStaticPaths() { 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 { return {
params: { slug: article.slug }, params: { slug: article.frontmatter.slug },
props: { props: {
article: article, article: article,
content: astro.html,
md: astro.source,
} }
} }
}) })

View file

@ -2,7 +2,7 @@
import MainHead from '../components/MainHead.astro'; import MainHead from '../components/MainHead.astro';
import Nav from '../components/Nav/index.jsx'; import Nav from '../components/Nav/index.jsx';
import { test as ssrConfigTest } from '@test/static-build-pkg'; import { test as ssrConfigTest } from '@test/static-build-pkg';
let allPosts = await Astro.fetchContent('./posts/*.md'); let allPosts = await Astro.glob('./posts/*.md');
--- ---
<html> <html>
<head> <head>

View file

@ -21,7 +21,7 @@ describe('Static build', () => {
expect(html).to.be.a('string'); 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 html = await fixture.readFile('/index.html');
const $ = cheerioLoad(html); const $ = cheerioLoad(html);
const link = $('.posts a'); const link = $('.posts a');

21
pnpm-lock.yaml generated
View file

@ -473,6 +473,7 @@ importers:
'@types/send': ^0.17.1 '@types/send': ^0.17.1
'@types/yargs-parser': ^21.0.0 '@types/yargs-parser': ^21.0.0
'@web/parse5-utils': ^1.3.0 '@web/parse5-utils': ^1.3.0
ast-types: ^0.14.2
astro-scripts: workspace:* astro-scripts: workspace:*
boxen: ^6.2.1 boxen: ^6.2.1
chai: ^4.3.6 chai: ^4.3.6
@ -504,6 +505,7 @@ importers:
preferred-pm: ^3.0.3 preferred-pm: ^3.0.3
prismjs: ^1.27.0 prismjs: ^1.27.0
prompts: ^2.4.2 prompts: ^2.4.2
recast: ^0.20.5
rehype-slug: ^5.0.1 rehype-slug: ^5.0.1
resolve: ^1.22.0 resolve: ^1.22.0
rollup: ^2.70.1 rollup: ^2.70.1
@ -536,6 +538,7 @@ importers:
'@proload/core': 0.2.2 '@proload/core': 0.2.2
'@proload/plugin-tsm': 0.1.1_@proload+core@0.2.2 '@proload/plugin-tsm': 0.1.1_@proload+core@0.2.2
'@web/parse5-utils': 1.3.0 '@web/parse5-utils': 1.3.0
ast-types: 0.14.2
boxen: 6.2.1 boxen: 6.2.1
ci-info: 3.3.0 ci-info: 3.3.0
common-ancestor-path: 1.0.1 common-ancestor-path: 1.0.1
@ -563,6 +566,7 @@ importers:
preferred-pm: 3.0.3 preferred-pm: 3.0.3
prismjs: 1.27.0 prismjs: 1.27.0
prompts: 2.4.2 prompts: 2.4.2
recast: 0.20.5
rehype-slug: 5.0.1 rehype-slug: 5.0.1
resolve: 1.22.0 resolve: 1.22.0
rollup: 2.70.1 rollup: 2.70.1
@ -4615,6 +4619,13 @@ packages:
tslib: 2.3.1 tslib: 2.3.1
dev: true 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: /async/0.9.2:
resolution: {integrity: sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=} resolution: {integrity: sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=}
dev: true dev: true
@ -8807,6 +8818,16 @@ packages:
dependencies: dependencies:
picomatch: 2.3.1 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: /redent/3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'} engines: {node: '>=8'}