mirror of
https://gitlab.com/MisterBiggs/brain-quartz.git
synced 2025-07-21 22:01:34 +00:00
.github
content
docs
quartz
cli
components
i18n
plugins
emitters
404.tsx
aliases.ts
assets.ts
cname.ts
componentResources.ts
contentIndex.tsx
contentPage.tsx
folderPage.tsx
helpers.ts
index.ts
ogImage.tsx
static.ts
tagPage.tsx
filters
transformers
index.ts
types.ts
vfile.ts
processors
static
styles
util
bootstrap-cli.mjs
bootstrap-worker.mjs
build.ts
cfg.ts
worker.ts
.gitattributes
.gitignore
.node-version
.npmrc
.prettierignore
.prettierrc
CODE_OF_CONDUCT.md
Dockerfile
LICENSE.txt
README.md
globals.d.ts
index.d.ts
package-lock.json
package.json
quartz.config.ts
quartz.layout.ts
tsconfig.json
* fix(ogImage): update socialImage path to include base URL if defined * feat(path): add function to check if a file path is absolute * fix(ogImage): handle absolute paths for user defined og image paths * docs(CustomOgImages): update socialImage property to accept full URLs * fix(ogImage): typo * fix(ogImage): improve user-defined OG image path handling * Update docs/plugins/CustomOgImages.md Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com> * Update quartz/plugins/emitters/ogImage.tsx Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com> * refactor(path): remove isAbsoluteFilePath function * fix(ogImage): update user-defined OG image path handling to support relative URLs * feat(ogImage): enhance user-defined OG image path handling with absolute URL support * refactor(ogImage): remove debug log for ogImagePath * feat(path): add isAbsoluteURL function and corresponding tests * refactor(path): remove unused URL import for isomorphic compatibility --------- Co-authored-by: Karim H <karimh96@hotmail.com> Co-authored-by: Jacky Zhao <j.zhao2k19@gmail.com>
182 lines
5.9 KiB
TypeScript
182 lines
5.9 KiB
TypeScript
import { QuartzEmitterPlugin } from "../types"
|
|
import { i18n } from "../../i18n"
|
|
import { unescapeHTML } from "../../util/escape"
|
|
import { FullSlug, getFileExtension, isAbsoluteURL, joinSegments, QUARTZ } from "../../util/path"
|
|
import { ImageOptions, SocialImageOptions, defaultImage, getSatoriFonts } from "../../util/og"
|
|
import sharp from "sharp"
|
|
import satori, { SatoriOptions } from "satori"
|
|
import { loadEmoji, getIconCode } from "../../util/emoji"
|
|
import { Readable } from "stream"
|
|
import { write } from "./helpers"
|
|
import { BuildCtx } from "../../util/ctx"
|
|
import { QuartzPluginData } from "../vfile"
|
|
import fs from "node:fs/promises"
|
|
import chalk from "chalk"
|
|
|
|
const defaultOptions: SocialImageOptions = {
|
|
colorScheme: "lightMode",
|
|
width: 1200,
|
|
height: 630,
|
|
imageStructure: defaultImage,
|
|
excludeRoot: false,
|
|
}
|
|
|
|
/**
|
|
* Generates social image (OG/twitter standard) and saves it as `.webp` inside the public folder
|
|
* @param opts options for generating image
|
|
*/
|
|
async function generateSocialImage(
|
|
{ cfg, description, fonts, title, fileData }: ImageOptions,
|
|
userOpts: SocialImageOptions,
|
|
): Promise<Readable> {
|
|
const { width, height } = userOpts
|
|
const iconPath = joinSegments(QUARTZ, "static", "icon.png")
|
|
let iconBase64: string | undefined = undefined
|
|
try {
|
|
const iconData = await fs.readFile(iconPath)
|
|
iconBase64 = `data:image/png;base64,${iconData.toString("base64")}`
|
|
} catch (err) {
|
|
console.warn(chalk.yellow(`Warning: Could not find icon at ${iconPath}`))
|
|
}
|
|
|
|
const imageComponent = userOpts.imageStructure({
|
|
cfg,
|
|
userOpts,
|
|
title,
|
|
description,
|
|
fonts,
|
|
fileData,
|
|
iconBase64,
|
|
})
|
|
|
|
const svg = await satori(imageComponent, {
|
|
width,
|
|
height,
|
|
fonts,
|
|
loadAdditionalAsset: async (languageCode: string, segment: string) => {
|
|
if (languageCode === "emoji") {
|
|
return `data:image/svg+xml;base64,${btoa(await loadEmoji(getIconCode(segment)))}`
|
|
}
|
|
return languageCode
|
|
},
|
|
})
|
|
|
|
return sharp(Buffer.from(svg)).webp({ quality: 40 })
|
|
}
|
|
|
|
async function processOgImage(
|
|
ctx: BuildCtx,
|
|
fileData: QuartzPluginData,
|
|
fonts: SatoriOptions["fonts"],
|
|
fullOptions: SocialImageOptions,
|
|
) {
|
|
const cfg = ctx.cfg.configuration
|
|
const slug = fileData.slug!
|
|
const titleSuffix = cfg.pageTitleSuffix ?? ""
|
|
const title =
|
|
(fileData.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title) + titleSuffix
|
|
const description =
|
|
fileData.frontmatter?.socialDescription ??
|
|
fileData.frontmatter?.description ??
|
|
unescapeHTML(fileData.description?.trim() ?? i18n(cfg.locale).propertyDefaults.description)
|
|
|
|
const stream = await generateSocialImage(
|
|
{
|
|
title,
|
|
description,
|
|
fonts,
|
|
cfg,
|
|
fileData,
|
|
},
|
|
fullOptions,
|
|
)
|
|
|
|
return write({
|
|
ctx,
|
|
content: stream,
|
|
slug: `${slug}-og-image` as FullSlug,
|
|
ext: ".webp",
|
|
})
|
|
}
|
|
|
|
export const CustomOgImagesEmitterName = "CustomOgImages"
|
|
export const CustomOgImages: QuartzEmitterPlugin<Partial<SocialImageOptions>> = (userOpts) => {
|
|
const fullOptions = { ...defaultOptions, ...userOpts }
|
|
|
|
return {
|
|
name: CustomOgImagesEmitterName,
|
|
getQuartzComponents() {
|
|
return []
|
|
},
|
|
async *emit(ctx, content, _resources) {
|
|
const cfg = ctx.cfg.configuration
|
|
const headerFont = cfg.theme.typography.header
|
|
const bodyFont = cfg.theme.typography.body
|
|
const fonts = await getSatoriFonts(headerFont, bodyFont)
|
|
|
|
for (const [_tree, vfile] of content) {
|
|
if (vfile.data.frontmatter?.socialImage !== undefined) continue
|
|
yield processOgImage(ctx, vfile.data, fonts, fullOptions)
|
|
}
|
|
},
|
|
async *partialEmit(ctx, _content, _resources, changeEvents) {
|
|
const cfg = ctx.cfg.configuration
|
|
const headerFont = cfg.theme.typography.header
|
|
const bodyFont = cfg.theme.typography.body
|
|
const fonts = await getSatoriFonts(headerFont, bodyFont)
|
|
|
|
// find all slugs that changed or were added
|
|
for (const changeEvent of changeEvents) {
|
|
if (!changeEvent.file) continue
|
|
if (changeEvent.file.data.frontmatter?.socialImage !== undefined) continue
|
|
if (changeEvent.type === "add" || changeEvent.type === "change") {
|
|
yield processOgImage(ctx, changeEvent.file.data, fonts, fullOptions)
|
|
}
|
|
}
|
|
},
|
|
externalResources: (ctx) => {
|
|
if (!ctx.cfg.configuration.baseUrl) {
|
|
return {}
|
|
}
|
|
|
|
const baseUrl = ctx.cfg.configuration.baseUrl
|
|
return {
|
|
additionalHead: [
|
|
(pageData) => {
|
|
const isRealFile = pageData.filePath !== undefined
|
|
let userDefinedOgImagePath = pageData.frontmatter?.socialImage
|
|
|
|
if (userDefinedOgImagePath) {
|
|
userDefinedOgImagePath = isAbsoluteURL(userDefinedOgImagePath)
|
|
? userDefinedOgImagePath
|
|
: `https://${baseUrl}/static/${userDefinedOgImagePath}`
|
|
}
|
|
|
|
const generatedOgImagePath = isRealFile
|
|
? `https://${baseUrl}/${pageData.slug!}-og-image.webp`
|
|
: undefined
|
|
const defaultOgImagePath = `https://${baseUrl}/static/og-image.png`
|
|
const ogImagePath = userDefinedOgImagePath ?? generatedOgImagePath ?? defaultOgImagePath
|
|
const ogImageMimeType = `image/${getFileExtension(ogImagePath) ?? "png"}`
|
|
return (
|
|
<>
|
|
{!userDefinedOgImagePath && (
|
|
<>
|
|
<meta property="og:image:width" content={fullOptions.width.toString()} />
|
|
<meta property="og:image:height" content={fullOptions.height.toString()} />
|
|
</>
|
|
)}
|
|
|
|
<meta property="og:image" content={ogImagePath} />
|
|
<meta property="og:image:url" content={ogImagePath} />
|
|
<meta name="twitter:image" content={ogImagePath} />
|
|
<meta property="og:image:type" content={ogImageMimeType} />
|
|
</>
|
|
)
|
|
},
|
|
],
|
|
}
|
|
},
|
|
}
|
|
}
|