From 493e336443947069bbe81cc8f15777465fc0be3e Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Sun, 4 Aug 2024 16:51:38 +0800 Subject: [PATCH] feat(og-image): new option to disable existing meta tag overriding, completed some of the ut Signed-off-by: Neko Ayaka --- .../vitepress-plugin-og-image/package.json | 1 + .../src/vitepress/constants.ts | 3 + .../src/vitepress/index.ts | 259 ++---------------- .../src/vitepress/options.test.ts | 128 +++++++++ .../src/vitepress/options.ts | 118 ++++++++ .../src/vitepress/types.ts | 123 +++++++++ pnpm-lock.yaml | 3 + 7 files changed, 396 insertions(+), 239 deletions(-) create mode 100644 packages/vitepress-plugin-og-image/src/vitepress/constants.ts create mode 100644 packages/vitepress-plugin-og-image/src/vitepress/options.test.ts create mode 100644 packages/vitepress-plugin-og-image/src/vitepress/options.ts diff --git a/packages/vitepress-plugin-og-image/package.json b/packages/vitepress-plugin-og-image/package.json index be8224fd..965798ed 100644 --- a/packages/vitepress-plugin-og-image/package.json +++ b/packages/vitepress-plugin-og-image/package.json @@ -60,6 +60,7 @@ "dependencies": { "@resvg/resvg-wasm": "^2.6.2", "colorette": "^2.0.20", + "defu": "^6.1.4", "emoji-regex": "^10.3.0", "fs-extra": "^11.2.0", "glob": "^10.4.5", diff --git a/packages/vitepress-plugin-og-image/src/vitepress/constants.ts b/packages/vitepress-plugin-og-image/src/vitepress/constants.ts new file mode 100644 index 00000000..88dc8019 --- /dev/null +++ b/packages/vitepress-plugin-og-image/src/vitepress/constants.ts @@ -0,0 +1,3 @@ +import { cyan, gray } from 'colorette' + +export const logModulePrefix = `${cyan(`@nolebase/vitepress-plugin-og-image`)}${gray(':')}` diff --git a/packages/vitepress-plugin-og-image/src/vitepress/index.ts b/packages/vitepress-plugin-og-image/src/vitepress/index.ts index 4a6502e4..49e85375 100644 --- a/packages/vitepress-plugin-og-image/src/vitepress/index.ts +++ b/packages/vitepress-plugin-og-image/src/vitepress/index.ts @@ -1,54 +1,25 @@ -import { basename, dirname, join, relative, resolve, sep } from 'node:path' +import { basename, dirname, join, relative, sep } from 'node:path' import { sep as posixSep } from 'node:path/posix' -import { fileURLToPath } from 'node:url' import type { Buffer } from 'node:buffer' import fs from 'fs-extra' import { glob } from 'glob' import type { DefaultTheme, SiteConfig } from 'vitepress' -import { cyan, gray, green, red, yellow } from 'colorette' +import { gray, green, red, yellow } from 'colorette' import GrayMatter from 'gray-matter' import { unified } from 'unified' import RehypeMeta from 'rehype-meta' import RehypeParse from 'rehype-parse' import RehypeStringify from 'rehype-stringify' import { visit } from 'unist-util-visit' +import { defu } from 'defu' +import { applyCategoryTextWithFallback, tryToLocateFontFile, tryToLocateTemplateSVGFile } from './options' import { flattenSidebar, getSidebar } from './utils/vitepress/sidebar' import { type TaskResult, renderTaskResultsSummary, task } from './utils/task' -import type { PageItem } from './types' +import type { BuildEndGenerateOpenGraphImagesOptions, PageItem } from './types' import { getDescriptionWithLocales, getTitleWithLocales } from './utils/vitepress/locales' import { initFontBuffer, initSVGRenderer, renderSVG, templateSVG } from './utils/svg/render' - -const logModulePrefix = `${cyan(`@nolebase/vitepress-plugin-og-image`)}${gray(':')}` - -async function tryToLocateTemplateSVGFile(siteConfig: SiteConfig, configTemplateSvgPath?: string): Promise { - if (configTemplateSvgPath != null) - return resolve(siteConfig.srcDir, configTemplateSvgPath) - - const templateSvgPathUnderPublicDir = resolve(siteConfig.srcDir, 'public', 'og-template.svg') - if (await fs.pathExists(templateSvgPathUnderPublicDir)) - return templateSvgPathUnderPublicDir - - const __dirname = dirname(fileURLToPath(import.meta.url)) - const templateSvgPathUnderRootDir = resolve(__dirname, 'assets', 'og-template.svg') - if (await fs.pathExists(templateSvgPathUnderRootDir)) - return templateSvgPathUnderRootDir - - return undefined -} - -async function tryToLocateFontFile(siteConfig: SiteConfig): Promise { - const fontPathUnderPublicDir = resolve(siteConfig.srcDir, 'public', 'SourceHanSansSC.otf') - if (await fs.pathExists(fontPathUnderPublicDir)) - return fontPathUnderPublicDir - - const __dirname = dirname(fileURLToPath(import.meta.url)) - const fontPathUnderRootDir = resolve(__dirname, 'assets', 'SourceHanSansSC.otf') - if (await fs.pathExists(fontPathUnderRootDir)) - return fontPathUnderRootDir - - return undefined -} +import { logModulePrefix } from './constants' /** * Render SVG and rewrite HTML @@ -64,6 +35,11 @@ async function tryToLocateFontFile(siteConfig: SiteConfig): Promise} Task result */ async function renderSVGAndRewriteHTML( @@ -79,6 +55,7 @@ async function renderSVGAndRewriteHTML( additionalFontBuffers?: Buffer[], resultImageWidth?: number, maxCharactersPerLine?: number, + overrideExistingMetaTags?: boolean, ): Promise { const fileName = basename(file, '.html') const ogImageFilePathBaseName = `og-${fileName}.png` @@ -97,7 +74,7 @@ async function renderSVGAndRewriteHTML( return true }) - if (hasOgImage) { + if (hasOgImage && !overrideExistingMetaTags) { return { filePath: file, status: 'skipped', @@ -222,215 +199,18 @@ async function renderSVGAndSavePNG( } } -export interface BuildEndGenerateOpenGraphImagesOptions { - /** - * The base URL to use for open graph image. - * - * Must be a full URL, e.g. `https://example.com` or `https://example.com/path/of/baseUrl`. - * - * This is because for platforms like Telegram, Twitter, and Facebook, they wouldn't accept - * relative URLs for open graph image when dynamically fetching the image from the HTML meta tag. - * Instead, they require a full URL to the image. - * - * If you would ever need to use a dynamic base URL (e.g. Cloudflare Pages, Vercel, Netlify staging - * preview URL), you may need to create a separate stabled sub-domain or use a standalone services - * S3 to host the generated open graph images to make sure the image URL is full with domain. - */ - baseUrl: string - /** - * The category options to use for open graph image. - */ - category?: BuildEndGenerateOpenGraphImagesOptionsCategory - - /** - * This function will be called with each URL of the image hrefs in the SVG template. - * You can return a Buffer of the image to use to avoid fetching the image from its URL. - * If you return undefined, the image will be fetched from its URL. - */ - svgImageUrlResolver?: (imageUrl: string) => Promise | Buffer | undefined - - /** - * Font buffers to load for rendering the template SVG - */ - svgFontBuffers?: Buffer[] - - /** - * Temaplte SVG file path. - * If not supplied, will try to locate `og-template.svg` under `public` or `assets` directory, - * and will fallback to a builtin template. - */ - templateSvgPath?: string - - /** - * Width of the result image. - * - * @default 1200 - */ - resultImageWidth?: number - - /** - * Maximum characters per line. - * - * @default 17 - */ - maxCharactersPerLine?: number -} - -export interface BuildEndGenerateOpenGraphImagesOptionsCategory { - /** - * Automatically extract category text from path with a specific level. - * - * For example, if you have a path like `/foo/bar/baz/index.md`, and you set `byLevel` to `1`, - * the category text will be `bar`. This is extremely useful when you have a file based routing, - * while having all the contents organized in a stable directory structure (e.g. knowledge base). - * - * As end user, either specify one of `byLevel`, `byPathPrefix`, or `byCustomGetter`, if multiple - * options are provided, `byCustomGetter` would be used as the first priority. `byPathPrefix` secondary, - * and `byLevel` as the last resort. If none of them are provided or produced undefined result for category - * text, it will fallback to frontmatter category text. - */ - byLevel?: number - /** - * Automatically extract category text from path with a specific prefix. - * - * For example, if you have a path like `/foo/bar/baz/index.md`, and you set `byPathPrefix` to `[{ prefix: 'foo', text: 'Foo' }]`, - * the category text will be `Foo`. This is extremely useful when you use file based routing, while organized the contents - * inside a directory name that friendly to browsers. - * - * As end user, either specify one of `byLevel`, `byPathPrefix`, or `byCustomGetter`, if multiple - * options are provided, `byCustomGetter` would be used as the first priority. `byPathPrefix` secondary, - * and `byLevel` as the last resort. If none of them are provided or produced undefined result for category - * text, it will fallback to frontmatter category text. - */ - byPathPrefix?: { - /** - * The prefix to match. - */ - prefix: string - /** - * The text to use as category. - */ - text: string - }[] - /** - * If `byLevel` or `byPathPrefix` is not enough, you can provide a custom getter to extract category text programmatically. - * - * For example you have a complex i18n system, or you want to extract category text from a specific field in frontmatter. - * - * As end user, either specify one of `byLevel`, `byPathPrefix`, or `byCustomGetter`, if multiple - * options are provided, `byCustomGetter` would be used as the first priority. `byPathPrefix` secondary, - * and `byLevel` as the last resort. If none of them are provided or produced undefined result for category - * text, it will fallback to frontmatter category text. - * - * @param {PageItem} page - The page item to process - * @returns {string} The category text - */ - byCustomGetter?: (page: PageItem) => string | undefined | Promise - /** - * Fallback to frontmatter category text when no category text found. - * - * Only effective when no category text found from `byLevel`, `byPathPrefix`, or `byCustomGetter`, or none of them - * were provided. If `true`, it will fallback to frontmatter category text when no category text found. Otherwise a 'Un-categorized' - * will be used as category text. - * - * @default true - */ - fallbackWithFrontmatter?: boolean -} - -async function applyCategoryText(pageItem: PageItem, categoryOptions?: BuildEndGenerateOpenGraphImagesOptionsCategory): Promise { - if (typeof categoryOptions?.byCustomGetter !== 'undefined') { - const gotTextMaybePromise = categoryOptions.byCustomGetter({ ...pageItem }) - - if (typeof gotTextMaybePromise === 'undefined') - return undefined - - if (gotTextMaybePromise instanceof Promise) - return await gotTextMaybePromise - - if (gotTextMaybePromise) - return gotTextMaybePromise - - return undefined - } - - if (typeof categoryOptions?.byPathPrefix !== 'undefined') { - for (const { prefix, text } of categoryOptions.byPathPrefix) { - if (pageItem.normalizedSourceFilePath.startsWith(prefix)) { - if (!text) { - console.warn( - `${logModulePrefix} ${yellow('[WARN]')} empty text for prefix ${prefix} when processing ${pageItem.sourceFilePath} with categoryOptions.byPathPrefix, will ignore...`, - ) - return undefined - } - - return text - } - if (pageItem.normalizedSourceFilePath.startsWith(`/${prefix}`)) { - if (!text) { - console.warn( - `${logModulePrefix} ${yellow('[WARN]')} empty text for prefix ${prefix} when processing ${pageItem.sourceFilePath} with categoryOptions.byPathPrefix, will ignore...`, - ) - return undefined - } - - return text - } - } - - console.warn( - `${logModulePrefix} ${yellow('[WARN]')} no path prefix matched for ${pageItem.sourceFilePath} with categoryOptions.byPathPrefix, will ignore...`, - ) - return undefined - } - - if (typeof categoryOptions?.byLevel !== 'undefined') { - const level = Number.parseInt(String(categoryOptions?.byLevel ?? 0)) - if (Number.isNaN(level)) { - console.warn( - `${logModulePrefix} ${yellow('[ERROR]')} byLevel must be a number, but got ${categoryOptions.byLevel} instead when processing ${pageItem.sourceFilePath} with categoryOptions.byLevel, will ignore...`, - ) - return undefined - } - - const dirs = pageItem.sourceFilePath.split(sep) - if (dirs.length > level) - return dirs[level] - - console.warn(`${logModulePrefix} ${red(`[ERROR] byLevel is out of range for ${pageItem.sourceFilePath} with categoryOptions.byLevel.`)} will ignore...`) - return undefined - } - - return undefined -} - -async function applyCategoryTextWithFallback(pageItem: PageItem, categoryOptions?: BuildEndGenerateOpenGraphImagesOptionsCategory): Promise { - const customText = await applyCategoryText(pageItem, categoryOptions) - if (customText) - return customText - - const fallbackWithFrontmatter = typeof categoryOptions?.fallbackWithFrontmatter === 'undefined' - ? true - : categoryOptions.fallbackWithFrontmatter - - if (fallbackWithFrontmatter - && 'category' in pageItem.frontmatter - && pageItem.frontmatter.category - && typeof pageItem.frontmatter.category === 'string' - ) { - return (pageItem.frontmatter as { category?: string }).category ?? '' - } - - console.warn(`${logModulePrefix} ${yellow('[WARN]')} no category text found for ${pageItem.sourceFilePath} with categoryOptions ${JSON.stringify(categoryOptions)}.}`) - return 'Un-categorized' -} - /** * Build end generate open graph images. * @param {BuildEndGenerateOpenGraphImagesOptions} options - Options used for generating open graph images. * @returns Build end hook for VitePress */ export function buildEndGenerateOpenGraphImages(options: BuildEndGenerateOpenGraphImagesOptions) { + options = defu(options, { + resultImageWidth: 1200, + maxCharactersPerLine: 17, + overrideExistingMetaTags: true, + } satisfies Omit) + return async (siteConfig: SiteConfig) => { await initSVGRenderer() @@ -533,6 +313,7 @@ export function buildEndGenerateOpenGraphImages(options: BuildEndGenerateOpenGra options.svgFontBuffers, options.resultImageWidth, options.maxCharactersPerLine, + options.overrideExistingMetaTags, ) })) diff --git a/packages/vitepress-plugin-og-image/src/vitepress/options.test.ts b/packages/vitepress-plugin-og-image/src/vitepress/options.test.ts new file mode 100644 index 00000000..8ccca484 --- /dev/null +++ b/packages/vitepress-plugin-og-image/src/vitepress/options.test.ts @@ -0,0 +1,128 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' +import type { SiteConfig } from 'vitepress' +import { applyCategoryText, applyCategoryTextWithFallback, tryToLocateFontFile, tryToLocateTemplateSVGFile } from './options' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +describe('tryToLocateTemplateSVGFile', () => { + it('should return the correct file path', async () => { + const res = await tryToLocateTemplateSVGFile({ srcDir: './' } as SiteConfig, undefined) + expect(res).toBe(join(__dirname, 'assets', 'og-template.svg')) + }) +}) + +describe('tryToLocateFontFile', () => { + it('should return the correct file path', async () => { + const res = await tryToLocateFontFile({ srcDir: './' } as SiteConfig) + expect(res).toBe(join(__dirname, 'assets', 'SourceHanSansSC.otf')) + }) +}) + +describe('applyCategoryText', () => { + it('should return undefined when no category options', async () => { + const res = await applyCategoryText({ + title: 'title', + category: 'some-category', + locale: 'en', + sourceFilePath: './path-prefix/title.md', + normalizedSourceFilePath: './title.md', + frontmatter: {}, + }) + expect(res).toBeUndefined() + }) + + it('should return level 1 path prefix', async () => { + const res = await applyCategoryText({ + title: 'title', + category: 'some-category', + locale: 'en', + sourceFilePath: './path-prefix/title.md', + normalizedSourceFilePath: './path-prefix/title.md', + frontmatter: {}, + }, { byLevel: 1 }) + expect(res).toBe('path-prefix') + }) + + it('should return level 2 path prefix', async () => { + const res = await applyCategoryText({ + title: 'title', + category: 'some-category', + locale: 'en', + sourceFilePath: './path-prefix-level-1/path-prefix-level-2/title.md', + normalizedSourceFilePath: './path-prefix-level-1/path-prefix-level-2/title.md', + frontmatter: {}, + }, { byLevel: 2 }) + expect(res).toBe('path-prefix-level-2') + }) + + it('should return by custom getter', async () => { + const res = await applyCategoryText({ + title: 'title', + category: 'some-category', + locale: 'en', + sourceFilePath: './path-prefix-level-1/path-prefix-level-2/title.md', + normalizedSourceFilePath: './path-prefix-level-1/path-prefix-level-2/title.md', + frontmatter: {}, + }, { + byCustomGetter: async () => { + return 'Custom Getter' + }, + }) + expect(res).toBe('Custom Getter') + }) + + it('should return by path prefixes definitions', async () => { + const res = await applyCategoryText({ + title: 'title', + category: 'some-category', + locale: 'en', + sourceFilePath: './path-prefix-level-1/path-prefix-level-2/title.md', + normalizedSourceFilePath: './path-prefix-level-1/path-prefix-level-2/title.md', + frontmatter: {}, + }, { + byPathPrefix: [ + { prefix: './path-prefix-level-1/path-prefix-level-2', text: 'Level 2' }, + { prefix: './path-prefix-level-1', text: 'Level 1' }, + ], + }) + expect(res).toBe('Level 2') + }) + + it('should return by path prefixes definitions with orders', async () => { + const res = await applyCategoryText({ + title: 'title', + category: 'some-category', + locale: 'en', + sourceFilePath: './path-prefix-level-1/path-prefix-level-2/title.md', + normalizedSourceFilePath: './path-prefix-level-1/path-prefix-level-2/title.md', + frontmatter: {}, + }, { + byPathPrefix: [ + { prefix: './path-prefix-level-1', text: 'Level 1' }, + { prefix: './path-prefix-level-1/path-prefix-level-2', text: 'Level 2' }, + ], + }) + expect(res).toBe('Level 1') + }) +}) + +describe('applyCategoryTextWithFallback', () => { + it('should return frontmatter category text', async () => { + const res = await applyCategoryTextWithFallback({ + title: 'title', + category: 'some-category', + locale: 'en', + sourceFilePath: './path-prefix/title.md', + normalizedSourceFilePath: './title.md', + frontmatter: { category: 'Fallback' }, + }, { + fallbackWithFrontmatter: true, + }) + + expect(res).toBe('Fallback') + }) +}) diff --git a/packages/vitepress-plugin-og-image/src/vitepress/options.ts b/packages/vitepress-plugin-og-image/src/vitepress/options.ts new file mode 100644 index 00000000..f2f006ad --- /dev/null +++ b/packages/vitepress-plugin-og-image/src/vitepress/options.ts @@ -0,0 +1,118 @@ +import { dirname, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import fs from 'fs-extra' + +import type { SiteConfig } from 'vitepress' +import { red, yellow } from 'colorette' + +import { logModulePrefix } from './constants' +import type { BuildEndGenerateOpenGraphImagesOptionsCategory, PageItem } from './types' + +export async function tryToLocateTemplateSVGFile(siteConfig: SiteConfig, configTemplateSvgPath?: string): Promise { + if (configTemplateSvgPath != null) + return resolve(siteConfig.srcDir, configTemplateSvgPath) + + const templateSvgPathUnderPublicDir = resolve(siteConfig.srcDir, 'public', 'og-template.svg') + if (await fs.pathExists(templateSvgPathUnderPublicDir)) + return templateSvgPathUnderPublicDir + + const __dirname = dirname(fileURLToPath(import.meta.url)) + const templateSvgPathUnderRootDir = resolve(__dirname, 'assets', 'og-template.svg') + if (await fs.pathExists(templateSvgPathUnderRootDir)) + return templateSvgPathUnderRootDir +} + +export async function tryToLocateFontFile(siteConfig: SiteConfig): Promise { + const fontPathUnderPublicDir = resolve(siteConfig.srcDir, 'public', 'SourceHanSansSC.otf') + if (await fs.pathExists(fontPathUnderPublicDir)) + return fontPathUnderPublicDir + + const __dirname = dirname(fileURLToPath(import.meta.url)) + const fontPathUnderRootDir = resolve(__dirname, 'assets', 'SourceHanSansSC.otf') + if (await fs.pathExists(fontPathUnderRootDir)) + return fontPathUnderRootDir +} + +export async function applyCategoryText(pageItem: PageItem, categoryOptions?: BuildEndGenerateOpenGraphImagesOptionsCategory): Promise { + if (typeof categoryOptions?.byCustomGetter !== 'undefined') { + const gotTextMaybePromise = categoryOptions.byCustomGetter({ ...pageItem }) + + if (typeof gotTextMaybePromise !== 'undefined') { + if (gotTextMaybePromise instanceof Promise) + return await gotTextMaybePromise + + if (gotTextMaybePromise) + return gotTextMaybePromise + } + } + + if (typeof categoryOptions?.byPathPrefix !== 'undefined') { + for (const { prefix, text } of categoryOptions.byPathPrefix) { + if (pageItem.normalizedSourceFilePath.startsWith(prefix)) { + if (!text) { + console.warn( + `${logModulePrefix} ${yellow('[WARN]')} empty text for prefix ${prefix} when processing ${pageItem.sourceFilePath} with categoryOptions.byPathPrefix, will ignore...`, + ) + + return + } + + return text + } + if (pageItem.normalizedSourceFilePath.startsWith(`/${prefix}`)) { + if (!text) { + console.warn( + `${logModulePrefix} ${yellow('[WARN]')} empty text for prefix ${prefix} when processing ${pageItem.sourceFilePath} with categoryOptions.byPathPrefix, will ignore...`, + ) + + return + } + + return text + } + } + + console.warn( + `${logModulePrefix} ${yellow('[WARN]')} no path prefix matched for ${pageItem.sourceFilePath} with categoryOptions.byPathPrefix, will ignore...`, + ) + + return + } + + if (typeof categoryOptions?.byLevel !== 'undefined') { + const level = Number.parseInt(String(categoryOptions?.byLevel ?? 0)) + if (Number.isNaN(level)) { + console.warn( + `${logModulePrefix} ${yellow('[ERROR]')} byLevel must be a number, but got ${categoryOptions.byLevel} instead when processing ${pageItem.sourceFilePath} with categoryOptions.byLevel, will ignore...`, + ) + return + } + + const dirs = pageItem.sourceFilePath.split(sep) + if (dirs.length > level) + return dirs[level] + + console.warn(`${logModulePrefix} ${red(`[ERROR] byLevel is out of range for ${pageItem.sourceFilePath} with categoryOptions.byLevel.`)} will ignore...`) + } +} + +export async function applyCategoryTextWithFallback(pageItem: PageItem, categoryOptions?: BuildEndGenerateOpenGraphImagesOptionsCategory): Promise { + const customText = await applyCategoryText(pageItem, categoryOptions) + if (customText) + return customText + + const fallbackWithFrontmatter = typeof categoryOptions?.fallbackWithFrontmatter === 'undefined' + ? true + : categoryOptions.fallbackWithFrontmatter + + if (fallbackWithFrontmatter + && 'category' in pageItem.frontmatter + && pageItem.frontmatter.category + && typeof pageItem.frontmatter.category === 'string' + ) { + return (pageItem.frontmatter as { category?: string }).category ?? '' + } + + console.warn(`${logModulePrefix} ${yellow('[WARN]')} no category text found for ${pageItem.sourceFilePath} with categoryOptions ${JSON.stringify(categoryOptions)}.}`) + return 'Un-categorized' +} diff --git a/packages/vitepress-plugin-og-image/src/vitepress/types.ts b/packages/vitepress-plugin-og-image/src/vitepress/types.ts index 1c7badaa..fbfea49a 100644 --- a/packages/vitepress-plugin-og-image/src/vitepress/types.ts +++ b/packages/vitepress-plugin-og-image/src/vitepress/types.ts @@ -1,5 +1,128 @@ +import type { Buffer } from 'node:buffer' import type { DefaultTheme } from 'vitepress' +export interface BuildEndGenerateOpenGraphImagesOptions { + /** + * The base URL to use for open graph image. + * + * Must be a full URL, e.g. `https://example.com` or `https://example.com/path/of/baseUrl`. + * + * This is because for platforms like Telegram, Twitter, and Facebook, they wouldn't accept + * relative URLs for open graph image when dynamically fetching the image from the HTML meta tag. + * Instead, they require a full URL to the image. + * + * If you would ever need to use a dynamic base URL (e.g. Cloudflare Pages, Vercel, Netlify staging + * preview URL), you may need to create a separate stabled sub-domain or use a standalone services + * S3 to host the generated open graph images to make sure the image URL is full with domain. + */ + baseUrl: string + /** + * The category options to use for open graph image. + */ + category?: BuildEndGenerateOpenGraphImagesOptionsCategory + + /** + * This function will be called with each URL of the image hrefs in the SVG template. + * You can return a Buffer of the image to use to avoid fetching the image from its URL. + * If you return undefined, the image will be fetched from its URL. + */ + svgImageUrlResolver?: (imageUrl: string) => Promise | Buffer | undefined + + /** + * Font buffers to load for rendering the template SVG + */ + svgFontBuffers?: Buffer[] + + /** + * Temaplte SVG file path. + * If not supplied, will try to locate `og-template.svg` under `public` or `assets` directory, + * and will fallback to a builtin template. + */ + templateSvgPath?: string + + /** + * Width of the result image. + * + * @default 1200 + */ + resultImageWidth?: number + + /** + * Maximum characters per line. + * + * @default 17 + */ + maxCharactersPerLine?: number + /** + * Whether to override existing meta tags. + * + * @default true + */ + overrideExistingMetaTags?: boolean +} + +export interface BuildEndGenerateOpenGraphImagesOptionsCategory { + /** + * Automatically extract category text from path with a specific level. + * + * For example, if you have a path like `/foo/bar/baz/index.md`, and you set `byLevel` to `1`, + * the category text will be `bar`. This is extremely useful when you have a file based routing, + * while having all the contents organized in a stable directory structure (e.g. knowledge base). + * + * As end user, either specify one of `byLevel`, `byPathPrefix`, or `byCustomGetter`, if multiple + * options are provided, `byCustomGetter` would be used as the first priority. `byPathPrefix` secondary, + * and `byLevel` as the last resort. If none of them are provided or produced undefined result for category + * text, it will fallback to frontmatter category text. + */ + byLevel?: number + /** + * Automatically extract category text from path with a specific prefix. + * + * For example, if you have a path like `/foo/bar/baz/index.md`, and you set `byPathPrefix` to `[{ prefix: 'foo', text: 'Foo' }]`, + * the category text will be `Foo`. This is extremely useful when you use file based routing, while organized the contents + * inside a directory name that friendly to browsers. + * + * As end user, either specify one of `byLevel`, `byPathPrefix`, or `byCustomGetter`, if multiple + * options are provided, `byCustomGetter` would be used as the first priority. `byPathPrefix` secondary, + * and `byLevel` as the last resort. If none of them are provided or produced undefined result for category + * text, it will fallback to frontmatter category text. + */ + byPathPrefix?: { + /** + * The prefix to match. + */ + prefix: string + /** + * The text to use as category. + */ + text: string + }[] + /** + * If `byLevel` or `byPathPrefix` is not enough, you can provide a custom getter to extract category text programmatically. + * + * For example you have a complex i18n system, or you want to extract category text from a specific field in frontmatter. + * + * As end user, either specify one of `byLevel`, `byPathPrefix`, or `byCustomGetter`, if multiple + * options are provided, `byCustomGetter` would be used as the first priority. `byPathPrefix` secondary, + * and `byLevel` as the last resort. If none of them are provided or produced undefined result for category + * text, it will fallback to frontmatter category text. + * + * @param {PageItem} page - The page item to process + * @returns {string} The category text + */ + byCustomGetter?: (page: PageItem) => string | void | Promise + /** + * Fallback to frontmatter category text when no category text found. + * + * Only effective when no category text found from `byLevel`, `byPathPrefix`, or `byCustomGetter`, or none of them + * were provided. If `true`, it will fallback to frontmatter category text when no category text found. Otherwise a 'Un-categorized' + * will be used as category text. + * + * @default true + */ + fallbackWithFrontmatter?: boolean +} + export interface PageItem extends DefaultTheme.SidebarItem { title: string category: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f27d62f..ffee5e53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -545,6 +545,9 @@ importers: colorette: specifier: ^2.0.20 version: 2.0.20 + defu: + specifier: ^6.1.4 + version: 6.1.4 emoji-regex: specifier: ^10.3.0 version: 10.3.0