@anja.codes/vitakit 0.1.0 → 0.2.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 (63) hide show
  1. package/README.md +51 -131
  2. package/dist/check.mjs +12 -5
  3. package/dist/chunks/authoring.mjs +367 -0
  4. package/dist/chunks/build.mjs +176 -0
  5. package/dist/chunks/documents.mjs +143 -0
  6. package/dist/chunks/draft.mjs +222 -0
  7. package/dist/chunks/eject.mjs +59 -0
  8. package/dist/chunks/emit.mjs +200 -0
  9. package/dist/chunks/eta.mjs +16 -0
  10. package/dist/chunks/fields.mjs +79 -0
  11. package/dist/chunks/inventory.mjs +199 -0
  12. package/dist/chunks/plan.mjs +144 -0
  13. package/dist/chunks/prompt.mjs +33 -0
  14. package/dist/chunks/schema-source.mjs +12 -0
  15. package/dist/chunks/schemas.mjs +109 -0
  16. package/dist/chunks/sections.mjs +109 -0
  17. package/dist/chunks/snippets.mjs +199 -0
  18. package/dist/chunks/validate.mjs +124 -0
  19. package/dist/chunks/vue.mjs +116 -0
  20. package/dist/cli.d.mts +33 -40
  21. package/dist/cli.mjs +7 -224
  22. package/dist/index.d.mts +372 -42
  23. package/dist/index.mjs +10 -4
  24. package/dist/nuxt.mjs +35 -5
  25. package/dist/runtime/components/VitaSection.vue +94 -38
  26. package/dist/schemas/education.ts +4 -4
  27. package/dist/schemas/employment.ts +3 -3
  28. package/dist/schemas/publications.ts +2 -2
  29. package/dist/schemas/supervision.ts +3 -3
  30. package/dist/schemas/talks.ts +1 -1
  31. package/dist/schemas/teaching.ts +3 -3
  32. package/dist/shared/index.d.mts +60 -9
  33. package/dist/shared/index.mjs +3 -57
  34. package/dist/shared/vitakit.BZZIsWlY.mjs +179 -0
  35. package/dist/shared/vitakit.BerUaxtF.mjs +121 -0
  36. package/dist/shared/{vitakit.D8WRg_Xx.d.mts → vitakit.BfpxDtz1.d.mts} +9 -8
  37. package/dist/shared/{vitakit.CypCF-4Q.mjs → vitakit.By5VWQ1n.mjs} +12 -78
  38. package/dist/shared/vitakit.CfB3hMHl.mjs +60 -0
  39. package/dist/shared/vitakit.CoWX2Z4R.mjs +1554 -0
  40. package/dist/shared/{vitakit.BlU-p01e.mjs → vitakit.CtChKO0I.mjs} +1 -1
  41. package/dist/shared/vitakit.D-DdJaDZ.mjs +20 -0
  42. package/dist/shared/vitakit.D10HyDhF.mjs +19 -0
  43. package/dist/shared/vitakit.DQ6PmGoL.mjs +34 -0
  44. package/dist/shared/vitakit.DbSeENVS.mjs +58 -0
  45. package/dist/shared/vitakit.DcQOdM4I.mjs +57 -0
  46. package/dist/shared/vitakit.Do5HC_MD.mjs +142 -0
  47. package/dist/shared/vitakit.TSMTC5Jl.mjs +6 -0
  48. package/dist/tex/templates/_generic.eta +6 -4
  49. package/dist/tex/templates/education.eta +10 -5
  50. package/dist/tex/templates/employment.eta +12 -5
  51. package/dist/tex/templates/publications.eta +4 -2
  52. package/dist/tex/templates/supervision.eta +10 -2
  53. package/dist/tex/templates/talks.eta +5 -1
  54. package/dist/tex/templates/teaching.eta +6 -4
  55. package/dist/tex/vitakit-cv.cls +21 -6
  56. package/dist/tex.d.mts +53 -5
  57. package/dist/tex.mjs +20 -113
  58. package/package.json +10 -7
  59. package/src/bin/vitakit.mjs +3 -153
  60. package/dist/shared/vitakit.BRhjqpAt.mjs +0 -250
  61. package/dist/shared/vitakit.BxcnlFGP.mjs +0 -325
  62. package/src/bin/vitakit-build-cv.mjs +0 -50
  63. package/src/bin/vitakit-check.mjs +0 -57
@@ -0,0 +1,176 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, readFile, writeFile, rm, copyFile, rename } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import process from 'node:process';
5
+ import { join, delimiter, resolve } from 'node:path';
6
+ import { l as loadContent, a as loadPublicationsSchema } from '../shared/vitakit.By5VWQ1n.mjs';
7
+ import { f as fail } from '../shared/vitakit.DbSeENVS.mjs';
8
+ import { discoverDocuments, validateDocumentSections, selectEntries } from './documents.mjs';
9
+ import { discoverSections } from './sections.mjs';
10
+ import { emitDocument, bundledTexDir, SITE_PATH_HELP } from './emit.mjs';
11
+ import { a as escapeTex, b as escapeUrl } from '../shared/vitakit.DcQOdM4I.mjs';
12
+ import { i as isSitePath } from '../shared/vitakit.D10HyDhF.mjs';
13
+ import 'gray-matter';
14
+ import 'yaml';
15
+ import 'unified';
16
+ import 'remark-parse';
17
+ import 'remark-gfm';
18
+ import 'remark-math';
19
+ import 'remark-rehype';
20
+ import 'rehype-katex';
21
+ import 'rehype-raw';
22
+ import 'rehype-stringify';
23
+ import 'unist-util-visit';
24
+ import '../shared/vitakit.DQ6PmGoL.mjs';
25
+ import 'node:url';
26
+ import '@behackl/citation-js-extras';
27
+ import 'rehype-parse';
28
+ import 'rehype-sanitize';
29
+ import 'zod';
30
+ import './schemas.mjs';
31
+ import '../shared/vitakit.BerUaxtF.mjs';
32
+ import '../shared/vitakit.TSMTC5Jl.mjs';
33
+ import '../shared/vitakit.D-DdJaDZ.mjs';
34
+ import 'eta';
35
+ import '../shared/vitakit.CfB3hMHl.mjs';
36
+
37
+ const CLASS_FILE = "vitakit-cv.cls";
38
+ class MissingToolError extends Error {
39
+ constructor(tool) {
40
+ super(`${tool} not found; install a TeX distribution that includes latexmk and biber`);
41
+ this.tool = tool;
42
+ this.name = "MissingToolError";
43
+ }
44
+ }
45
+ function run(command, args, cwd, env = {}, output = "inherit", log) {
46
+ return new Promise((resolve2, reject) => {
47
+ const child = spawn(command, args, { cwd, stdio: output === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"], env: { ...process.env, ...env } });
48
+ let captured = "";
49
+ child.stdout?.on("data", (chunk) => {
50
+ captured += chunk.toString();
51
+ });
52
+ child.stderr?.on("data", (chunk) => {
53
+ captured += chunk.toString();
54
+ });
55
+ child.on("error", (error) => reject(
56
+ error.code === "ENOENT" ? new MissingToolError(command) : error
57
+ ));
58
+ child.on("exit", (code) => {
59
+ if (code === 0) return resolve2();
60
+ if (output === "inherit") return reject(new Error(`${command} exited with code ${code}`));
61
+ void readFile(log ?? "", "utf8").catch(() => "").then((text) => {
62
+ const lines = text.split("\n");
63
+ const start = lines.findIndex((line) => line.startsWith("! "));
64
+ const excerpt = start === -1 ? captured.trim().split("\n").slice(-25).join("\n") : lines.slice(start, start + 8).join("\n").trim();
65
+ reject(new Error(`${command} exited with code ${code}${excerpt ? `
66
+
67
+ ${excerpt}` : ""}${log ? `
68
+
69
+ Full log: ${log}` : ""}`));
70
+ });
71
+ });
72
+ });
73
+ }
74
+ function profilePreamble(content, document) {
75
+ const { profile } = content;
76
+ const href = (url) => {
77
+ if (isSitePath(url)) fail("content/profile.yml", `links: ${url} ${SITE_PATH_HELP}`);
78
+ return escapeUrl(url);
79
+ };
80
+ const affiliation = profile.affiliation?.label ? escapeTex(profile.affiliation.label) : "";
81
+ const tagline = [profile.title && escapeTex(profile.title), affiliation].filter(Boolean).join(", ");
82
+ const contact = [
83
+ profile.email && `\\href{mailto:${escapeUrl(profile.email)}}{${escapeTex(profile.email)}}`,
84
+ ...profile.links.map((link) => `\\href{${href(link.url)}}{${escapeTex(link.label)}}`)
85
+ ].filter(Boolean).join(" \\quad ");
86
+ return [
87
+ `% Generated by vitakit. Do not edit; edit content/profile.yml or cv-tex/${document.name}.yml instead.`,
88
+ `\\vitaname{${escapeTex(profile.name)}}`,
89
+ `\\vitatagline{${tagline}}`,
90
+ `\\vitacontact{${contact}}`,
91
+ ...profile.url ? siteUrlSourcemap(profile.url) : [],
92
+ ...Object.entries(content.sections).flatMap(([id, entries]) => {
93
+ const title = document.options.sections[id]?.title;
94
+ return [
95
+ ...selectEntries(entries, document.options, id).length ? [] : [`\\vitaemptysection{${id}}`],
96
+ ...title ? [`\\vitasectiontitle{${id}}{${escapeTex(title)}}`] : []
97
+ ];
98
+ }),
99
+ ""
100
+ ].join("\n");
101
+ }
102
+ function siteUrlSourcemap(siteUrl) {
103
+ const base = siteUrl.replace(/\/?$/, "/");
104
+ if (!/^https?:\/\/[\w.:/-]+$/.test(base)) {
105
+ fail("content/profile.yml", `url: ${siteUrl} may only contain letters, digits and - . _ : /`);
106
+ }
107
+ return [
108
+ "\\ifdefined\\DeclareSourcemap",
109
+ ` \\DeclareSourcemap{\\maps[datatype=bibtex]{\\map{\\step[fieldsource=url, match=\\regexp{\\A/}, replace={${base}}]}}}`,
110
+ "\\fi"
111
+ ];
112
+ }
113
+ async function buildDocument(document, content, options, cvDir) {
114
+ const outDir = join(cvDir, "generated", document.name);
115
+ const written = await emitDocument({
116
+ sections: content.sections,
117
+ publications: content.publications,
118
+ siteUrl: content.profile.url,
119
+ options: document.options,
120
+ templateDir: join(cvDir, "templates"),
121
+ outDir
122
+ });
123
+ await writeFile(join(outDir, "profile.tex"), profilePreamble(content, document));
124
+ const generatedClass = join(outDir, CLASS_FILE);
125
+ if (existsSync(join(cvDir, CLASS_FILE))) await rm(generatedClass, { force: true });
126
+ else await copyFile(join(bundledTexDir, CLASS_FILE), generatedClass);
127
+ options.onProgress?.({ document: document.name, step: "tex", dir: outDir, files: written.length + 1 });
128
+ if (options.noPdf) return;
129
+ const relativeOut = join("generated", document.name);
130
+ await run("latexmk", [
131
+ "-pdf",
132
+ "-halt-on-error",
133
+ "-interaction=nonstopmode",
134
+ `-outdir=${relativeOut}`,
135
+ // Otherwise -outdir can shadow teaching.tex with its generated section.
136
+ resolve(document.texFile)
137
+ ], cvDir, {
138
+ // A trailing delimiter keeps the standard search paths.
139
+ TEXINPUTS: `.${delimiter}${relativeOut}${delimiter}${process.env.TEXINPUTS ?? ""}`
140
+ }, options.latexOutput, join(outDir, `${document.name}.log`));
141
+ await rename(join(outDir, `${document.name}.pdf`), join(cvDir, `${document.name}.pdf`));
142
+ options.onProgress?.({ document: document.name, step: "pdf", file: join(cvDir, `${document.name}.pdf`) });
143
+ }
144
+ async function buildCv(options) {
145
+ const cvDir = join(options.rootDir, "cv-tex");
146
+ if (!existsSync(cvDir)) {
147
+ throw new Error(`no cv-tex/ directory in ${options.rootDir}`);
148
+ }
149
+ const content = await loadContent(options.rootDir, { strict: true, maxVisibility: "private", target: "pdf" });
150
+ const documents = await discoverDocuments(cvDir);
151
+ const sources = await discoverSections(options.rootDir);
152
+ const groups = (await loadPublicationsSchema(options.rootDir)).groups.map((group) => group.id);
153
+ for (const document of documents) {
154
+ validateDocumentSections(document, sources);
155
+ const unknown2 = (document.options.publications.groups ?? []).filter((id) => !groups.includes(id));
156
+ if (unknown2.length) {
157
+ fail(`cv-tex/${document.name}.yml`, `publications.groups: unknown group ${unknown2.join(", ")}; the groups are ${groups.join(", ")} (schemas/publications.ts)`);
158
+ }
159
+ }
160
+ const unknown = options.only?.filter((name) => !documents.some((document) => document.name === name)) ?? [];
161
+ if (unknown.length) {
162
+ const known = documents.map((document) => document.name).join(", ");
163
+ throw new Error(`no document matched ${unknown.join(", ")}; available: ${known}`);
164
+ }
165
+ const selected = options.only?.length ? documents.filter((document) => options.only.includes(document.name)) : documents;
166
+ await mkdir(join(cvDir, "generated"), { recursive: true });
167
+ for (const document of selected) {
168
+ await buildDocument(document, content, options, cvDir);
169
+ }
170
+ return selected.map((document) => join(cvDir, `${document.name}.pdf`));
171
+ }
172
+ async function readGenerated(rootDir, document, section) {
173
+ return readFile(join(rootDir, "cv-tex", "generated", document, `${section}.tex`), "utf8");
174
+ }
175
+
176
+ export { MissingToolError, buildCv, readGenerated };
@@ -0,0 +1,143 @@
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.DbSeENVS.mjs';
6
+ import { a as atMost } from '../shared/vitakit.TSMTC5Jl.mjs';
7
+ import { f as fieldValue, a as filterWhere } from '../shared/vitakit.D-DdJaDZ.mjs';
8
+ import 'node:url';
9
+
10
+ const whereScalar = z.union([z.string(), z.number(), z.boolean()]);
11
+ const sectionOptions = z.strictObject({
12
+ /**
13
+ * Keep entries whose field equals the value, or one of several values:
14
+ *
15
+ * ```yaml
16
+ * where: { kind: invited }
17
+ * where: { kind: [invited, seminar] }
18
+ * ```
19
+ */
20
+ where: z.record(z.string(), z.union([whereScalar, z.array(whereScalar).min(1)])).default({}),
21
+ /** Keep only the newest N entries. */
22
+ limit: z.number().int().positive().optional(),
23
+ /** Drop entries starting before this year. */
24
+ since: z.number().int().optional(),
25
+ /** Drop entries starting after this year. */
26
+ until: z.number().int().optional(),
27
+ /** Heading used by `\vitasection`; overrides the shared preamble's label. */
28
+ title: z.string().min(1).optional()
29
+ });
30
+ const documentSchema = z.strictObject({
31
+ /**
32
+ * Highest visibility level this document may include. Defaults to `cv`,
33
+ * because a CV is the reason the `cv` level exists; use `private` for an
34
+ * application containing a cover letter, address or referees.
35
+ *
36
+ * There is no "publish this PDF" option: PDFs are never part of the website
37
+ * build (see `vitakit verify`), and the website renders CV data directly.
38
+ */
39
+ visibility: z.enum(["public", "cv", "private"]).default("cv"),
40
+ sections: z.record(z.string(), sectionOptions).default({}),
41
+ publications: z.strictObject({
42
+ groups: z.array(z.string()).optional()
43
+ }).default({})
44
+ });
45
+ const DEFAULTS = documentSchema.parse({});
46
+ async function findDocuments(cvDir) {
47
+ const files = (await readdir(cvDir).catch(() => [])).filter((name) => name.endsWith(".tex")).sort();
48
+ const documents = [];
49
+ const problems = [];
50
+ for (const file of files) {
51
+ const name = basename(file, ".tex");
52
+ const source = await readFile(join(cvDir, file), "utf8");
53
+ if (!/\\begin\{document\}/.test(source)) continue;
54
+ const where = `cv-tex/${name}.yml`;
55
+ const raw = await readFile(join(cvDir, `${name}.yml`), "utf8").catch(() => void 0);
56
+ let options = DEFAULTS;
57
+ if (raw !== void 0) {
58
+ let data;
59
+ try {
60
+ data = parse(raw);
61
+ } catch (error) {
62
+ problems.push({ where, message: messageOf(error) });
63
+ continue;
64
+ }
65
+ const parsed = documentSchema.safeParse(data ?? {});
66
+ if (!parsed.success) {
67
+ problems.push({ where, message: `invalid
68
+ ${formatZodIssues(parsed.error)}` });
69
+ continue;
70
+ }
71
+ options = parsed.data;
72
+ }
73
+ documents.push({ name, texFile: join(cvDir, file), options });
74
+ }
75
+ return { documents, problems };
76
+ }
77
+ async function discoverDocuments(cvDir) {
78
+ await readdir(cvDir).catch(() => fail("cv-tex/", "directory is missing"));
79
+ const { documents, problems } = await findDocuments(cvDir);
80
+ if (problems.length) fail(problems[0].where, problems[0].message);
81
+ if (!documents.length) fail("cv-tex/", "no .tex file contains \\begin{document}");
82
+ return documents;
83
+ }
84
+ function documentSectionProblems(document, sources) {
85
+ const where = `cv-tex/${document.name}.yml`;
86
+ const problems = [];
87
+ const sections = new Map(sources.map((source) => [source.id, source.schema]));
88
+ for (const [id, options] of Object.entries(document.options.sections)) {
89
+ const schema = sections.get(id);
90
+ if (!schema) {
91
+ problems.push({ where, message: `sections.${id}: unknown section; the sections are ${[...sections.keys()].join(", ") || "(none)"}` });
92
+ continue;
93
+ }
94
+ if (schema instanceof z.ZodArray && schema.element instanceof z.ZodObject) {
95
+ const fields = /* @__PURE__ */ new Set(["year", ...Object.keys(schema.element.shape)]);
96
+ for (const field of Object.keys(options.where)) {
97
+ if (!fields.has(field)) {
98
+ problems.push({ where, message: `sections.${id}.where.${field}: unknown field; the fields are ${[...fields].join(", ")}` });
99
+ }
100
+ }
101
+ }
102
+ }
103
+ return problems;
104
+ }
105
+ function validateDocumentSections(document, sources) {
106
+ const [problem] = documentSectionProblems(document, sources);
107
+ if (problem) fail(problem.where, problem.message);
108
+ }
109
+ function selectEntries(entries, options, sectionId) {
110
+ const section = options.sections[sectionId] ?? { where: {} };
111
+ let result = entries.filter((entry) => atMost(entry.visibility, options.visibility));
112
+ if (section.since !== void 0) {
113
+ result = result.filter((entry) => Number(fieldValue(entry, "year") ?? 0) >= section.since);
114
+ }
115
+ if (section.until !== void 0) {
116
+ result = result.filter((entry) => Number(fieldValue(entry, "year") ?? Infinity) <= section.until);
117
+ }
118
+ result = filterWhere(result, section.where);
119
+ return section.limit ? result.slice(0, section.limit) : result;
120
+ }
121
+ function explainSelection(entries, options, sectionId) {
122
+ const section = options.sections[sectionId] ?? { where: {} };
123
+ const steps = [];
124
+ let current = entries;
125
+ const step = (rule, next) => {
126
+ steps.push({ rule, kept: next.length, removed: current.length - next.length });
127
+ current = next;
128
+ };
129
+ step(`visibility: ${options.visibility}`, current.filter((entry) => atMost(entry.visibility, options.visibility)));
130
+ if (section.since !== void 0) {
131
+ step(`since: ${section.since}`, current.filter((entry) => Number(fieldValue(entry, "year") ?? 0) >= section.since));
132
+ }
133
+ if (section.until !== void 0) {
134
+ step(`until: ${section.until}`, current.filter((entry) => Number(fieldValue(entry, "year") ?? Infinity) <= section.until));
135
+ }
136
+ for (const [field, value] of Object.entries(section.where ?? {})) {
137
+ step(`where ${field}: ${Array.isArray(value) ? `[${value.join(", ")}]` : String(value)}`, filterWhere(current, { [field]: value }));
138
+ }
139
+ if (section.limit) step(`limit: ${section.limit}`, current.slice(0, section.limit));
140
+ return { total: entries.length, steps };
141
+ }
142
+
143
+ 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.BerUaxtF.mjs';
3
+ import { p as propertyKey } from '../shared/vitakit.BZZIsWlY.mjs';
4
+ import { i as isSitePath } from '../shared/vitakit.D10HyDhF.mjs';
5
+ import '../shared/vitakit.DbSeENVS.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.CoWX2Z4R.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: "the publication list and its 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 };