redirecter for ao3 that adds opengraph metadata
1import DOM from "fauxdom" 2import { readFile } from 'node:fs/promises' 3import querystring from 'node:querystring' 4import { join } from 'node:path' 5import themes from '@/lib/themes.js' 6import baseFonts from '@/lib/baseFonts.js' 7import titleFonts from '@/lib/titleFonts.js' 8 9const getWork = async (workId, archive = null) => { 10 const domainParam = archive ? `?archive=${archive}` : '' 11 const data = await fetch(`http://${process.env.DOMAIN}/api/works/${workId}${domainParam}`) 12 const work = await data.json() 13 return work 14} 15 16const getHighestRating = async (works, archive = null) => { 17 console.log('get rating') 18 const ratings = await Promise.all(works.map(async (w) => { 19 const work = await getWork(w.id, archive) 20 return work.rating 21 })) 22 if (ratings.includes("Not Rated")) { 23 return "NR" 24 } else if (ratings.includes("Explicit")) { 25 return "E" 26 } else if (ratings.includes("Mature")) { 27 return "M" 28 } else if (ratings.includes("Teen")) { 29 return "T" 30 } 31 return "G" 32} 33 34const getHighestWarning = async (works, archive = null) => { 35 console.log('get warning') 36 const warnings = await Promise.all(works.map(async (w) => { 37 const work = await getWork(w.id) 38 return work.tags.warnings 39 })) 40 const warningsUnique = warnings.reduce((a, b) => { return a.concat(b) }).filter((w, i) => { return i === warnings.indexOf(w) }) 41 if (warningsUnique.length === 1 && warningsUnique[0] === "Creator Chose Not To Use Archive Warnings") { 42 return "CNTW" 43 } else if (warningsUnique.length === 1 && warningsUnique[0] === "No Archive Warnings Apply") { 44 return "NW" 45 } 46 return "W" 47} 48 49const getCategory = async (works, archive = null) => { 50 console.log('get category') 51 const categories = await Promise.all(works.map(async (w) => { 52 const work = await getWork(w.id) 53 return work.category 54 })) 55 const categoriesJoined = categories.reduce((a, b) => { return a.concat(b) }) 56 const categoriesUnique = categoriesJoined.filter((w, i) => { return i === categoriesJoined.indexOf(w) }) 57 58 if (categoriesUnique.length === 1) { 59 if (categoriesUnique[0] === "F/F") return "F" 60 if (categoriesUnique[0] === "M/M") return "M" 61 if (categoriesUnique[0] === "F/M") return "FM" 62 if (categoriesUnique[0] === "Gen") return "G" 63 if (categoriesUnique[0] === "Multi") return "MX" 64 if (categoriesUnique[0] === "Other") return "O" 65 } 66 return "MX" 67} 68 69const sanitizeProps = (props) => { 70 let propsParsed = {} 71 Object.keys(props).forEach((pr) => { 72 if (props[pr] === 'true') { 73 propsParsed[pr] = true 74 return 75 } else if (props[pr] === 'false') { 76 propsParsed[pr] = false 77 return 78 } else if (typeof parseInt(props[pr]) === 'Number') { 79 propsParsed[pr] = parseInt(props[pr]) 80 return 81 } 82 propsParsed[pr] = props[pr] 83 }) 84 return propsParsed 85} 86 87export default async function sanitizeData ({ type, data, props}) { 88 const propsParsed = sanitizeProps(querystring.parse(props.toString())) 89 const archive = props.has('archive') ? props.get('archive') : 'https://archiveofourown.org' 90 const baseFont = propsParsed.baseFont ? propsParsed.baseFont : process.env.DEFAULT_BASE_FONT 91 const baseFontData = baseFonts[baseFont] 92 const titleFont = propsParsed.titleFont ? propsParsed.titleFont : process.env.DEFAULT_TITLE_FONT 93 const titleFontData = titleFonts[titleFont] 94 const themeData = propsParsed.theme ? themes[propsParsed.theme] : themes[process.env.DEFAULT_THEME] 95 const parentWork = type === 'work' && data.chapterInfo ? await getWork(data.id) : null 96 const bfs = await Promise.all(baseFontData.defs.map(async (bf) => { 97 return { 98 name: baseFontData.displayName, 99 data: await readFile( 100 join(process.cwd(), bf.path) 101 ), 102 style: bf.style, 103 weight: bf.weight 104 } 105 })).then(x => x) 106 const tfs = await Promise.all(titleFontData.defs.map(async (tf) => { 107 return { 108 name: titleFontData.displayName, 109 data: await readFile( 110 join(process.cwd(), tf.path) 111 ), 112 style: tf.style, 113 weight: tf.weight 114 } 115 })).then(x => x) 116 const authorsFormatted = data.authors 117 ? data.authors.map((a) => { 118 if (a.anonymous) return "Anonymous" 119 if (a.pseud !== a.username) return `${a.pseud} (${a.username})` 120 return a.username 121 }) 122 : [] 123 const rating = type === 'work' ? await getHighestRating([data], archive) : await getHighestRating(data.works, archive) 124 const warning = type === 'work' ? await getHighestWarning([data], archive) : await getHighestWarning(data.works, archive) 125 const category = type === 'work' ? await getCategory([data], archive) : await getCategory(data.works, archive) 126 const authorString = authorsFormatted.length > 1 127 ? authorsFormatted.slice(0, -1).join(", ") + " & " + 128 authorsFormatted.slice(-1)[0] 129 : authorsFormatted[0] 130 const summaryContent = type === 'work' 131 ? (propsParsed.summaryType === 'chapter' && data.chapterInfo && data.chapterInfo.summary ? data.chapterInfo.summary : (propsParsed.summaryType === 'custom' && propsParsed.customSummary !== '' ? propsParsed.customSummary : (data.summary ? data.summary : (parentWork ? parentWork.summary : '')))) 132 : (propsParsed.summaryType === 'custom' && propsParsed.customSummary !== '' ? propsParsed.customSummary : data.notes) 133 const formatter = new Intl.NumberFormat('en-US') 134 const words = formatter.format(data.words) 135 const summaryDOM = new DOM(summaryContent, {decodeEntities: true}); 136 const summaryFormatted = summaryDOM.innerHTML.replace(/\<br(?: \/)?\>/g, "\n").replace( 137 /(<([^>]+)>)/ig, 138 "", 139 ).split("\n") 140 const titleString = type === 'work' ? data.title : data.name 141 const chapterString = data.chapterInfo ? (data.chapterInfo.name 142 ? data.chapterInfo.name 143 : "Chapter " + data.chapterInfo.index) : null 144 const chapterCountString = type === 'work' ? (data.chapters 145 ? data.chapters.published+'/'+( 146 data.chapters.total 147 ? data.chapters.total 148 : '?' 149 ) 150 : '') : null 151 const fandomString = type === 'work' ? ( 152 data.fandoms.length > 1 153 ? ( 154 data.fandoms.length <= 2 155 ? data.fandoms.slice(0, -1).join(", ")+" & "+data.fandoms.slice(-1) 156 : data.fandoms.join(", ")+" (+"+(data.fandoms.length - 2)+")" 157 ) 158 : data.fandoms[0] 159 ) : ( 160 '' 161 ) 162 const charTags = type === 'work' ? data.tags.characters : data.works.map(w => w.tags.characters).reduce((a, b) => { return b ? (a ? a.concat(b) : []) : (a ? a : []) }).filter((w, i) => { return i === data.works.indexOf(w) }) 163 const relTags = type === 'work' ? data.tags.relationships : data.works.map(w => w.tags.relationships).reduce((a, b) => { return b ? (a ? a.concat(b) : []) : (a ? a : []) }).filter((w, i) => { return i === data.works.indexOf(w) }) 164 const freeTags = type === 'work' ? data.tags.additional : data.works.map(w => w.tags.additional).reduce((a, b) => { return b ? (a ? a.concat(b) : []) : (a ? a : []) }).filter((w, i) => { return i === data.works.indexOf(w) }) 165 const warnings = type === 'work' ? data.tags.warnings : data.works.map(w => w.tags.warnings).reduce((a, b) => { return b ? (a ? a.concat(b) : []) : (a ? a : []) }).filter((w, i) => { return i === data.works.indexOf(w) }) 166 167 return { 168 topLine: fandomString, 169 titleLine: titleString, 170 authorLine: authorString, 171 chapterLine: chapterString, 172 chapterCount: chapterCountString, 173 words: words, 174 rating: rating, 175 warning: warning, 176 category: category, 177 summary: summaryFormatted, 178 theme: themeData, 179 charTags: charTags, 180 relTags: relTags, 181 freeTags: freeTags, 182 postedAt: type === 'work' ? data.publishedAt : data.startedAt, 183 updatedAt: data.updatedAt, 184 baseFont: baseFont, 185 titleFont: titleFont, 186 props: propsParsed, 187 opts: { 188 fonts: bfs.concat(tfs) 189 } 190 } 191}