diff --git a/packages/platform/src/lib/content-plugin.spec.ts b/packages/platform/src/lib/content-plugin.spec.ts new file mode 100644 index 000000000..228415c02 --- /dev/null +++ b/packages/platform/src/lib/content-plugin.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect } from 'vitest'; +import * as fs from 'fs'; + +vi.mock('fs'); + +import { contentPlugin } from './content-plugin'; + +describe('content plugin', () => { + const [plugin] = contentPlugin(); + const transform = (code: string, id: string): any => { + // Use `any` because not of the signatures are callable and it also expects + // to pass a valid `this` type. + const pluginTransform: any = plugin.transform; + return pluginTransform(code, id); + }; + + it('should skip transforming code if there is no `analog-content-list` at the end', async () => { + // Arrange + const code = 'Some_code'; + const id = '/src/content/post.md'; + // Act & Assert + expect(await transform(code, id)).toEqual(undefined); + }); + + it('should cache parsed attributes if the code is the same', async () => { + // Arrange + const code = + '---\n' + + 'title: My First Post\n' + + 'slug: 2022-12-27-my-first-post\n' + + 'description: My First Post Description\n' + + '---\n' + + '\n' + + 'Hello World\n'; + const id = '/src/content/post.md?analog-content-list=true'; + const readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue(code); + const result = + 'export default {"title":"My First Post","slug":"2022-12-27-my-first-post","description":"My First Post Description"}'; + // Act & Assert + expect(await transform(code, id)).toEqual(result); + expect(await transform(code, id)).toEqual(result); + // Ensure the `readFileSync` has been called only once. + expect(readFileSyncSpy).toBeCalledTimes(1); + }); +}); diff --git a/packages/platform/src/lib/content-plugin.ts b/packages/platform/src/lib/content-plugin.ts index 6865e7d45..a5c6f2926 100644 --- a/packages/platform/src/lib/content-plugin.ts +++ b/packages/platform/src/lib/content-plugin.ts @@ -1,21 +1,46 @@ import { Plugin } from 'vite'; import * as fs from 'fs'; +interface Content { + code: string; + attributes: string; +} + export function contentPlugin(): Plugin[] { + const cache = new Map(); + return [ { name: 'analogjs-content-frontmatter', - async transform(_code, id) { + async transform(code, id) { // Transform only the frontmatter into a JSON object for lists - if (id.includes('.md?analog-content-list')) { - const fm: any = await import('front-matter'); - const fileContents = fs.readFileSync(id.split('?')[0], 'utf8'); - const frontmatter = fm(fileContents).attributes; + if (!id.includes('.md?analog-content-list')) { + return; + } - return `export default ${JSON.stringify(frontmatter)}`; + const cachedContent = cache.get(id); + // There's no reason to run `readFileSync` and frontmatter parsing if the + // `transform` hook is called with the same code. In such cases, we can simply + // return the cached attributes, which is faster than repeatedly reading files + // synchronously during the build process. + if (cachedContent?.code === code) { + return `export default ${cachedContent.attributes}`; } - return; + const fm: any = await import('front-matter'); + // The `default` property will be available in CommonJS environment, for instance, + // when running unit tests. It's safe to retrieve `default` first, since we still + // fallback to the original implementation. + const frontmatter = fm.default || fm; + const fileContents = fs.readFileSync(id.split('?')[0], 'utf8'); + const { attributes } = frontmatter(fileContents); + const content = { + code, + attributes: JSON.stringify(attributes), + }; + cache.set(id, content); + + return `export default ${content.attributes}`; }, }, ];