@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.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/check.d.mts +22 -0
- package/dist/check.mjs +196 -0
- package/dist/cli.d.mts +45 -0
- package/dist/cli.mjs +225 -0
- package/dist/index.d.mts +398 -0
- package/dist/index.mjs +21 -0
- package/dist/nuxt.d.mts +15 -0
- package/dist/nuxt.mjs +98 -0
- package/dist/runtime/components/VitaDate.vue +20 -0
- package/dist/runtime/components/VitaEntry.vue +24 -0
- package/dist/runtime/components/VitaIssues.vue +25 -0
- package/dist/runtime/components/VitaLinks.vue +19 -0
- package/dist/runtime/components/VitaMarkdown.vue +14 -0
- package/dist/runtime/components/VitaPublication.vue +30 -0
- package/dist/runtime/components/VitaPublications.vue +39 -0
- package/dist/runtime/components/VitaSection.vue +60 -0
- package/dist/runtime/composables/useVitakit.ts +47 -0
- package/dist/schemas/education.ts +21 -0
- package/dist/schemas/employment.ts +21 -0
- package/dist/schemas/publications.ts +48 -0
- package/dist/schemas/supervision.ts +20 -0
- package/dist/schemas/talks.ts +19 -0
- package/dist/schemas/teaching.ts +19 -0
- package/dist/shared/index.d.mts +68 -0
- package/dist/shared/index.mjs +57 -0
- package/dist/shared/vitakit.BRhjqpAt.mjs +250 -0
- package/dist/shared/vitakit.BlU-p01e.mjs +14 -0
- package/dist/shared/vitakit.BxcnlFGP.mjs +325 -0
- package/dist/shared/vitakit.CypCF-4Q.mjs +503 -0
- package/dist/shared/vitakit.D8WRg_Xx.d.mts +153 -0
- package/dist/styles/vitakit-math.csl +314 -0
- package/dist/tex/templates/_generic.eta +6 -0
- package/dist/tex/templates/education.eta +9 -0
- package/dist/tex/templates/employment.eta +9 -0
- package/dist/tex/templates/publications.eta +7 -0
- package/dist/tex/templates/supervision.eta +4 -0
- package/dist/tex/templates/talks.eta +3 -0
- package/dist/tex/templates/teaching.eta +6 -0
- package/dist/tex/vitakit-cv.cls +119 -0
- package/dist/tex.d.mts +93 -0
- package/dist/tex.mjs +129 -0
- package/package.json +121 -0
- package/src/bin/vitakit-build-cv.mjs +50 -0
- package/src/bin/vitakit-check.mjs +57 -0
- package/src/bin/vitakit.mjs +158 -0
- package/styles/vitakit-math.csl +314 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { readdir, readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { Eta } from 'eta';
|
|
5
|
+
import { m as fail, p as formatZodIssues, k as atMost, o as resolveAsset, n as isSitePath } from './vitakit.BxcnlFGP.mjs';
|
|
6
|
+
import { entrySecondaryParts, entryTitleField, formatDateRange } from './index.mjs';
|
|
7
|
+
import { r as richSource } from './vitakit.BlU-p01e.mjs';
|
|
8
|
+
import { parse } from 'yaml';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
const REPLACEMENTS = {
|
|
12
|
+
"\\": "\\textbackslash{}",
|
|
13
|
+
"&": "\\&",
|
|
14
|
+
"%": "\\%",
|
|
15
|
+
$: "\\$",
|
|
16
|
+
"#": "\\#",
|
|
17
|
+
_: "\\_",
|
|
18
|
+
"{": "\\{",
|
|
19
|
+
"}": "\\}",
|
|
20
|
+
"~": "\\textasciitilde{}",
|
|
21
|
+
"^": "\\textasciicircum{}"
|
|
22
|
+
};
|
|
23
|
+
const TYPOGRAPHY = [
|
|
24
|
+
// Directional quotes: "..." -> ``...''
|
|
25
|
+
[/"([^"]*)"/g, "``$1''"],
|
|
26
|
+
// An en dash written literally
|
|
27
|
+
[/\u2013/g, "--"],
|
|
28
|
+
[/\u2014/g, "---"],
|
|
29
|
+
[/\u2026/g, "\\ldots{}"],
|
|
30
|
+
[/\u00a0/g, "~"]
|
|
31
|
+
];
|
|
32
|
+
function escapeTex(value, { typography = true, source } = {}) {
|
|
33
|
+
if (source !== void 0 && /(?<!\\)\$/.test(value)) {
|
|
34
|
+
throw new Error(`unbalanced math delimiters in: ${source}`);
|
|
35
|
+
}
|
|
36
|
+
let result = value.replace(/[\\&%$#_{}~^]/g, (character) => REPLACEMENTS[character]);
|
|
37
|
+
if (typography) {
|
|
38
|
+
for (const [pattern, replacement] of TYPOGRAPHY) result = result.replace(pattern, replacement);
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
function escapeRichTex(value) {
|
|
43
|
+
const parts = [];
|
|
44
|
+
const pattern = /\$\$[\s\S]*?\$\$|\$(?:\\.|[^$\\])*\$/g;
|
|
45
|
+
let index = 0;
|
|
46
|
+
for (const match of value.matchAll(pattern)) {
|
|
47
|
+
const start = match.index;
|
|
48
|
+
parts.push(escapeTex(value.slice(index, start), { source: value }));
|
|
49
|
+
parts.push(match[0]);
|
|
50
|
+
index = start + match[0].length;
|
|
51
|
+
}
|
|
52
|
+
parts.push(escapeTex(value.slice(index), { source: value }));
|
|
53
|
+
return parts.join("");
|
|
54
|
+
}
|
|
55
|
+
const URL_SPECIAL = /%[0-9A-Fa-f]{2}|[%\\{}^ #&]/g;
|
|
56
|
+
function escapeUrl(value) {
|
|
57
|
+
return value.replace(URL_SPECIAL, (match) => {
|
|
58
|
+
if (match.length === 3 || match === "#" || match === "&") return `\\${match}`;
|
|
59
|
+
return `\\%${match.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sectionOptions = z.strictObject({
|
|
64
|
+
/**
|
|
65
|
+
* Keep entries whose field equals the value, or one of several values:
|
|
66
|
+
*
|
|
67
|
+
* ```yaml
|
|
68
|
+
* where: { kind: invited }
|
|
69
|
+
* where: { kind: [invited, seminar] }
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
where: z.record(z.string(), z.union([z.string(), z.array(z.string()).min(1)])).default({}),
|
|
73
|
+
/** Keep only the newest N entries. */
|
|
74
|
+
limit: z.number().int().positive().optional(),
|
|
75
|
+
/** Drop entries starting before this year. */
|
|
76
|
+
since: z.number().int().optional(),
|
|
77
|
+
/** Drop entries starting after this year. */
|
|
78
|
+
until: z.number().int().optional(),
|
|
79
|
+
/** Heading used by `\vitasection`; defaults to the section id, capitalised. */
|
|
80
|
+
title: z.string().min(1).optional()
|
|
81
|
+
});
|
|
82
|
+
const documentSchema = z.strictObject({
|
|
83
|
+
/**
|
|
84
|
+
* Highest visibility level this document may include. Defaults to `cv`,
|
|
85
|
+
* because a CV is the reason the `cv` level exists; use `private` for an
|
|
86
|
+
* application containing a cover letter, address or referees.
|
|
87
|
+
*
|
|
88
|
+
* There is no "publish this PDF" option: PDFs are never part of the website
|
|
89
|
+
* build (see `vitakit-check`), and the website renders CV data directly.
|
|
90
|
+
*/
|
|
91
|
+
visibility: z.enum(["public", "cv", "private"]).default("cv"),
|
|
92
|
+
sections: z.record(z.string(), sectionOptions).default({}),
|
|
93
|
+
publications: z.strictObject({
|
|
94
|
+
groups: z.array(z.string()).optional()
|
|
95
|
+
}).default({})
|
|
96
|
+
});
|
|
97
|
+
const DEFAULTS = documentSchema.parse({});
|
|
98
|
+
async function discoverDocuments(cvDir) {
|
|
99
|
+
const files = (await readdir(cvDir).catch(() => fail("cv-tex/", "directory is missing"))).filter((name) => name.endsWith(".tex")).sort();
|
|
100
|
+
const documents = [];
|
|
101
|
+
for (const file of files) {
|
|
102
|
+
const name = basename(file, ".tex");
|
|
103
|
+
const source = await readFile(join(cvDir, file), "utf8");
|
|
104
|
+
if (!/\\begin\{document\}/.test(source)) continue;
|
|
105
|
+
const optionsFile = join(cvDir, `${name}.yml`);
|
|
106
|
+
const raw = await readFile(optionsFile, "utf8").catch(() => void 0);
|
|
107
|
+
let options = DEFAULTS;
|
|
108
|
+
if (raw !== void 0) {
|
|
109
|
+
const parsed = documentSchema.safeParse(parse(raw) ?? {});
|
|
110
|
+
if (!parsed.success) fail(`cv-tex/${name}.yml`, `invalid
|
|
111
|
+
${formatZodIssues(parsed.error)}`);
|
|
112
|
+
options = parsed.data;
|
|
113
|
+
}
|
|
114
|
+
documents.push({ name, texFile: join(cvDir, file), options });
|
|
115
|
+
}
|
|
116
|
+
if (!documents.length) fail("cv-tex/", "no .tex file contains \\begin{document}");
|
|
117
|
+
return documents;
|
|
118
|
+
}
|
|
119
|
+
function valueOf(entry, field) {
|
|
120
|
+
if (field === "year") return entry.date?.from?.year;
|
|
121
|
+
const value = entry[field];
|
|
122
|
+
if (typeof value === "object" && value !== null && "text" in value) return value.text;
|
|
123
|
+
if (typeof value === "object" && value !== null && "name" in value) return value.name;
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
function selectEntries(entries, options, sectionId) {
|
|
127
|
+
const section = options.sections[sectionId] ?? { where: {} };
|
|
128
|
+
let result = entries.filter((entry) => atMost(entry.visibility, options.visibility));
|
|
129
|
+
if (section.since !== void 0) {
|
|
130
|
+
result = result.filter((entry) => Number(valueOf(entry, "year") ?? 0) >= section.since);
|
|
131
|
+
}
|
|
132
|
+
if (section.until !== void 0) {
|
|
133
|
+
result = result.filter((entry) => Number(valueOf(entry, "year") ?? Infinity) <= section.until);
|
|
134
|
+
}
|
|
135
|
+
for (const [field, expected] of Object.entries(section.where ?? {})) {
|
|
136
|
+
const wanted = new Set(Array.isArray(expected) ? expected : [expected]);
|
|
137
|
+
result = result.filter((entry) => wanted.has(String(valueOf(entry, field) ?? "")));
|
|
138
|
+
}
|
|
139
|
+
return section.limit ? result.slice(0, section.limit) : result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const bundledTexDir = resolveAsset("tex");
|
|
143
|
+
const eta = new Eta({ autoEscape: false, autoTrim: [false, false] });
|
|
144
|
+
function tidy(body) {
|
|
145
|
+
return body.replace(/[ \t]+$/gm, "").replace(/\n{2,}/g, "\n").trim();
|
|
146
|
+
}
|
|
147
|
+
const SITE_PATH_HELP = "is a path on the website, and the PDF can only link it with the address the site is published at: add it to content/profile.yml, e.g. `url: https://jane.example.org/`";
|
|
148
|
+
function helpers(where, source) {
|
|
149
|
+
const tex = (value) => value === void 0 || value === null ? "" : escapeTex(String(value));
|
|
150
|
+
const rich = (value) => {
|
|
151
|
+
if (value === void 0 || value === null) return "";
|
|
152
|
+
const source2 = richSource(value);
|
|
153
|
+
try {
|
|
154
|
+
return escapeRichTex(source2);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return fail(where, error instanceof Error ? error.message : String(error));
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const href = (value) => {
|
|
160
|
+
if (isSitePath(value)) fail(source, `${value} ${SITE_PATH_HELP}`);
|
|
161
|
+
return escapeUrl(value);
|
|
162
|
+
};
|
|
163
|
+
const url = (value) => value ? href(String(value)) : "";
|
|
164
|
+
const inst = (value) => {
|
|
165
|
+
const institution = value;
|
|
166
|
+
if (!institution?.name) return "";
|
|
167
|
+
return institution.url ? `\\href{${href(institution.url)}}{${escapeTex(institution.name)}}` : escapeTex(institution.name);
|
|
168
|
+
};
|
|
169
|
+
const linkOrText = (value) => {
|
|
170
|
+
if (typeof value === "string") return escapeTex(value);
|
|
171
|
+
const item = value;
|
|
172
|
+
if (!item?.label) return "";
|
|
173
|
+
return item.url ? `\\href{${href(item.url)}}{${escapeTex(item.label)}}` : escapeTex(item.label);
|
|
174
|
+
};
|
|
175
|
+
const date = (value) => escapeTex(formatDateRange(value));
|
|
176
|
+
const term = (entry) => typeof entry.term === "string" ? escapeTex(entry.term) : date(entry.date);
|
|
177
|
+
const titleOf = (entry) => {
|
|
178
|
+
const field = entryTitleField(entry);
|
|
179
|
+
return field ? entry[field] : "";
|
|
180
|
+
};
|
|
181
|
+
const secondaryOf = (entry) => entrySecondaryParts(entry, entryTitleField(entry)).map((part) => part.kind === "institution" ? inst(part.institution) : escapeTex(part.text)).filter(Boolean).join(", ");
|
|
182
|
+
const bibgroup = (group) => {
|
|
183
|
+
const unsafe = /[\s%#\\{},~]/;
|
|
184
|
+
const bad = [group.id, ...group.entries.map((entry) => entry.key)].find((value) => !value || unsafe.test(value));
|
|
185
|
+
if (bad !== void 0) {
|
|
186
|
+
fail(where, `"${bad}" cannot be used as a citation key or group id in LaTeX; use letters, digits and - _ : . / +`);
|
|
187
|
+
}
|
|
188
|
+
return `\\vitabibgroup{${group.id}}{${group.entries.map((entry) => entry.key).join(",")}}`;
|
|
189
|
+
};
|
|
190
|
+
return { tex, rich, url, inst, linkOrText, date, term, titleOf, secondaryOf, bibgroup };
|
|
191
|
+
}
|
|
192
|
+
async function resolveTemplate(id, userDir) {
|
|
193
|
+
const candidates = [join(userDir, `${id}.eta`), join(bundledTexDir, "templates", `${id}.eta`), join(bundledTexDir, "templates", "_generic.eta")];
|
|
194
|
+
for (const path of candidates) {
|
|
195
|
+
if (existsSync(path)) return { path, source: await readFile(path, "utf8") };
|
|
196
|
+
}
|
|
197
|
+
throw new Error(`vitakit: no template for section ${id}`);
|
|
198
|
+
}
|
|
199
|
+
async function assertTemplatesAreUsed(templateDir, ids) {
|
|
200
|
+
const files = await readdir(templateDir).catch(() => []);
|
|
201
|
+
const known = /* @__PURE__ */ new Set([...ids, "publications", "_generic"]);
|
|
202
|
+
for (const file of files.filter((name) => name.endsWith(".eta")).sort()) {
|
|
203
|
+
const id = file.slice(0, -".eta".length);
|
|
204
|
+
if (known.has(id)) continue;
|
|
205
|
+
const suggestion = ids.find((candidate) => candidate.startsWith(id) || id.startsWith(candidate));
|
|
206
|
+
fail(
|
|
207
|
+
`cv-tex/templates/${file}`,
|
|
208
|
+
`no section is called "${id}", so this template is never used${suggestion ? `; did you mean ${suggestion}.eta?` : ""}
|
|
209
|
+
sections: ${ids.join(", ") || "(none)"}`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function emitDocument(emit) {
|
|
214
|
+
await mkdir(emit.outDir, { recursive: true });
|
|
215
|
+
await assertTemplatesAreUsed(emit.templateDir, Object.keys(emit.sections));
|
|
216
|
+
const written = [];
|
|
217
|
+
for (const [id, entries] of Object.entries(emit.sections)) {
|
|
218
|
+
const where = `cv-tex/generated/${id}.tex`;
|
|
219
|
+
const selected = selectEntries(entries, emit.options, id);
|
|
220
|
+
const { source } = await resolveTemplate(id, emit.templateDir);
|
|
221
|
+
let body;
|
|
222
|
+
try {
|
|
223
|
+
body = tidy(eta.renderString(source, { entries: selected, ...helpers(where, `content/${id}.yml`) }));
|
|
224
|
+
} catch (error) {
|
|
225
|
+
throw new Error(`template ${id}.eta: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
226
|
+
}
|
|
227
|
+
const file = join(emit.outDir, `${id}.tex`);
|
|
228
|
+
await writeFile(file, `% Generated by vitakit. Do not edit; edit content/${id}.yml instead.
|
|
229
|
+
${body.trim()}
|
|
230
|
+
`);
|
|
231
|
+
written.push(file);
|
|
232
|
+
}
|
|
233
|
+
if (emit.publications.length) {
|
|
234
|
+
const groups = emit.options.publications.groups ? emit.options.publications.groups.map((id) => emit.publications.find((group) => group.id === id)).filter((group) => group !== void 0) : emit.publications;
|
|
235
|
+
if (!emit.siteUrl) {
|
|
236
|
+
const local = groups.flatMap((group) => group.entries).find((entry) => entry.titleUrl && isSitePath(entry.titleUrl));
|
|
237
|
+
if (local) fail("content/publications.bib", `${local.key}: url ${local.titleUrl} ${SITE_PATH_HELP}`);
|
|
238
|
+
}
|
|
239
|
+
const { source } = await resolveTemplate("publications", emit.templateDir);
|
|
240
|
+
const body = tidy(eta.renderString(source, { groups, ...helpers("publications.tex", "content/publications.bib") }));
|
|
241
|
+
const file = join(emit.outDir, "publications.tex");
|
|
242
|
+
await writeFile(file, `% Generated by vitakit. Do not edit; edit content/publications.bib instead.
|
|
243
|
+
${body.trim()}
|
|
244
|
+
`);
|
|
245
|
+
written.push(file);
|
|
246
|
+
}
|
|
247
|
+
return written;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export { SITE_PATH_HELP as S, escapeTex as a, bundledTexDir as b, escapeUrl as c, discoverDocuments as d, emitDocument as e, documentSchema as f, escapeRichTex as g, selectEntries as s };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
function isRichText(value) {
|
|
2
|
+
return typeof value === "object" && value !== null && typeof value.text === "string";
|
|
3
|
+
}
|
|
4
|
+
function richSource(value) {
|
|
5
|
+
if (typeof value === "string") return value;
|
|
6
|
+
return isRichText(value) ? value.text : "";
|
|
7
|
+
}
|
|
8
|
+
function richHtml(value) {
|
|
9
|
+
if (typeof value === "string") return value || void 0;
|
|
10
|
+
if (isRichText(value) && typeof value.html === "string") return value.html;
|
|
11
|
+
return void 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export { richHtml as a, richSource as r };
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
const PATH_KEY = "__vitakitPath";
|
|
7
|
+
const isSitePath = (value) => value.startsWith("/") && !value.startsWith("//");
|
|
8
|
+
const sitePathMarker = (path) => ({ [PATH_KEY]: path });
|
|
9
|
+
function isSitePathMarker(value) {
|
|
10
|
+
return typeof value === "object" && value !== null && PATH_KEY in value;
|
|
11
|
+
}
|
|
12
|
+
function resolveSitePaths(value, resolve) {
|
|
13
|
+
if (isSitePathMarker(value)) return resolve(value[PATH_KEY]);
|
|
14
|
+
if (Array.isArray(value)) return value.map((item) => resolveSitePaths(item, resolve));
|
|
15
|
+
if (typeof value === "object" && value !== null) {
|
|
16
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveSitePaths(item, resolve)]));
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function absoluteSitePath(path, siteUrl) {
|
|
21
|
+
return siteUrl ? `${siteUrl.replace(/\/?$/, "/")}${path.slice(1)}` : path;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sourcePath(asset) {
|
|
25
|
+
return asset === "styles" ? asset : join("src", asset);
|
|
26
|
+
}
|
|
27
|
+
let root;
|
|
28
|
+
function packageRoot() {
|
|
29
|
+
if (root) return root;
|
|
30
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
32
|
+
if (existsSync(join(dir, "package.json"))) return root = dir;
|
|
33
|
+
const parent = dirname(dir);
|
|
34
|
+
if (parent === dir) break;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
throw new Error("vitakit: cannot locate the package root; the installation is incomplete");
|
|
38
|
+
}
|
|
39
|
+
const built = /[/\\]dist[/\\]/.test(import.meta.url);
|
|
40
|
+
function resolveAsset(asset) {
|
|
41
|
+
const base = packageRoot();
|
|
42
|
+
const order = built ? ["dist", void 0] : [void 0, "dist"];
|
|
43
|
+
const candidates = order.map((prefix) => prefix ? join(base, prefix, asset) : join(base, sourcePath(asset)));
|
|
44
|
+
for (const candidate of candidates) {
|
|
45
|
+
if (existsSync(candidate)) return candidate;
|
|
46
|
+
}
|
|
47
|
+
throw new Error(
|
|
48
|
+
`vitakit: missing bundled asset "${asset}"; looked in
|
|
49
|
+
` + candidates.map((candidate) => ` ${candidate}`).join("\n") + "\nRun `pnpm build` in the package, or reinstall it."
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class ContentError extends Error {
|
|
54
|
+
constructor(where, message) {
|
|
55
|
+
super(`${where}: ${message}`);
|
|
56
|
+
this.where = where;
|
|
57
|
+
this.name = "ContentError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function fail(where, message) {
|
|
61
|
+
throw new ContentError(where, message);
|
|
62
|
+
}
|
|
63
|
+
function messageOf(error) {
|
|
64
|
+
if (error instanceof ContentError) return error.message.slice(error.where.length + 2);
|
|
65
|
+
return error instanceof Error ? error.message : String(error);
|
|
66
|
+
}
|
|
67
|
+
function whereOf(error, fallback) {
|
|
68
|
+
return error instanceof ContentError ? error.where : fallback;
|
|
69
|
+
}
|
|
70
|
+
async function importProjectModule(path, mtimeMs, where) {
|
|
71
|
+
try {
|
|
72
|
+
return await import(`${pathToFileURL(path).href}?mtime=${mtimeMs}`);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
fail(where, importFailure(error));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function importFailure(error, node = process.versions.node) {
|
|
78
|
+
if (error?.code === "ERR_UNKNOWN_FILE_EXTENSION") {
|
|
79
|
+
return `cannot load TypeScript on Node ${node}; vitakit needs Node 22.18 or newer`;
|
|
80
|
+
}
|
|
81
|
+
return messageOf(error);
|
|
82
|
+
}
|
|
83
|
+
function formatZodIssues(error) {
|
|
84
|
+
return error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
|
|
85
|
+
}
|
|
86
|
+
class Issues {
|
|
87
|
+
constructor(strict) {
|
|
88
|
+
this.strict = strict;
|
|
89
|
+
}
|
|
90
|
+
list = [];
|
|
91
|
+
/** Record a problem, or throw it when strict. */
|
|
92
|
+
record(where, message) {
|
|
93
|
+
if (this.strict) fail(where, message);
|
|
94
|
+
this.list.push({ where, message });
|
|
95
|
+
}
|
|
96
|
+
/** Run `run`; when lenient, record any failure and use `fallback` instead. */
|
|
97
|
+
async attempt(where, run, fallback) {
|
|
98
|
+
try {
|
|
99
|
+
return await run();
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (this.strict) throw error;
|
|
102
|
+
this.list.push({ where: whereOf(error, where), message: messageOf(error) });
|
|
103
|
+
return fallback;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const MARKER_KEY = "__vitakitRich";
|
|
109
|
+
function isRichMarker(value) {
|
|
110
|
+
return typeof value === "object" && value !== null && MARKER_KEY in value;
|
|
111
|
+
}
|
|
112
|
+
function richMarker(value) {
|
|
113
|
+
return { [MARKER_KEY]: value };
|
|
114
|
+
}
|
|
115
|
+
function renderRichFields(value, renderer, path, where) {
|
|
116
|
+
if (isRichMarker(value)) {
|
|
117
|
+
try {
|
|
118
|
+
return { text: value[MARKER_KEY], html: renderer.inline(value[MARKER_KEY]) };
|
|
119
|
+
} catch (error) {
|
|
120
|
+
fail(where, `${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(value)) {
|
|
124
|
+
return value.map((item, index) => renderRichFields(item, renderer, `${path}[${index}]`, where));
|
|
125
|
+
}
|
|
126
|
+
if (typeof value === "object" && value !== null) {
|
|
127
|
+
return Object.fromEntries(Object.entries(value).map(
|
|
128
|
+
([key, item]) => [key, renderRichFields(item, renderer, path ? `${path}.${key}` : key, where)]
|
|
129
|
+
));
|
|
130
|
+
}
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const text = z.string().trim().min(1);
|
|
135
|
+
const richText = text.transform((value) => richMarker(value));
|
|
136
|
+
const absoluteUrl = z.string().trim().refine(
|
|
137
|
+
// One check, not `.url().refine(...)`: zod runs a refinement even after
|
|
138
|
+
// `.url()` failed, and `new URL()` then throws instead of reporting.
|
|
139
|
+
(value) => URL.canParse(value) && ["http:", "https:", "mailto:"].includes(new URL(value).protocol),
|
|
140
|
+
"expected an http(s) or mailto URL"
|
|
141
|
+
);
|
|
142
|
+
const url = z.string().trim().transform((value, context) => {
|
|
143
|
+
if (isSitePath(value) && !/\s/.test(value)) return sitePathMarker(value);
|
|
144
|
+
const parsed = absoluteUrl.safeParse(value);
|
|
145
|
+
if (parsed.success) return parsed.data;
|
|
146
|
+
context.addIssue({ code: "custom", message: "expected an http(s) or mailto URL, or a path on the site such as /slides/talk.pdf" });
|
|
147
|
+
return z.NEVER;
|
|
148
|
+
});
|
|
149
|
+
const DATE = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/;
|
|
150
|
+
function parseDatePoint(value, context) {
|
|
151
|
+
const match = DATE.exec(value);
|
|
152
|
+
if (!match) {
|
|
153
|
+
context.addIssue({ code: "custom", message: `expected a date like 2024, 2024-03 or 2024-03-15, got "${value}"` });
|
|
154
|
+
return void 0;
|
|
155
|
+
}
|
|
156
|
+
const [, y, m, d] = match;
|
|
157
|
+
const year = Number(y);
|
|
158
|
+
const month = m === void 0 ? void 0 : Number(m);
|
|
159
|
+
const day = d === void 0 ? void 0 : Number(d);
|
|
160
|
+
if (month !== void 0 && (month < 1 || month > 12)) {
|
|
161
|
+
context.addIssue({ code: "custom", message: `month out of range in "${value}"` });
|
|
162
|
+
return void 0;
|
|
163
|
+
}
|
|
164
|
+
if (day !== void 0) {
|
|
165
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
166
|
+
if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
|
|
167
|
+
context.addIssue({ code: "custom", message: `"${value}" is not a real date` });
|
|
168
|
+
return void 0;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
raw: value,
|
|
173
|
+
year,
|
|
174
|
+
month,
|
|
175
|
+
day,
|
|
176
|
+
// Pad missing parts so that 2024 < 2024-03 < 2024-03-15 sorts correctly.
|
|
177
|
+
sortKey: `${String(year).padStart(4, "0")}-${String(month ?? 1).padStart(2, "0")}-${String(day ?? 1).padStart(2, "0")}`
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const datePoint = z.union([z.string(), z.number().int()]).transform((value) => String(value));
|
|
181
|
+
const dateRange = z.union([
|
|
182
|
+
datePoint,
|
|
183
|
+
z.strictObject({
|
|
184
|
+
from: datePoint,
|
|
185
|
+
to: z.union([datePoint, z.literal("present")]).optional()
|
|
186
|
+
})
|
|
187
|
+
]).transform((value, context) => {
|
|
188
|
+
const source = typeof value === "string" ? { from: value, to: void 0 } : value;
|
|
189
|
+
const from = parseDatePoint(source.from, context);
|
|
190
|
+
const ongoing = source.to === "present";
|
|
191
|
+
const to = source.to === void 0 || ongoing ? void 0 : parseDatePoint(source.to, context);
|
|
192
|
+
if (!from || source.to !== void 0 && !ongoing && !to) return z.NEVER;
|
|
193
|
+
if (to && to.sortKey < from.sortKey) {
|
|
194
|
+
context.addIssue({ code: "custom", message: `range ends (${to.raw}) before it starts (${from.raw})` });
|
|
195
|
+
return z.NEVER;
|
|
196
|
+
}
|
|
197
|
+
return { from, to, ongoing, point: typeof value === "string" };
|
|
198
|
+
});
|
|
199
|
+
const institution = z.union([
|
|
200
|
+
text.transform((name) => ({ name })),
|
|
201
|
+
z.strictObject({ name: text, url: url.optional() })
|
|
202
|
+
]);
|
|
203
|
+
const link = z.union([
|
|
204
|
+
url.transform((value) => ({
|
|
205
|
+
label: typeof value === "string" ? value.replace(/^https?:\/\//, "").replace(/\/$/, "") : value[PATH_KEY],
|
|
206
|
+
url: value
|
|
207
|
+
})),
|
|
208
|
+
z.strictObject({ label: text, url })
|
|
209
|
+
]);
|
|
210
|
+
const visibility = z.enum(["public", "cv", "private"]).default("public");
|
|
211
|
+
function entry(shape) {
|
|
212
|
+
return z.strictObject({ visibility, ...shape });
|
|
213
|
+
}
|
|
214
|
+
function section(shape) {
|
|
215
|
+
return z.array(entry(shape));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const education = section({
|
|
219
|
+
date: dateRange,
|
|
220
|
+
degree: text,
|
|
221
|
+
institution,
|
|
222
|
+
thesis: z.strictObject({
|
|
223
|
+
title: richText,
|
|
224
|
+
type: text.optional(),
|
|
225
|
+
supervisor: z.union([text, link]).optional(),
|
|
226
|
+
url: url.optional()
|
|
227
|
+
}).optional(),
|
|
228
|
+
note: text.optional()
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const employment = section({
|
|
232
|
+
date: dateRange,
|
|
233
|
+
position: text,
|
|
234
|
+
institution,
|
|
235
|
+
project: z.strictObject({
|
|
236
|
+
title: richText,
|
|
237
|
+
funder: text.optional(),
|
|
238
|
+
grant: text.optional(),
|
|
239
|
+
url: url.optional()
|
|
240
|
+
}).optional(),
|
|
241
|
+
note: text.optional()
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const teaching = section({
|
|
245
|
+
date: dateRange,
|
|
246
|
+
title: richText,
|
|
247
|
+
role: text.optional(),
|
|
248
|
+
institution: institution.optional(),
|
|
249
|
+
/** e.g. `2024S`, `Winter 2023/24` -- free text, shown as written. */
|
|
250
|
+
term: text.optional(),
|
|
251
|
+
url: url.optional(),
|
|
252
|
+
note: text.optional()
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const talks = section({
|
|
256
|
+
date: dateRange,
|
|
257
|
+
/** Markdown, math allowed. */
|
|
258
|
+
title: richText,
|
|
259
|
+
event: text.optional(),
|
|
260
|
+
location: text.optional(),
|
|
261
|
+
kind: z.enum(["invited", "contributed", "seminar", "poster", "outreach"]).optional(),
|
|
262
|
+
url: url.optional(),
|
|
263
|
+
slides: url.optional()
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const supervision = section({
|
|
267
|
+
date: dateRange,
|
|
268
|
+
student: text,
|
|
269
|
+
/** A thesis still in progress; shown as such instead of as completed. */
|
|
270
|
+
ongoing: z.boolean().default(false),
|
|
271
|
+
/** Markdown, math allowed. */
|
|
272
|
+
title: richText,
|
|
273
|
+
degree: text.optional(),
|
|
274
|
+
institution: institution.optional(),
|
|
275
|
+
coSupervisor: text.optional()
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const profile = z.strictObject({
|
|
279
|
+
name: text,
|
|
280
|
+
title: text.optional(),
|
|
281
|
+
affiliation: institution.optional(),
|
|
282
|
+
email: z.string().trim().email().optional(),
|
|
283
|
+
links: z.array(link).default([]),
|
|
284
|
+
/**
|
|
285
|
+
* One or two sentences for metadata: the page description and social
|
|
286
|
+
* previews. Prose belongs in `content/pages/index.md`, which is where a
|
|
287
|
+
* reader sees it -- this is deliberately not rendered on the page.
|
|
288
|
+
*/
|
|
289
|
+
summary: text.optional(),
|
|
290
|
+
/**
|
|
291
|
+
* Where the website is published, e.g. `https://jane.example.org/` or
|
|
292
|
+
* `https://jane.github.io/site/`. The PDF needs it to link paths on the site
|
|
293
|
+
* such as `/slides/talk.pdf`; the website itself does not.
|
|
294
|
+
*/
|
|
295
|
+
url: absoluteUrl.optional(),
|
|
296
|
+
/** Path below `public/`, e.g. `/img/portrait.jpg`. */
|
|
297
|
+
avatar: text.startsWith("/").optional(),
|
|
298
|
+
/** Navigation, in the order it should appear. */
|
|
299
|
+
nav: z.array(z.strictObject({ label: text, to: text })).default([]),
|
|
300
|
+
/** Footer line; inline Markdown. */
|
|
301
|
+
footer: text.optional()
|
|
302
|
+
});
|
|
303
|
+
const page = z.strictObject({
|
|
304
|
+
title: text,
|
|
305
|
+
description: text.optional(),
|
|
306
|
+
route: text.regex(/^\/(?:[a-z0-9-]+\/)*$/, "expected a route like /research/").optional(),
|
|
307
|
+
order: z.number().int().default(100)
|
|
308
|
+
});
|
|
309
|
+
const schemas = {
|
|
310
|
+
education,
|
|
311
|
+
employment,
|
|
312
|
+
teaching,
|
|
313
|
+
talks,
|
|
314
|
+
supervision
|
|
315
|
+
};
|
|
316
|
+
function extendSection(base, shape) {
|
|
317
|
+
return z.array(base.element.extend(shape));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const LEVEL = { public: 0, cv: 1, private: 2 };
|
|
321
|
+
function atMost(value, limit) {
|
|
322
|
+
return LEVEL[value ?? "public"] <= LEVEL[limit];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export { whereOf as A, messageOf as B, page as C, Issues as I, MARKER_KEY as M, employment as a, entry as b, extendSection as c, dateRange as d, education as e, section as f, supervision as g, teaching as h, institution as i, text as j, atMost as k, link as l, fail as m, isSitePath as n, resolveAsset as o, formatZodIssues as p, importProjectModule as q, richText as r, schemas as s, talks as t, url as u, visibility as v, renderRichFields as w, resolveSitePaths as x, profile as y, absoluteSitePath as z };
|