diff --git a/e2e/docs/.vuepress/theme/client/components/MarkdownContentHooks.vue b/e2e/docs/.vuepress/theme/client/components/MarkdownContentHooks.vue new file mode 100644 index 0000000000..29587458aa --- /dev/null +++ b/e2e/docs/.vuepress/theme/client/components/MarkdownContentHooks.vue @@ -0,0 +1,45 @@ + + + diff --git a/e2e/docs/.vuepress/theme/client/layouts/Layout.vue b/e2e/docs/.vuepress/theme/client/layouts/Layout.vue index 07519db48d..7888e994aa 100644 --- a/e2e/docs/.vuepress/theme/client/layouts/Layout.vue +++ b/e2e/docs/.vuepress/theme/client/layouts/Layout.vue @@ -1,5 +1,6 @@ @@ -18,6 +19,8 @@ const siteData = useSiteData()
+ + diff --git a/e2e/docs/composables/on-content-updated.md b/e2e/docs/composables/on-content-updated.md new file mode 100644 index 0000000000..a8badd3c11 --- /dev/null +++ b/e2e/docs/composables/on-content-updated.md @@ -0,0 +1,3 @@ +## title + +content diff --git a/e2e/tests/composables/on-content-updated.spec.ts b/e2e/tests/composables/on-content-updated.spec.ts new file mode 100644 index 0000000000..c3fce8d8f0 --- /dev/null +++ b/e2e/tests/composables/on-content-updated.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test' +import { BUNDLER, IS_DEV } from '../../utils/env' +import { readSourceMarkdown, writeSourceMarkdown } from '../../utils/source' + +const updateMarkdownContent = async (): Promise => { + const content = await readSourceMarkdown('composables/on-content-updated.md') + await writeSourceMarkdown( + 'composables/on-content-updated.md', + `${content}\n\nUpdated content`, + ) +} + +const restoreMarkdownContent = async (): Promise => { + await writeSourceMarkdown( + 'composables/on-content-updated.md', + '## title\n\ncontent\n', + ) +} + +test.afterAll(async () => { + await restoreMarkdownContent() +}) + +test('should call content hook on mounted', async ({ page }) => { + await page.goto('composables/on-content-updated.html') + const mountedLocator = page.locator( + '.markdown-content-hooks .markdown-content-mounted', + ) + await expect(mountedLocator).toHaveText( + 'mounted: /composables/on-content-updated.html 1', + ) + + // update content but mounted hook should not be called twice + await updateMarkdownContent() + await expect(mountedLocator).toHaveText( + 'mounted: /composables/on-content-updated.html 1', + ) +}) + +test('should call content hook on beforeUnmount', async ({ page }) => { + await page.goto('composables/on-content-updated.html') + + const beforeUnmountLocator = page.locator( + '.markdown-content-hooks .markdown-content-beforeUnmount', + ) + + await page.locator('.e2e-theme-nav ul > li > a').nth(0).click() + + await expect(beforeUnmountLocator).toHaveText('beforeUnmount: /') +}) + +/** + * Updated hooks are only supported for use in development environments. + * In CI environments, under both Linux and Windows, using Vite fails to correctly trigger hooks. + */ +if (IS_DEV && BUNDLER !== 'vite') { + test('should call content hook on updated', async ({ page }) => { + await page.goto('composables/on-content-updated.html') + const updatedLocator = page.locator( + '.markdown-content-hooks .markdown-content-updated', + ) + + await updateMarkdownContent() + await expect(updatedLocator).toHaveText(`updatedCount: 1`) + + await updateMarkdownContent() + await expect(updatedLocator).toHaveText(`updatedCount: 2`) + }) +} diff --git a/packages/client/src/components/Content.ts b/packages/client/src/components/Content.ts index e58b46214d..aedd941dc2 100644 --- a/packages/client/src/components/Content.ts +++ b/packages/client/src/components/Content.ts @@ -1,6 +1,17 @@ -import { computed, defineAsyncComponent, defineComponent, h } from 'vue' -import { usePageComponent } from '../composables/index.js' +import { computed, defineAsyncComponent, defineComponent, h, watch } from 'vue' +import { usePageComponent, usePageFrontmatter } from '../composables/index.js' +import { contentUpdatedCallbacks } from '../internal/contentUpdatedCallbacks' import { resolveRoute } from '../router/index.js' +import type { ContentUpdatedReason } from '../types/index.js' + +/** + * Execute all callbacks registered via `onContentUpdated`. + * + * @internal + */ +const runContentUpdatedCallbacks = (reason: ContentUpdatedReason): void => { + contentUpdatedCallbacks.value.forEach((fn) => fn(reason)) +} /** * Markdown rendered content @@ -26,6 +37,26 @@ export const Content = defineComponent({ ) }) - return () => h(ContentComponent.value) + const frontmatter = usePageFrontmatter() + watch( + frontmatter, + () => { + runContentUpdatedCallbacks('updated') + }, + { deep: true, flush: 'post' }, + ) + + return () => + h(ContentComponent.value, { + onVnodeMounted: () => { + runContentUpdatedCallbacks('mounted') + }, + onVnodeUpdated: () => { + runContentUpdatedCallbacks('updated') + }, + onVnodeBeforeUnmount: () => { + runContentUpdatedCallbacks('beforeUnmount') + }, + }) }, }) diff --git a/packages/client/src/composables/index.ts b/packages/client/src/composables/index.ts index c834060742..b2a3e5ad54 100644 --- a/packages/client/src/composables/index.ts +++ b/packages/client/src/composables/index.ts @@ -1,3 +1,4 @@ export * from './clientData.js' export * from './clientDataUtils.js' +export * from './onContentUpdated.js' export * from './updateHead.js' diff --git a/packages/client/src/composables/onContentUpdated.ts b/packages/client/src/composables/onContentUpdated.ts new file mode 100644 index 0000000000..acddd15d80 --- /dev/null +++ b/packages/client/src/composables/onContentUpdated.ts @@ -0,0 +1,16 @@ +import { onUnmounted } from 'vue' +import { contentUpdatedCallbacks } from '../internal/contentUpdatedCallbacks' +import type { ContentUpdatedCallback } from '../types/index.js' + +/** + * Register callback that is called every time the markdown content is updated + * in the DOM. + */ +export const onContentUpdated = (fn: ContentUpdatedCallback): void => { + contentUpdatedCallbacks.value.push(fn) + onUnmounted(() => { + contentUpdatedCallbacks.value = contentUpdatedCallbacks.value.filter( + (f) => f !== fn, + ) + }) +} diff --git a/packages/client/src/internal/contentUpdatedCallbacks.ts b/packages/client/src/internal/contentUpdatedCallbacks.ts new file mode 100644 index 0000000000..a0f9471c5b --- /dev/null +++ b/packages/client/src/internal/contentUpdatedCallbacks.ts @@ -0,0 +1,9 @@ +import type { Ref } from 'vue' +import { shallowRef } from 'vue' +import type { ContentUpdatedCallback } from '../types/index.js' + +/** + * Global content updated callbacks ref + */ +export const contentUpdatedCallbacks: Ref = + shallowRef([]) diff --git a/packages/client/src/types/index.ts b/packages/client/src/types/index.ts index c07dbc3fcd..a1d85e5487 100644 --- a/packages/client/src/types/index.ts +++ b/packages/client/src/types/index.ts @@ -1,4 +1,5 @@ export type * from './clientConfig.js' export type * from './clientData.js' +export type * from './onContentUpdated.js' export type * from './createVueAppFunction.js' export type * from './routes.js' diff --git a/packages/client/src/types/onContentUpdated.ts b/packages/client/src/types/onContentUpdated.ts new file mode 100644 index 0000000000..16c5e096b2 --- /dev/null +++ b/packages/client/src/types/onContentUpdated.ts @@ -0,0 +1,3 @@ +export type ContentUpdatedReason = 'beforeUnmount' | 'mounted' | 'updated' + +export type ContentUpdatedCallback = (reason: ContentUpdatedReason) => unknown