redirecter for ao3 that adds opengraph metadata

update stuff in .env.example, move common code to lib files

Changed files
+1097 -183
src
app
generator
series
[seriesId]
preview
works
[workId]
chapters
[chapterId]
preview
preview
lib
+4 -1
.env.example
···
SITENAME=fixAO3
DESCRIPTION=Unofficial AO3 embed prettifier for social media
-
DOMAIN=localhost:3000
+
DOMAIN=localhost:3000
+
DEFAULT_THEME=ao3
+
DEFAULT_BASE_FONT=bricolagegrotesque
+
DEFAULT_TITLE_FONT=stacksansnotch
+12 -12
src/app/generator/page.js
···
<div className="input-field">
<label htmlFor="features">Features:</label>
<ul>
-
<li><label><input type="checkbox" name="features[]" value="category" onChange={e => updateProp(e.target.value, e.target.checked)} /> Category</label></li>
-
<li><label><input type="checkbox" name="features[]" value="rating" onChange={e => updateProp(e.target.value, e.target.checked)} /> Rating</label></li>
-
<li><label><input type="checkbox" name="features[]" value="warnings" onChange={e => updateProp(e.target.value, e.target.checked)} /> Archive Warnings</label></li>
-
<li><label><input type="checkbox" name="features[]" value="chartags" onChange={e => updateProp(e.target.value, e.target.checked)} /> Character Tags</label></li>
-
<li><label><input type="checkbox" name="features[]" value="reltags" onChange={e => updateProp(e.target.value, e.target.checked)} /> Relationship Tags</label></li>
-
<li><label><input type="checkbox" name="features[]" value="freetags" onChange={e => updateProp(e.target.value, e.target.checked)} /> Free Tags</label></li>
-
<li><label><input type="checkbox" name="features[]" value="summary" onChange={e => updateProp(e.target.value, e.target.checked)} /> Summary</label></li>
-
<li><label><input type="checkbox" name="features[]" value="wordcount" onChange={e => updateProp(e.target.value, e.target.checked)} /> Wordcount</label></li>
-
<li><label><input type="checkbox" name="features[]" value="chapters" onChange={e => updateProp(e.target.value, e.target.checked)} /> Chapters</label></li>
+
<li><label><input type="checkbox" name="features[]" value="category" defaultChecked={props.category} onChange={e => updateProp(e.target.value, e.target.checked)} /> Category</label></li>
+
<li><label><input type="checkbox" name="features[]" value="rating" defaultChecked={props.rating} onChange={e => updateProp(e.target.value, e.target.checked)} /> Rating</label></li>
+
<li><label><input type="checkbox" name="features[]" value="warnings" defaultChecked={props.warnings} onChange={e => updateProp(e.target.value, e.target.checked)} /> Archive Warnings</label></li>
+
<li><label><input type="checkbox" name="features[]" value="charTags" defaultChecked={props.charTags} onChange={e => updateProp(e.target.value, e.target.checked)} /> Character Tags</label></li>
+
<li><label><input type="checkbox" name="features[]" value="relTags" defaultChecked={props.relTags} onChange={e => updateProp(e.target.value, e.target.checked)} /> Relationship Tags</label></li>
+
<li><label><input type="checkbox" name="features[]" value="freetags" defaultChecked={props.freeTags} onChange={e => updateProp(e.target.value, e.target.checked)} /> Free Tags</label></li>
+
<li><label><input type="checkbox" name="features[]" value="summary" defaultChecked={props.summary} onChange={e => updateProp(e.target.value, e.target.checked)} /> Summary</label></li>
+
<li><label><input type="checkbox" name="features[]" value="wordcount" defaultChecked={props.wordcount} onChange={e => updateProp(e.target.value, e.target.checked)} /> Wordcount</label></li>
+
<li><label><input type="checkbox" name="features[]" value="chapters" defaultChecked={props.chapters} onChange={e => updateProp(e.target.value, e.target.checked)} /> Chapters</label></li>
</ul>
</div>
<div className="input-field">
<label htmlFor="summaryOptions">Summary Type</label>
<ul>
-
<li><label><input type="radio" name="summaryType" value="basic" onChange={e => updateProp(e.target.name, e.target.value)} /> Story Summary</label></li>
-
<li><label><input type="radio" name="summaryType" value="chapter" onChange={e => updateProp(e.target.name, e.target.value)} /> Chapter Summary (if available)</label></li>
-
<li><label><input type="radio" name="summaryType" value="custom" onChange={e => updateProp(e.target.name, e.target.value)} /> Custom Summary</label></li>
+
<li><label><input type="radio" name="summaryType" value="basic" defaultChecked={props.summaryType === 'basic'} onChange={e => updateProp(e.target.name, e.target.value)} /> Story Summary</label></li>
+
<li><label><input type="radio" name="summaryType" defaultChecked={props.summaryType === 'chapter'} value="chapter" onChange={e => updateProp(e.target.name, e.target.value)} /> Chapter Summary (if available)</label></li>
+
<li><label><input type="radio" name="summaryType" defaultChecked={props.summaryType === 'custom'} value="custom" onChange={e => updateProp(e.target.name, e.target.value)} /> Custom Summary</label></li>
</ul>
</div>
{props.summaryType === 'custom' && (
+25
src/app/series/[seriesId]/preview/route.js
···
+
import { getSeries } from "@fujocoded/ao3.js"
+
import sanitizeData from "@/lib/sanitizeData.js"
+
import OGImage from "@/lib/ogimage.js"
+
import baseFonts from "@/lib/baseFonts.js"
+
import titleFonts from "@/lib/titleFonts.js"
+
+
export const size = {
+
width: 1600,
+
height: 900,
+
}
+
+
export const contentType = 'image/webp'
+
+
export async function GET(req, ctx) {
+
const { seriesId } = await ctx.params
+
const props = await req.nextUrl.searchParams
+
const addr = `series/${seriesId}`
+
const data = await getSeries({seriesId: seriesId})
+
const imageParams = await sanitizeData({type: 'series', data: data, props: props})
+
const theme = imageParams.theme
+
const baseFont = baseFonts[imageParams.baseFont].displayName
+
const titleFont = titleFonts[imageParams.titleFont].displayName
+
const opts = imageParams.opts
+
return OGImage({theme: theme, baseFont: baseFont, titleFont: titleFont, image: imageParams, addr: addr, opts: opts})
+
}
+25
src/app/works/[workId]/chapters/[chapterId]/preview/route.js
···
+
import { getWork } from "@fujocoded/ao3.js"
+
import sanitizeData from "@/lib/sanitizeData.js"
+
import OGImage from "@/lib/ogimage.js"
+
import baseFonts from "@/lib/baseFonts.js"
+
import titleFonts from "@/lib/titleFonts.js"
+
+
export const size = {
+
width: 1600,
+
height: 900,
+
}
+
+
export const contentType = 'image/webp'
+
+
export async function GET(req, ctx) {
+
const { workId, chapterId } = await ctx.params
+
const props = await req.nextUrl.searchParams
+
const addr = `works/${workId}/chapters/${chapterId}`
+
const data = await getWork({workId: workId, chapterId: chapterId})
+
const imageParams = await sanitizeData({type: 'work', data: data, props: props})
+
const theme = imageParams.theme
+
const baseFont = baseFonts[imageParams.baseFont].displayName
+
const titleFont = titleFonts[imageParams.titleFont].displayName
+
const opts = imageParams.opts
+
return OGImage({theme: theme, baseFont: baseFont, titleFont: titleFont, image: imageParams, addr: addr, opts: opts})
+
}
+10 -170
src/app/works/[workId]/preview/route.js
···
import { getWork } from "@fujocoded/ao3.js"
-
import DOM from "fauxdom"
-
import { ImageResponse } from "next/og"
-
import { readFile } from 'node:fs/promises'
-
import { join } from 'node:path'
-
import themes from '../../../themes.js'
-
import baseFonts from '../../../baseFonts.js'
-
import titleFonts from '../../../titleFonts.js'
+
import sanitizeData from "@/lib/sanitizeData.js"
+
import OGImage from "@/lib/ogimage.js"
+
import baseFonts from "@/lib/baseFonts.js"
+
import titleFonts from "@/lib/titleFonts.js"
export const size = {
width: 1600,
···
const props = await req.nextUrl.searchParams
const addr = `works/${workId}`
const data = await getWork({workId: workId})
-
const baseFontData = baseFonts[props.has('baseFont') ? props.get('baseFont') : 'bricolagegrotesque']
-
const titleFontData = titleFonts[props.has('titleFont') ? props.get('titleFont') : 'stacksansnotch']
-
const themeData = props.has('theme') ? themes[props.get('theme')] : themes['ao3']
-
const bfs = await Promise.all(baseFontData.defs.map(async (bf) => {
-
return {
-
name: baseFontData.displayName,
-
data: await readFile(
-
join(process.cwd(), bf.path)
-
),
-
style: bf.style,
-
weight: bf.weight
-
}
-
})).then(x => x)
-
const tfs = await Promise.all(titleFontData.defs.map(async (tf) => {
-
return {
-
name: titleFontData.displayName,
-
data: await readFile(
-
join(process.cwd(), tf.path)
-
),
-
style: tf.style,
-
weight: tf.weight
-
}
-
})).then(x => x)
-
const authorsFormatted = data.authors
-
? data.authors.map((a) => {
-
if (a.anonymous) return "Anonymous"
-
if (a.pseud !== a.username) return `${a.pseud} (${a.username})`
-
return a.username
-
})
-
: []
-
const authorString = authorsFormatted.length > 1
-
? authorsFormatted.slice(0, -1).join(", ") + " & " +
-
authorsFormatted.slice(-1)[0]
-
: authorsFormatted[0]
-
const summaryDOM = new DOM(props.get('summaryType') === 'chapter' && data.chapterInfo && data.chapterInfo.summary ? data.chapterInfo.summary : (props.get('summaryType') === 'custom' && props.has('customSummary') ? props.get('customSummary') : data.summary), {decodeEntities: true});
-
const summaryFormatted = summaryDOM.innerHTML.replace("<br />", "\n").replace(
-
/(<([^>]+)>)/ig,
-
"",
-
).split("\n")
-
const titleString = `<b>${data.title}</b> by ${authorString}`
-
const chapterString = data.chapterInfo ? (data.chapterInfo.name
-
? data.chapterInfo.name
-
: "Chapter " + data.chapterInfo.index) : ''
-
const chapterCountString = data.chapters
-
? ' | <b>Chapters:</b> '+data.chapters.published+' / '+(
-
data.chapters.total
-
? data.chapters.total
-
: '?'
-
)
-
: ''
-
const fandomString = (data.fandoms.length > 1 ? (data.fandoms.length <= 5 ? data.fandoms.slice(0, -1).join(", ")+" & "+data.fandoms.slice(-1) : data.fandoms.join(", ")+" (+"+(data.fandoms.length - 4)+")") : data.fandoms[0]).toUpperCase()
-
const headingString = `<span size='16pt'>${fandomString}</span>\n${titleString}${chapterString !== '' ? "\n<span size='36pt'><i>"+chapterString+"</i></span></span>" : ''}`
-
const opts = {
-
fonts: bfs.concat(tfs)
-
}
-
console.log(themeData)
-
console.log(baseFontData)
-
console.log(titleFontData)
-
return new ImageResponse(
-
(
-
<div
-
style={{
-
display: "flex",
-
flexDirection: "column",
-
color: themeData.color,
-
backgroundColor: themeData.background,
-
fontFamily: baseFontData.displayName,
-
fontSize: 24,
-
padding: 40,
-
width: "100%",
-
height: "100%",
-
}}
-
>
-
<div
-
style={{
-
display: "flex",
-
flexDirection: "column",
-
marginBottom: 20
-
}}
-
>
-
<div
-
style={{
-
textTransform: "uppercase",
-
display: "flex",
-
justifyContent: "center",
-
color: themeData.accent
-
}}
-
>
-
{fandomString}
-
</div>
-
<div
-
style={{
-
fontSize: 54,
-
justifyContent: "center",
-
fontFamily: titleFontData.displayName,
-
fontWeight: "bold"
-
}}
-
>
-
{data.title}
-
</div>
-
<div
-
style={{
-
fontSize: 42,
-
justifyContent: "center",
-
fontFamily: titleFontData.displayName
-
}}
-
>
-
{`by ${authorString}`}
-
</div>
-
<div
-
style={{
-
fontStyle: "italic",
-
fontSize: 36,
-
fontFamily: titleFontData.displayName
-
}}
-
>
-
{chapterString}
-
</div>
-
</div>
-
<div
-
style={{
-
backgroundColor: themeData.descBackground,
-
padding: 20,
-
display: "flex",
-
flexDirection: "column",
-
flexGrow: 1,
-
color: themeData.descColor,
-
alignItems: "flex-end"
-
}}
-
>
-
<div
-
style={{
-
display: "flex",
-
flexDirection: "column",
-
flexGrow: 1,
-
width: '100%'
-
}}
-
>
-
{summaryFormatted.map(l => (
-
<div
-
style={{
-
width: "100%",
-
marginBottom: 10
-
}}
-
>
-
{l}
-
</div>
-
))}
-
</div>
-
<div
-
style={{
-
textAlign: "right",
-
fontSize: 18,
-
color: themeData.accent2
-
}}
-
>
-
{`https://archiveofourown.org/${addr}`}
-
</div>
-
</div>
-
</div>
-
),
-
opts
-
)
+
const imageParams = await sanitizeData({type: 'work', data: data, props: props})
+
const theme = imageParams.theme
+
const baseFont = baseFonts[imageParams.baseFont].displayName
+
const titleFont = titleFonts[imageParams.titleFont].displayName
+
const opts = imageParams.opts
+
return OGImage({theme: theme, baseFont: baseFont, titleFont: titleFont, image: imageParams, addr: addr, opts: opts})
}
+498
src/lib/baseFonts.js
···
+
const baseFonts = {
+
opensans: {
+
displayName: 'Open Sans',
+
defs: [
+
{
+
path: '/fonts/OpenSans-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/OpenSans-Italic.ttf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/OpenSans-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/OpenSans-BoldItalic.ttf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
bricolagegrotesque: {
+
displayName: 'Bricolage Grotesque',
+
defs: [
+
{
+
path: '/fonts/BricolageGrotesque-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/BricolageGrotesque-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
}
+
]
+
},
+
spacemono: {
+
displayName: 'Space Mono',
+
defs: [
+
{
+
path: '/fonts/SpaceMono-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/SpaceMono-Italic.ttf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/SpaceMono-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/SpaceMono-BoldItalic.ttf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
inconsolata: {
+
displayName: 'Inconsolata',
+
defs: [
+
{
+
path: '/fonts/Inconsolata.otf',
+
style: 'normal'
+
}
+
]
+
},
+
bitter: {
+
displayName: 'Bitter',
+
defs: [
+
{
+
path: '/fonts/Bitter-Regular.otf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/Bitter-Italic.otf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/Bitter-Bold.otf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/Bitter-BoldItalic.otf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
archivo: {
+
displayName: 'Archivo',
+
defs: [
+
{
+
path: '/fonts/Archivo-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/Archivo-Italic.ttf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/Archivo-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/Archivo-BoldItalic.ttf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
outfit: {
+
displayName: 'Outfit',
+
defs: [
+
{
+
path: '/fonts/outfit-regular-webfont.woff2',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/outfit-italic-webfont.woff2',
+
style: 'italic',
+
weight: 400
+
}
+
]
+
},
+
notosans: {
+
displayName: 'Noto Sans',
+
defs: [
+
{
+
path: '/fonts/NotoSans-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/NotoSans-Italic.ttf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/NotoSans-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/NotoSans-BoldItalic.ttf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
alegreya: {
+
displayName: 'Alegreya',
+
defs: [
+
{
+
path: '/fonts/Alegreya-Regular.otf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/Alegreya-Italic.otf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/Alegreya-Bold.otf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/Alegreya-BoldItalic.otf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
alegreyasans: {
+
displayName: 'Alegreya Sans',
+
defs: [
+
{
+
path: '/fonts/AlegreyaSans-Regular.otf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/AlegreyaSans-Italic.otf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/AlegreyaSans-Bold.otf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/AlegreyaSans-BoldItalic.otf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
stacksanstext: {
+
displayName: 'Stack Sans Text',
+
defs: [
+
{
+
path: '/fonts/StackSansText-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/StackSansText-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
}
+
],
+
},
+
momotrustsans: {
+
displayName: 'Momo Trust Sans',
+
defs: [
+
{
+
path: '/fonts/MomoTrustSans-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/MomoTrustSans-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
}
+
]
+
},
+
montserrat: {
+
displayName: 'Montserrat',
+
defs: [
+
{
+
path: '/fonts/Montserrat-Regular.otf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/Montserrat-Italic.otf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/Montserrat-Bold.otf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/Montserrat-BoldItalic.otf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
robotoslab: {
+
displayName: 'Roboto Slab',
+
defs: [
+
{
+
path: '/fonts/RobotoSlab-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/RobotoSlab-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
}
+
]
+
},
+
quicksand: {
+
displayName: 'Quicksand',
+
defs: [
+
{
+
path: '/fonts/Quicksand-Regular.otf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/Quicksand-Italic.otf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/Quicksand-Bold.otf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/Quicksand-BoldItalic.otf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
worksans: {
+
displayName: 'Work Sans',
+
defs: [
+
{
+
path: '/fonts/WorkSans-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/WorkSans-Italic.ttf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/WorkSans-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/WorkSans-BoldItalic.ttf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
notosans: {
+
displayName: 'Noto Sans',
+
defs: [
+
{
+
path: '/fonts/NotoSans-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/NotoSans-Italic.ttf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/NotoSans-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/NotoSans-BoldItalic.ttf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
notoserif: {
+
displayName: 'Noto Serif',
+
defs: [
+
{
+
path: '/fonts/NotoSerif-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/NotoSerif-Italic.ttf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/NotoSerif-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/NotoSerif-BoldItalic.ttf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
librebaskerville: {
+
displayName: 'Libre Baskerville',
+
defs: [
+
{
+
path: '/fonts/LibreBaskerville-Regular.otf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/LibreBaskerville-Italic.otf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/LibreBaskerville-Bold.otf',
+
style: 'normal',
+
weight: 700
+
}
+
]
+
},
+
ubuntu: {
+
displayName: 'Ubuntu',
+
defs: [
+
{
+
path: '/fonts/Ubuntu-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/Ubuntu-Italic.ttf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/Ubuntu-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/Ubuntu-BoldItalic.ttf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
parkinsans: {
+
displayName: 'Parkinsans',
+
defs: [
+
{
+
path: '/fonts/Parkinsans-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/Parkinsans-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
}
+
]
+
},
+
lora: {
+
displayName: 'Lora',
+
defs: [
+
{
+
path: '/fonts/Lora-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/Lora-Italic.ttf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/Lora-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/Lora-BoldItalic.ttf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
josefinsans: {
+
displayName: 'Josefin Sans',
+
defs: [
+
{
+
path: '/fonts/JosefinSans-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/JosefinSans-Italic.ttf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/JosefinSans-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/JosefinSans-BoldItalic.ttf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
}
+
}
+
+
export default baseFonts
+112
src/lib/ogimage.js
···
+
import { ImageResponse } from "next/og"
+
+
export default async function OGImage ({ theme, baseFont, titleFont, image, addr, opts }) {
+
return new ImageResponse(
+
(
+
<div
+
style={{
+
display: "flex",
+
flexDirection: "column",
+
color: theme.color,
+
backgroundColor: theme.background,
+
fontFamily: baseFont,
+
fontSize: 24,
+
padding: 40,
+
width: "100%",
+
height: "100%",
+
}}
+
>
+
<div
+
style={{
+
display: "flex",
+
flexDirection: "column",
+
marginBottom: 20
+
}}
+
>
+
<div
+
style={{
+
textTransform: "uppercase",
+
display: "flex",
+
justifyContent: "center",
+
color: theme.accent
+
}}
+
>
+
{image.topLine}
+
</div>
+
<div
+
style={{
+
fontSize: 54,
+
justifyContent: "center",
+
fontFamily: titleFont,
+
fontWeight: "bold"
+
}}
+
>
+
{image.titleLine}
+
</div>
+
<div
+
style={{
+
fontSize: 42,
+
display: "flex",
+
justifyContent: "center",
+
fontFamily: titleFont
+
}}
+
>
+
{`by ${image.authorLine}`}
+
</div>
+
<div
+
style={{
+
fontStyle: "italic",
+
fontSize: 36,
+
fontFamily: titleFont,
+
display: "flex",
+
justifyContent: "center"
+
}}
+
>
+
{image.chapterLine}
+
</div>
+
</div>
+
<div
+
style={{
+
backgroundColor: theme.descBackground,
+
padding: 20,
+
display: "flex",
+
flexDirection: "column",
+
flexGrow: 1,
+
color: theme.descColor,
+
alignItems: "flex-end"
+
}}
+
>
+
<div
+
style={{
+
display: "flex",
+
flexDirection: "column",
+
flexGrow: 1,
+
width: '100%'
+
}}
+
>
+
{image.summary.map(l => (
+
<div
+
style={{
+
width: "100%",
+
marginBottom: 10
+
}}
+
>
+
{l}
+
</div>
+
))}
+
</div>
+
<div
+
style={{
+
textAlign: "right",
+
fontSize: 18,
+
color: theme.accent2
+
}}
+
>
+
{`https://archiveofourown.org/${addr}`}
+
</div>
+
</div>
+
</div>
+
),
+
opts
+
)
+
}
+91
src/lib/sanitizeData.js
···
+
import { getWork } from "@fujocoded/ao3.js"
+
import DOM from "fauxdom"
+
import { readFile } from 'node:fs/promises'
+
import { join } from 'node:path'
+
import themes from '@/lib/themes.js'
+
import baseFonts from '@/lib/baseFonts.js'
+
import titleFonts from '@/lib/titleFonts.js'
+
+
export default async function sanitizeData ({ type, data, props}) {
+
const baseFont = props.has('baseFont') ? props.get('baseFont') : process.env.DEFAULT_BASE_FONT
+
const baseFontData = baseFonts[baseFont]
+
const titleFont = props.has('titleFont') ? props.get('titleFont') : process.env.DEFAULT_TITLE_FONT
+
const titleFontData = titleFonts[titleFont]
+
const themeData = props.has('theme') ? themes[props.get('theme')] : themes[process.env.DEFAULT_THEME]
+
const parentWork = type === 'work' && data.chapterInfo ? await getWork({workId: data.id}) : null
+
const bfs = await Promise.all(baseFontData.defs.map(async (bf) => {
+
return {
+
name: baseFontData.displayName,
+
data: await readFile(
+
join(process.cwd(), bf.path)
+
),
+
style: bf.style,
+
weight: bf.weight
+
}
+
})).then(x => x)
+
const tfs = await Promise.all(titleFontData.defs.map(async (tf) => {
+
return {
+
name: titleFontData.displayName,
+
data: await readFile(
+
join(process.cwd(), tf.path)
+
),
+
style: tf.style,
+
weight: tf.weight
+
}
+
})).then(x => x)
+
const authorsFormatted = data.authors
+
? data.authors.map((a) => {
+
if (a.anonymous) return "Anonymous"
+
if (a.pseud !== a.username) return `${a.pseud} (${a.username})`
+
return a.username
+
})
+
: []
+
const authorString = authorsFormatted.length > 1
+
? authorsFormatted.slice(0, -1).join(", ") + " & " +
+
authorsFormatted.slice(-1)[0]
+
: authorsFormatted[0]
+
const summaryContent = type === 'work'
+
? (props.get('summaryType') === 'chapter' && data.chapterInfo && data.chapterInfo.summary ? data.chapterInfo.summary : (props.get('summaryType') === 'custom' && props.has('customSummary') ? props.get('customSummary') : (data.summary ? data.summary : (parentWork ? parentWork.summary : ''))))
+
: (props.get('summaryType') === 'custom' && props.has('customSummary') ? props.get('customSummary') : data.notes)
+
const summaryDOM = new DOM(summaryContent, {decodeEntities: true});
+
const summaryFormatted = summaryDOM.innerHTML.replace(/\<br(?: \/)?\>/g, "\n").replace(
+
/(<([^>]+)>)/ig,
+
"",
+
).split("\n")
+
const titleString = type === 'work' ? data.title : data.name
+
const chapterString = data.chapterInfo ? (data.chapterInfo.name
+
? data.chapterInfo.name
+
: "Chapter " + data.chapterInfo.index) : null
+
const chapterCountString = data.chapters
+
? ' | <b>Chapters:</b> '+data.chapters.published+' / '+(
+
data.chapters.total
+
? data.chapters.total
+
: '?'
+
)
+
: ''
+
const fandomString = type === 'work' ? (
+
data.fandoms.length > 1
+
? (
+
data.fandoms.length <= 5
+
? data.fandoms.slice(0, -1).join(", ")+" & "+data.fandoms.slice(-1)
+
: data.fandoms.join(", ")+" (+"+(data.fandoms.length - 4)+")"
+
)
+
: data.fandoms[0]
+
) : (
+
data.works.map(w => w.fandoms).reduce((a, b) => { return a.concat(b) }).filter((w, i) => { return i === data.works.indexOf(w) })
+
)
+
return {
+
topLine: fandomString,
+
titleLine: titleString,
+
authorLine: authorString,
+
chapterLine: chapterString,
+
summary: summaryFormatted,
+
url: 'https://archiveofourown.org/',
+
theme: themeData,
+
baseFont: baseFont,
+
titleFont: titleFont,
+
opts: {
+
fonts: bfs.concat(tfs)
+
}
+
}
+
}
+94
src/lib/themes.js
···
+
const themes = {
+
ao3: {
+
name: 'AO3',
+
background: '#990000',
+
color: '#FFFFFF',
+
descBackground: '#FFFFFF',
+
descColor: '#000000',
+
accent: '#FFFFFF',
+
accent2: '#990000'
+
},
+
softEra: {
+
name: 'Soft Era',
+
background: '#F9F5F5',
+
color: '#C8B3B3',
+
descBackground: '#F9F5F5',
+
descColor: '#414141',
+
accent: '#DB90A7',
+
accent2: '#EEAABE'
+
},
+
wildCherry: {
+
name: 'Wild Cherry',
+
background: '#2B1F32',
+
color: '#FFFFFF',
+
descBackground: '#FFFFFF',
+
descColor: '#2B1F32',
+
accent: '#E15D97',
+
accent2: '#0AACC5'
+
},
+
rosePine: {
+
name: 'Rosé Pine',
+
background: '#191724',
+
color: '#e0def4',
+
descBackground: '#1f1d2e',
+
descColor: '#e0def4',
+
accent: '#eb6f92',
+
accent2: '#31748f'
+
},
+
rosePineDawn: {
+
name: 'Rosé Pine Dawn',
+
background: '#faf4ed',
+
color: '#575279',
+
descBackground: '#fffaf3',
+
descColor: '#575279',
+
accent: '#eb6f92',
+
accent2: '#286983'
+
},
+
rosePineMoon: {
+
name: 'Rosé Pine Moon',
+
background: '#232136',
+
color: '#e0def4',
+
descBackground: '#2a273f',
+
descColor: '#e0def4',
+
accent: '#b4637a',
+
accent2: '#3e8fb0'
+
},
+
solarizedLight: {
+
name: 'Solarized Light',
+
background: '#fdf6e3',
+
color: '#b58900',
+
descBackground: '#eee8d5',
+
descColor: '#002b36',
+
accent: '#d33682',
+
accent2: '#2aa198'
+
},
+
solarizedDark: {
+
name: 'Solarized Dark',
+
background: '#002b36',
+
color: '#b58900',
+
descBackground: '#073642',
+
descColor: '#fdf6e3',
+
accent: '#d33682',
+
accent2: '#2aa198'
+
},
+
squidgeworld: {
+
name: 'Squidgeworld',
+
background: '#b8860b',
+
color: '#f5f5dc',
+
descBackground: '#f5f5dc',
+
color: '#2a2a2a',
+
accent: '#fece3f',
+
accent2: '#818D4C'
+
},
+
superlove: {
+
name: 'Superlove',
+
background: '#df6191',
+
color: '#ffffff',
+
descBackground: '#FFFFFF',
+
color: '#2a2a2a',
+
accent: '#F9E4E6',
+
accent2: '#a33961'
+
}
+
}
+
+
export default themes
+226
src/lib/titleFonts.js
···
+
import baseFonts from "./baseFonts.js"
+
+
const titleFonts = {
+
...baseFonts,
+
playfairdisplay: {
+
displayName: 'Playfair Display',
+
defs: [
+
{
+
path: '/fonts/Playfair-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/Playfair-Italic.ttf',
+
style: 'italic',
+
weight: 400
+
},
+
{
+
path: '/fonts/Playfair-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
},
+
{
+
path: '/fonts/Playfair-BoldItalic.ttf',
+
style: 'italic',
+
weight: 700
+
}
+
]
+
},
+
ultra: {
+
displayName: 'Ultra',
+
defs: [
+
{
+
path: '/fonts/Ultra-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
}
+
]
+
},
+
stacksansheadline: {
+
displayName: 'Stack Sans Headline',
+
defs: [
+
{
+
path: '/fonts/StackSansHeadline-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/StackSansHeadline-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
}
+
]
+
},
+
stacksansnotch: {
+
displayName: 'Stack Sans Notch',
+
defs: [
+
{
+
path: '/fonts/StackSansNotch-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/StackSansNotch-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
}
+
]
+
},
+
titanone: {
+
displayName: 'Titan One',
+
defs: []
+
},
+
momotrustdisplay: {
+
displayName: 'Momo Trust Display',
+
defs: [
+
{
+
path: '/fonts/MomoTrustDisplay-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/MomoTrustDisplay-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
}
+
]
+
},
+
momosignature: {
+
displayName: 'Momo Signature',
+
defs: [
+
{
+
path: '/fonts/MomoSignature-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
}
+
]
+
},
+
londrinasketch: {
+
displayName: 'Londrina Sketch',
+
defs: [
+
{
+
path: '/fonts/LondrinaSketch-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
}
+
]
+
},
+
londrinashadow: {
+
displayName: 'Londrina Shadow',
+
defs: [
+
{
+
path: '/fonts/LondrinaShadow-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
}
+
]
+
},
+
londrinasolid: {
+
displayName: 'Londrina Solid',
+
defs: [
+
{
+
path: '/fonts/LondrinaSolid-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/LondrinaSolid-Black.ttf',
+
style: 'normal',
+
weight: 700
+
}
+
]
+
},
+
bebasneue: {
+
displayName: 'Bebas Neue',
+
defs: [
+
{
+
path: '/fonts/BebasNeue-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
}
+
]
+
},
+
oswald: {
+
displayName: 'Oswald',
+
defs: [
+
{
+
path: '/fonts/Oswald-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/Oswald-Bold.ttf',
+
style: 'normal',
+
weight: 700
+
}
+
]
+
},
+
archivoblack: {
+
displayName: 'Archivo Black',
+
defs: [
+
{
+
path: '/fonts/ArchivoBlack.otf',
+
style: 'normal',
+
weight: 400
+
}
+
]
+
},
+
alfaslabone: {
+
displayName: 'Alfa Slab One',
+
defs: [
+
{
+
path: '/fonts/AlfaSlabOne-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
}
+
]
+
},
+
sixtyfour: {
+
displayName: 'SixtyFour',
+
defs: [
+
{
+
path: '/fonts/Sixtyfour-Regular.ttf',
+
style: 'normal',
+
weight: 400
+
},
+
{
+
path: '/fonts/Sixtyfour-Regular.ttf',
+
style: 'normal',
+
weight: 700
+
}
+
]
+
},
+
datalegreyathin: {
+
displayName: 'Datalegreya Thin',
+
defs: [
+
{
+
path: '/fonts/Datalegreya-Thin.otf',
+
style: 'normal',
+
weight: 400
+
}
+
]
+
},
+
datalegreyadot: {
+
displayName: 'Datalegreya Dot',
+
defs: [
+
{
+
path: '/fonts/Datalegreya-Dot.otf',
+
style: 'normal',
+
weight: 400
+
}
+
]
+
},
+
datalegreyagradient: {
+
displayName: 'Datalegreya Gradient',
+
defs: [
+
{
+
path: '/fonts/Datalegreya-Gradient.otf',
+
style: 'normal',
+
weight: 400
+
}
+
]
+
}
+
}
+
+
export default titleFonts