Astro 框架的 Content Collections 让我们能够以文件系统的方式便捷地管理 markdown 文件,并且内部给我们提供渲染 markdown 文本为的功能。Astro 通过 unified 生态下的 remark 和 rehype (见 之前的博客 中的介绍)来把 markdown 编译为 HTML。通过编辑 astro.config.mjs
文件,我们还可以给 Astro 内置的 remark 和 rehype 配置插件,以实现更多的自定义 markdown 语法。再加上 Astro Content Collections 的 markdown frontmatter 类型校验,这使得基于 Astro 来管理 markdown 数据库(如博客)非常地方便。
在 Astro 项目中,我们可以从 astro:content
导入函数来获取 Content Collection 中不同的 markdown 文件所对应的条目(entry),如 getEntry
函数,接受输入的 collection 和 entry id 后,返回的是一个类型为 CollectionEntry
的实例,有 id
,collection
,body
,slug
,data
和 render
等 6 个属性。通过 data
属性,我们可以访问到该 entry 的 frontmatter;通过 body
我们可以得到 entry 的 markdown 原文;通过调用 render
属性(该属性是一个函数),我们可以得到一个 Astro 组件。该 Astro 组件可以直接在 .astro
文件中使用,如下面的例子:
---
...
const { Content } = await entry.render();
---
<h1>{entry.data.title}</h1>
<Content />
到此为止,都挺好的。但是,一个我想要的功能 Astro 并没有提供:如何得到 entry 渲染后的 HTML 字符串?
仅从 Astro 的官方文档,我并没有找到基于 entry 获取 HTML 字符串的 API。我决定研究一下 Astro 的源码,看看能不能找到办法。
TLDR?太长不看?点击这里直达解决方案部分
值得一提的是,Astro 提供了直接导入 markdown 文件的功能。通过直接导入 markdown 文件,导入后,我们可以通过模块的
compiledContent
函数来获取渲染后的 HTML 字符串。文档见这里:Exported Properties of markdown,对应解决方案见 方案三。
源码探索
entry.render
首先贴上 render
函数的源代码(其中不相关的部分用 ...
省略,下同):
async function render({
collection,
id,
renderEntryImport,
}: {
collection: string;
id: string;
renderEntryImport?: LazyImport;
}): Promise<RenderResult> {
...
const baseMod = await renderEntryImport();
if (baseMod == null || typeof baseMod !== 'object') throw UnexpectedRenderError;
// markdown 文件的 defaultMod 大概长这样:{ __astroPropagation: true, getMod, collectedLinks, collectedStyles, collectedScripts }
const { default: defaultMod } = baseMod;
if (isPropagatedAssetsModule(defaultMod)) {
const { collectedStyles, collectedLinks, collectedScripts, getMod } = defaultMod;
if (typeof getMod !== 'function') throw UnexpectedRenderError;
// markdown content 的 getMod 函数 import 3 个 mjs 文件的另外一个(有 Content 的那个),这个 mjs 由 vite-plugin-markdown 产出。
const propagationMod = await getMod();
if (propagationMod == null || typeof propagationMod !== 'object') throw UnexpectedRenderError;
const Content = createComponent({
factory(result, baseProps, slots) {
...
let props = baseProps;
// Auto-apply MDX components export
if (id.endsWith('mdx')) {
props = {
components: propagationMod.components ?? {},
...baseProps,
};
}
// markdown content rendered 的结果竟然是 createHeadandContent!
return createHeadAndContent(
// head 部分
unescapeHTML(styles + links + scripts) as any,
// content 部分
renderTemplate`${renderComponent(
result,
'Content',
// progagationMod.Content 是一个无参的函数,返回 createVNode(Fragment, { 'set:html': html })
propagationMod.Content,
props,
slots
)}`
);
},
propagation: 'self',
});
return {
Content,
headings: propagationMod.getHeadings?.() ?? [],
remarkPluginFrontmatter: propagationMod.frontmatter ?? {},
};
} else if (baseMod.Content && typeof baseMod.Content === 'function') {
...
} else {
...
}
}
可以看到,render
函数确实返回了一个 Content
对象,这个 Content
是 createComponent
的输出。createComponent
可以看作是 Python 中的装饰器,对上面的 factory
函数进行修饰,加入了一些额外的属性和参数检验,使其成为 AstroComponentFactory
类型,即
Content.isAstroComponentFactory === true
成立。
Astro 中渲染组件的函数是 renderComponent
。当遇到 AstroComponentFacotry
类型的实例时,内部将会调用 createAstroComponentInstance
,
export class AstroComponentInstance {
[astroComponentInstanceSym] = true;
private readonly result: SSRResult;
private readonly props: ComponentProps;
private readonly slotValues: ComponentSlots;
private readonly factory: AstroComponentFactory;
private returnValue: ReturnType<AstroComponentFactory> | undefined;
constructor(
result: SSRResult,
props: ComponentProps,
slots: ComponentSlots,
factory: AstroComponentFactory
) {
this.result = result;
this.props = props;
this.factory = factory;
this.slotValues = {};
for (const name in slots) {
const value = slots[name](result);
this.slotValues[name] = () => value;
}
}
async init(result: SSRResult) {
this.returnValue = this.factory(result, this.props, this.slotValues);
return this.returnValue;
}
// 在 RenderTemplateResult render 时,Astro Component 都先被创立 instance,然后走这个 render
async *render() {
if (this.returnValue === undefined) {
await this.init(this.result);
}
let value: AstroFactoryReturnValue | undefined = this.returnValue;
if (isPromise(value)) {
value = await value;
}
if (isHeadAndContent(value)) {
yield* value.content;
} else {
yield* renderChild(value);
}
}
}
它的关键是其生成器方法 async *render
,这个方法让对应的实例满足异步可迭代协议,从而可以迭代之就可以渲染了。在上面的代码中,可以看到也是基于 factory
函数的返回值进行迭代的。那么,如果我们能够得到 factory
函数的返回值,便可以基于其进行迭代,从而得到渲染的结果。
factory
函数的返回值
从前面的代码可以看出,factory
的函数返回值为
return createHeadAndContent(
// head 部分
unescapeHTML(styles + links + scripts) as any,
// content 部分
renderTemplate`${renderComponent(
result,
'Content',
// progagationMod.Content 是一个无参的函数,返回 createVNode(Fragment, { 'set:html': html })
propagationMod.Content,
props,
slots
)}`
);
其 content
属性对应的部分为喂给 renderTemplate
函数一个模板字符串得到的结果。篇幅所限,这里简单概括一下 renderTemplate
函数。它是一个处理模板字符串的 tag function,返回值是一个可迭代对象。迭代时,会分别返回模板字符串里各个字符串以及表达式的值,如果表达式是一个可迭代对象,那么就 yield*
到该可迭代对象。这里的模板字符串有一个唯一的表达式,是 renderComponent
的返回值。是的,又是 renderComponent
。不过此时,需要渲染的组件是 propagationMod.Content
。
propagationMod.Content
Astro 经过 SSR build 之后,每个 content collection entry 都会生成 3 个 .mjs
文件(chunk),其中一个 chunk 即为初始化存储渲染相关变量的 chunk。propagationMod
就是动态导入这个 chunk 之后得到的变量。下面是一个例子:
...
const html = updateImageReferences("<h1 id=\"test-title\">Test title.</h1>\n<p>This is the test main body.</p>\n<h2 id=\"heading-2\">Heading 2</h2>\n<p>test, test.</p>\n<p>Over.</p>");
async function Content() {
const { layout, ...content } = frontmatter;
content.file = file;
content.url = url;
const contentFragment = createVNode(Fragment, { 'set:html': html });
return contentFragment;
}
Content[Symbol.for('astro.needsHeadRendering')] = true;
export { Content, compiledContent, Content as default, file, frontmatter, getHeadings, images, rawContent, url };
可以看到,Content
是一个函数。我们对这样的一个函数进行 renderComponent
,在 renderComponent
内部将会走 renderFrameworkComponent
函数,再内部则是调用这个函数,大体类似于通过
propagationMod.Content().props.children
获得 HTML。
至此,我们发现,通过调用 factory
函数(即 `entry.render` 中 render
函数返回的 Content
)就可以实现渲染。
如何调用 Content
函数?
仿照 async init
中的调用方式,我们只需给 factory
提供 result, props, slotValues
共 3 个参数,即可获得返回值。问题的关键来到了如何获得这三个变量。
result
参数的类型是 SSRResult
,
export interface SSRResult {
styles: Set<SSRElement>;
scripts: Set<SSRElement>;
links: Set<SSRElement>;
componentMetadata: Map<string, SSRComponentMetadata>;
createAstro(
Astro: AstroGlobalPartial,
props: Record<string, any>,
slots: Record<string, any> | null
): AstroGlobal;
resolve: (s: string) => Promise<string>;
response: ResponseInit;
renderers: SSRLoadedRenderer[];
/**
* Map of directive name (e.g. `load`) to the directive script code
*/
clientDirectives: Map<string, string>;
compressHTML: boolean;
/**
* Only used for logging
*/
pathname: string;
cookies: AstroCookies | undefined;
_metadata: SSRMetadata;
}
乍一看还挺复杂的。但是如果进一步阅读 renderFrameworkComponent
函数,我们可以发现,只需要提供 renderes
属性,Astro 内部的流程就可走通。那么如何获取 renderers
呢?一个方法是通过导入 '@astro-renderers'
这个虚拟模块(在源码中由 plugin-renderers.ts
提供,是一个 rollup 插件)来实现。
import { renderers } from '@astro-renderers';
一般来说,我们需要渲染的 markdown 文件不需要 props
,也没有 slotValues
,可以直接置为 {}
。
至此,我们已经可以得到解决方案了!
解决方案
方案一
通过前面的分析,我们可以得到这个方案:
// src/page 文件夹下的某个 .astro 文件
import { getEntry, getEntries } from 'astro:content';
import { renderers } from '@astro-renderers';
const blogPost = await getEntry('foo', 'bar');
const { Content } = await blogPost.render();
const output = await Content({ renderers }, {}, {});
let html = '';
for await (const chunk of output.content) {
html += chunk;
}
console.log(html);
方案二
本方案和方案一的唯一区别就是 { renderers }
换成了 $$result
。事实上,经过 Astro 的 预构建之后,暴露给我们的运行时变量不止 Astro
,$$result
便是其中之一。这个方案不需要额外导入 @astro-renderers
模块。
// src/page 文件夹下的某个 .astro 文件
import { getEntry, getEntries } from 'astro:content';
const blogPost = await getEntry('foo', 'bar');
const { Content } = await blogPost.render();
// 通过 $$result 可以获得全部的 result。类似 Astro,这是 .astro 文件编译后才有的变量
const output = await Content($$result, {}, {});
let html = '';
for await (const chunk of output.content) {
html += chunk;
}
console.log(html);
下附一个典型的编译后的 Astro 文件的例子:
const $$Astro = createAstro();
const $$Index = createComponent(async ($$result, $$props, $$slots) => {
const Astro2 = $$result.createAstro($$Astro, $$props, $$slots);
Astro2.self = $$Index;
const products = await getProducts(Astro2.request);
return renderTemplate`<html lang="en" class="astro-J7PV25F6">
<head>
<title>Online Store</title>
${renderHead()}</head>
<body class="astro-J7PV25F6">
${renderComponent($$result, "Header", $$Header, { "class": "astro-J7PV25F6" })}
${renderComponent($$result, "Container", $$Container, { "tag": "main", "class": "astro-J7PV25F6" }, { "default": ($$result2) => renderTemplate`
${renderComponent($$result2, "ProductListing", $$ProductListing, { "products": products, "class": "astro-J7PV25F6" }, { "title": ($$result3) => renderTemplate`<h2 class="product-listing-title astro-J7PV25F6">Product Listing</h2>` })}
` })}
</body></html>`;
}, "/Users/gz/Documents/Web/learn/astro/examples/ssr/src/pages/index.astro");
const $$file = "/Users/gz/Documents/Web/learn/astro/examples/ssr/src/pages/index.astro";
const $$url = "";
export { $$Index as default, $$file as file, $$url as url };
方案三
该方案和前面两个方案相比需要的代码最少,但是和 entry 有所割裂,因为没有用到 Astro 给我们封装好的 entry。
// 不通过 entry,直接把 markdown 文件导入进来
import { compiledContent } from '../content/foo/bar.md';
const html = compiledContent();
console.log(html);