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
··· 1 1 SITENAME=fixAO3 2 2 DESCRIPTION=Unofficial AO3 embed prettifier for social media 3 - DOMAIN=localhost:3000 3 + DOMAIN=localhost:3000 4 + DEFAULT_THEME=ao3 5 + DEFAULT_BASE_FONT=bricolagegrotesque 6 + DEFAULT_TITLE_FONT=stacksansnotch
+12 -12
src/app/generator/page.js
··· 114 114 <div className="input-field"> 115 115 <label htmlFor="features">Features:</label> 116 116 <ul> 117 - <li><label><input type="checkbox" name="features[]" value="category" onChange={e => updateProp(e.target.value, e.target.checked)} /> Category</label></li> 118 - <li><label><input type="checkbox" name="features[]" value="rating" onChange={e => updateProp(e.target.value, e.target.checked)} /> Rating</label></li> 119 - <li><label><input type="checkbox" name="features[]" value="warnings" onChange={e => updateProp(e.target.value, e.target.checked)} /> Archive Warnings</label></li> 120 - <li><label><input type="checkbox" name="features[]" value="chartags" onChange={e => updateProp(e.target.value, e.target.checked)} /> Character Tags</label></li> 121 - <li><label><input type="checkbox" name="features[]" value="reltags" onChange={e => updateProp(e.target.value, e.target.checked)} /> Relationship Tags</label></li> 122 - <li><label><input type="checkbox" name="features[]" value="freetags" onChange={e => updateProp(e.target.value, e.target.checked)} /> Free Tags</label></li> 123 - <li><label><input type="checkbox" name="features[]" value="summary" onChange={e => updateProp(e.target.value, e.target.checked)} /> Summary</label></li> 124 - <li><label><input type="checkbox" name="features[]" value="wordcount" onChange={e => updateProp(e.target.value, e.target.checked)} /> Wordcount</label></li> 125 - <li><label><input type="checkbox" name="features[]" value="chapters" onChange={e => updateProp(e.target.value, e.target.checked)} /> Chapters</label></li> 117 + <li><label><input type="checkbox" name="features[]" value="category" defaultChecked={props.category} onChange={e => updateProp(e.target.value, e.target.checked)} /> Category</label></li> 118 + <li><label><input type="checkbox" name="features[]" value="rating" defaultChecked={props.rating} onChange={e => updateProp(e.target.value, e.target.checked)} /> Rating</label></li> 119 + <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> 120 + <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> 121 + <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> 122 + <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> 123 + <li><label><input type="checkbox" name="features[]" value="summary" defaultChecked={props.summary} onChange={e => updateProp(e.target.value, e.target.checked)} /> Summary</label></li> 124 + <li><label><input type="checkbox" name="features[]" value="wordcount" defaultChecked={props.wordcount} onChange={e => updateProp(e.target.value, e.target.checked)} /> Wordcount</label></li> 125 + <li><label><input type="checkbox" name="features[]" value="chapters" defaultChecked={props.chapters} onChange={e => updateProp(e.target.value, e.target.checked)} /> Chapters</label></li> 126 126 </ul> 127 127 </div> 128 128 <div className="input-field"> 129 129 <label htmlFor="summaryOptions">Summary Type</label> 130 130 <ul> 131 - <li><label><input type="radio" name="summaryType" value="basic" onChange={e => updateProp(e.target.name, e.target.value)} /> Story Summary</label></li> 132 - <li><label><input type="radio" name="summaryType" value="chapter" onChange={e => updateProp(e.target.name, e.target.value)} /> Chapter Summary (if available)</label></li> 133 - <li><label><input type="radio" name="summaryType" value="custom" onChange={e => updateProp(e.target.name, e.target.value)} /> Custom Summary</label></li> 131 + <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> 132 + <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> 133 + <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> 134 134 </ul> 135 135 </div> 136 136 {props.summaryType === 'custom' && (
+25
src/app/series/[seriesId]/preview/route.js
··· 1 + import { getSeries } from "@fujocoded/ao3.js" 2 + import sanitizeData from "@/lib/sanitizeData.js" 3 + import OGImage from "@/lib/ogimage.js" 4 + import baseFonts from "@/lib/baseFonts.js" 5 + import titleFonts from "@/lib/titleFonts.js" 6 + 7 + export const size = { 8 + width: 1600, 9 + height: 900, 10 + } 11 + 12 + export const contentType = 'image/webp' 13 + 14 + export async function GET(req, ctx) { 15 + const { seriesId } = await ctx.params 16 + const props = await req.nextUrl.searchParams 17 + const addr = `series/${seriesId}` 18 + const data = await getSeries({seriesId: seriesId}) 19 + const imageParams = await sanitizeData({type: 'series', data: data, props: props}) 20 + const theme = imageParams.theme 21 + const baseFont = baseFonts[imageParams.baseFont].displayName 22 + const titleFont = titleFonts[imageParams.titleFont].displayName 23 + const opts = imageParams.opts 24 + return OGImage({theme: theme, baseFont: baseFont, titleFont: titleFont, image: imageParams, addr: addr, opts: opts}) 25 + }
+25
src/app/works/[workId]/chapters/[chapterId]/preview/route.js
··· 1 + import { getWork } from "@fujocoded/ao3.js" 2 + import sanitizeData from "@/lib/sanitizeData.js" 3 + import OGImage from "@/lib/ogimage.js" 4 + import baseFonts from "@/lib/baseFonts.js" 5 + import titleFonts from "@/lib/titleFonts.js" 6 + 7 + export const size = { 8 + width: 1600, 9 + height: 900, 10 + } 11 + 12 + export const contentType = 'image/webp' 13 + 14 + export async function GET(req, ctx) { 15 + const { workId, chapterId } = await ctx.params 16 + const props = await req.nextUrl.searchParams 17 + const addr = `works/${workId}/chapters/${chapterId}` 18 + const data = await getWork({workId: workId, chapterId: chapterId}) 19 + const imageParams = await sanitizeData({type: 'work', data: data, props: props}) 20 + const theme = imageParams.theme 21 + const baseFont = baseFonts[imageParams.baseFont].displayName 22 + const titleFont = titleFonts[imageParams.titleFont].displayName 23 + const opts = imageParams.opts 24 + return OGImage({theme: theme, baseFont: baseFont, titleFont: titleFont, image: imageParams, addr: addr, opts: opts}) 25 + }
+10 -170
src/app/works/[workId]/preview/route.js
··· 1 1 import { getWork } from "@fujocoded/ao3.js" 2 - import DOM from "fauxdom" 3 - import { ImageResponse } from "next/og" 4 - import { readFile } from 'node:fs/promises' 5 - import { join } from 'node:path' 6 - import themes from '../../../themes.js' 7 - import baseFonts from '../../../baseFonts.js' 8 - import titleFonts from '../../../titleFonts.js' 2 + import sanitizeData from "@/lib/sanitizeData.js" 3 + import OGImage from "@/lib/ogimage.js" 4 + import baseFonts from "@/lib/baseFonts.js" 5 + import titleFonts from "@/lib/titleFonts.js" 9 6 10 7 export const size = { 11 8 width: 1600, ··· 19 16 const props = await req.nextUrl.searchParams 20 17 const addr = `works/${workId}` 21 18 const data = await getWork({workId: workId}) 22 - const baseFontData = baseFonts[props.has('baseFont') ? props.get('baseFont') : 'bricolagegrotesque'] 23 - const titleFontData = titleFonts[props.has('titleFont') ? props.get('titleFont') : 'stacksansnotch'] 24 - const themeData = props.has('theme') ? themes[props.get('theme')] : themes['ao3'] 25 - const bfs = await Promise.all(baseFontData.defs.map(async (bf) => { 26 - return { 27 - name: baseFontData.displayName, 28 - data: await readFile( 29 - join(process.cwd(), bf.path) 30 - ), 31 - style: bf.style, 32 - weight: bf.weight 33 - } 34 - })).then(x => x) 35 - const tfs = await Promise.all(titleFontData.defs.map(async (tf) => { 36 - return { 37 - name: titleFontData.displayName, 38 - data: await readFile( 39 - join(process.cwd(), tf.path) 40 - ), 41 - style: tf.style, 42 - weight: tf.weight 43 - } 44 - })).then(x => x) 45 - const authorsFormatted = data.authors 46 - ? data.authors.map((a) => { 47 - if (a.anonymous) return "Anonymous" 48 - if (a.pseud !== a.username) return `${a.pseud} (${a.username})` 49 - return a.username 50 - }) 51 - : [] 52 - const authorString = authorsFormatted.length > 1 53 - ? authorsFormatted.slice(0, -1).join(", ") + " & " + 54 - authorsFormatted.slice(-1)[0] 55 - : authorsFormatted[0] 56 - 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}); 57 - const summaryFormatted = summaryDOM.innerHTML.replace("<br />", "\n").replace( 58 - /(<([^>]+)>)/ig, 59 - "", 60 - ).split("\n") 61 - const titleString = `<b>${data.title}</b> by ${authorString}` 62 - const chapterString = data.chapterInfo ? (data.chapterInfo.name 63 - ? data.chapterInfo.name 64 - : "Chapter " + data.chapterInfo.index) : '' 65 - const chapterCountString = data.chapters 66 - ? ' | <b>Chapters:</b> '+data.chapters.published+' / '+( 67 - data.chapters.total 68 - ? data.chapters.total 69 - : '?' 70 - ) 71 - : '' 72 - 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() 73 - const headingString = `<span size='16pt'>${fandomString}</span>\n${titleString}${chapterString !== '' ? "\n<span size='36pt'><i>"+chapterString+"</i></span></span>" : ''}` 74 - const opts = { 75 - fonts: bfs.concat(tfs) 76 - } 77 - console.log(themeData) 78 - console.log(baseFontData) 79 - console.log(titleFontData) 80 - return new ImageResponse( 81 - ( 82 - <div 83 - style={{ 84 - display: "flex", 85 - flexDirection: "column", 86 - color: themeData.color, 87 - backgroundColor: themeData.background, 88 - fontFamily: baseFontData.displayName, 89 - fontSize: 24, 90 - padding: 40, 91 - width: "100%", 92 - height: "100%", 93 - }} 94 - > 95 - <div 96 - style={{ 97 - display: "flex", 98 - flexDirection: "column", 99 - marginBottom: 20 100 - }} 101 - > 102 - <div 103 - style={{ 104 - textTransform: "uppercase", 105 - display: "flex", 106 - justifyContent: "center", 107 - color: themeData.accent 108 - }} 109 - > 110 - {fandomString} 111 - </div> 112 - <div 113 - style={{ 114 - fontSize: 54, 115 - justifyContent: "center", 116 - fontFamily: titleFontData.displayName, 117 - fontWeight: "bold" 118 - }} 119 - > 120 - {data.title} 121 - </div> 122 - <div 123 - style={{ 124 - fontSize: 42, 125 - justifyContent: "center", 126 - fontFamily: titleFontData.displayName 127 - }} 128 - > 129 - {`by ${authorString}`} 130 - </div> 131 - <div 132 - style={{ 133 - fontStyle: "italic", 134 - fontSize: 36, 135 - fontFamily: titleFontData.displayName 136 - }} 137 - > 138 - {chapterString} 139 - </div> 140 - </div> 141 - <div 142 - style={{ 143 - backgroundColor: themeData.descBackground, 144 - padding: 20, 145 - display: "flex", 146 - flexDirection: "column", 147 - flexGrow: 1, 148 - color: themeData.descColor, 149 - alignItems: "flex-end" 150 - }} 151 - > 152 - <div 153 - style={{ 154 - display: "flex", 155 - flexDirection: "column", 156 - flexGrow: 1, 157 - width: '100%' 158 - }} 159 - > 160 - {summaryFormatted.map(l => ( 161 - <div 162 - style={{ 163 - width: "100%", 164 - marginBottom: 10 165 - }} 166 - > 167 - {l} 168 - </div> 169 - ))} 170 - </div> 171 - <div 172 - style={{ 173 - textAlign: "right", 174 - fontSize: 18, 175 - color: themeData.accent2 176 - }} 177 - > 178 - {`https://archiveofourown.org/${addr}`} 179 - </div> 180 - </div> 181 - </div> 182 - ), 183 - opts 184 - ) 19 + const imageParams = await sanitizeData({type: 'work', data: data, props: props}) 20 + const theme = imageParams.theme 21 + const baseFont = baseFonts[imageParams.baseFont].displayName 22 + const titleFont = titleFonts[imageParams.titleFont].displayName 23 + const opts = imageParams.opts 24 + return OGImage({theme: theme, baseFont: baseFont, titleFont: titleFont, image: imageParams, addr: addr, opts: opts}) 185 25 }
+498
src/lib/baseFonts.js
··· 1 + const baseFonts = { 2 + opensans: { 3 + displayName: 'Open Sans', 4 + defs: [ 5 + { 6 + path: '/fonts/OpenSans-Regular.ttf', 7 + style: 'normal', 8 + weight: 400 9 + }, 10 + { 11 + path: '/fonts/OpenSans-Italic.ttf', 12 + style: 'italic', 13 + weight: 400 14 + }, 15 + { 16 + path: '/fonts/OpenSans-Bold.ttf', 17 + style: 'normal', 18 + weight: 700 19 + }, 20 + { 21 + path: '/fonts/OpenSans-BoldItalic.ttf', 22 + style: 'italic', 23 + weight: 700 24 + } 25 + ] 26 + }, 27 + bricolagegrotesque: { 28 + displayName: 'Bricolage Grotesque', 29 + defs: [ 30 + { 31 + path: '/fonts/BricolageGrotesque-Regular.ttf', 32 + style: 'normal', 33 + weight: 400 34 + }, 35 + { 36 + path: '/fonts/BricolageGrotesque-Bold.ttf', 37 + style: 'normal', 38 + weight: 700 39 + } 40 + ] 41 + }, 42 + spacemono: { 43 + displayName: 'Space Mono', 44 + defs: [ 45 + { 46 + path: '/fonts/SpaceMono-Regular.ttf', 47 + style: 'normal', 48 + weight: 400 49 + }, 50 + { 51 + path: '/fonts/SpaceMono-Italic.ttf', 52 + style: 'italic', 53 + weight: 400 54 + }, 55 + { 56 + path: '/fonts/SpaceMono-Bold.ttf', 57 + style: 'normal', 58 + weight: 700 59 + }, 60 + { 61 + path: '/fonts/SpaceMono-BoldItalic.ttf', 62 + style: 'italic', 63 + weight: 700 64 + } 65 + ] 66 + }, 67 + inconsolata: { 68 + displayName: 'Inconsolata', 69 + defs: [ 70 + { 71 + path: '/fonts/Inconsolata.otf', 72 + style: 'normal' 73 + } 74 + ] 75 + }, 76 + bitter: { 77 + displayName: 'Bitter', 78 + defs: [ 79 + { 80 + path: '/fonts/Bitter-Regular.otf', 81 + style: 'normal', 82 + weight: 400 83 + }, 84 + { 85 + path: '/fonts/Bitter-Italic.otf', 86 + style: 'italic', 87 + weight: 400 88 + }, 89 + { 90 + path: '/fonts/Bitter-Bold.otf', 91 + style: 'normal', 92 + weight: 700 93 + }, 94 + { 95 + path: '/fonts/Bitter-BoldItalic.otf', 96 + style: 'italic', 97 + weight: 700 98 + } 99 + ] 100 + }, 101 + archivo: { 102 + displayName: 'Archivo', 103 + defs: [ 104 + { 105 + path: '/fonts/Archivo-Regular.ttf', 106 + style: 'normal', 107 + weight: 400 108 + }, 109 + { 110 + path: '/fonts/Archivo-Italic.ttf', 111 + style: 'italic', 112 + weight: 400 113 + }, 114 + { 115 + path: '/fonts/Archivo-Bold.ttf', 116 + style: 'normal', 117 + weight: 700 118 + }, 119 + { 120 + path: '/fonts/Archivo-BoldItalic.ttf', 121 + style: 'italic', 122 + weight: 700 123 + } 124 + ] 125 + }, 126 + outfit: { 127 + displayName: 'Outfit', 128 + defs: [ 129 + { 130 + path: '/fonts/outfit-regular-webfont.woff2', 131 + style: 'normal', 132 + weight: 400 133 + }, 134 + { 135 + path: '/fonts/outfit-italic-webfont.woff2', 136 + style: 'italic', 137 + weight: 400 138 + } 139 + ] 140 + }, 141 + notosans: { 142 + displayName: 'Noto Sans', 143 + defs: [ 144 + { 145 + path: '/fonts/NotoSans-Regular.ttf', 146 + style: 'normal', 147 + weight: 400 148 + }, 149 + { 150 + path: '/fonts/NotoSans-Italic.ttf', 151 + style: 'italic', 152 + weight: 400 153 + }, 154 + { 155 + path: '/fonts/NotoSans-Bold.ttf', 156 + style: 'normal', 157 + weight: 700 158 + }, 159 + { 160 + path: '/fonts/NotoSans-BoldItalic.ttf', 161 + style: 'italic', 162 + weight: 700 163 + } 164 + ] 165 + }, 166 + alegreya: { 167 + displayName: 'Alegreya', 168 + defs: [ 169 + { 170 + path: '/fonts/Alegreya-Regular.otf', 171 + style: 'normal', 172 + weight: 400 173 + }, 174 + { 175 + path: '/fonts/Alegreya-Italic.otf', 176 + style: 'italic', 177 + weight: 400 178 + }, 179 + { 180 + path: '/fonts/Alegreya-Bold.otf', 181 + style: 'normal', 182 + weight: 700 183 + }, 184 + { 185 + path: '/fonts/Alegreya-BoldItalic.otf', 186 + style: 'italic', 187 + weight: 700 188 + } 189 + ] 190 + }, 191 + alegreyasans: { 192 + displayName: 'Alegreya Sans', 193 + defs: [ 194 + { 195 + path: '/fonts/AlegreyaSans-Regular.otf', 196 + style: 'normal', 197 + weight: 400 198 + }, 199 + { 200 + path: '/fonts/AlegreyaSans-Italic.otf', 201 + style: 'italic', 202 + weight: 400 203 + }, 204 + { 205 + path: '/fonts/AlegreyaSans-Bold.otf', 206 + style: 'normal', 207 + weight: 700 208 + }, 209 + { 210 + path: '/fonts/AlegreyaSans-BoldItalic.otf', 211 + style: 'italic', 212 + weight: 700 213 + } 214 + ] 215 + }, 216 + stacksanstext: { 217 + displayName: 'Stack Sans Text', 218 + defs: [ 219 + { 220 + path: '/fonts/StackSansText-Regular.ttf', 221 + style: 'normal', 222 + weight: 400 223 + }, 224 + { 225 + path: '/fonts/StackSansText-Bold.ttf', 226 + style: 'normal', 227 + weight: 700 228 + } 229 + ], 230 + }, 231 + momotrustsans: { 232 + displayName: 'Momo Trust Sans', 233 + defs: [ 234 + { 235 + path: '/fonts/MomoTrustSans-Regular.ttf', 236 + style: 'normal', 237 + weight: 400 238 + }, 239 + { 240 + path: '/fonts/MomoTrustSans-Bold.ttf', 241 + style: 'normal', 242 + weight: 700 243 + } 244 + ] 245 + }, 246 + montserrat: { 247 + displayName: 'Montserrat', 248 + defs: [ 249 + { 250 + path: '/fonts/Montserrat-Regular.otf', 251 + style: 'normal', 252 + weight: 400 253 + }, 254 + { 255 + path: '/fonts/Montserrat-Italic.otf', 256 + style: 'italic', 257 + weight: 400 258 + }, 259 + { 260 + path: '/fonts/Montserrat-Bold.otf', 261 + style: 'normal', 262 + weight: 700 263 + }, 264 + { 265 + path: '/fonts/Montserrat-BoldItalic.otf', 266 + style: 'italic', 267 + weight: 700 268 + } 269 + ] 270 + }, 271 + robotoslab: { 272 + displayName: 'Roboto Slab', 273 + defs: [ 274 + { 275 + path: '/fonts/RobotoSlab-Regular.ttf', 276 + style: 'normal', 277 + weight: 400 278 + }, 279 + { 280 + path: '/fonts/RobotoSlab-Bold.ttf', 281 + style: 'normal', 282 + weight: 700 283 + } 284 + ] 285 + }, 286 + quicksand: { 287 + displayName: 'Quicksand', 288 + defs: [ 289 + { 290 + path: '/fonts/Quicksand-Regular.otf', 291 + style: 'normal', 292 + weight: 400 293 + }, 294 + { 295 + path: '/fonts/Quicksand-Italic.otf', 296 + style: 'italic', 297 + weight: 400 298 + }, 299 + { 300 + path: '/fonts/Quicksand-Bold.otf', 301 + style: 'normal', 302 + weight: 700 303 + }, 304 + { 305 + path: '/fonts/Quicksand-BoldItalic.otf', 306 + style: 'italic', 307 + weight: 700 308 + } 309 + ] 310 + }, 311 + worksans: { 312 + displayName: 'Work Sans', 313 + defs: [ 314 + { 315 + path: '/fonts/WorkSans-Regular.ttf', 316 + style: 'normal', 317 + weight: 400 318 + }, 319 + { 320 + path: '/fonts/WorkSans-Italic.ttf', 321 + style: 'italic', 322 + weight: 400 323 + }, 324 + { 325 + path: '/fonts/WorkSans-Bold.ttf', 326 + style: 'normal', 327 + weight: 700 328 + }, 329 + { 330 + path: '/fonts/WorkSans-BoldItalic.ttf', 331 + style: 'italic', 332 + weight: 700 333 + } 334 + ] 335 + }, 336 + notosans: { 337 + displayName: 'Noto Sans', 338 + defs: [ 339 + { 340 + path: '/fonts/NotoSans-Regular.ttf', 341 + style: 'normal', 342 + weight: 400 343 + }, 344 + { 345 + path: '/fonts/NotoSans-Italic.ttf', 346 + style: 'italic', 347 + weight: 400 348 + }, 349 + { 350 + path: '/fonts/NotoSans-Bold.ttf', 351 + style: 'normal', 352 + weight: 700 353 + }, 354 + { 355 + path: '/fonts/NotoSans-BoldItalic.ttf', 356 + style: 'italic', 357 + weight: 700 358 + } 359 + ] 360 + }, 361 + notoserif: { 362 + displayName: 'Noto Serif', 363 + defs: [ 364 + { 365 + path: '/fonts/NotoSerif-Regular.ttf', 366 + style: 'normal', 367 + weight: 400 368 + }, 369 + { 370 + path: '/fonts/NotoSerif-Italic.ttf', 371 + style: 'italic', 372 + weight: 400 373 + }, 374 + { 375 + path: '/fonts/NotoSerif-Bold.ttf', 376 + style: 'normal', 377 + weight: 700 378 + }, 379 + { 380 + path: '/fonts/NotoSerif-BoldItalic.ttf', 381 + style: 'italic', 382 + weight: 700 383 + } 384 + ] 385 + }, 386 + librebaskerville: { 387 + displayName: 'Libre Baskerville', 388 + defs: [ 389 + { 390 + path: '/fonts/LibreBaskerville-Regular.otf', 391 + style: 'normal', 392 + weight: 400 393 + }, 394 + { 395 + path: '/fonts/LibreBaskerville-Italic.otf', 396 + style: 'italic', 397 + weight: 400 398 + }, 399 + { 400 + path: '/fonts/LibreBaskerville-Bold.otf', 401 + style: 'normal', 402 + weight: 700 403 + } 404 + ] 405 + }, 406 + ubuntu: { 407 + displayName: 'Ubuntu', 408 + defs: [ 409 + { 410 + path: '/fonts/Ubuntu-Regular.ttf', 411 + style: 'normal', 412 + weight: 400 413 + }, 414 + { 415 + path: '/fonts/Ubuntu-Italic.ttf', 416 + style: 'italic', 417 + weight: 400 418 + }, 419 + { 420 + path: '/fonts/Ubuntu-Bold.ttf', 421 + style: 'normal', 422 + weight: 700 423 + }, 424 + { 425 + path: '/fonts/Ubuntu-BoldItalic.ttf', 426 + style: 'italic', 427 + weight: 700 428 + } 429 + ] 430 + }, 431 + parkinsans: { 432 + displayName: 'Parkinsans', 433 + defs: [ 434 + { 435 + path: '/fonts/Parkinsans-Regular.ttf', 436 + style: 'normal', 437 + weight: 400 438 + }, 439 + { 440 + path: '/fonts/Parkinsans-Bold.ttf', 441 + style: 'normal', 442 + weight: 700 443 + } 444 + ] 445 + }, 446 + lora: { 447 + displayName: 'Lora', 448 + defs: [ 449 + { 450 + path: '/fonts/Lora-Regular.ttf', 451 + style: 'normal', 452 + weight: 400 453 + }, 454 + { 455 + path: '/fonts/Lora-Italic.ttf', 456 + style: 'italic', 457 + weight: 400 458 + }, 459 + { 460 + path: '/fonts/Lora-Bold.ttf', 461 + style: 'normal', 462 + weight: 700 463 + }, 464 + { 465 + path: '/fonts/Lora-BoldItalic.ttf', 466 + style: 'italic', 467 + weight: 700 468 + } 469 + ] 470 + }, 471 + josefinsans: { 472 + displayName: 'Josefin Sans', 473 + defs: [ 474 + { 475 + path: '/fonts/JosefinSans-Regular.ttf', 476 + style: 'normal', 477 + weight: 400 478 + }, 479 + { 480 + path: '/fonts/JosefinSans-Italic.ttf', 481 + style: 'italic', 482 + weight: 400 483 + }, 484 + { 485 + path: '/fonts/JosefinSans-Bold.ttf', 486 + style: 'normal', 487 + weight: 700 488 + }, 489 + { 490 + path: '/fonts/JosefinSans-BoldItalic.ttf', 491 + style: 'italic', 492 + weight: 700 493 + } 494 + ] 495 + } 496 + } 497 + 498 + export default baseFonts
+112
src/lib/ogimage.js
··· 1 + import { ImageResponse } from "next/og" 2 + 3 + export default async function OGImage ({ theme, baseFont, titleFont, image, addr, opts }) { 4 + return new ImageResponse( 5 + ( 6 + <div 7 + style={{ 8 + display: "flex", 9 + flexDirection: "column", 10 + color: theme.color, 11 + backgroundColor: theme.background, 12 + fontFamily: baseFont, 13 + fontSize: 24, 14 + padding: 40, 15 + width: "100%", 16 + height: "100%", 17 + }} 18 + > 19 + <div 20 + style={{ 21 + display: "flex", 22 + flexDirection: "column", 23 + marginBottom: 20 24 + }} 25 + > 26 + <div 27 + style={{ 28 + textTransform: "uppercase", 29 + display: "flex", 30 + justifyContent: "center", 31 + color: theme.accent 32 + }} 33 + > 34 + {image.topLine} 35 + </div> 36 + <div 37 + style={{ 38 + fontSize: 54, 39 + justifyContent: "center", 40 + fontFamily: titleFont, 41 + fontWeight: "bold" 42 + }} 43 + > 44 + {image.titleLine} 45 + </div> 46 + <div 47 + style={{ 48 + fontSize: 42, 49 + display: "flex", 50 + justifyContent: "center", 51 + fontFamily: titleFont 52 + }} 53 + > 54 + {`by ${image.authorLine}`} 55 + </div> 56 + <div 57 + style={{ 58 + fontStyle: "italic", 59 + fontSize: 36, 60 + fontFamily: titleFont, 61 + display: "flex", 62 + justifyContent: "center" 63 + }} 64 + > 65 + {image.chapterLine} 66 + </div> 67 + </div> 68 + <div 69 + style={{ 70 + backgroundColor: theme.descBackground, 71 + padding: 20, 72 + display: "flex", 73 + flexDirection: "column", 74 + flexGrow: 1, 75 + color: theme.descColor, 76 + alignItems: "flex-end" 77 + }} 78 + > 79 + <div 80 + style={{ 81 + display: "flex", 82 + flexDirection: "column", 83 + flexGrow: 1, 84 + width: '100%' 85 + }} 86 + > 87 + {image.summary.map(l => ( 88 + <div 89 + style={{ 90 + width: "100%", 91 + marginBottom: 10 92 + }} 93 + > 94 + {l} 95 + </div> 96 + ))} 97 + </div> 98 + <div 99 + style={{ 100 + textAlign: "right", 101 + fontSize: 18, 102 + color: theme.accent2 103 + }} 104 + > 105 + {`https://archiveofourown.org/${addr}`} 106 + </div> 107 + </div> 108 + </div> 109 + ), 110 + opts 111 + ) 112 + }
+91
src/lib/sanitizeData.js
··· 1 + import { getWork } from "@fujocoded/ao3.js" 2 + import DOM from "fauxdom" 3 + import { readFile } from 'node:fs/promises' 4 + import { join } from 'node:path' 5 + import themes from '@/lib/themes.js' 6 + import baseFonts from '@/lib/baseFonts.js' 7 + import titleFonts from '@/lib/titleFonts.js' 8 + 9 + export default async function sanitizeData ({ type, data, props}) { 10 + const baseFont = props.has('baseFont') ? props.get('baseFont') : process.env.DEFAULT_BASE_FONT 11 + const baseFontData = baseFonts[baseFont] 12 + const titleFont = props.has('titleFont') ? props.get('titleFont') : process.env.DEFAULT_TITLE_FONT 13 + const titleFontData = titleFonts[titleFont] 14 + const themeData = props.has('theme') ? themes[props.get('theme')] : themes[process.env.DEFAULT_THEME] 15 + const parentWork = type === 'work' && data.chapterInfo ? await getWork({workId: data.id}) : null 16 + const bfs = await Promise.all(baseFontData.defs.map(async (bf) => { 17 + return { 18 + name: baseFontData.displayName, 19 + data: await readFile( 20 + join(process.cwd(), bf.path) 21 + ), 22 + style: bf.style, 23 + weight: bf.weight 24 + } 25 + })).then(x => x) 26 + const tfs = await Promise.all(titleFontData.defs.map(async (tf) => { 27 + return { 28 + name: titleFontData.displayName, 29 + data: await readFile( 30 + join(process.cwd(), tf.path) 31 + ), 32 + style: tf.style, 33 + weight: tf.weight 34 + } 35 + })).then(x => x) 36 + const authorsFormatted = data.authors 37 + ? data.authors.map((a) => { 38 + if (a.anonymous) return "Anonymous" 39 + if (a.pseud !== a.username) return `${a.pseud} (${a.username})` 40 + return a.username 41 + }) 42 + : [] 43 + const authorString = authorsFormatted.length > 1 44 + ? authorsFormatted.slice(0, -1).join(", ") + " & " + 45 + authorsFormatted.slice(-1)[0] 46 + : authorsFormatted[0] 47 + const summaryContent = type === 'work' 48 + ? (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 : '')))) 49 + : (props.get('summaryType') === 'custom' && props.has('customSummary') ? props.get('customSummary') : data.notes) 50 + const summaryDOM = new DOM(summaryContent, {decodeEntities: true}); 51 + const summaryFormatted = summaryDOM.innerHTML.replace(/\<br(?: \/)?\>/g, "\n").replace( 52 + /(<([^>]+)>)/ig, 53 + "", 54 + ).split("\n") 55 + const titleString = type === 'work' ? data.title : data.name 56 + const chapterString = data.chapterInfo ? (data.chapterInfo.name 57 + ? data.chapterInfo.name 58 + : "Chapter " + data.chapterInfo.index) : null 59 + const chapterCountString = data.chapters 60 + ? ' | <b>Chapters:</b> '+data.chapters.published+' / '+( 61 + data.chapters.total 62 + ? data.chapters.total 63 + : '?' 64 + ) 65 + : '' 66 + const fandomString = type === 'work' ? ( 67 + data.fandoms.length > 1 68 + ? ( 69 + data.fandoms.length <= 5 70 + ? data.fandoms.slice(0, -1).join(", ")+" & "+data.fandoms.slice(-1) 71 + : data.fandoms.join(", ")+" (+"+(data.fandoms.length - 4)+")" 72 + ) 73 + : data.fandoms[0] 74 + ) : ( 75 + data.works.map(w => w.fandoms).reduce((a, b) => { return a.concat(b) }).filter((w, i) => { return i === data.works.indexOf(w) }) 76 + ) 77 + return { 78 + topLine: fandomString, 79 + titleLine: titleString, 80 + authorLine: authorString, 81 + chapterLine: chapterString, 82 + summary: summaryFormatted, 83 + url: 'https://archiveofourown.org/', 84 + theme: themeData, 85 + baseFont: baseFont, 86 + titleFont: titleFont, 87 + opts: { 88 + fonts: bfs.concat(tfs) 89 + } 90 + } 91 + }
+94
src/lib/themes.js
··· 1 + const themes = { 2 + ao3: { 3 + name: 'AO3', 4 + background: '#990000', 5 + color: '#FFFFFF', 6 + descBackground: '#FFFFFF', 7 + descColor: '#000000', 8 + accent: '#FFFFFF', 9 + accent2: '#990000' 10 + }, 11 + softEra: { 12 + name: 'Soft Era', 13 + background: '#F9F5F5', 14 + color: '#C8B3B3', 15 + descBackground: '#F9F5F5', 16 + descColor: '#414141', 17 + accent: '#DB90A7', 18 + accent2: '#EEAABE' 19 + }, 20 + wildCherry: { 21 + name: 'Wild Cherry', 22 + background: '#2B1F32', 23 + color: '#FFFFFF', 24 + descBackground: '#FFFFFF', 25 + descColor: '#2B1F32', 26 + accent: '#E15D97', 27 + accent2: '#0AACC5' 28 + }, 29 + rosePine: { 30 + name: 'Rosé Pine', 31 + background: '#191724', 32 + color: '#e0def4', 33 + descBackground: '#1f1d2e', 34 + descColor: '#e0def4', 35 + accent: '#eb6f92', 36 + accent2: '#31748f' 37 + }, 38 + rosePineDawn: { 39 + name: 'Rosé Pine Dawn', 40 + background: '#faf4ed', 41 + color: '#575279', 42 + descBackground: '#fffaf3', 43 + descColor: '#575279', 44 + accent: '#eb6f92', 45 + accent2: '#286983' 46 + }, 47 + rosePineMoon: { 48 + name: 'Rosé Pine Moon', 49 + background: '#232136', 50 + color: '#e0def4', 51 + descBackground: '#2a273f', 52 + descColor: '#e0def4', 53 + accent: '#b4637a', 54 + accent2: '#3e8fb0' 55 + }, 56 + solarizedLight: { 57 + name: 'Solarized Light', 58 + background: '#fdf6e3', 59 + color: '#b58900', 60 + descBackground: '#eee8d5', 61 + descColor: '#002b36', 62 + accent: '#d33682', 63 + accent2: '#2aa198' 64 + }, 65 + solarizedDark: { 66 + name: 'Solarized Dark', 67 + background: '#002b36', 68 + color: '#b58900', 69 + descBackground: '#073642', 70 + descColor: '#fdf6e3', 71 + accent: '#d33682', 72 + accent2: '#2aa198' 73 + }, 74 + squidgeworld: { 75 + name: 'Squidgeworld', 76 + background: '#b8860b', 77 + color: '#f5f5dc', 78 + descBackground: '#f5f5dc', 79 + color: '#2a2a2a', 80 + accent: '#fece3f', 81 + accent2: '#818D4C' 82 + }, 83 + superlove: { 84 + name: 'Superlove', 85 + background: '#df6191', 86 + color: '#ffffff', 87 + descBackground: '#FFFFFF', 88 + color: '#2a2a2a', 89 + accent: '#F9E4E6', 90 + accent2: '#a33961' 91 + } 92 + } 93 + 94 + export default themes
+226
src/lib/titleFonts.js
··· 1 + import baseFonts from "./baseFonts.js" 2 + 3 + const titleFonts = { 4 + ...baseFonts, 5 + playfairdisplay: { 6 + displayName: 'Playfair Display', 7 + defs: [ 8 + { 9 + path: '/fonts/Playfair-Regular.ttf', 10 + style: 'normal', 11 + weight: 400 12 + }, 13 + { 14 + path: '/fonts/Playfair-Italic.ttf', 15 + style: 'italic', 16 + weight: 400 17 + }, 18 + { 19 + path: '/fonts/Playfair-Bold.ttf', 20 + style: 'normal', 21 + weight: 700 22 + }, 23 + { 24 + path: '/fonts/Playfair-BoldItalic.ttf', 25 + style: 'italic', 26 + weight: 700 27 + } 28 + ] 29 + }, 30 + ultra: { 31 + displayName: 'Ultra', 32 + defs: [ 33 + { 34 + path: '/fonts/Ultra-Regular.ttf', 35 + style: 'normal', 36 + weight: 400 37 + } 38 + ] 39 + }, 40 + stacksansheadline: { 41 + displayName: 'Stack Sans Headline', 42 + defs: [ 43 + { 44 + path: '/fonts/StackSansHeadline-Regular.ttf', 45 + style: 'normal', 46 + weight: 400 47 + }, 48 + { 49 + path: '/fonts/StackSansHeadline-Bold.ttf', 50 + style: 'normal', 51 + weight: 700 52 + } 53 + ] 54 + }, 55 + stacksansnotch: { 56 + displayName: 'Stack Sans Notch', 57 + defs: [ 58 + { 59 + path: '/fonts/StackSansNotch-Regular.ttf', 60 + style: 'normal', 61 + weight: 400 62 + }, 63 + { 64 + path: '/fonts/StackSansNotch-Bold.ttf', 65 + style: 'normal', 66 + weight: 700 67 + } 68 + ] 69 + }, 70 + titanone: { 71 + displayName: 'Titan One', 72 + defs: [] 73 + }, 74 + momotrustdisplay: { 75 + displayName: 'Momo Trust Display', 76 + defs: [ 77 + { 78 + path: '/fonts/MomoTrustDisplay-Regular.ttf', 79 + style: 'normal', 80 + weight: 400 81 + }, 82 + { 83 + path: '/fonts/MomoTrustDisplay-Bold.ttf', 84 + style: 'normal', 85 + weight: 700 86 + } 87 + ] 88 + }, 89 + momosignature: { 90 + displayName: 'Momo Signature', 91 + defs: [ 92 + { 93 + path: '/fonts/MomoSignature-Regular.ttf', 94 + style: 'normal', 95 + weight: 400 96 + } 97 + ] 98 + }, 99 + londrinasketch: { 100 + displayName: 'Londrina Sketch', 101 + defs: [ 102 + { 103 + path: '/fonts/LondrinaSketch-Regular.ttf', 104 + style: 'normal', 105 + weight: 400 106 + } 107 + ] 108 + }, 109 + londrinashadow: { 110 + displayName: 'Londrina Shadow', 111 + defs: [ 112 + { 113 + path: '/fonts/LondrinaShadow-Regular.ttf', 114 + style: 'normal', 115 + weight: 400 116 + } 117 + ] 118 + }, 119 + londrinasolid: { 120 + displayName: 'Londrina Solid', 121 + defs: [ 122 + { 123 + path: '/fonts/LondrinaSolid-Regular.ttf', 124 + style: 'normal', 125 + weight: 400 126 + }, 127 + { 128 + path: '/fonts/LondrinaSolid-Black.ttf', 129 + style: 'normal', 130 + weight: 700 131 + } 132 + ] 133 + }, 134 + bebasneue: { 135 + displayName: 'Bebas Neue', 136 + defs: [ 137 + { 138 + path: '/fonts/BebasNeue-Regular.ttf', 139 + style: 'normal', 140 + weight: 400 141 + } 142 + ] 143 + }, 144 + oswald: { 145 + displayName: 'Oswald', 146 + defs: [ 147 + { 148 + path: '/fonts/Oswald-Regular.ttf', 149 + style: 'normal', 150 + weight: 400 151 + }, 152 + { 153 + path: '/fonts/Oswald-Bold.ttf', 154 + style: 'normal', 155 + weight: 700 156 + } 157 + ] 158 + }, 159 + archivoblack: { 160 + displayName: 'Archivo Black', 161 + defs: [ 162 + { 163 + path: '/fonts/ArchivoBlack.otf', 164 + style: 'normal', 165 + weight: 400 166 + } 167 + ] 168 + }, 169 + alfaslabone: { 170 + displayName: 'Alfa Slab One', 171 + defs: [ 172 + { 173 + path: '/fonts/AlfaSlabOne-Regular.ttf', 174 + style: 'normal', 175 + weight: 400 176 + } 177 + ] 178 + }, 179 + sixtyfour: { 180 + displayName: 'SixtyFour', 181 + defs: [ 182 + { 183 + path: '/fonts/Sixtyfour-Regular.ttf', 184 + style: 'normal', 185 + weight: 400 186 + }, 187 + { 188 + path: '/fonts/Sixtyfour-Regular.ttf', 189 + style: 'normal', 190 + weight: 700 191 + } 192 + ] 193 + }, 194 + datalegreyathin: { 195 + displayName: 'Datalegreya Thin', 196 + defs: [ 197 + { 198 + path: '/fonts/Datalegreya-Thin.otf', 199 + style: 'normal', 200 + weight: 400 201 + } 202 + ] 203 + }, 204 + datalegreyadot: { 205 + displayName: 'Datalegreya Dot', 206 + defs: [ 207 + { 208 + path: '/fonts/Datalegreya-Dot.otf', 209 + style: 'normal', 210 + weight: 400 211 + } 212 + ] 213 + }, 214 + datalegreyagradient: { 215 + displayName: 'Datalegreya Gradient', 216 + defs: [ 217 + { 218 + path: '/fonts/Datalegreya-Gradient.otf', 219 + style: 'normal', 220 + weight: 400 221 + } 222 + ] 223 + } 224 + } 225 + 226 + export default titleFonts