the only good website on the internet quaso.engineering
at main 4.9 kB view raw
1import * as fs from 'fs'; 2import * as crypto from 'crypto'; 3 4import showdown from 'showdown'; 5import { footnotes, highlight, prettify, spoiler, video } from '$lib/showdown'; 6import { dither } from '$lib/img'; 7 8interface Metadata { 9 title: string; 10 hero?: string; 11 series?: string; 12 part?: number; 13} 14 15export interface Post extends Metadata { 16 type: 'post'; 17 slug: string; 18 date: string; 19 readtime: number; 20 html?: string; 21} 22 23export interface Note extends Metadata { 24 type: 'note'; 25 slug: string; 26 date: string; 27 html?: string; 28} 29 30export type Article = Post | Note; 31 32const converter = new showdown.Converter({ 33 tables: true, 34 strikethrough: true, 35 // `highlight` needs to be before `prettify` 36 extensions: [footnotes, highlight, prettify, spoiler, video] 37}); 38 39const calculateReadingTime = (text: string) => { 40 const wordsPerMinute = 200; 41 const words = text 42 .trim() 43 .split(/\s+/) 44 .filter((word) => word.length > 0).length; 45 46 return Math.ceil(words / wordsPerMinute) || 1; 47}; 48 49const getContentData = (data: string, includeReadTime = true) => { 50 // because there is no metadata extension, we need to read the lines 51 const lines = data.split('\n'); 52 const metadataEnd = lines.indexOf('---', 1); 53 const body = lines.slice(metadataEnd + 1, lines.length).join('\n'); 54 const metadata: Metadata = Object.fromEntries( 55 lines.slice(1, metadataEnd).map((x: string) => x.split(/:(.*)/s).map((x) => x.trim())) 56 ) as Metadata; 57 58 const result = { 59 ...metadata, 60 html: converter.makeHtml(body) 61 }; 62 63 if (includeReadTime) { 64 return { 65 ...result, 66 readtime: Math.ceil(calculateReadingTime(body)) 67 }; 68 } 69 70 return result; 71}; 72 73const parseFilename = (file: string) => { 74 const f = file.split('/')[1].split('.')[0].split('-'); 75 const [date, slug] = [f.splice(0, 3).join('-'), f.splice(0, 1)[0]]; 76 77 if (slug !== '[cid]') return [date, slug]; 78 79 const file_buffer = fs.readFileSync(file); 80 const sum = crypto.createHash('sha256'); 81 sum.update(file_buffer); 82 return [date, sum.digest('hex')]; 83}; 84 85const getPostFileList = () => [ 86 ...fs.readdirSync('_posts').map((x) => `_posts/${x}`), 87 ...(process.env.SHOW_DRAFTS ? fs.readdirSync('_drafts').map((x) => `_drafts/${x}`) : []) 88]; 89 90const getNoteFileList = () => { 91 try { 92 return fs.readdirSync('_notes').map((x) => `_notes/${x}`); 93 } catch (e) { 94 return []; 95 } 96}; 97 98const getPosts = (): Post[] => { 99 const series: Record<string, string[]> = {}; 100 101 return getPostFileList() 102 .map((file) => { 103 const [date, slug] = parseFilename(file); 104 const post = getContentData(fs.readFileSync(file, { encoding: 'utf-8' }), true); 105 106 if (post.series) { 107 if (post.series in series) series[post.series].push(date); 108 else series[post.series] = [date]; 109 } 110 111 return { ...post, slug, date, type: 'post' } as Post; 112 }) 113 .map((post) => { 114 if (post.series) { 115 const part = series[post.series].findIndex((p) => p === post.date); 116 post.part = part; 117 } 118 return post; 119 }); 120}; 121 122const getNotes = (): Note[] => { 123 const series: Record<string, string[]> = {}; 124 125 return getNoteFileList().map((file) => { 126 const [date, slug] = parseFilename(file); 127 const note = getContentData(fs.readFileSync(file, { encoding: 'utf-8' }), false); 128 129 if (note.series) { 130 if (note.series in series) series[note.series].push(date); 131 else series[note.series] = [date]; 132 } 133 134 return { ...note, slug, date, type: 'note' } as Note; 135 }); 136}; 137 138const getPost = (snail: string) => getPosts().find((p) => p.slug === snail)!; 139const getNote = (snail: string) => getNotes().find((n) => n.slug === snail)!; 140 141const processHTML = (html: string) => { 142 html = html.replace( 143 /<img src="\/img\/(.*?)" alt="(.*?)"([^>]*)>/gi, 144 (match, imgName, altText, attrs) => { 145 const found = Object.entries(dither).find(([path, _]) => path.endsWith(imgName))?.[1]; 146 if (found) { 147 return `<img src="${found.default.img.src}" alt="${altText}" data-original="${imgName}"${attrs}>`; 148 } 149 return match; 150 } 151 ); 152 153 const footnoteContents: Record<string, string> = {}; 154 html = html.replace( 155 /<aside class="footnote-body" id="footnote-(\w+)-body"><sup>\((\w+)\)<\/sup><span>(.*?)<\/span><\/aside>/g, 156 (match, footnoteId, _, content) => { 157 footnoteContents[footnoteId] = content; 158 return ''; 159 } 160 ); 161 162 html = html.replace( 163 /<span class="footnote-link" id="footnote-(\w+)"><sup>\((\w+)\)<\/sup><\/span>/g, 164 (match, footnoteId, displayId) => { 165 const content = footnoteContents[footnoteId] || ''; 166 return ` 167 <span class="footnote-container"> 168 <sup>(${displayId})</sup> 169 <span class="footnote-content" data-footnote="${footnoteId}"> 170 <sup>(${displayId})</sup> 171 <span>${content}</span> 172 </span> 173 </span>`; 174 } 175 ); 176 177 return html; 178}; 179 180export { getPosts, getPost, getNotes, getNote, processHTML };