@anja.codes/vitakit 0.1.0 → 0.3.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 (70) hide show
  1. package/README.md +52 -131
  2. package/dist/check.mjs +20 -11
  3. package/dist/chunks/authoring.mjs +372 -0
  4. package/dist/{shared/vitakit.CypCF-4Q.mjs → chunks/bibliography.mjs} +74 -237
  5. package/dist/chunks/build.mjs +178 -0
  6. package/dist/chunks/documents.mjs +152 -0
  7. package/dist/chunks/draft.mjs +222 -0
  8. package/dist/chunks/eject.mjs +59 -0
  9. package/dist/chunks/emit.mjs +216 -0
  10. package/dist/chunks/eta.mjs +16 -0
  11. package/dist/chunks/fields.mjs +79 -0
  12. package/dist/chunks/inventory.mjs +201 -0
  13. package/dist/chunks/migrations.mjs +218 -0
  14. package/dist/chunks/plan.mjs +172 -0
  15. package/dist/chunks/prompt.mjs +33 -0
  16. package/dist/chunks/publication-lists.mjs +202 -0
  17. package/dist/chunks/schema-source.mjs +12 -0
  18. package/dist/chunks/schemas.mjs +109 -0
  19. package/dist/chunks/sections.mjs +109 -0
  20. package/dist/chunks/snippets.mjs +199 -0
  21. package/dist/chunks/validate.mjs +133 -0
  22. package/dist/chunks/vue.mjs +153 -0
  23. package/dist/chunks/yaml.mjs +62 -0
  24. package/dist/cli.d.mts +33 -40
  25. package/dist/cli.mjs +7 -224
  26. package/dist/index.d.mts +379 -49
  27. package/dist/index.mjs +15 -7
  28. package/dist/nuxt.mjs +48 -10
  29. package/dist/runtime/components/VitaPublications.vue +21 -22
  30. package/dist/runtime/components/VitaSection.vue +94 -38
  31. package/dist/runtime/composables/useVitakit.ts +14 -3
  32. package/dist/schemas/education.ts +4 -4
  33. package/dist/schemas/employment.ts +3 -3
  34. package/dist/schemas/publications.ts +10 -8
  35. package/dist/schemas/supervision.ts +3 -3
  36. package/dist/schemas/talks.ts +1 -1
  37. package/dist/schemas/teaching.ts +3 -3
  38. package/dist/shared/index.d.mts +60 -9
  39. package/dist/shared/index.mjs +3 -57
  40. package/dist/shared/{vitakit.D8WRg_Xx.d.mts → vitakit.BfR_mUHm.d.mts} +25 -12
  41. package/dist/shared/vitakit.CCTDg7pz.mjs +199 -0
  42. package/dist/shared/vitakit.CfB3hMHl.mjs +60 -0
  43. package/dist/shared/{vitakit.BlU-p01e.mjs → vitakit.CtChKO0I.mjs} +1 -1
  44. package/dist/shared/vitakit.D-DdJaDZ.mjs +20 -0
  45. package/dist/shared/vitakit.D10HyDhF.mjs +19 -0
  46. package/dist/shared/vitakit.DCK90zq4.mjs +67 -0
  47. package/dist/shared/vitakit.DQ6PmGoL.mjs +34 -0
  48. package/dist/shared/vitakit.DcQOdM4I.mjs +57 -0
  49. package/dist/shared/vitakit.DteA9vLy.mjs +135 -0
  50. package/dist/shared/vitakit.DvABWuUc.mjs +121 -0
  51. package/dist/shared/vitakit.DvvUYUTS.mjs +200 -0
  52. package/dist/shared/vitakit.TSMTC5Jl.mjs +6 -0
  53. package/dist/shared/vitakit.UfoXdL7U.mjs +22 -0
  54. package/dist/shared/vitakit.zwr1ssO5.mjs +1850 -0
  55. package/dist/tex/templates/_generic.eta +6 -4
  56. package/dist/tex/templates/education.eta +10 -5
  57. package/dist/tex/templates/employment.eta +12 -5
  58. package/dist/tex/templates/publications.eta +7 -3
  59. package/dist/tex/templates/supervision.eta +10 -2
  60. package/dist/tex/templates/talks.eta +5 -1
  61. package/dist/tex/templates/teaching.eta +6 -4
  62. package/dist/tex/vitakit-cv.cls +36 -10
  63. package/dist/tex.d.mts +80 -11
  64. package/dist/tex.mjs +27 -117
  65. package/package.json +10 -7
  66. package/src/bin/vitakit.mjs +3 -153
  67. package/dist/shared/vitakit.BRhjqpAt.mjs +0 -250
  68. package/dist/shared/vitakit.BxcnlFGP.mjs +0 -325
  69. package/src/bin/vitakit-build-cv.mjs +0 -50
  70. package/src/bin/vitakit-check.mjs +0 -57
@@ -0,0 +1,152 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { z } from 'zod';
5
+ import { f as fail, m as messageOf, a as formatZodIssues } from '../shared/vitakit.DCK90zq4.mjs';
6
+ import { c as findPdfPublicationPlacements, f as findPdfPlacements } from '../shared/vitakit.DvvUYUTS.mjs';
7
+ import { a as atMost } from '../shared/vitakit.TSMTC5Jl.mjs';
8
+ import { f as fieldValue, a as filterWhere } from '../shared/vitakit.D-DdJaDZ.mjs';
9
+ import 'node:url';
10
+
11
+ const whereScalar = z.union([z.string(), z.number(), z.boolean()]);
12
+ const sectionOptions = z.strictObject({
13
+ /**
14
+ * Keep entries whose field equals the value, or one of several values:
15
+ *
16
+ * ```yaml
17
+ * where: { kind: invited }
18
+ * where: { kind: [invited, seminar] }
19
+ * ```
20
+ */
21
+ where: z.record(z.string(), z.union([whereScalar, z.array(whereScalar).min(1)])).default({}),
22
+ /** Keep only the newest N entries. */
23
+ limit: z.number().int().positive().optional(),
24
+ /** Drop entries starting before this year. */
25
+ since: z.number().int().optional(),
26
+ /** Drop entries starting after this year. */
27
+ until: z.number().int().optional(),
28
+ /** Heading used by `\vitasection`; overrides the shared preamble's label. */
29
+ title: z.string().min(1).optional()
30
+ });
31
+ const documentSchema = z.strictObject({
32
+ /**
33
+ * Highest visibility level this document may include. Defaults to `cv`,
34
+ * because a CV is the reason the `cv` level exists; use `private` for an
35
+ * application containing a cover letter, address or referees.
36
+ *
37
+ * There is no "publish this PDF" option: PDFs are never part of the website
38
+ * build (see `vitakit verify`), and the website renders CV data directly.
39
+ */
40
+ visibility: z.enum(["public", "cv", "private"]).default("cv"),
41
+ sections: z.record(z.string(), sectionOptions).default({})
42
+ });
43
+ const DEFAULTS = documentSchema.parse({});
44
+ async function findDocuments(cvDir) {
45
+ const files = (await readdir(cvDir).catch(() => [])).filter((name) => name.endsWith(".tex")).sort();
46
+ const documents = [];
47
+ const problems = [];
48
+ for (const file of files) {
49
+ const name = basename(file, ".tex");
50
+ const source = await readFile(join(cvDir, file), "utf8");
51
+ if (!/\\begin\{document\}/.test(source)) continue;
52
+ const where = `cv-tex/${name}.yml`;
53
+ const raw = await readFile(join(cvDir, `${name}.yml`), "utf8").catch(() => void 0);
54
+ let options = DEFAULTS;
55
+ if (raw !== void 0) {
56
+ let data;
57
+ try {
58
+ data = parse(raw);
59
+ } catch (error) {
60
+ problems.push({ where, message: messageOf(error) });
61
+ continue;
62
+ }
63
+ if (typeof data === "object" && data !== null && Object.hasOwn(data, "publications")) {
64
+ problems.push({ where, message: "publications: which publications a PDF shows is now a list in content/publication-lists.yml, placed with \\vitapublications[Heading]{list}; `vitakit migrate` converts it" });
65
+ continue;
66
+ }
67
+ const parsed = documentSchema.safeParse(data ?? {});
68
+ if (!parsed.success) {
69
+ problems.push({ where, message: `invalid
70
+ ${formatZodIssues(parsed.error)}` });
71
+ continue;
72
+ }
73
+ options = parsed.data;
74
+ }
75
+ const tex = `cv-tex/${file}`;
76
+ documents.push({
77
+ name,
78
+ texFile: join(cvDir, file),
79
+ options,
80
+ placements: findPdfPlacements(name, tex, source),
81
+ publications: findPdfPublicationPlacements(name, tex, source)
82
+ });
83
+ }
84
+ return { documents, problems };
85
+ }
86
+ async function discoverDocuments(cvDir) {
87
+ await readdir(cvDir).catch(() => fail("cv-tex/", "directory is missing"));
88
+ const { documents, problems } = await findDocuments(cvDir);
89
+ if (problems.length) fail(problems[0].where, problems[0].message);
90
+ if (!documents.length) fail("cv-tex/", "no .tex file contains \\begin{document}");
91
+ return documents;
92
+ }
93
+ function documentSectionProblems(document, sources) {
94
+ const where = `cv-tex/${document.name}.yml`;
95
+ const problems = [];
96
+ const sections = new Map(sources.map((source) => [source.id, source.schema]));
97
+ for (const [id, options] of Object.entries(document.options.sections)) {
98
+ const schema = sections.get(id);
99
+ if (!schema) {
100
+ problems.push({ where, message: `sections.${id}: unknown section; the sections are ${[...sections.keys()].join(", ") || "(none)"}` });
101
+ continue;
102
+ }
103
+ if (schema instanceof z.ZodArray && schema.element instanceof z.ZodObject) {
104
+ const fields = /* @__PURE__ */ new Set(["year", ...Object.keys(schema.element.shape)]);
105
+ for (const field of Object.keys(options.where)) {
106
+ if (!fields.has(field)) {
107
+ problems.push({ where, message: `sections.${id}.where.${field}: unknown field; the fields are ${[...fields].join(", ")}` });
108
+ }
109
+ }
110
+ }
111
+ }
112
+ return problems;
113
+ }
114
+ function validateDocumentSections(document, sources) {
115
+ const [problem] = documentSectionProblems(document, sources);
116
+ if (problem) fail(problem.where, problem.message);
117
+ }
118
+ function selectEntries(entries, options, sectionId) {
119
+ const section = options.sections[sectionId] ?? { where: {} };
120
+ let result = entries.filter((entry) => atMost(entry.visibility, options.visibility));
121
+ if (section.since !== void 0) {
122
+ result = result.filter((entry) => Number(fieldValue(entry, "year") ?? 0) >= section.since);
123
+ }
124
+ if (section.until !== void 0) {
125
+ result = result.filter((entry) => Number(fieldValue(entry, "year") ?? Infinity) <= section.until);
126
+ }
127
+ result = filterWhere(result, section.where);
128
+ return section.limit ? result.slice(0, section.limit) : result;
129
+ }
130
+ function explainSelection(entries, options, sectionId) {
131
+ const section = options.sections[sectionId] ?? { where: {} };
132
+ const steps = [];
133
+ let current = entries;
134
+ const step = (rule, next) => {
135
+ steps.push({ rule, kept: next.length, removed: current.length - next.length });
136
+ current = next;
137
+ };
138
+ step(`visibility: ${options.visibility}`, current.filter((entry) => atMost(entry.visibility, options.visibility)));
139
+ if (section.since !== void 0) {
140
+ step(`since: ${section.since}`, current.filter((entry) => Number(fieldValue(entry, "year") ?? 0) >= section.since));
141
+ }
142
+ if (section.until !== void 0) {
143
+ step(`until: ${section.until}`, current.filter((entry) => Number(fieldValue(entry, "year") ?? Infinity) <= section.until));
144
+ }
145
+ for (const [field, value] of Object.entries(section.where ?? {})) {
146
+ step(`where ${field}: ${Array.isArray(value) ? `[${value.join(", ")}]` : String(value)}`, filterWhere(current, { [field]: value }));
147
+ }
148
+ if (section.limit) step(`limit: ${section.limit}`, current.slice(0, section.limit));
149
+ return { total: entries.length, steps };
150
+ }
151
+
152
+ export { discoverDocuments, documentSchema, documentSectionProblems, explainSelection, findDocuments, selectEntries, validateDocumentSections };
@@ -0,0 +1,222 @@
1
+ import { z } from 'zod';
2
+ import { s as section, t as text, d as dateRange, l as linked, u as url, a as richText } from '../shared/vitakit.DvABWuUc.mjs';
3
+ import { p as propertyKey } from '../shared/vitakit.CCTDg7pz.mjs';
4
+ import { i as isSitePath } from '../shared/vitakit.D10HyDhF.mjs';
5
+ import '../shared/vitakit.DCK90zq4.mjs';
6
+ import 'node:url';
7
+ import '@babel/parser';
8
+
9
+ const DATE_POINT = /^\d{4}(?:-\d{2}(?:-\d{2})?)?$/;
10
+ const isDatePoint = (value) => typeof value === "number" && Number.isInteger(value) && value >= 1e3 && value <= 9999 || typeof value === "string" && DATE_POINT.test(value);
11
+ const isDateRange = (value) => isDatePoint(value) || isRecord(value) && Object.keys(value).every((key) => key === "from" || key === "to") && isDatePoint(value.from) && (value.to === void 0 || value.to === "present" || isDatePoint(value.to));
12
+ function isRecord(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ const isUrl = (value) => typeof value === "string" && (isSitePath(value) && !/\s/.test(value) || /^(?:https?:\/\/|mailto:)\S+$/.test(value));
16
+ const isLinked = (value) => isRecord(value) && (typeof value.name === "string" || typeof value.label === "string") && Object.keys(value).every((key) => ["name", "label", "url"].includes(key)) && !("name" in value && "label" in value);
17
+ function dateSuggestion(value) {
18
+ if (typeof value === "string") {
19
+ const monthYear = /^(\d{1,2})\/(\d{4})$/.exec(value);
20
+ if (monthYear) return `${monthYear[2]}-${monthYear[1].padStart(2, "0")}`;
21
+ const dotted = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(value);
22
+ if (dotted) return `${dotted[3]}-${dotted[2].padStart(2, "0")}-${dotted[1].padStart(2, "0")}`;
23
+ const since = /^since\s+(.+)$/i.exec(value);
24
+ if (since) return `{ from: ${dateSuggestion(since[1]) ?? since[1]}, to: present }`;
25
+ }
26
+ if (Array.isArray(value) && value.length >= 1 && value.length <= 2 && value.every((item) => isDatePoint(item) || dateSuggestion(item))) {
27
+ const [from, to] = value.map((item) => isDatePoint(item) ? String(item) : dateSuggestion(item));
28
+ return to ? `{ from: ${from}, to: ${to} }` : String(from);
29
+ }
30
+ if (isRecord(value) && "start" in value) {
31
+ return value.end !== void 0 ? `{ from: ${String(value.start)}, to: ${String(value.end)} }` : String(value.start);
32
+ }
33
+ return void 0;
34
+ }
35
+ const TITLE = /^(title|headline|name)$/;
36
+ function inferValues(name, values, depth) {
37
+ const present = values.filter((value) => value !== void 0 && value !== null && value !== "");
38
+ if (!present.length) {
39
+ return { code: "text", schema: text, imports: ["text"], notes: ["never filled in so far; text is a guess"] };
40
+ }
41
+ if (name === "date") {
42
+ if (present.every(isDateRange)) return { code: "dateRange", schema: dateRange, imports: ["dateRange"], notes: [] };
43
+ const example = present.map(dateSuggestion).find(Boolean);
44
+ return {
45
+ code: "dateRange",
46
+ schema: dateRange,
47
+ imports: ["dateRange"],
48
+ notes: [`dates must be written like 2024, 2024-03, 2024-03-15 or { from: 2019, to: present }${example ? `, e.g. ${example}` : ""}`]
49
+ };
50
+ }
51
+ if (present.every((value) => typeof value === "boolean")) return { code: "z.boolean()", schema: z.boolean(), imports: ["z"], notes: [] };
52
+ const numeric = (value) => typeof value === "number" || typeof value === "string" && /^-?\d+(\.\d+)?$/.test(value);
53
+ if (present.every(numeric) && present.some((value) => typeof value === "number")) {
54
+ const notes = present.some((value) => typeof value === "string") ? ["some entries write the number in quotes; remove them"] : [];
55
+ if (/^(year|time)$/.test(name) && present.every(isDatePoint)) notes.push("to sort entries by it, call this field date");
56
+ return { code: "z.number()", schema: z.number(), imports: ["z"], notes };
57
+ }
58
+ if (present.every(isLinked)) return { code: "linked", schema: linked, imports: ["linked"], notes: [] };
59
+ if (depth === 0 && ["institution", "issuer", "organisation"].includes(name) && present.every((value) => typeof value === "string" || isLinked(value))) {
60
+ return { code: "linked", schema: linked, imports: ["linked"], notes: present.some(isLinked) ? ["a name, or { name, url }"] : ["a name; { name, url } links it"] };
61
+ }
62
+ if (present.every((value) => typeof value === "string" || isLinked(value)) && present.some(isLinked)) {
63
+ return { code: "linked", schema: linked, imports: ["linked"], notes: ["a name, or { name, url }"] };
64
+ }
65
+ if (present.every(isRecord)) {
66
+ const keys = [...new Set(present.flatMap((value) => Object.keys(value)))];
67
+ if (keys.every((key) => key === "from" || key === "to" || key === "start" || key === "end")) {
68
+ const example = present.map(dateSuggestion).find(Boolean);
69
+ return {
70
+ code: "dateRange",
71
+ schema: dateRange,
72
+ imports: ["dateRange"],
73
+ notes: [`a date or a range; write { from, to }${example ? `, e.g. ${example}` : ""}${name === "date" ? "" : ", and call the field date to sort by it"}`]
74
+ };
75
+ }
76
+ const nested = inferObject(present, depth + 1);
77
+ return { code: nested.code, schema: nested.schema, imports: ["z", ...nested.imports], notes: [] };
78
+ }
79
+ if (present.some(Array.isArray)) {
80
+ const items = present.flatMap((value) => Array.isArray(value) ? value : [value]);
81
+ const element = inferValues(name, items, depth);
82
+ const dates = present.find((value) => Array.isArray(value) && dateSuggestion(value));
83
+ const notes = dates ? element.notes.filter((note) => !note.startsWith("looks like a date")) : [...element.notes];
84
+ if (!present.every(Array.isArray)) notes.push("a list; where an entry has a single value, write it as a list: [value]");
85
+ if (dates) notes.push(`if this is a period, write it as a date instead: ${dateSuggestion(dates)}${name === "date" ? "" : ", and call the field date to sort by it"}`);
86
+ return { code: `z.array(${element.code})`, schema: z.array(element.schema), imports: ["z", ...element.imports], notes };
87
+ }
88
+ if (present.every((value) => typeof value === "string")) {
89
+ const strings = present;
90
+ if (strings.every(isUrl)) return { code: "url", schema: url, imports: ["url"], notes: [] };
91
+ const notes = [];
92
+ const odd = strings.map(dateSuggestion).find(Boolean);
93
+ if (odd) notes.push(`looks like a date; to use it as one, write ${odd}${name === "date" ? "" : " and call the field date"}`);
94
+ else if (strings.every((value) => DATE_POINT.test(value)) && name !== "date") notes.push("looks like a date; to sort by it, call this field date and use dateRange");
95
+ const unique = [...new Set(strings)];
96
+ if (unique.length > 1 && unique.length <= 5 && strings.length >= 2 * unique.length && unique.every((value) => value.length <= 30)) {
97
+ notes.push(`values so far: ${unique.join(", ")}; to allow only these: z.enum([${unique.map((value) => `'${value.replace(/'/g, "\\'")}'`).join(", ")}])`);
98
+ }
99
+ if (depth === 0 && TITLE.test(name) || strings.some((value) => /\$[^$]+\$/.test(value))) {
100
+ return { code: "richText", schema: richText, imports: ["richText"], notes: ["Markdown and $math$ allowed", ...notes] };
101
+ }
102
+ return { code: "text", schema: text, imports: ["text"], notes };
103
+ }
104
+ const numbers = present.filter((value) => typeof value === "number").length;
105
+ if (numbers * 2 >= present.length && present.every((value) => typeof value === "number" || typeof value === "string")) {
106
+ const odd = [...new Set(present.filter((value) => typeof value === "string"))];
107
+ return {
108
+ code: "z.number()",
109
+ schema: z.number(),
110
+ imports: ["z"],
111
+ notes: [`mostly numbers; ${odd.slice(0, 3).map((value) => JSON.stringify(value)).join(", ")}${odd.length > 3 ? ", \u2026" : ""} ${odd.length === 1 ? "is" : "are"} not: write a number, or use text`]
112
+ };
113
+ }
114
+ const kinds = [...new Set(present.map((value) => Array.isArray(value) ? "list" : typeof value))];
115
+ return {
116
+ code: "text",
117
+ schema: text,
118
+ imports: ["text"],
119
+ notes: [`the entries mix ${kinds.join(" and ")}; text is a guess, make them consistent or write a custom schema`]
120
+ };
121
+ }
122
+ function inferObject(entries, depth, skip = []) {
123
+ const names = [...new Set(entries.flatMap((entry) => Object.keys(entry)))].filter((name) => !skip.includes(name));
124
+ const fields = [];
125
+ const shape = {};
126
+ const imports = [];
127
+ for (const name of names) {
128
+ const values = entries.map((entry) => entry[name]);
129
+ const inferred = inferValues(name, values, depth);
130
+ const filled = values.filter((value) => value !== void 0 && value !== null && value !== "").length;
131
+ const empty = values.filter((value) => value === "" || value === null).length;
132
+ const optional = filled < entries.length;
133
+ const notes = [...inferred.notes];
134
+ if (empty) notes.push(`empty in ${empty} ${empty === 1 ? "entry" : "entries"}; leave the field out instead`);
135
+ if (propertyKey(name) !== name) notes.push(`a name like ${name.replace(/[^A-Za-z0-9]+(.)/g, (_match, next) => next.toUpperCase())} needs no quotes`);
136
+ fields.push({ name, code: `${inferred.code}${optional ? ".optional()" : ""}`, notes });
137
+ shape[name] = optional ? inferred.schema.optional() : inferred.schema;
138
+ imports.push(...inferred.imports);
139
+ }
140
+ const indent = " ".repeat(depth + 1);
141
+ const lines = fields.flatMap((field) => [
142
+ ...field.notes.map((note) => `${indent}// ${note}`),
143
+ `${indent}${propertyKey(field.name)}: ${field.code},`
144
+ ]);
145
+ return {
146
+ code: depth === 0 ? lines.join("\n") : `z.strictObject({
147
+ ${lines.join("\n")}
148
+ ${" ".repeat(depth)}})`,
149
+ schema: z.strictObject(shape),
150
+ imports,
151
+ fields
152
+ };
153
+ }
154
+ function ranges(numbers) {
155
+ const parts = [];
156
+ for (let index = 0; index < numbers.length; index++) {
157
+ let end = index;
158
+ while (end + 1 < numbers.length && numbers[end + 1] === numbers[end] + 1) end++;
159
+ parts.push(end > index + 1 ? `${numbers[index]}\u2013${numbers[end]}` : end === index + 1 ? `${numbers[index]}, ${numbers[end]}` : `${numbers[index]}`);
160
+ index = end;
161
+ }
162
+ return parts.join(", ");
163
+ }
164
+ function changesFrom(error, entries) {
165
+ const grouped = /* @__PURE__ */ new Map();
166
+ for (const issue of error.issues) {
167
+ const [index, ...path] = issue.path;
168
+ let message = issue.message === "Invalid input" ? "not written in a form this field accepts" : issue.message.replace(/^Invalid input: /, "");
169
+ if (typeof index === "number") {
170
+ let value = entries[index];
171
+ for (const key2 of path) value = value?.[key2];
172
+ if (value === "" || value === null) message = "empty; leave the field out";
173
+ else if (value === void 0 && path.length) message = "missing; every entry needs it";
174
+ }
175
+ const key = `${path.join(".") || "(entry)"}\0${message}`;
176
+ grouped.set(key, [...grouped.get(key) ?? [], typeof index === "number" ? index + 1 : 0]);
177
+ }
178
+ return [...grouped].map(([key, numbers]) => {
179
+ const [field, message] = key.split("\0");
180
+ const unique = [...new Set(numbers)].sort((a, b) => a - b);
181
+ return `${field}, ${unique.length === 1 ? "entry" : "entries"} ${ranges(unique)}: ${message}`;
182
+ });
183
+ }
184
+ function draftSchema(id, entries, { file = `content/${id}.yml` } = {}) {
185
+ const records = entries.filter(isRecord);
186
+ const object = inferObject(records, 0, ["visibility"]);
187
+ const imports = [.../* @__PURE__ */ new Set(["section", ...object.imports])].sort();
188
+ const source = [
189
+ "/**",
190
+ ` * What ${file} may contain.`,
191
+ " *",
192
+ " * Drafted from its entries by `vitakit add section`: check the comments,",
193
+ " * change what is not right, then delete them. Every entry also has",
194
+ " * `visibility: public | cv | private`.",
195
+ ...object.fields.some((field) => field.name === "date") ? [] : [" *", " * Without a field called `date`, entries keep the order of the file."],
196
+ " */",
197
+ `import { ${imports.join(", ")} } from '@anja.codes/vitakit'`,
198
+ "",
199
+ "export default section({",
200
+ object.code,
201
+ "})",
202
+ ""
203
+ ].join("\n");
204
+ const schema = section(object.schema.shape);
205
+ const parsed = schema.safeParse(entries);
206
+ return { source, fields: object.fields, schema, changes: parsed.success ? [] : changesFrom(parsed.error, entries) };
207
+ }
208
+ function draftExtension(base, entries) {
209
+ if (!(base instanceof z.ZodArray) || !(base.element instanceof z.ZodObject)) return void 0;
210
+ const element = base.element;
211
+ const known = Object.keys(element.shape);
212
+ const object = inferObject(entries.filter(isRecord), 0, known);
213
+ const schema = z.array(element.extend(object.schema.shape));
214
+ const parsed = schema.safeParse(entries);
215
+ return {
216
+ fields: object.fields,
217
+ imports: [...new Set(object.imports)].sort(),
218
+ changes: parsed.success ? [] : changesFrom(parsed.error, entries)
219
+ };
220
+ }
221
+
222
+ export { draftExtension, draftSchema };
@@ -0,0 +1,59 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir, readFile } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
4
+ import { r as resolveAsset } from '../shared/vitakit.DQ6PmGoL.mjs';
5
+ import { C as CommandError } from '../shared/vitakit.zwr1ssO5.mjs';
6
+ import 'node:url';
7
+ import 'node:process';
8
+ import 'node:util';
9
+ import 'node:readline/promises';
10
+
11
+ const SCOPES = {
12
+ VitaSection: "every section on every page; prefer customize for one section",
13
+ VitaEntry: "every entry row of every section",
14
+ VitaDate: "every date on the website",
15
+ VitaLinks: "the profile links",
16
+ VitaMarkdown: "every Markdown page body",
17
+ VitaIssues: "the development error banner",
18
+ VitaPublications: "how a publication list is laid out: its headings and groups",
19
+ VitaPublication: "how each reference looks on the website"
20
+ };
21
+ async function ejectableAssets() {
22
+ const componentsDir = resolveAsset("runtime/components");
23
+ const components = (await readdir(componentsDir)).filter((file) => file.endsWith(".vue")).sort();
24
+ return [
25
+ ...components.map((file) => ({
26
+ name: basename(file, ".vue"),
27
+ source: join(componentsDir, file),
28
+ target: `app/components/${file}`,
29
+ scope: SCOPES[basename(file, ".vue")] ?? "the website",
30
+ after: "Your copy is used instead of vitakit's from now on."
31
+ })),
32
+ {
33
+ name: "publications.csl",
34
+ source: join(resolveAsset("styles"), "vitakit-math.csl"),
35
+ target: "content/publications.csl",
36
+ scope: "how publications.bib is formatted on the website (not the PDF)",
37
+ after: "Edit it, or replace it with any CSL style, e.g. from zotero.org/styles."
38
+ },
39
+ {
40
+ name: "vitakit-cv.cls",
41
+ source: resolveAsset("tex/vitakit-cv.cls"),
42
+ target: "cv-tex/vitakit-cv.cls",
43
+ scope: "the LaTeX class of every PDF document; preamble.tex is usually enough",
44
+ after: "Every PDF document uses your copy from now on."
45
+ }
46
+ ];
47
+ }
48
+ async function ejectPlan(root, asset, { force }) {
49
+ const exists = existsSync(join(root, asset.target));
50
+ if (exists && !force) {
51
+ throw new CommandError(`${asset.target} is already yours. --force replaces it with vitakit's current version, keeping yours as a backup.`);
52
+ }
53
+ const content = await readFile(asset.source, "utf8");
54
+ return {
55
+ items: [exists ? { kind: "replace", path: asset.target, content, reason: "vitakit's current version; yours is kept next to it as a .backup" } : { kind: "create", path: asset.target, content, reason: asset.scope }]
56
+ };
57
+ }
58
+
59
+ export { ejectPlan, ejectableAssets };
@@ -0,0 +1,216 @@
1
+ import { mkdir, writeFile, readdir, rm, readFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, basename } from 'node:path';
4
+ import { Eta } from 'eta';
5
+ import { r as resolveAsset } from '../shared/vitakit.DQ6PmGoL.mjs';
6
+ import { b as escapeUrl, a as escapeTex, e as escapeRichTex } from '../shared/vitakit.DcQOdM4I.mjs';
7
+ import { f as formatDateRange, e as entrySecondaryParts, b as entryTitleField } from '../shared/vitakit.CfB3hMHl.mjs';
8
+ import { f as fail } from '../shared/vitakit.DCK90zq4.mjs';
9
+ import { i as isSitePath } from '../shared/vitakit.D10HyDhF.mjs';
10
+ import { selectEntries } from './documents.mjs';
11
+ import 'node:url';
12
+ import 'yaml';
13
+ import 'zod';
14
+ import '../shared/vitakit.DvvUYUTS.mjs';
15
+ import '../shared/vitakit.TSMTC5Jl.mjs';
16
+ import '../shared/vitakit.D-DdJaDZ.mjs';
17
+
18
+ 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/`";
19
+ class Fragment {
20
+ constructor(value) {
21
+ this.value = value;
22
+ }
23
+ toString() {
24
+ return this.value;
25
+ }
26
+ }
27
+ const fragment = (value) => value ? new Fragment(value) : "";
28
+ function isRichText(value) {
29
+ return typeof value === "object" && value !== null && "text" in value && typeof value.text === "string" && "html" in value && typeof value.html === "string";
30
+ }
31
+ function isTemplate(value) {
32
+ return Array.isArray(value) && "raw" in value && Array.isArray(value.raw);
33
+ }
34
+ function createTemplateHelpers(where, source) {
35
+ const text = (value) => {
36
+ if (value === void 0 || value === null || value === "") return "";
37
+ if (value instanceof Fragment) return value;
38
+ if (typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
39
+ return fragment(escapeTex(String(value)));
40
+ }
41
+ if (isRichText(value)) {
42
+ try {
43
+ return fragment(escapeRichTex(value.text));
44
+ } catch (error) {
45
+ return fail(where, error instanceof Error ? error.message : String(error));
46
+ }
47
+ }
48
+ return fail(where, "text expects text or a number; use date(...) or linked(...) for structured values");
49
+ };
50
+ const url = (value) => {
51
+ if (value === void 0 || value === null || value === "") return "";
52
+ if (typeof value !== "string") return fail(where, "url expects a URL string");
53
+ if (isSitePath(value)) fail(source, `${value} ${SITE_PATH_HELP}`);
54
+ return fragment(escapeUrl(value));
55
+ };
56
+ const linked = (value, target) => {
57
+ let label = value;
58
+ if (typeof value === "object" && value !== null && !(value instanceof Fragment) && !isRichText(value)) {
59
+ if ("label" in value && typeof value.label === "string") label = value.label;
60
+ else return fail(where, "linked expects text or { label, url? }; use `linked` in the section schema");
61
+ if (target === void 0 && "url" in value) target = value.url;
62
+ }
63
+ const body = text(label);
64
+ if (!body) return "";
65
+ const href = url(target);
66
+ return href ? fragment(`\\href{${href}}{${body}}`) : body;
67
+ };
68
+ const date = (value) => {
69
+ if (value === void 0 || value === null) return "";
70
+ if (typeof value !== "object" || !("from" in value) || typeof value.from !== "object" || value.from === null || !("year" in value.from) || typeof value.from.year !== "number" || !("point" in value) || typeof value.point !== "boolean" || !("ongoing" in value) || typeof value.ongoing !== "boolean") {
71
+ return fail(where, "date expects a normalized date; use dateRange in the collection schema");
72
+ }
73
+ return fragment(escapeTex(formatDateRange(value)));
74
+ };
75
+ const join = (values, separator = ", ") => {
76
+ if (!Array.isArray(values)) return fail(where, "join expects an array of values");
77
+ const parts = values.map((value) => String(text(value))).filter((value) => value !== "");
78
+ return fragment(parts.join(String(text(separator))));
79
+ };
80
+ const optional = (strings, ...values) => {
81
+ if (!isTemplate(strings) || strings.raw.length !== values.length + 1) {
82
+ return fail(where, "optional must be used as a tagged template: optional`...${value}...`");
83
+ }
84
+ if (values.some((value) => value === void 0 || value === null || value === "")) return "";
85
+ const parts = values.map(text);
86
+ if (parts.some((value) => !value)) return "";
87
+ return fragment(strings.raw.map((literal, index) => literal + (parts[index] ?? "")).join(""));
88
+ };
89
+ function note(value, ...values) {
90
+ const body = isTemplate(value) ? optional(value, ...values) : text(value);
91
+ return body ? fragment(`\\vitanote{${body}}`) : "";
92
+ }
93
+ const entry = (layout) => {
94
+ if (typeof layout !== "object" || layout === null || !Object.hasOwn(layout, "title") || Object.keys(layout).some((key) => !["date", "title", "context"].includes(key))) {
95
+ return fail(where, "vita.entry expects { title, date?, context? }");
96
+ }
97
+ return fragment(`\\vitaentry{${text(layout.date)}}{${text(layout.title)}}{${text(layout.context)}}`);
98
+ };
99
+ return { text, date, linked, url, join, optional, vita: { entry, note } };
100
+ }
101
+
102
+ const bundledTexDir = resolveAsset("tex");
103
+ const eta = new Eta({ autoEscape: false, autoTrim: [false, false] });
104
+ function tidy(body) {
105
+ return body.replace(/[ \t]+$/gm, "").replace(/\n{2,}/g, "\n").trim();
106
+ }
107
+ function genericHelpers(h) {
108
+ return {
109
+ title(entry) {
110
+ const field = entryTitleField(entry);
111
+ return h.text(field ? entry[field] : void 0);
112
+ },
113
+ context(entry) {
114
+ return h.join(entrySecondaryParts(entry, entryTitleField(entry)).map((part) => part.kind === "linked" ? h.linked(part.linked) : part.text));
115
+ }
116
+ };
117
+ }
118
+ function texSafe(values, where) {
119
+ const bad = values.find((value) => !value || /[\s%#\\{},~]/.test(value));
120
+ if (bad !== void 0) {
121
+ fail(where, `"${bad}" cannot be used as a citation key or group id in LaTeX; use letters, digits and - _ : . / +`);
122
+ }
123
+ }
124
+ function bibHelpers(list, where) {
125
+ return {
126
+ cite() {
127
+ const keys = list.groups.flatMap((group) => group.entries.map((entry) => entry.key));
128
+ texSafe(keys, where);
129
+ return fragment(`\\nocite{${keys.join(",")}}`);
130
+ },
131
+ group(group) {
132
+ texSafe([group.id, ...group.entries.map((entry) => entry.key)], where);
133
+ return fragment(`\\vitabibgroup{${group.id}}{${group.entries.map((entry) => entry.key).join(",")}}`);
134
+ }
135
+ };
136
+ }
137
+ async function resolveTemplate(id, userDir, { exclude } = {}) {
138
+ const candidates = [
139
+ { path: join(userDir, `${id}.eta`), origin: "local", generic: false },
140
+ { path: join(bundledTexDir, "templates", `${id}.eta`), origin: "bundled", generic: false },
141
+ { path: join(userDir, "_generic.eta"), origin: "local", generic: true },
142
+ { path: join(bundledTexDir, "templates", "_generic.eta"), origin: "bundled", generic: true }
143
+ ];
144
+ for (const candidate of candidates) {
145
+ if (candidate.path === exclude) continue;
146
+ if (existsSync(candidate.path)) return { ...candidate, source: await readFile(candidate.path, "utf8") };
147
+ }
148
+ throw new Error(`vitakit: no template for section ${id}`);
149
+ }
150
+ async function assertTemplatesAreUsed(templateDir, ids) {
151
+ const files = await readdir(templateDir).catch(() => []);
152
+ const known = /* @__PURE__ */ new Set([...ids, "publications", "_generic"]);
153
+ for (const file of files.filter((name) => name.endsWith(".eta")).sort()) {
154
+ const id = file.slice(0, -".eta".length);
155
+ if (known.has(id)) continue;
156
+ const suggestion = ids.find((candidate) => candidate.startsWith(id) || id.startsWith(candidate));
157
+ fail(
158
+ `cv-tex/templates/${file}`,
159
+ `no section is called "${id}", so this template is never used${suggestion ? `; did you mean ${suggestion}.eta?` : ""}
160
+ sections: ${ids.join(", ") || "(none)"}`
161
+ );
162
+ }
163
+ }
164
+ async function renderSection(id, entries, options, templateDir) {
165
+ const selected = selectEntries(entries, options, id);
166
+ const { path: templatePath, source } = await resolveTemplate(id, templateDir);
167
+ const h = createTemplateHelpers(templatePath, `content/${id}.yml`);
168
+ try {
169
+ return tidy(eta.renderString(source, { entries: selected, ...h, generic: genericHelpers(h) }));
170
+ } catch (error) {
171
+ throw new Error(`template ${id}.eta: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
172
+ }
173
+ }
174
+ async function renderPublications(list, templateDir, siteUrl) {
175
+ if (!siteUrl) {
176
+ const local = list.groups.flatMap((group) => group.entries).find((entry) => entry.titleUrl && isSitePath(entry.titleUrl));
177
+ if (local) fail("content/publications.bib", `${local.key}: url ${local.titleUrl} ${SITE_PATH_HELP}`);
178
+ }
179
+ const { path: templatePath, source } = await resolveTemplate("publications", templateDir);
180
+ return tidy(eta.renderString(source, {
181
+ groups: list.groups,
182
+ ...createTemplateHelpers(templatePath, "content/publications.bib"),
183
+ bib: bibHelpers(list, templatePath)
184
+ }));
185
+ }
186
+ async function emitDocument(emit) {
187
+ await mkdir(emit.outDir, { recursive: true });
188
+ await assertTemplatesAreUsed(emit.templateDir, Object.keys(emit.sections));
189
+ const written = [];
190
+ for (const [id, entries] of Object.entries(emit.sections)) {
191
+ const body = await renderSection(id, entries, emit.options, emit.templateDir);
192
+ const file = join(emit.outDir, `${id}.tex`);
193
+ await writeFile(file, `% Generated by vitakit. Do not edit; edit content/${id}.yml instead.
194
+ ${body.trim()}
195
+ `);
196
+ written.push(file);
197
+ }
198
+ const current = /* @__PURE__ */ new Set(["profile.tex", ...written.map((file) => basename(file))]);
199
+ for (const file of await readdir(emit.outDir)) {
200
+ if (file.endsWith(".tex") && !current.has(file)) await rm(join(emit.outDir, file));
201
+ }
202
+ const listDir = join(emit.outDir, "publications");
203
+ await rm(listDir, { recursive: true, force: true });
204
+ await mkdir(listDir);
205
+ for (const [name, list] of Object.entries(emit.publications)) {
206
+ const body = await renderPublications(list, emit.templateDir, emit.siteUrl);
207
+ const file = join(listDir, `${name}.tex`);
208
+ await writeFile(file, `% Generated by vitakit. Do not edit; edit content/publications.bib or content/publication-lists.yml instead.
209
+ ${body.trim()}
210
+ `);
211
+ written.push(file);
212
+ }
213
+ return written;
214
+ }
215
+
216
+ export { SITE_PATH_HELP, assertTemplatesAreUsed, bundledTexDir, emitDocument, renderPublications, renderSection, resolveTemplate };
@@ -0,0 +1,16 @@
1
+ function addToEntryLoop(source, lines) {
2
+ const all = source.split("\n");
3
+ let close = -1;
4
+ for (let index = all.length - 1; index >= 0; index--) {
5
+ if (/^\s*<%\s*}\s*%>\s*$/.test(all[index])) {
6
+ close = index;
7
+ break;
8
+ }
9
+ }
10
+ if (close === -1) return { ok: false, reason: "it has no `<% } %>` line ending its loop over the entries" };
11
+ const fresh = lines.filter((line) => !source.includes(line));
12
+ if (!fresh.length) return { ok: true, value: source };
13
+ return { ok: true, value: [...all.slice(0, close), ...fresh, ...all.slice(close)].join("\n") };
14
+ }
15
+
16
+ export { addToEntryLoop };