this repo has no description
1import { file, glob } from "astro/loaders";
2import type { ZodNullable, ZodObject, ZodRawShape, ZodString } from "astro/zod";
3import { defineCollection, reference, z } from "astro:content";
4import * as YAML from "yaml";
5import PO from "pofile";
6import { wakatimeCollection } from "./wakatime";
7import { readFile } from "node:fs/promises";
8import slug from "slug";
9
10const translatedString = z.object({
11 en: z.string(),
12 fr: z.string(),
13});
14
15const nullableDate = z
16 .string()
17 .nullable()
18 .optional()
19 .transform((value) => (value ? new Date(value) : null));
20
21const year = z.number().min(2003).max(new Date().getFullYear()).int();
22
23export const collections = {
24 frenchMessages: gettextPoMessages("i18n/fr.po"),
25 englishMessages: gettextPoMessages("i18n/en.po"),
26 wakatimeLanguages: await wakatimeCollection(
27 ".wakatime-cache.json",
28 "languages",
29 ),
30 wakatimeProjects: await wakatimeCollection(
31 ".wakatime-cache.json",
32 "projects",
33 ),
34 blogEntries: defineCollection({
35 loader: glob({ pattern: "*.md", base: "./blog" }),
36 schema: z.object({
37 title: z.string(),
38 date: z.coerce.date(),
39 works: z.array(reference("works")).optional().default([]),
40 draft: z.boolean().optional().default(false),
41 }),
42 }),
43 works: defineCollection({
44 loader: file("works.json"),
45 schema: z.object({
46 id: z.string(),
47 builtAt: z.coerce.date(),
48 descriptionHash: z.string(),
49 source: z.string(),
50 metadata: z
51 .object({
52 aliases: z
53 .array(z.string())
54 .optional()
55 .nullable()
56 .default([])
57 .transform((aliases) => {
58 if (process.platform !== "win32") return aliases;
59 // On Windows, we want to some characters that are not allowed in filenames
60 return aliases?.filter((a) => !a.match(/[<>:"/\\|?*]/));
61 }),
62 finished: nullableDate,
63 started: nullableDate,
64 madeWith: z
65 .array(reference("technologies"))
66 .optional()
67 .nullable()
68 .default([]),
69 tags: z.array(reference("tags")).optional().nullable().default([]),
70 thumbnail: z.string().optional(),
71 thumbnailSource: z.string().optional(),
72 titleStyle: z.string().optional(),
73 colors: z.object({
74 primary: z.string(),
75 secondary: z.string(),
76 tertiary: z.string(),
77 }),
78 pageBackground: z.string().optional(),
79 wip: z.boolean(),
80 private: z.boolean(),
81 additionalMetadata: z
82 .object({
83 layout: z
84 .array(
85 z.union([
86 z.string().nullable(),
87 z.array(z.string().nullable()),
88 ]),
89 )
90 .optional(),
91 created: nullableDate,
92 title_style: z.string().optional(),
93 made_with: z.array(z.string()).optional(),
94 wakatime: z
95 .union([
96 z.string(),
97 z.record(z.string(), z.string().nullable()),
98 z.array(
99 z.union([
100 z.string(),
101 z.record(z.string(), z.string().nullable()),
102 ]),
103 ),
104 ])
105 .optional()
106 .transform((names) =>
107 names
108 ? typeof names === "string"
109 ? { [names]: null }
110 : Array.isArray(names)
111 ? Object.fromEntries(
112 names.map((n) => {
113 if (typeof n === "string") {
114 return [n, null];
115 }
116
117 const [[key, value]] = Object.entries(n);
118 return [key, value];
119 }),
120 )
121 : names
122 : undefined,
123 ),
124 tagline: z
125 .string()
126 .optional()
127 .transform((s) => s?.trim() || undefined),
128 })
129 .nullable()
130 .default(() => ({})),
131 databaseMetadata: z.object({
132 Partial: z.boolean(),
133 }),
134 })
135 .transform(({ started, finished, additionalMetadata, ...rest }) => ({
136 started: started ?? additionalMetadata?.created ?? null,
137 finished: finished ?? additionalMetadata?.created ?? null,
138 additionalMetadata,
139 ...rest,
140 })),
141 Partial: z.boolean(),
142 content: z.record(
143 z.object({
144 layout: z.array(z.array(z.string())),
145 title: z.string(),
146 footnotes: z.record(z.string()),
147 abbreviations: z.record(z.string()),
148 blocks: z.array(
149 z.object({
150 id: z.string(),
151 type: z.enum(["paragraph", "media", "link"]),
152 anchor: z.string(),
153 index: z.number(),
154 alt: z.string(),
155 caption: z.string(),
156 relativeSource: z.string(),
157 distSource: z.string(),
158 contentType: z.string(),
159 size: z.number(),
160 dimensions: z.object({
161 width: z.number(),
162 height: z.number(),
163 aspectRatio: z.number(),
164 }),
165 online: z.boolean(),
166 duration: z.number(),
167 hasSound: z.boolean(),
168 colors: z.object({
169 primary: z.string(),
170 secondary: z.string(),
171 tertiary: z.string(),
172 }),
173 thumbnails: z.record(z.string()).nullable().default({}),
174 thumbnailsBuiltAt: z.coerce.date().optional(),
175 attributes: z.object({
176 loop: z.boolean(),
177 autoplay: z.boolean(),
178 muted: z.boolean(),
179 playsinline: z.boolean(),
180 controls: z.boolean(),
181 }),
182 analyzed: z.boolean(),
183 hash: z.string(),
184 content: z.string(),
185 text: z.string(),
186 title: z.string(),
187 url: z.string(),
188 }),
189 ),
190 }),
191 ),
192 }),
193 }),
194 experiences: yamlDataCollection(
195 "experiences.yaml",
196 z.object({
197 time: z.union([year, z.tuple([year, year])]),
198 title: z.string(),
199 links: z.array(z.string().url()).optional().default([]),
200 company: z.object({
201 name: z.string(),
202 url: z.string().url().optional(),
203 description: z.string(),
204 }),
205 location: z.string(),
206 description: z.string(),
207 skills: z.array(z.string()).optional(),
208 }),
209 (exp) => `${exp.company.name}, ${exp.time}`,
210 ),
211 education: yamlDataCollection(
212 "education.yaml",
213 z.object({
214 time: year,
215 title: z.string(),
216 school: z.object({
217 name: z.string(),
218 url: z.string().url().optional(),
219 }),
220 location: z.string(),
221 diploma: z.object({
222 results: z
223 .object({
224 location: z.string().optional(),
225 scores: z.record(z.tuple([z.string(), z.string()])),
226 })
227 .optional(),
228 name: z.string(),
229 }),
230 }),
231 (exp) => exp.time.toString(),
232 ),
233 sites: yamlDataCollection(
234 "sites.yaml",
235 z.object({
236 name: z.string(),
237 url: z.string().url(),
238 purpose: z.string().optional(),
239 username: z.string().optional(),
240 slug: z.string(),
241 }),
242 (site) => site.name,
243 ),
244 collections: yamlDataCollection(
245 "collections.yaml",
246 z.object({
247 title: translatedString,
248 description: translatedString.optional(),
249 includes: z.string(),
250 singular: z.string().optional(),
251 plural: z.string().optional(),
252 }),
253 ),
254 tags: yamlDataCollection(
255 "tags.yaml",
256 z.object({
257 isAliasOf: z.string().nullable().default(null),
258 slug: z.string(),
259 singular: z.string(),
260 plural: z.string(),
261 description: z.string().optional(),
262 "learn more at": z.string().url().optional(),
263 detect: z
264 .object({
265 "made with": z.array(reference("technologies")).optional(),
266 })
267 .optional(),
268 }),
269 (tag) => tag.plural,
270 (tag) => [...tag.aliases, tag.singular],
271 ),
272 technologies: yamlDataCollection(
273 "technologies.yaml",
274 z.object({
275 isAliasOf: z.string().nullable().default(null),
276 aliases: z.array(z.string()).optional().default([]),
277 slug: z.string(),
278 name: z.string(),
279 by: z.string().optional(),
280 files: z.array(z.string()).optional(),
281 "learn more at": z.string().url().optional(),
282 description: z.string().optional(),
283 autodetect: z.array(z.string()).optional(),
284 includes: z.array(z.string()).optional(),
285 }),
286 (tech) => tech.name,
287 (tech) => tech.aliases,
288 ),
289};
290
291function yamlDataCollection<
292 Shape extends ZodRawShape,
293 Schema extends ZodObject<Shape>,
294>(
295 filename: string,
296 schema: Schema,
297 slugify?: (data: z.infer<Schema>) => string,
298 aliases?: (data: z.infer<Schema> & { aliases: string[] }) => string[],
299) {
300 return defineCollection({
301 schema,
302 loader: {
303 name: "YAML Loader",
304 async load({ renderMarkdown, store, generateDigest, watcher }) {
305 const raw = await readFile(filename);
306 const parsed: Array<z.infer<Schema>> | Record<string, z.infer<Schema>> =
307 YAML.parse(raw.toString());
308
309 watcher?.add(filename);
310
311 if (Array.isArray(parsed)) {
312 const entries = parsed.flatMap((data) => {
313 const out: z.infer<Schema> & {
314 slug?: string;
315 } = { ...data };
316
317 const aliasIds = aliases?.({ aliases: [], ...data }) ?? [];
318
319 if (slugify) out.slug ??= slugify(data);
320
321 return [
322 { ...out, isAliasOf: null },
323 ...aliasIds
324 .filter((slug) => slug !== out.slug)
325 .map((slug) => ({
326 ...out,
327 slug,
328 isAliasOf: out.slug,
329 })),
330 ];
331 });
332
333 store.clear();
334 for (const entry of entries) {
335 store.set({
336 id: entry.slug ?? slug(entry.title),
337 data: entry,
338 digest: generateDigest(JSON.stringify(entry)),
339 rendered: await renderMarkdown(
340 "description" in entry
341 ? typeof entry.description === "string"
342 ? entry.description
343 : entry.description[process.env.LANG === "fr" ? "fr" : "en"]
344 : "",
345 ),
346 });
347 }
348 } else {
349 store.clear();
350 for (const [key, data] of Object.entries(parsed)) {
351 store.set({
352 id: key,
353 data: data as z.infer<Schema>,
354 digest: generateDigest(JSON.stringify(data)),
355 rendered: await renderMarkdown(
356 "description" in data
357 ? typeof data.description === "string"
358 ? data.description
359 : data.description[process.env.LANG === "fr" ? "fr" : "en"]
360 : "",
361 ),
362 });
363 }
364 }
365 },
366 },
367 });
368}
369
370function gettextPoMessages(filename: string) {
371 return defineCollection({
372 schema: z.object({
373 msgid: z.string(),
374 msgstr: z.string(),
375 msgctxt: z.string().optional(),
376 }),
377 loader: file(filename, {
378 parser(text) {
379 return PO.parse(text).items.map((item) => ({
380 id: item.msgid,
381 ...item,
382 msgstr: item.msgstr[0],
383 msgctxt: item.msgctxt || undefined,
384 }));
385 },
386 }),
387 });
388}