feat: auto generate opengraph images

This commit is contained in:
Hossein Mehrabi
2023-06-19 10:29:51 +03:30
parent 344d9f9c59
commit c442f5d468
43 changed files with 1486 additions and 152 deletions
+1
View File
@@ -19,6 +19,7 @@ coverage
.cache-loader
packages/logos-*/lib/*
packages/docusaurus-*/lib/*
packages/logos-docusaurus-preset/static/common/data/team.json
lerna-debug.log
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@acid-info/docusaurus-og",
"version": "1.0.0-alpha.44",
"description": "Docusaurus local search plugin",
"main": "lib/index.js",
"types": "src/plugin.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/acid-info/logos-docusaurus-plugins.git",
"directory": "packages/docusaurus-search-local"
},
"license": "MIT",
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
"prepublishOnly": "yarn build"
},
"dependencies": {
"@docusaurus/core": "^2.4.1",
"@docusaurus/module-type-aliases": "^2.4.1",
"@docusaurus/types": "^2.4.1",
"@docusaurus/utils": "^2.4.1",
"@docusaurus/utils-common": "^2.4.1",
"@docusaurus/utils-validation": "^2.4.1",
"@easyops-cn/docusaurus-search-local": "^0.33.6",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=16.14"
},
"devDependencies": {
"@types/lodash": "^4.14.186"
},
"gitHead": "d2ee08c6c0678f78ad70f5d04183f7f781d11563"
}
+19
View File
@@ -0,0 +1,19 @@
import type { LoadContext, Plugin } from '@docusaurus/types'
import { postBuildFactory } from './server/index'
import { PluginOptions } from './server/types/plugin.types'
export { imageRendererFactory } from './server/imageRenderer.factory'
export * from './server/types'
export type { PluginOptions }
export default function logosTheme(
context: LoadContext,
options: PluginOptions,
): Plugin<any> {
return {
name: 'docusaurus-og',
async postBuild(props) {
await postBuildFactory(options)(props)
},
}
}
+1
View File
@@ -0,0 +1 @@
export type * from './index'
@@ -0,0 +1,125 @@
import {
BlogContent,
PluginOptions as BlogPluginOptions,
} from '@docusaurus/plugin-content-blog'
import { LoadedPlugin, Props } from '@docusaurus/types'
import * as fs from 'fs'
import * as path from 'path'
import { Document } from './document'
import { ImageGenerator } from './imageGenerator'
import { BlogPageData } from './types/blog.types'
import { ImageRenderer } from './types/image.types'
import { PluginOptions } from './types/plugin.types'
export class BlogPlugin {
static plugin = 'docusaurus-plugin-content-blog'
pages: BlogPageData[] = []
constructor(
private context: Props,
private options: PluginOptions,
private imageGenerator: ImageGenerator,
private imageRenderer: ImageRenderer,
) {}
process = async () => {
await this.loadData()
await this.generate()
}
loadData = async () => {
const plugins = this.context.plugins.filter(
(plugin) => plugin.name === BlogPlugin.plugin,
)
for (const plugin of plugins) {
await this.loadInstance(plugin)
}
}
loadInstance = async (plugin: LoadedPlugin) => {
const content = plugin.content as BlogContent
const options = plugin.options as BlogPluginOptions
content.blogListPaginated.forEach((value) => {
this.pages.push({
data: value,
plugin: options,
pageType: 'list',
permalink: value.metadata.permalink,
})
})
content.blogPosts.forEach((post) => {
this.pages.push({
data: post,
plugin: options,
pageType: 'post',
permalink: post.metadata.permalink,
})
})
if (content.blogTagsListPath) {
const filePath = this.getHtmlPath(content.blogTagsListPath)
fs.existsSync(filePath) &&
this.pages.push({
pageType: 'tags',
plugin: options,
data: {
permalink: content.blogTagsListPath,
},
permalink: content.blogTagsListPath,
})
}
if (options.archiveBasePath) {
this.pages.push({
plugin: options,
pageType: 'archive',
data: { permalink: options.archiveBasePath },
permalink: options.archiveBasePath,
})
}
{
Object.entries(content.blogTags).map(([key, value]) => {
value.pages.forEach((page) => {
this.pages.push({
pageType: 'tag',
plugin: options,
data: { ...page.metadata, label: value.label },
permalink: page.metadata.permalink,
})
})
})
}
}
generate = async () => {
for (const page of this.pages) {
const image = await this.imageRenderer(
{
...page,
websiteOutDir: this.context.outDir,
},
this.context,
)
if (!image) continue
const generated = await this.imageGenerator.generate(...image)
const document = new Document(this.getHtmlPath(page.permalink))
await document.load()
if (!document.loaded) continue
await document.setImage(generated.url)
await document.write()
}
}
getHtmlPath = (permalink: string) =>
path.join(this.context.outDir, permalink, 'index.html')
}
@@ -0,0 +1,87 @@
import {
LoadedContent,
LoadedVersion,
PluginOptions as DocsPluginOptions,
} from '@docusaurus/plugin-content-docs'
import { LoadedPlugin, Props } from '@docusaurus/types'
import * as path from 'path'
import { Document } from './document'
import { ImageGenerator } from './imageGenerator'
import { DocsPageData } from './types/docs.types'
import { ImageRenderer } from './types/image.types'
import { PluginOptions } from './types/plugin.types'
export class DocsPlugin {
static plugin = 'docusaurus-plugin-content-docs'
docs: DocsPageData[] = []
constructor(
private context: Props,
private options: PluginOptions,
private imageGenerator: ImageGenerator,
private imageRenderer: ImageRenderer,
) {}
process = async () => {
await this.loadData()
await this.generate()
}
loadData = async () => {
const { plugins = [] } = this.context
const docPlugins = plugins.filter(
(plugin) => plugin.name === DocsPlugin.plugin,
)
for (const plugin of docPlugins) {
await this.loadInstance(plugin)
}
}
loadInstance = async (plugin: LoadedPlugin) => {
const content = plugin.content as LoadedContent
const options = plugin.options as DocsPluginOptions
const { loadedVersions } = content
for (const version of loadedVersions) {
await this.loadVersion(options, version)
}
}
loadVersion = async (options: DocsPluginOptions, version: LoadedVersion) => {
this.docs.push(
...version.docs.map((doc) => ({
version,
metadata: doc,
plugin: options,
})),
)
}
generate = async () => {
for (const doc of this.docs) {
const image = await this.imageRenderer(
{
...doc,
websiteOutDir: this.context.outDir,
},
this.context,
)
if (!image) continue
const generated = await this.imageGenerator.generate(...image)
const document = new Document(this.getHtmlPath(doc))
await document.load()
await document.setImage(generated.url)
await document.write()
}
}
getHtmlPath = (doc: DocsPageData) =>
path.join(this.context.outDir, doc.metadata.permalink, 'index.html')
}
@@ -0,0 +1,57 @@
import * as fsp from 'fs/promises'
import { HTMLElement, parse as parseHTML } from 'node-html-parser'
export class Document {
root: HTMLElement
loaded = false
constructor(private path: string) {}
load = async () => {
const htmlString = await fsp.readFile(this.path, 'utf-8')
this.root = parseHTML(htmlString)
this.loaded = true
}
write = async () => {
await fsp.writeFile(this.path, Buffer.from(this.root.outerHTML))
}
setImage = async (url: string) => {
this.updateMeta('property', 'og:image', {
content: url,
})
this.updateMeta('property', 'image', {
content: url,
})
}
get head() {
return this.root.querySelector('head')!
}
private getMeta(attr: string, value: string) {
const { head } = this
let meta = head.querySelector(`meta[${attr}=${value}]`)
if (!meta) {
meta = new HTMLElement('meta', {}, '', null, [0, 0])
meta.setAttribute(attr, value)
head.appendChild(meta)
}
return meta
}
private updateMeta = (
attr: string,
value: string,
attrs: Record<string, any>,
) => {
const el = this.getMeta(attr, value)
Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, value))
return el
}
}
@@ -0,0 +1,74 @@
import * as fs from 'fs'
import * as fsp from 'fs/promises'
import hashObj from 'object-hash'
import path from 'path'
import React from 'react'
// @ts-expect-error
import type Satori from 'satori'
// @ts-expect-error
import { type SatoriOptions } from 'satori'
import type Sharp from 'sharp'
export type ImageGeneratorOptions = SatoriOptions
export type ImageGeneratorResult = {
url: string
relativePath: string
absolutePath: string
}
export class ImageGenerator {
private satori: typeof Satori
private sharp: typeof Sharp
private cache: Record<string, ImageGeneratorResult> = {}
private outDir: string = ''
constructor(
private args: {
dir: string
websiteUrl: string
websiteOutDir: string
},
) {
this.outDir = path.join(this.args.websiteOutDir, this.args.dir)
}
public init = async () => {
this.satori = await import('satori').then((mod) => mod.default)
this.sharp = await import('sharp').then((mod) => mod.default)
if (!fs.existsSync(this.outDir))
await fsp.mkdir(this.outDir, { recursive: true })
}
public generate = async (
element: React.ReactNode,
options: ImageGeneratorOptions,
): Promise<ImageGeneratorResult> => {
const hash = hashObj([element, options])
if (this.cache[hash]) return this.cache[hash]!
const imageName = `${hash}.png`
const absolutePath = path.join(this.outDir, imageName)
const relativePath = path.join(
'/',
this.args.dir,
absolutePath.slice(this.outDir.length),
)
const svg = await this.satori(element, options)
await this.sharp(Buffer.from(svg)).png().toFile(absolutePath)
const url = new URL(this.args.websiteUrl)
url.pathname = relativePath
this.cache[hash] = {
relativePath,
absolutePath,
url: url.toString(),
}
return this.cache[hash]!
}
}
@@ -0,0 +1,26 @@
import { PageData } from '../index'
import { BlogPageData } from './types/blog.types'
import { DocsPageData } from './types/docs.types'
import { ImageRenderer } from './types/image.types'
export function imageRendererFactory(
plugin: 'docusaurus-plugin-content-docs',
handler: ImageRenderer<DocsPageData>,
): ImageRenderer
export function imageRendererFactory(
plugin: 'docusaurus-plugin-content-blog',
handler: ImageRenderer<BlogPageData>,
): ImageRenderer
export function imageRendererFactory(
plugin: 'docusaurus-plugin-content-pages',
handler: ImageRenderer<PageData>,
): ImageRenderer
export function imageRendererFactory(
plugin:
| 'docusaurus-plugin-content-blog'
| 'docusaurus-plugin-content-docs'
| 'docusaurus-plugin-content-pages',
handler: ImageRenderer,
): ImageRenderer {
return handler
}
@@ -0,0 +1,34 @@
import { Props } from '@docusaurus/types'
import { BlogPlugin } from './blog.plugin'
import { DocsPlugin } from './docs.plugin'
import { ImageGenerator } from './imageGenerator'
import { PagesPlugin } from './pages.plugin'
import { PluginOptions } from './types/plugin.types'
const plugins = {
[DocsPlugin.plugin]: DocsPlugin,
[BlogPlugin.plugin]: BlogPlugin,
[PagesPlugin.plugin]: PagesPlugin,
}
export const postBuildFactory =
(options: PluginOptions) => async (props: Props) => {
const imageGenerator = new ImageGenerator({
websiteUrl: props.siteConfig.url,
websiteOutDir: props.outDir,
dir: options.path,
})
await imageGenerator.init()
const pluginNames = Object.keys(options.imageRenderers)
for (const pluginName of pluginNames) {
const renderer = options.imageRenderers[pluginName]
const Plugin = plugins[pluginName]
const plugin =
Plugin && new Plugin(props, options, imageGenerator, renderer!)
plugin && (await plugin.process())
}
}
@@ -0,0 +1,93 @@
import {
LoadedContent,
PluginOptions as PagesPluginOptions,
} from '@docusaurus/plugin-content-pages'
import { LoadedPlugin, Props } from '@docusaurus/types'
import * as path from 'path'
import { PageData } from '../index'
import { Document } from './document'
import { ImageGenerator } from './imageGenerator'
import { ImageRenderer } from './types/image.types'
import { PluginOptions } from './types/plugin.types'
export class PagesPlugin {
static plugin = 'docusaurus-plugin-content-pages'
pages: PageData[] = []
constructor(
private context: Props,
private options: PluginOptions,
private imageGenerator: ImageGenerator,
private imageRenderer: ImageRenderer,
) {}
process = async () => {
await this.loadData()
await this.generate()
}
loadData = async () => {
const plugins = this.context.plugins.filter(
(plugin) => plugin.name === PagesPlugin.plugin,
)
for (const plugin of plugins) {
await this.loadInstance(plugin)
}
}
loadInstance = async (plugin: LoadedPlugin) => {
const content = plugin.content as LoadedContent
const options = plugin.options as PagesPluginOptions
for (const metadata of content) {
const doc = new Document(this.getHtmlPath(metadata.permalink))
await doc.load()
const title =
(doc.loaded && doc.root.querySelector('title')?.textContent) || ''
const description =
(doc.loaded &&
doc.root.querySelector('meta[name=description]')?.textContent) ||
''
this.pages.push({
metadata: {
...metadata,
title,
description,
},
plugin: options,
})
}
}
generate = async () => {
for (const page of this.pages) {
const image = await this.imageRenderer(
{
...page,
websiteOutDir: this.context.outDir,
},
this.context,
)
if (!image) continue
const generated = await this.imageGenerator.generate(...image)
const document = new Document(this.getHtmlPath(page.metadata.permalink))
await document.load()
if (!document.loaded) continue
await document.setImage(generated.url)
await document.write()
}
}
getHtmlPath = (permalink: string) =>
path.join(this.context.outDir, permalink, 'index.html')
}
@@ -0,0 +1,44 @@
import {
BlogPaginated,
BlogPost,
BlogTag,
PluginOptions,
} from '@docusaurus/plugin-content-blog'
export type BlogPageType =
| 'post'
| 'list'
| 'archive'
| 'tags'
| 'tag'
| 'archive'
export type BlogPageData = {
plugin: PluginOptions
permalink: string
} & (
| {
pageType: Extract<BlogPageType, 'post'>
data: BlogPost
}
| {
pageType: Extract<BlogPageType, 'list'>
data: BlogPaginated
}
| {
pageType: Extract<BlogPageType, 'tags'>
data: {
permalink: string
}
}
| {
pageType: Extract<BlogPageType, 'tag'>
data: BlogTag['pages'][number]['metadata'] & Pick<BlogTag, 'label'>
}
| {
pageType: Extract<BlogPageType, 'archive'>
data: {
permalink: string
}
}
)
@@ -0,0 +1,11 @@
import {
DocMetadata,
LoadedVersion,
PluginOptions,
} from '@docusaurus/plugin-content-docs'
export type DocsPageData = {
plugin: PluginOptions
version: LoadedVersion
metadata: DocMetadata
}
@@ -0,0 +1,22 @@
import { Props } from '@docusaurus/types'
// @ts-expect-error
import type { SatoriOptions } from 'satori'
export type ImageGeneratorOptions = SatoriOptions
export type ImageGeneratorResult = {
path: string
absolutePath: string
}
export type ImageRenderer<T = any> = (
data: T,
context: Props,
) =>
| [React.ReactNode, ImageGeneratorOptions]
| undefined
| void
| false
| Promise<[React.ReactNode, ImageGeneratorOptions] | undefined | void | false>
export type ImageGenerator<T = any> = (data: T) => string
@@ -0,0 +1,5 @@
export * from './blog.types'
export * from './docs.types'
export * from './image.types'
export * from './pages.types'
export * from './plugin.types'
@@ -0,0 +1,9 @@
import { Metadata, PluginOptions } from '@docusaurus/plugin-content-pages'
export type PageData = {
metadata: Omit<Metadata, 'title' | 'description'> & {
title: string
description?: string
}
plugin: PluginOptions
}
@@ -0,0 +1,6 @@
import { ImageRenderer } from './image.types'
export type PluginOptions = {
path: string
imageRenderers: Record<string, ImageRenderer>
}
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"incremental": true,
"tsBuildInfoFile": "./lib/.tsbuildinfo-client",
"rootDir": "src",
"outDir": "lib",
"module": "esnext",
"target": "esnext",
"lib": ["DOM"]
},
"include": ["src/client/**/*", "src/*.d.ts"],
"exclude": ["**/__tests__/**"]
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"incremental": true,
"tsBuildInfoFile": "./lib/.tsbuildinfo",
"rootDir": "src",
"outDir": "lib",
"target": "ES3",
"lib": ["DOM"]
},
"include": ["src"],
"exclude": ["src/theme", "**/__tests__/**"]
}
@@ -1,4 +1,5 @@
// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion
require('dotenv').config()

Some files were not shown because too many files have changed in this diff Show More