From c2c98d8fe93b75732d702583b1c25a6de9c78687 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 24 Dec 2024 23:33:35 +1100 Subject: [PATCH] feat: pre / post parsing hooks (#2925) --- docs/content/docs/7.advanced/5.hooks.md | 41 +++++++++++++++++ src/types/module.ts | 22 +++++++++- src/utils/collection.ts | 3 +- src/utils/content/index.ts | 17 ++++++-- test/unit/hooks.test.ts | 58 +++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 docs/content/docs/7.advanced/5.hooks.md create mode 100644 test/unit/hooks.test.ts diff --git a/docs/content/docs/7.advanced/5.hooks.md b/docs/content/docs/7.advanced/5.hooks.md new file mode 100644 index 000000000..e224b8516 --- /dev/null +++ b/docs/content/docs/7.advanced/5.hooks.md @@ -0,0 +1,41 @@ +--- +title: Hooks +description: Modify your content using Nuxt build time hooks +navigation: + title: Hooks +--- + +## `content:file:beforeParse`{lang="ts"} + +This hook is called before the content is parsed. + +It can be used to modify the raw content from a `file` before it is transformed +or modify the transform options. + +```ts +import type { FileBeforeParseHook } from '@nuxt/content' + +export default defineNuxtConfig({ + hooks: { + 'content:file:beforeParse'(ctx: FileBeforeParseHook) { + // ... + } + } +}) +``` + +## `content:file:afterParse`{lang="ts"} + +This hook is called after the content is parsed and before it is saved to the database. + +```ts +import type { FileAfterParseHook } from '@nuxt/content' + +export default defineNuxtConfig({ + hooks: { + 'content:file:afterParse'(ctx: FileAfterParseHook) { + // ... + } + } +}) +``` diff --git a/src/types/module.ts b/src/types/module.ts index bca4ac232..cca32b10d 100644 --- a/src/types/module.ts +++ b/src/types/module.ts @@ -1,8 +1,9 @@ import type { BuiltinLanguage as ShikiLang, BuiltinTheme as ShikiTheme, LanguageRegistration, ThemeRegistrationAny, ThemeRegistrationRaw } from 'shiki' import type { ListenOptions } from 'listhen' import type { GitInfo } from '../utils/git' -import type { MarkdownPlugin } from './content' +import type { ContentFile, MarkdownPlugin, TransformContentOptions } from './content' import type { PathMetaOptions } from './path-meta' +import type { ResolvedCollection } from './collection' export interface D1DatabaseConfig { type: 'd1' @@ -187,3 +188,22 @@ export interface PublicRuntimeConfig { iframeMessagingAllowedOrigins?: string } } + +// TODO improve types +export type ParsedContentFile = Record + +export interface FileBeforeParseHook { + collection: Readonly + file: ContentFile + parserOptions: TransformContentOptions +} +export interface FileAfterParseHook { + collection: Readonly + file: ContentFile + content: ParsedContentFile +} + +export interface ModuleHooks { + 'content:file:beforeParse': (hook: FileBeforeParseHook) => void | Promise + 'content:file:afterParse': (hook: FileAfterParseHook) => void | Promise +} diff --git a/src/utils/collection.ts b/src/utils/collection.ts index 4ca5b4baa..fdbb74052 100644 --- a/src/utils/collection.ts +++ b/src/utils/collection.ts @@ -1,6 +1,7 @@ import type { ZodObject, ZodOptionalDef, ZodRawShape, ZodStringDef, ZodType } from 'zod' import type { Collection, ResolvedCollection, CollectionSource, DefinedCollection, ResolvedCollectionSource } from '../types/collection' import { getOrderedSchemaKeys } from '../runtime/internal/schema' +import type { ParsedContentFile } from '../types' import { defineLocalSource, defineGitHubSource } from './source' import { metaSchema, pageSchema } from './schema' import type { ZodFieldType } from './zod' @@ -98,7 +99,7 @@ function resolveSource(source: string | CollectionSource | CollectionSource[] | } // Convert collection data to SQL insert statement -export function generateCollectionInsert(collection: ResolvedCollection, data: Record): string[] { +export function generateCollectionInsert(collection: ResolvedCollection, data: ParsedContentFile): string[] { const fields: string[] = [] const values: Array = [] const sortedKeys = getOrderedSchemaKeys((collection.extendedSchema).shape) diff --git a/src/utils/content/index.ts b/src/utils/content/index.ts index 410199bcd..7643840b6 100644 --- a/src/utils/content/index.ts +++ b/src/utils/content/index.ts @@ -6,8 +6,10 @@ import type { Nuxt } from '@nuxt/schema' import { defu } from 'defu' import { createOnigurumaEngine } from 'shiki/engine/oniguruma' import { visit } from 'unist-util-visit' -import type { ResolvedCollection } from '../../types/collection' -import type { ModuleOptions } from '../../types/module' +import type { + ResolvedCollection, +} from '../../types/collection' +import type { FileAfterParseHook, FileBeforeParseHook, ModuleOptions } from '../../types/module' import { transformContent } from './transformers' import type { ContentFile } from '~/src/types' @@ -131,8 +133,12 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) if (file.path && !file.dirname) { file.dirname = dirname(file.path) } + const beforeParseCtx: FileBeforeParseHook = { file, collection, parserOptions } + // @ts-expect-error runtime type + await nuxt?.callHook?.('content:file:beforeParse', beforeParseCtx) + const { file: hookedFile } = beforeParseCtx - const parsedContent = await transformContent(file, parserOptions) + const parsedContent = await transformContent(hookedFile, beforeParseCtx.parserOptions) const { id: id, ...parsedContentFields } = parsedContent const result = { id } as typeof collection.extendedSchema._type const meta = {} as Record @@ -160,6 +166,9 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) result.seo.description = result.seo.description || result.description } - return result + const afterParseCtx: FileAfterParseHook = { file: hookedFile, content: result, collection } + // @ts-expect-error runtime type + await nuxt?.callHook?.('content:file:afterParse', afterParseCtx) + return afterParseCtx.content } } diff --git a/test/unit/hooks.test.ts b/test/unit/hooks.test.ts new file mode 100644 index 000000000..064a0ed0f --- /dev/null +++ b/test/unit/hooks.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { defineCollection } from '../../src/utils' +import { resolveCollection } from '../../src/utils/collection' +import { parseContent } from '../utils/content' +import type { FileAfterParseHook, FileBeforeParseHook } from '../../src/types' + +describe('Hooks', () => { + const collection = resolveCollection('hookTest', defineCollection({ + type: 'data', + source: 'content/**', + schema: z.object({ + body: z.any(), + foo: z.any(), + }), + }))! + it('content:file:beforeParse', async () => { + let hookCtx: FileBeforeParseHook + const nuxtMock = { + callHook(hook: string, ctx: FileBeforeParseHook) { + if (hook === 'content:file:beforeParse') { + ctx.file.body = ctx.file.body.replace('replace-me', 'bar') + hookCtx = ctx + } + }, + } + const content = await parseContent('content/index.md', `--- +foo: 'replace-me' +--- + + # Hello World +`, collection, nuxtMock) + expect(hookCtx.file.id).toEqual('content/index.md') + expect(content.foo).toEqual('bar') + }) + it('content:file:afterParse', async () => { + let hookCtx: FileAfterParseHook + const nuxtMock = { + callHook(hook: string, ctx: FileAfterParseHook) { + if (hook === 'content:file:afterParse') { + // augment + ctx.content.bar = 'foo' + hookCtx = ctx + } + }, + } + const parsed = await parseContent('content/index.md', `--- +foo: 'bar' +--- + + # Hello World +`, collection, nuxtMock) + expect(hookCtx.collection.name).toEqual('hookTest') + expect(parsed.id).toEqual('content/index.md') + expect(parsed.foo).toEqual('bar') + expect(parsed.bar).toEqual('foo') + }) +})