the only good website on the internet
quaso.engineering
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 };