@anja.codes/vitakit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +159 -0
  3. package/dist/check.d.mts +22 -0
  4. package/dist/check.mjs +196 -0
  5. package/dist/cli.d.mts +45 -0
  6. package/dist/cli.mjs +225 -0
  7. package/dist/index.d.mts +398 -0
  8. package/dist/index.mjs +21 -0
  9. package/dist/nuxt.d.mts +15 -0
  10. package/dist/nuxt.mjs +98 -0
  11. package/dist/runtime/components/VitaDate.vue +20 -0
  12. package/dist/runtime/components/VitaEntry.vue +24 -0
  13. package/dist/runtime/components/VitaIssues.vue +25 -0
  14. package/dist/runtime/components/VitaLinks.vue +19 -0
  15. package/dist/runtime/components/VitaMarkdown.vue +14 -0
  16. package/dist/runtime/components/VitaPublication.vue +30 -0
  17. package/dist/runtime/components/VitaPublications.vue +39 -0
  18. package/dist/runtime/components/VitaSection.vue +60 -0
  19. package/dist/runtime/composables/useVitakit.ts +47 -0
  20. package/dist/schemas/education.ts +21 -0
  21. package/dist/schemas/employment.ts +21 -0
  22. package/dist/schemas/publications.ts +48 -0
  23. package/dist/schemas/supervision.ts +20 -0
  24. package/dist/schemas/talks.ts +19 -0
  25. package/dist/schemas/teaching.ts +19 -0
  26. package/dist/shared/index.d.mts +68 -0
  27. package/dist/shared/index.mjs +57 -0
  28. package/dist/shared/vitakit.BRhjqpAt.mjs +250 -0
  29. package/dist/shared/vitakit.BlU-p01e.mjs +14 -0
  30. package/dist/shared/vitakit.BxcnlFGP.mjs +325 -0
  31. package/dist/shared/vitakit.CypCF-4Q.mjs +503 -0
  32. package/dist/shared/vitakit.D8WRg_Xx.d.mts +153 -0
  33. package/dist/styles/vitakit-math.csl +314 -0
  34. package/dist/tex/templates/_generic.eta +6 -0
  35. package/dist/tex/templates/education.eta +9 -0
  36. package/dist/tex/templates/employment.eta +9 -0
  37. package/dist/tex/templates/publications.eta +7 -0
  38. package/dist/tex/templates/supervision.eta +4 -0
  39. package/dist/tex/templates/talks.eta +3 -0
  40. package/dist/tex/templates/teaching.eta +6 -0
  41. package/dist/tex/vitakit-cv.cls +119 -0
  42. package/dist/tex.d.mts +93 -0
  43. package/dist/tex.mjs +129 -0
  44. package/package.json +121 -0
  45. package/src/bin/vitakit-build-cv.mjs +50 -0
  46. package/src/bin/vitakit-check.mjs +57 -0
  47. package/src/bin/vitakit.mjs +158 -0
  48. package/styles/vitakit-math.csl +314 -0
@@ -0,0 +1,503 @@
1
+ import { readFile, stat, readdir } from 'node:fs/promises';
2
+ import { join, isAbsolute, resolve } from 'node:path';
3
+ import matter from 'gray-matter';
4
+ import { parse } from 'yaml';
5
+ import { unified } from 'unified';
6
+ import remarkParse from 'remark-parse';
7
+ import remarkGfm from 'remark-gfm';
8
+ import remarkMath from 'remark-math';
9
+ import remarkRehype from 'remark-rehype';
10
+ import rehypeKatex from 'rehype-katex';
11
+ import rehypeRaw from 'rehype-raw';
12
+ import rehypeStringify from 'rehype-stringify';
13
+ import { visit } from 'unist-util-visit';
14
+ import { m as fail, p as formatZodIssues, o as resolveAsset, q as importProjectModule, s as schemas, k as atMost, w as renderRichFields, x as resolveSitePaths, I as Issues, y as profile, z as absoluteSitePath, A as whereOf, B as messageOf, C as page } from './vitakit.BxcnlFGP.mjs';
15
+ import { existsSync } from 'node:fs';
16
+ import { badgePresets, Bibliography } from '@behackl/citation-js-extras';
17
+ import rehypeParse from 'rehype-parse';
18
+ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
19
+ import { z } from 'zod';
20
+
21
+ const URL_PROPERTIES = ["href", "src", "poster", "data", "action", "formAction"];
22
+ const isRootRelative = (value) => value.startsWith("/") && !value.startsWith("//");
23
+ function prefixRootRelative(value, prefix) {
24
+ return prefix && isRootRelative(value) ? `${prefix}${value}` : value;
25
+ }
26
+ function baseUrlPlugin(baseURL) {
27
+ const prefix = baseURL.replace(/\/$/, "");
28
+ return () => (tree) => {
29
+ if (!prefix) return;
30
+ visit(tree, "element", (node) => {
31
+ const properties = node.properties;
32
+ if (!properties) return;
33
+ for (const name of URL_PROPERTIES) {
34
+ const value = properties[name];
35
+ if (typeof value === "string") properties[name] = prefixRootRelative(value, prefix);
36
+ }
37
+ const srcSet = properties.srcSet;
38
+ if (typeof srcSet === "string" || Array.isArray(srcSet)) {
39
+ const candidates = typeof srcSet === "string" ? srcSet.split(/\s*,\s+|\s*,(?=\/)/) : srcSet;
40
+ properties.srcSet = candidates.map((candidate) => String(candidate).trim()).filter(Boolean).map((candidate) => prefixRootRelative(candidate, prefix)).join(", ");
41
+ }
42
+ });
43
+ };
44
+ }
45
+ const ERROR_COLOR = "#c0ffee";
46
+ const ONE_LINE_DISPLAY = /^([ \t]*)\$\$[ \t]*(\S[^\n]*?)[ \t]*\$\$[ \t]*$/;
47
+ const FENCE = /^[ \t]*(`{3,}|~{3,})/;
48
+ function normaliseDisplayMath(source) {
49
+ let fence;
50
+ return source.split("\n").map((line) => {
51
+ const marker = FENCE.exec(line)?.[1];
52
+ if (fence === void 0) {
53
+ if (marker) fence = marker;
54
+ } else if (marker && marker.startsWith(fence[0]) && marker.length >= fence.length) {
55
+ fence = void 0;
56
+ return line;
57
+ }
58
+ if (fence !== void 0) return line;
59
+ return line.replace(ONE_LINE_DISPLAY, "$1$$$$\n$2\n$1$$$$");
60
+ }).join("\n");
61
+ }
62
+ function createRenderer({ baseURL = "/" } = {}) {
63
+ const processor = unified().use(remarkParse).use(remarkGfm).use(remarkMath).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeKatex, { strict: "error", trust: false, errorColor: ERROR_COLOR }).use(baseUrlPlugin(baseURL)).use(rehypeStringify, { allowDangerousHtml: true });
64
+ function markdown(source) {
65
+ const file = processor.processSync(normaliseDisplayMath(source));
66
+ if (file.messages.length) {
67
+ const details = file.messages.map((message) => ` - line ${message.line ?? "?"}: ${message.cause?.message ?? message.reason}`).join("\n");
68
+ throw new Error(`invalid TeX
69
+ ${details}`);
70
+ }
71
+ const html = String(file).trim();
72
+ if (html.includes(ERROR_COLOR)) {
73
+ const refused = /<annotation[^>]*>([^<]*)<\/annotation>/.exec(html)?.[1] ?? source;
74
+ throw new Error(
75
+ `KaTeX refused to render: ${refused.trim()}
76
+ commands such as \\href, \\url and \\includegraphics are disabled, because
77
+ rendered mathematics is inserted into the page without further sanitization.`
78
+ );
79
+ }
80
+ return html;
81
+ }
82
+ function inline(source) {
83
+ const html = markdown(source);
84
+ const unwrapped = /^<p>([\s\S]*)<\/p>$/.exec(html);
85
+ if (!unwrapped) throw new Error(`expected a single paragraph, got: ${html}`);
86
+ return unwrapped[1];
87
+ }
88
+ return { markdown, inline };
89
+ }
90
+
91
+ function publications(input2) {
92
+ return input2;
93
+ }
94
+ const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
95
+ const callable = z.custom((value) => typeof value === "function", "expected a function");
96
+ const badge = z.strictObject({
97
+ field: z.string().min(1),
98
+ label: z.union([z.string(), callable]),
99
+ url: z.union([z.string(), callable]),
100
+ match: z.instanceof(RegExp).optional(),
101
+ split: z.union([z.string(), z.instanceof(RegExp)]).optional(),
102
+ className: z.string().optional()
103
+ });
104
+ const input = z.strictObject({
105
+ groups: z.array(z.strictObject({
106
+ id: z.string().regex(ID, "expected a lowercase id such as `preprint` or `talk-slides`"),
107
+ label: z.string().trim().min(1)
108
+ })).min(1, "expected at least one group").optional(),
109
+ defaultGroup: z.string().optional(),
110
+ badges: z.array(badge).optional(),
111
+ fields: z.array(z.string().regex(/^[a-z][a-z0-9_-]*$/i, "expected a BibTeX field name")).optional(),
112
+ cslStyle: z.string().trim().min(1).optional()
113
+ });
114
+ function resolvePublications(value, defaults = { groups: [], defaultGroup: "", badges: [], fields: [] }, where = "schemas/publications.ts") {
115
+ const parsed = input.safeParse(value);
116
+ if (!parsed.success) fail(where, `invalid
117
+ ${formatZodIssues(parsed.error)}`);
118
+ const groups = parsed.data.groups ?? defaults.groups;
119
+ const ids = groups.map((group) => group.id);
120
+ const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
121
+ if (duplicate) fail(where, `group "${duplicate}" is defined twice`);
122
+ const defaultGroup = parsed.data.defaultGroup ?? (parsed.data.groups ? ids[0] : defaults.defaultGroup);
123
+ if (!ids.includes(defaultGroup)) {
124
+ fail(where, `defaultGroup "${defaultGroup}" is not one of the groups (${ids.join(", ")})`);
125
+ }
126
+ return {
127
+ groups,
128
+ defaultGroup,
129
+ badges: parsed.data.badges ?? defaults.badges,
130
+ fields: parsed.data.fields ?? defaults.fields,
131
+ ...parsed.data.cslStyle ? { cslStyle: parsed.data.cslStyle } : {}
132
+ };
133
+ }
134
+
135
+ const packageDefaults = publications({
136
+ /**
137
+ * The values of the `status` field, in the order the groups appear. A
138
+ * status that is not listed here is an error, in any capitalisation.
139
+ */
140
+ groups: [
141
+ { id: "published", label: "Published" },
142
+ { id: "accepted", label: "Accepted" },
143
+ { id: "preprint", label: "Preprints" },
144
+ { id: "software", label: "Software" },
145
+ { id: "other", label: "Other" }
146
+ ],
147
+ /** The group of an entry without `status`; the first group if left out. */
148
+ defaultGroup: "published",
149
+ /**
150
+ * BibTeX fields that become links after a reference on the website. The
151
+ * presets accept the spellings real exports use (`doi:10...`, doi.org URLs,
152
+ * `arXiv:...`, `MR1234567`, `Zbl 1234.56789`) and biblatex's `eprint`. A
153
+ * badge of your own, e.g. for slides in `public/slides/`:
154
+ *
155
+ * { field: 'slides', label: 'Slides', url: '/slides/$1.pdf', className: 'vita-badge' }
156
+ */
157
+ badges: [
158
+ { ...badgePresets.doi, className: "vita-badge" },
159
+ { ...badgePresets.arxiv, className: "vita-badge" },
160
+ { ...badgePresets.mrnumber, className: "vita-badge" },
161
+ { ...badgePresets.zbl, className: "vita-badge" }
162
+ ],
163
+ /**
164
+ * Further BibTeX fields a component can show, as `entry.fields.<name>`,
165
+ * e.g. `['project']` for a copy of VitaPublication that names the project.
166
+ */
167
+ fields: []
168
+ });
169
+
170
+ const defaultPublications = resolvePublications(packageDefaults, void 0, "src/schemas/publications.ts");
171
+ defaultPublications.badges;
172
+ defaultPublications.groups;
173
+ async function loadPublicationsSchema(rootDir) {
174
+ const where = "schemas/publications.ts";
175
+ const path = join(rootDir, where);
176
+ const stats = await stat(path).catch(() => void 0);
177
+ if (!stats) return defaultPublications;
178
+ const module = await importProjectModule(path, stats.mtimeMs, where);
179
+ if (module.default === void 0) {
180
+ fail(where, "must export the settings as its default export: `export default publications({ ... })`");
181
+ }
182
+ const schema = resolvePublications(module.default, defaultPublications, where);
183
+ return schema.cslStyle && /\.csl$/i.test(schema.cslStyle) && !isAbsolute(schema.cslStyle) ? { ...schema, cslStyle: resolve(rootDir, schema.cslStyle) } : schema;
184
+ }
185
+ function allowClasses(tag, ...classNames) {
186
+ const rules = [...defaultSchema.attributes?.[tag] ?? []];
187
+ const index = rules.findIndex((rule) => Array.isArray(rule) && rule[0] === "className");
188
+ const existing = index === -1 ? [] : rules[index].slice(1);
189
+ const merged = ["className", ...existing, ...classNames];
190
+ if (index === -1) rules.push(merged);
191
+ else rules[index] = merged;
192
+ return rules;
193
+ }
194
+ const sanitizer = unified().use(rehypeParse, { fragment: true }).use(rehypeSanitize, {
195
+ ...defaultSchema,
196
+ attributes: {
197
+ ...defaultSchema.attributes,
198
+ li: [...defaultSchema.attributes?.li ?? [], "dataCslEntryId"],
199
+ div: allowClasses("div", "csl-block", "csl-left-margin", "csl-right-inline", "csl-indent"),
200
+ // citeproc uses these fixed styles for formatting without semantic tags,
201
+ // e.g. upright emphasis within an italic title. Never allow arbitrary CSS.
202
+ span: [...defaultSchema.attributes?.span ?? [], [
203
+ "style",
204
+ "font-style:normal;",
205
+ "font-variant:small-caps;",
206
+ "font-variant:normal;",
207
+ "font-weight:normal;",
208
+ "text-decoration:none;",
209
+ "text-decoration:underline;"
210
+ ]]
211
+ }
212
+ }).use(rehypeStringify);
213
+ const parser = unified().use(rehypeParse, { fragment: true });
214
+ const serializer = unified().use(rehypeStringify);
215
+ function splitList(html) {
216
+ const items = /* @__PURE__ */ new Map();
217
+ for (const list of parser.parse(html).children) {
218
+ if (list.type !== "element") continue;
219
+ for (const item of list.children) {
220
+ if (item.type !== "element" || item.tagName !== "li") continue;
221
+ const inner = { type: "root", children: item.children };
222
+ items.set(String(item.properties.dataCslEntryId), serializer.stringify(inner).trim());
223
+ }
224
+ }
225
+ return items;
226
+ }
227
+ const defaultCslStyle = join(resolveAsset("styles"), "vitakit-math.csl");
228
+ function projectCslStyle(bibPath) {
229
+ const path = bibPath.replace(/\.bib$/i, ".csl");
230
+ return path !== bibPath && existsSync(path) ? path : void 0;
231
+ }
232
+ const GENRES = {
233
+ phdthesis: "PhD thesis",
234
+ mastersthesis: "Master's thesis",
235
+ mathesis: "Master's thesis",
236
+ techreport: "Technical report"
237
+ };
238
+ const readableGenre = (html, { variable }) => variable === "genre" ? GENRES[html.toLowerCase()] ?? html : html;
239
+ async function loadPublications(bibPath, renderer, schema = defaultPublications, baseURL = "/") {
240
+ const { groups, fields } = schema;
241
+ const prefix = baseURL.replace(/\/$/, "");
242
+ const withFile = (error) => new Error(`${bibPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
243
+ let bib;
244
+ try {
245
+ bib = new Bibliography({
246
+ data: await readFile(bibPath, "utf8"),
247
+ cslStyle: schema.cslStyle ?? projectCslStyle(bibPath) ?? defaultCslStyle,
248
+ // Only fields listed here survive the citation-js pipeline; they then
249
+ // appear as `entry.custom.<field>`. Badges read the raw fields.
250
+ customFields: ["status", ...fields],
251
+ preserveMath: true,
252
+ // Defaults for every formatHtml call below.
253
+ badges: schema.badges,
254
+ sanitize: (html) => String(sanitizer.processSync(html)),
255
+ wrapVariable: readableGenre,
256
+ linkAttributes: (link) => ({ href: prefixRootRelative(link.url, prefix) })
257
+ });
258
+ } catch (error) {
259
+ throw withFile(error);
260
+ }
261
+ const statusOf = (entry) => (entry.custom.status?.trim() || schema.defaultGroup).toLowerCase();
262
+ const known = new Set(groups.map((group) => group.id));
263
+ const unknown = bib.entries.filter((entry) => !known.has(statusOf(entry)));
264
+ if (unknown.length) {
265
+ throw new Error(
266
+ `${bibPath}: unknown status ${[...new Set(unknown.map(statusOf))].join(", ")} on ${unknown.map((entry) => entry.key).join(", ")}; expected one of ${[...known].join(", ")} (groups are defined in schemas/publications.ts)`
267
+ );
268
+ }
269
+ const result = [];
270
+ for (const group of groups) {
271
+ const entries = bib.sort(bib.entries.filter((entry) => statusOf(entry) === group.id), { by: "date" });
272
+ if (!entries.length) continue;
273
+ let items;
274
+ try {
275
+ items = splitList(bib.formatHtml(entries, {
276
+ // Badges are data (`Publication.badges`), laid out by the component.
277
+ appendBadges: false,
278
+ // Inserted after sanitizing. citation-js-extras names the citation key
279
+ // when a formula cannot be rendered; the file is added by withFile.
280
+ renderMath: (tex, { display }) => display ? renderer.markdown(`$$${tex}$$`) : renderer.inline(`$${tex}$`)
281
+ }));
282
+ } catch (error) {
283
+ throw withFile(error);
284
+ }
285
+ const publications = entries.map((entry) => {
286
+ const html = items.get(entry.key);
287
+ if (html === void 0) throw new Error(`${bibPath}: ${entry.key} is missing from the formatted list`);
288
+ const { title, badges } = bib.links(entry);
289
+ return {
290
+ key: entry.key,
291
+ html,
292
+ ...title ? { titleUrl: prefixRootRelative(title.url, prefix) } : {},
293
+ fields: Object.fromEntries(fields.filter((field) => entry.custom[field] !== void 0).map((field) => [field, entry.custom[field]])),
294
+ badges: badges.map((badge) => ({
295
+ field: badge.field,
296
+ value: badge.value,
297
+ label: badge.label,
298
+ url: prefixRootRelative(badge.url, prefix),
299
+ ...badge.className ? { className: badge.className } : {}
300
+ }))
301
+ };
302
+ });
303
+ result.push({ id: group.id, label: group.label, count: entries.length, entries: publications });
304
+ }
305
+ return result;
306
+ }
307
+
308
+ const RESERVED = /* @__PURE__ */ new Set(["site", "profile"]);
309
+ async function exists(path) {
310
+ return stat(path).then(() => true, () => false);
311
+ }
312
+ async function loadRepoSchema(path, id) {
313
+ const { mtimeMs } = await stat(path);
314
+ const module = await importProjectModule(path, mtimeMs, `schemas/${id}.ts`);
315
+ const schema = module.default ?? module.schema;
316
+ if (!schema || typeof schema.safeParse !== "function") {
317
+ fail(`schemas/${id}.ts`, "must export a Zod schema as its default export");
318
+ }
319
+ return schema;
320
+ }
321
+ const STARTER = (id) => `create schemas/${id}.ts, for example:
322
+
323
+ import { z, section, dateRange } from '@anja.codes/vitakit'
324
+
325
+ export default section({
326
+ date: dateRange,
327
+ title: z.string(),
328
+ })`;
329
+ async function discoverSections(rootDir) {
330
+ const contentDir = join(rootDir, "content");
331
+ const schemaDir = join(rootDir, "schemas");
332
+ const files = (await readdir(contentDir).catch(() => fail("content/", "directory is missing"))).filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")).sort();
333
+ const sources = [];
334
+ for (const file of files) {
335
+ const id = file.replace(/\.ya?ml$/, "");
336
+ if (RESERVED.has(id)) continue;
337
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) {
338
+ fail(`content/${file}`, "file name must be lowercase kebab-case, e.g. `research-stays.yml`");
339
+ }
340
+ const override = join(schemaDir, `${id}.ts`);
341
+ if (await exists(override)) {
342
+ sources.push({ id, file: join(contentDir, file), schemaOrigin: "repo", schema: await loadRepoSchema(override, id) });
343
+ continue;
344
+ }
345
+ const builtin = schemas[id];
346
+ if (!builtin) fail(`content/${file}`, `no schema for this file; ${STARTER(id)}`);
347
+ sources.push({ id, file: join(contentDir, file), schemaOrigin: "built-in", schema: builtin });
348
+ }
349
+ return sources;
350
+ }
351
+ function byDateDesc(a, b) {
352
+ const left = a.date?.from?.sortKey;
353
+ const right = b.date?.from?.sortKey;
354
+ if (left === right) return 0;
355
+ if (!left) return 1;
356
+ if (!right) return -1;
357
+ return right.localeCompare(left);
358
+ }
359
+ async function loadSection(source, options) {
360
+ const where = `content/${source.id}.yml`;
361
+ const raw = await readFile(source.file, "utf8");
362
+ let data;
363
+ try {
364
+ data = parse(raw);
365
+ } catch (error) {
366
+ fail(where, error instanceof Error ? error.message : String(error));
367
+ }
368
+ if (data === null) return [];
369
+ if (!Array.isArray(data)) fail(where, "expected a list of entries");
370
+ const parsed = source.schema.safeParse(data);
371
+ if (!parsed.success) {
372
+ const origin = source.schemaOrigin === "repo" ? ` (schema: schemas/${source.id}.ts)` : "";
373
+ fail(where, `invalid${origin}
374
+ ${formatZodIssues(parsed.error)}`);
375
+ }
376
+ return parsed.data.map((item, index) => ({ ...item, id: `${source.id}-${index}` })).filter((item) => atMost(item.visibility, options.maxVisibility)).map((item, index) => renderRichFields(item, options.renderer, String(index), where)).map((item) => resolveSitePaths(item, options.resolvePath ?? ((path) => path))).sort(byDateDesc);
377
+ }
378
+
379
+ const FALLBACK_PROFILE = { name: "Your Name", links: [], nav: [], footerHtml: "" };
380
+ async function loadYaml(file, where) {
381
+ const raw = await readFile(file, "utf8").catch(() => fail(where, "missing"));
382
+ try {
383
+ return parse(raw);
384
+ } catch (error) {
385
+ fail(where, messageOf(error));
386
+ }
387
+ }
388
+ async function loadProfile(contentDir, baseURL, renderer, target) {
389
+ const data = await loadYaml(join(contentDir, "profile.yml"), "content/profile.yml");
390
+ const parsed = profile.safeParse(data);
391
+ if (!parsed.success) fail("content/profile.yml", `invalid
392
+ ${formatZodIssues(parsed.error)}`);
393
+ const prefix = baseURL.replace(/\/$/, "");
394
+ const { avatar, nav, footer, ...rest } = parsed.data;
395
+ return resolveSitePaths({
396
+ ...rest,
397
+ ...avatar ? { avatar: prefixRootRelative(avatar, prefix) } : {},
398
+ nav: nav.map((item) => ({ label: item.label, url: item.to })),
399
+ footerHtml: footer ? renderer.inline(footer) : ""
400
+ }, sitePathResolver(target, baseURL, parsed.data.url));
401
+ }
402
+ function sitePathResolver(target, baseURL, siteUrl) {
403
+ const prefix = baseURL.replace(/\/$/, "");
404
+ return target === "pdf" ? (path) => absoluteSitePath(path, siteUrl) : (path) => prefixRootRelative(path, prefix);
405
+ }
406
+ function errorPage(route, where, message) {
407
+ const escaped = message.replace(/[&<>]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[character]);
408
+ return {
409
+ route,
410
+ title: `Error in ${where}`,
411
+ titleHtml: `Error in <code>${where}</code>`,
412
+ order: -1,
413
+ bodyHtml: `<pre>${escaped}</pre>`,
414
+ error: message
415
+ };
416
+ }
417
+ async function loadPage(dir, file, renderer) {
418
+ const where = `content/pages/${file}`;
419
+ const slug = file.slice(0, -".md".length);
420
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
421
+ fail(where, "file name must be lowercase kebab-case, e.g. `research-interests.md`");
422
+ }
423
+ const { data, content } = matter(await readFile(join(dir, file), "utf8"));
424
+ const parsed = page.safeParse(data);
425
+ if (!parsed.success) fail(where, `invalid front matter
426
+ ${formatZodIssues(parsed.error)}`);
427
+ const route = parsed.data.route ?? (slug === "index" ? "/" : `/${slug}/`);
428
+ try {
429
+ return {
430
+ route,
431
+ title: parsed.data.title,
432
+ titleHtml: renderer.inline(parsed.data.title),
433
+ description: parsed.data.description,
434
+ order: parsed.data.order,
435
+ bodyHtml: content.trim() ? renderer.markdown(content) : ""
436
+ };
437
+ } catch (error) {
438
+ fail(where, messageOf(error));
439
+ }
440
+ }
441
+ async function loadPages(contentDir, renderer, issues) {
442
+ const dir = join(contentDir, "pages");
443
+ const files = (await readdir(dir).catch(() => [])).filter((name) => name.endsWith(".md")).sort();
444
+ const pages = await Promise.all(files.map(async (file) => {
445
+ try {
446
+ return await loadPage(dir, file, renderer);
447
+ } catch (error) {
448
+ if (issues.strict) throw error;
449
+ const where = whereOf(error, `content/pages/${file}`);
450
+ const message = messageOf(error);
451
+ issues.record(where, message);
452
+ const slug = file.slice(0, -".md".length);
453
+ return errorPage(slug === "index" ? "/" : `/${slug}/`, where, message);
454
+ }
455
+ }));
456
+ const seen = /* @__PURE__ */ new Map();
457
+ for (const [index, page] of pages.entries()) {
458
+ const previous = seen.get(page.route);
459
+ if (previous) issues.record(`content/pages/${files[index]}`, `route ${page.route} is already used by ${previous}`);
460
+ else seen.set(page.route, `content/pages/${files[index]}`);
461
+ }
462
+ return pages.sort((a, b) => a.order - b.order || a.route.localeCompare(b.route));
463
+ }
464
+ async function loadSections(rootDir, maxVisibility, renderer, resolvePath, issues) {
465
+ const sources = await issues.attempt("content/", () => discoverSections(rootDir), []);
466
+ const sections = {};
467
+ for (const source of sources) {
468
+ sections[source.id] = await issues.attempt(
469
+ `content/${source.id}.yml`,
470
+ () => loadSection(source, { maxVisibility, renderer, resolvePath }),
471
+ []
472
+ );
473
+ }
474
+ return sections;
475
+ }
476
+ async function loadContent(rootDir, options = {}) {
477
+ const strict = options.strict ?? true;
478
+ const baseURL = options.baseURL ?? "/";
479
+ const maxVisibility = options.maxVisibility ?? "public";
480
+ const issues = new Issues(strict);
481
+ const renderer = createRenderer({ baseURL });
482
+ const contentDir = join(rootDir, "content");
483
+ const target = options.target ?? "website";
484
+ const profile = await issues.attempt("content/profile.yml", () => loadProfile(contentDir, baseURL, renderer, target), FALLBACK_PROFILE);
485
+ const resolvePath = sitePathResolver(target, baseURL, profile.url);
486
+ const [pages, sections, publications] = await Promise.all([
487
+ loadPages(contentDir, renderer, issues),
488
+ loadSections(rootDir, maxVisibility, renderer, resolvePath, issues),
489
+ issues.attempt(
490
+ "content/publications.bib",
491
+ async () => {
492
+ const bib = join(contentDir, "publications.bib");
493
+ const present = await readFile(bib, "utf8").then(() => true, () => false);
494
+ if (!present) return [];
495
+ return loadPublications(bib, renderer, await loadPublicationsSchema(rootDir), baseURL);
496
+ },
497
+ []
498
+ )
499
+ ]);
500
+ return { profile, pages, sections, publications, issues: issues.list };
501
+ }
502
+
503
+ export { loadPublicationsSchema as a, loadContent as l, publications as p };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The contract between the build (Node) and the app (Vue).
3
+ *
4
+ * Everything with an `Html` suffix is trusted markup produced at build time;
5
+ * render it with `v-html`. Nothing here is a Zod schema: schemas describe what
6
+ * a user may *write*, these types describe what a component *receives*.
7
+ */
8
+ /** How widely an entry may be shown. */
9
+ type Visibility = 'public' | 'cv' | 'private';
10
+ /** A calendar point with the precision the user actually wrote. */
11
+ interface DatePoint {
12
+ /** ISO-ish source: `2024`, `2024-03`, or `2024-03-15`. */
13
+ raw: string;
14
+ year: number;
15
+ month?: number;
16
+ day?: number;
17
+ /** Sort key: always comparable across precisions. */
18
+ sortKey: string;
19
+ }
20
+ /** A point in time or a span, normalised from what the user wrote. */
21
+ interface DateRange {
22
+ from: DatePoint;
23
+ /** Absent for a single date or an ongoing range; see `ongoing`. */
24
+ to?: DatePoint;
25
+ /** True when the user wrote `to: present`. */
26
+ ongoing: boolean;
27
+ /** Whether the source was a single date rather than a range. */
28
+ point: boolean;
29
+ }
30
+ /**
31
+ * Intermediate form of a `richText` field, before rendering.
32
+ *
33
+ * Internal: it exists only between validation and `renderRichFields`, never in
34
+ * a `VitakitContent`. `vitakit-check` fails the build if one reaches the output.
35
+ * Read rich fields with `richSource` / `richHtml` instead of inspecting shapes.
36
+ */
37
+ interface RichTextMarker {
38
+ __vitakitRich: string;
39
+ }
40
+ /** A field that went through the Markdown/KaTeX renderer. */
41
+ interface RichText {
42
+ /** The Markdown source, for plain-text contexts such as `title` attributes. */
43
+ text: string;
44
+ /** Rendered inline HTML; use with `v-html`. */
45
+ html: string;
46
+ }
47
+ interface Link {
48
+ label: string;
49
+ url: string;
50
+ }
51
+ interface Institution {
52
+ name: string;
53
+ url?: string;
54
+ }
55
+ /** Fields every section entry has, whatever else its schema adds. */
56
+ interface EntryBase {
57
+ /** Stable identity for `v-for` keys: the source file plus the index. */
58
+ id: string;
59
+ visibility: Visibility;
60
+ }
61
+ /** A validated entry of an arbitrary section. */
62
+ type Entry = EntryBase & Record<string, unknown>;
63
+ /** A link after a reference: DOI, arXiv, MR, zbMATH, or a badge of your own. */
64
+ interface PublicationBadge {
65
+ /** The BibTeX field it comes from, e.g. `doi`. */
66
+ field: string;
67
+ /** The identifier as matched, e.g. `10.1000/xyz` or `2301.00001`. */
68
+ value: string;
69
+ label: string;
70
+ /** Ready to use: root-relative URLs already carry the base path. */
71
+ url: string;
72
+ /** The badge's `className`; `vita-badge` for the built-in ones. */
73
+ className?: string;
74
+ }
75
+ /** One formatted reference. */
76
+ interface Publication {
77
+ /** Citation key in the .bib file. */
78
+ key: string;
79
+ /**
80
+ * The reference as the citation style formats it, title linked, formulas
81
+ * typeset; without the badges. Sanitized HTML.
82
+ */
83
+ html: string;
84
+ /** Where the title links to, if anywhere (already in `html`). */
85
+ titleUrl?: string;
86
+ badges: PublicationBadge[];
87
+ /**
88
+ * The BibTeX fields listed under `fields` in `schemas/publications.ts`, as
89
+ * written in the .bib file, where the entry has them.
90
+ */
91
+ fields: Record<string, string>;
92
+ }
93
+ /** One group of formatted references, e.g. all preprints. */
94
+ interface PublicationGroup {
95
+ id: string;
96
+ label: string;
97
+ count: number;
98
+ /**
99
+ * Newest first. Formatted together, so numbering and year suffixes
100
+ * (2024a, 2024b) are right within the group.
101
+ */
102
+ entries: Publication[];
103
+ }
104
+ interface Page {
105
+ /** Route path, with leading and trailing slash: `/`, `/research/`. */
106
+ route: string;
107
+ title: string;
108
+ /** Rendered inline Markdown; math allowed. */
109
+ titleHtml: string;
110
+ description?: string;
111
+ order: number;
112
+ /** Rendered Markdown body. */
113
+ bodyHtml: string;
114
+ /** Set only in development, when the page could not be built. */
115
+ error?: string;
116
+ }
117
+ interface Profile {
118
+ name: string;
119
+ title?: string;
120
+ affiliation?: Institution;
121
+ email?: string;
122
+ links: Link[];
123
+ /** Short plain-text description used for page metadata, not rendered. */
124
+ summary?: string;
125
+ /**
126
+ * Where the website is published, e.g. `https://jane.example.org/`. Links
127
+ * to paths on the site (`/slides/talk.pdf`) need it to work in the PDF.
128
+ */
129
+ url?: string;
130
+ avatar?: string;
131
+ /** Navigation entries, as written. */
132
+ nav: Link[];
133
+ /** Rendered inline Markdown, or an empty string. */
134
+ footerHtml: string;
135
+ }
136
+ /** A content problem, reported in the browser during development. */
137
+ interface ContentIssue {
138
+ /** The file it belongs to, e.g. `content/talks.yml`. */
139
+ where: string;
140
+ message: string;
141
+ }
142
+ /** Everything compiled from `content/`, as the app sees it. */
143
+ interface VitakitContent {
144
+ profile: Profile;
145
+ pages: Page[];
146
+ /** Section id -> validated, sorted, visibility-filtered entries. */
147
+ sections: Record<string, Entry[]>;
148
+ publications: PublicationGroup[];
149
+ /** Always empty in a production build, which fails instead. */
150
+ issues: ContentIssue[];
151
+ }
152
+
153
+ export type { ContentIssue as C, DateRange as D, Entry as E, Institution as I, Link as L, Page as P, RichTextMarker as R, Visibility as V, VitakitContent as a, DatePoint as b, EntryBase as c, Profile as d, Publication as e, PublicationBadge as f, PublicationGroup as g, RichText as h };