跳转至正文
GZ Cloudhome Logo

基于 Astro content collection entry 获取 markdown 文件渲染后的 HTML 字符串

发布于:2023 年 7 月 31 日 at 16:09
更新于:2023 年 8 月 1 日 at 11:17

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 的实例,有 idcollectionbodyslugdatarender 等 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 的源码,看看能不能找到办法。

值得一提的是,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 对象,这个 ContentcreateComponent 的输出。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);