Skip to content

Commit

Permalink
feat(og-image): new option to disable existing meta tag overriding, c…
Browse files Browse the repository at this point in the history
…ompleted some of the ut

Signed-off-by: Neko Ayaka <[email protected]>
  • Loading branch information
nekomeowww committed Aug 4, 2024
1 parent f98e9d9 commit 493e336
Show file tree
Hide file tree
Showing 7 changed files with 396 additions and 239 deletions.
1 change: 1 addition & 0 deletions packages/vitepress-plugin-og-image/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/vitepress-plugin-og-image/src/vitepress/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { cyan, gray } from 'colorette'

export const logModulePrefix = `${cyan(`@nolebase/vitepress-plugin-og-image`)}${gray(':')}`
259 changes: 20 additions & 239 deletions packages/vitepress-plugin-og-image/src/vitepress/index.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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<string | undefined> {
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
Expand All @@ -64,6 +35,11 @@ async function tryToLocateFontFile(siteConfig: SiteConfig): Promise<string | und
* @param {string} ogImageTemplateSvg - Open Graph image template SVG
* @param {string} ogImageTemplateSvgPath - Open Graph image template SVG path
* @param {string} domain - Domain
* @param {BuildEndGenerateOpenGraphImagesOptions['svgImageUrlResolver']} imageUrlResolver - SVG image URL resolver
* @param {Buffer[]} additionalFontBuffers - Additional font buffers
* @param {number} resultImageWidth - Result image width
* @param {number} maxCharactersPerLine - Max characters per line
* @param {boolean} overrideExistingMetaTags - Whether to override existing meta tags
* @returns {Promise<TaskResult>} Task result
*/
async function renderSVGAndRewriteHTML(
Expand All @@ -79,6 +55,7 @@ async function renderSVGAndRewriteHTML(
additionalFontBuffers?: Buffer[],
resultImageWidth?: number,
maxCharactersPerLine?: number,
overrideExistingMetaTags?: boolean,
): Promise<TaskResult> {
const fileName = basename(file, '.html')
const ogImageFilePathBaseName = `og-${fileName}.png`
Expand All @@ -97,7 +74,7 @@ async function renderSVGAndRewriteHTML(
return true
})

if (hasOgImage) {
if (hasOgImage && !overrideExistingMetaTags) {
return {
filePath: file,
status: 'skipped',
Expand Down Expand Up @@ -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> | 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<string | undefined>
/**
* 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<string | undefined> {
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<string> {
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<BuildEndGenerateOpenGraphImagesOptions, 'baseUrl'>)

return async (siteConfig: SiteConfig) => {
await initSVGRenderer()

Expand Down Expand Up @@ -533,6 +313,7 @@ export function buildEndGenerateOpenGraphImages(options: BuildEndGenerateOpenGra
options.svgFontBuffers,
options.resultImageWidth,
options.maxCharactersPerLine,
options.overrideExistingMetaTags,
)
}))

Expand Down
Loading

0 comments on commit 493e336

Please sign in to comment.