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}