@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,200 @@
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.DbSeENVS.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.TSMTC5Jl.mjs';
15
+ import '../shared/vitakit.D-DdJaDZ.mjs';
16
+
17
+ 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/`";
18
+ class Fragment {
19
+ constructor(value) {
20
+ this.value = value;
21
+ }
22
+ toString() {
23
+ return this.value;
24
+ }
25
+ }
26
+ const fragment = (value) => value ? new Fragment(value) : "";
27
+ function isRichText(value) {
28
+ return typeof value === "object" && value !== null && "text" in value && typeof value.text === "string" && "html" in value && typeof value.html === "string";
29
+ }
30
+ function isTemplate(value) {
31
+ return Array.isArray(value) && "raw" in value && Array.isArray(value.raw);
32
+ }
33
+ function createTemplateHelpers(where, source) {
34
+ const text = (value) => {
35
+ if (value === void 0 || value === null || value === "") return "";
36
+ if (value instanceof Fragment) return value;
37
+ if (typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
38
+ return fragment(escapeTex(String(value)));
39
+ }
40
+ if (isRichText(value)) {
41
+ try {
42
+ return fragment(escapeRichTex(value.text));
43
+ } catch (error) {
44
+ return fail(where, error instanceof Error ? error.message : String(error));
45
+ }
46
+ }
47
+ return fail(where, "text expects text or a number; use date(...) or linked(...) for structured values");
48
+ };
49
+ const url = (value) => {
50
+ if (value === void 0 || value === null || value === "") return "";
51
+ if (typeof value !== "string") return fail(where, "url expects a URL string");
52
+ if (isSitePath(value)) fail(source, `${value} ${SITE_PATH_HELP}`);
53
+ return fragment(escapeUrl(value));
54
+ };
55
+ const linked = (value, target) => {
56
+ let label = value;
57
+ if (typeof value === "object" && value !== null && !(value instanceof Fragment) && !isRichText(value)) {
58
+ if ("label" in value && typeof value.label === "string") label = value.label;
59
+ else return fail(where, "linked expects text or { label, url? }; use `linked` in the section schema");
60
+ if (target === void 0 && "url" in value) target = value.url;
61
+ }
62
+ const body = text(label);
63
+ if (!body) return "";
64
+ const href = url(target);
65
+ return href ? fragment(`\\href{${href}}{${body}}`) : body;
66
+ };
67
+ const date = (value) => {
68
+ if (value === void 0 || value === null) return "";
69
+ 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") {
70
+ return fail(where, "date expects a normalized date; use dateRange in the collection schema");
71
+ }
72
+ return fragment(escapeTex(formatDateRange(value)));
73
+ };
74
+ const join = (values, separator = ", ") => {
75
+ if (!Array.isArray(values)) return fail(where, "join expects an array of values");
76
+ const parts = values.map((value) => String(text(value))).filter((value) => value !== "");
77
+ return fragment(parts.join(String(text(separator))));
78
+ };
79
+ const optional = (strings, ...values) => {
80
+ if (!isTemplate(strings) || strings.raw.length !== values.length + 1) {
81
+ return fail(where, "optional must be used as a tagged template: optional`...${value}...`");
82
+ }
83
+ if (values.some((value) => value === void 0 || value === null || value === "")) return "";
84
+ const parts = values.map(text);
85
+ if (parts.some((value) => !value)) return "";
86
+ return fragment(strings.raw.map((literal, index) => literal + (parts[index] ?? "")).join(""));
87
+ };
88
+ function note(value, ...values) {
89
+ const body = isTemplate(value) ? optional(value, ...values) : text(value);
90
+ return body ? fragment(`\\vitanote{${body}}`) : "";
91
+ }
92
+ const entry = (layout) => {
93
+ if (typeof layout !== "object" || layout === null || !Object.hasOwn(layout, "title") || Object.keys(layout).some((key) => !["date", "title", "context"].includes(key))) {
94
+ return fail(where, "vita.entry expects { title, date?, context? }");
95
+ }
96
+ return fragment(`\\vitaentry{${text(layout.date)}}{${text(layout.title)}}{${text(layout.context)}}`);
97
+ };
98
+ return { text, date, linked, url, join, optional, vita: { entry, note } };
99
+ }
100
+
101
+ const bundledTexDir = resolveAsset("tex");
102
+ const eta = new Eta({ autoEscape: false, autoTrim: [false, false] });
103
+ function tidy(body) {
104
+ return body.replace(/[ \t]+$/gm, "").replace(/\n{2,}/g, "\n").trim();
105
+ }
106
+ function genericHelpers(h) {
107
+ return {
108
+ title(entry) {
109
+ const field = entryTitleField(entry);
110
+ return h.text(field ? entry[field] : void 0);
111
+ },
112
+ context(entry) {
113
+ return h.join(entrySecondaryParts(entry, entryTitleField(entry)).map((part) => part.kind === "linked" ? h.linked(part.linked) : part.text));
114
+ }
115
+ };
116
+ }
117
+ function bibgroup(group, where) {
118
+ const unsafe = /[\s%#\\{},~]/;
119
+ const bad = [group.id, ...group.entries.map((entry) => entry.key)].find((value) => !value || unsafe.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
+ return fragment(`\\vitabibgroup{${group.id}}{${group.entries.map((entry) => entry.key).join(",")}}`);
124
+ }
125
+ async function resolveTemplate(id, userDir, { exclude } = {}) {
126
+ const candidates = [
127
+ { path: join(userDir, `${id}.eta`), origin: "local", generic: false },
128
+ { path: join(bundledTexDir, "templates", `${id}.eta`), origin: "bundled", generic: false },
129
+ { path: join(userDir, "_generic.eta"), origin: "local", generic: true },
130
+ { path: join(bundledTexDir, "templates", "_generic.eta"), origin: "bundled", generic: true }
131
+ ];
132
+ for (const candidate of candidates) {
133
+ if (candidate.path === exclude) continue;
134
+ if (existsSync(candidate.path)) return { ...candidate, source: await readFile(candidate.path, "utf8") };
135
+ }
136
+ throw new Error(`vitakit: no template for section ${id}`);
137
+ }
138
+ async function assertTemplatesAreUsed(templateDir, ids) {
139
+ const files = await readdir(templateDir).catch(() => []);
140
+ const known = /* @__PURE__ */ new Set([...ids, "publications", "_generic"]);
141
+ for (const file of files.filter((name) => name.endsWith(".eta")).sort()) {
142
+ const id = file.slice(0, -".eta".length);
143
+ if (known.has(id)) continue;
144
+ const suggestion = ids.find((candidate) => candidate.startsWith(id) || id.startsWith(candidate));
145
+ fail(
146
+ `cv-tex/templates/${file}`,
147
+ `no section is called "${id}", so this template is never used${suggestion ? `; did you mean ${suggestion}.eta?` : ""}
148
+ sections: ${ids.join(", ") || "(none)"}`
149
+ );
150
+ }
151
+ }
152
+ async function renderSection(id, entries, options, templateDir) {
153
+ const selected = selectEntries(entries, options, id);
154
+ const { path: templatePath, source } = await resolveTemplate(id, templateDir);
155
+ const h = createTemplateHelpers(templatePath, `content/${id}.yml`);
156
+ try {
157
+ return tidy(eta.renderString(source, { entries: selected, ...h, generic: genericHelpers(h) }));
158
+ } catch (error) {
159
+ throw new Error(`template ${id}.eta: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
160
+ }
161
+ }
162
+ async function renderPublications(publications, options, templateDir, siteUrl) {
163
+ const groups = options.publications.groups ? options.publications.groups.map((id) => publications.find((group) => group.id === id)).filter((group) => group !== void 0) : publications;
164
+ if (!siteUrl) {
165
+ const local = groups.flatMap((group) => group.entries).find((entry) => entry.titleUrl && isSitePath(entry.titleUrl));
166
+ if (local) fail("content/publications.bib", `${local.key}: url ${local.titleUrl} ${SITE_PATH_HELP}`);
167
+ }
168
+ const { path: templatePath, source } = await resolveTemplate("publications", templateDir);
169
+ return tidy(eta.renderString(source, {
170
+ groups,
171
+ ...createTemplateHelpers(templatePath, "content/publications.bib"),
172
+ bib: { group: (group) => bibgroup(group, templatePath) }
173
+ }));
174
+ }
175
+ async function emitDocument(emit) {
176
+ await mkdir(emit.outDir, { recursive: true });
177
+ await assertTemplatesAreUsed(emit.templateDir, Object.keys(emit.sections));
178
+ const written = [];
179
+ for (const [id, entries] of Object.entries(emit.sections)) {
180
+ const body2 = await renderSection(id, entries, emit.options, emit.templateDir);
181
+ const file2 = join(emit.outDir, `${id}.tex`);
182
+ await writeFile(file2, `% Generated by vitakit. Do not edit; edit content/${id}.yml instead.
183
+ ${body2.trim()}
184
+ `);
185
+ written.push(file2);
186
+ }
187
+ const body = await renderPublications(emit.publications, emit.options, emit.templateDir, emit.siteUrl);
188
+ const file = join(emit.outDir, "publications.tex");
189
+ await writeFile(file, `% Generated by vitakit. Do not edit; edit content/publications.bib instead.
190
+ ${body.trim()}
191
+ `);
192
+ written.push(file);
193
+ const current = /* @__PURE__ */ new Set(["profile.tex", ...written.map((file2) => basename(file2))]);
194
+ for (const file2 of await readdir(emit.outDir)) {
195
+ if (file2.endsWith(".tex") && !current.has(file2)) await rm(join(emit.outDir, file2));
196
+ }
197
+ return written;
198
+ }
199
+
200
+ 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 };
@@ -0,0 +1,79 @@
1
+ import { z } from 'zod';
2
+ import { v as visibility, t as text, a as richText, d as dateRange, u as url, b as absoluteUrl, l as linked, r as requiredLink } from '../shared/vitakit.BerUaxtF.mjs';
3
+ import '../shared/vitakit.DbSeENVS.mjs';
4
+ import 'node:url';
5
+ import '../shared/vitakit.D10HyDhF.mjs';
6
+
7
+ const FIELD_TYPE_NAMES = {
8
+ "text": "text",
9
+ "formatted-text": "formatted text",
10
+ "number": "number",
11
+ "yes-no": "yes/no",
12
+ "date": "date",
13
+ "web-address": "web address",
14
+ "link": "link",
15
+ "choice": "one of a fixed list",
16
+ "group": "group of fields",
17
+ "custom": "custom"
18
+ };
19
+ const PRIMITIVES = /* @__PURE__ */ new Map([
20
+ [text, "text"],
21
+ [richText, "formatted-text"],
22
+ [dateRange, "date"],
23
+ [url, "web-address"],
24
+ [absoluteUrl, "web-address"],
25
+ [linked, "link"],
26
+ [requiredLink, "link"]
27
+ ]);
28
+ function describeField(path, schema) {
29
+ let current = schema;
30
+ let optional = false;
31
+ let several = false;
32
+ let fallback;
33
+ let hasDefault = false;
34
+ for (; ; ) {
35
+ if (PRIMITIVES.has(current)) break;
36
+ if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
37
+ optional = true;
38
+ current = current.unwrap();
39
+ } else if (current instanceof z.ZodDefault) {
40
+ optional = true;
41
+ hasDefault = true;
42
+ fallback = current.def.defaultValue;
43
+ current = current.unwrap();
44
+ } else if (current instanceof z.ZodArray && !several) {
45
+ several = true;
46
+ current = current.element;
47
+ } else {
48
+ break;
49
+ }
50
+ }
51
+ const description = { path, type: "custom", optional, several };
52
+ if (hasDefault) description.default = fallback;
53
+ const primitive = PRIMITIVES.get(current);
54
+ if (primitive) description.type = primitive;
55
+ else if (current instanceof z.ZodString) description.type = "text";
56
+ else if (current instanceof z.ZodNumber) description.type = "number";
57
+ else if (current instanceof z.ZodBoolean) description.type = "yes-no";
58
+ else if (current instanceof z.ZodEnum) {
59
+ description.type = "choice";
60
+ description.choices = current.options.map(String);
61
+ } else if (current instanceof z.ZodObject) {
62
+ description.type = "group";
63
+ description.fields = Object.entries(current.shape).map(([name, field]) => describeField(`${path}.${name}`, field));
64
+ }
65
+ return description;
66
+ }
67
+ function describeSection(schema) {
68
+ if (!(schema instanceof z.ZodArray) || !(schema.element instanceof z.ZodObject)) return void 0;
69
+ return Object.entries(schema.element.shape).filter(([name, field]) => !(name === "visibility" && field === visibility)).map(([name, field]) => describeField(name, field));
70
+ }
71
+ function fieldSummary(field) {
72
+ const type = field.type === "choice" && field.choices ? `one of ${field.choices.join(", ")}` : FIELD_TYPE_NAMES[field.type];
73
+ return [
74
+ field.several ? `list of ${type}` : type,
75
+ field.optional ? field.default !== void 0 ? `default ${JSON.stringify(field.default)}` : "optional" : "required"
76
+ ].join(", ");
77
+ }
78
+
79
+ export { FIELD_TYPE_NAMES, describeField, describeSection, fieldSummary };
@@ -0,0 +1,199 @@
1
+ import { readFile, stat, readdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { join, relative, sep } from 'node:path';
4
+ import matter from 'gray-matter';
5
+ import { parse } from 'yaml';
6
+ import { l as loadContent } from '../shared/vitakit.By5VWQ1n.mjs';
7
+ import { describeSection } from './fields.mjs';
8
+ import { findSectionFiles, resolveSectionSchema } from './sections.mjs';
9
+ import { schemas } from './schemas.mjs';
10
+ import { findDocuments, documentSectionProblems } from './documents.mjs';
11
+ import { p as pageRoute, d as documentDirective, f as findPdfPlacements, a as findWebPlacements } from '../shared/vitakit.Do5HC_MD.mjs';
12
+ import { m as messageOf, w as whereOf } from '../shared/vitakit.DbSeENVS.mjs';
13
+ import 'unified';
14
+ import 'remark-parse';
15
+ import 'remark-gfm';
16
+ import 'remark-math';
17
+ import 'remark-rehype';
18
+ import 'rehype-katex';
19
+ import 'rehype-raw';
20
+ import 'rehype-stringify';
21
+ import 'unist-util-visit';
22
+ import '../shared/vitakit.D10HyDhF.mjs';
23
+ import '../shared/vitakit.DQ6PmGoL.mjs';
24
+ import 'node:url';
25
+ import '@behackl/citation-js-extras';
26
+ import 'rehype-parse';
27
+ import 'rehype-sanitize';
28
+ import 'zod';
29
+ import '../shared/vitakit.BerUaxtF.mjs';
30
+ import '../shared/vitakit.TSMTC5Jl.mjs';
31
+ import '../shared/vitakit.D-DdJaDZ.mjs';
32
+
33
+ const posix = (path) => path.split(sep).join("/");
34
+ async function files(dir, filter) {
35
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
36
+ const found = [];
37
+ for (const entry of entries) {
38
+ const path = join(dir, entry.name);
39
+ if (entry.isDirectory()) found.push(...await files(path, filter));
40
+ else if (filter(entry.name)) found.push(path);
41
+ }
42
+ return found.sort();
43
+ }
44
+ async function newest(paths) {
45
+ let latest = 0;
46
+ for (const path of paths) {
47
+ const info = await stat(path).catch(() => void 0);
48
+ if (info && info.mtimeMs > latest) latest = info.mtimeMs;
49
+ }
50
+ return latest;
51
+ }
52
+ function sharedSchema(source) {
53
+ return /export\s*\{\s*default\s*\}\s*from\s*['"]\.\/([a-z0-9-]+)(?:\.ts)?['"]/.exec(source)?.[1] ?? /^export\s+default\s+schemas\.([a-z]\w*)\s*;?\s*$/m.exec(source)?.[1];
54
+ }
55
+ function belongsTo(problem, file) {
56
+ return problem.where === file.where || problem.where === `schemas/${file.id}.ts`;
57
+ }
58
+ async function inventory(rootDir, options = {}) {
59
+ const content = await loadContent(rootDir, { strict: false, maxVisibility: "private" });
60
+ const problems = [...content.issues];
61
+ const { files: sectionFiles } = await findSectionFiles(rootDir).catch(() => ({ files: [] }));
62
+ const sections = [];
63
+ const schemasForDocuments = [];
64
+ for (const file of sectionFiles) {
65
+ const local = join(rootDir, "schemas", `${file.id}.ts`);
66
+ const builtIn = Object.hasOwn(schemas, file.id);
67
+ const section = {
68
+ id: file.id,
69
+ where: file.where,
70
+ schema: { origin: existsSync(local) ? "local" : builtIn ? "built-in" : "none" },
71
+ problems: problems.filter((problem) => belongsTo(problem, file))
72
+ };
73
+ if (section.schema.origin === "local") {
74
+ section.schema.file = `schemas/${file.id}.ts`;
75
+ const source = await readFile(local, "utf8");
76
+ const shared = sharedSchema(source);
77
+ if (shared) section.schema.sharedWith = shared;
78
+ if (builtIn && /\bextendSection\s*\(\s*schemas\.[\w$]+/.test(source)) section.schema.extendsBuiltIn = true;
79
+ }
80
+ try {
81
+ const raw = parse(await readFile(file.file, "utf8"));
82
+ if (Array.isArray(raw)) section.entries = raw.length;
83
+ else if (raw === null) section.entries = 0;
84
+ } catch {
85
+ }
86
+ if (section.schema.origin !== "none") {
87
+ try {
88
+ const source = await resolveSectionSchema(rootDir, file);
89
+ schemasForDocuments.push({ id: file.id, schema: source.schema });
90
+ const fields = describeSection(source.schema);
91
+ if (fields) section.fields = fields;
92
+ } catch {
93
+ }
94
+ }
95
+ if (!section.problems.length) {
96
+ const counts = { public: 0, cv: 0, private: 0 };
97
+ for (const entry of content.sections[file.id] ?? []) counts[entry.visibility]++;
98
+ section.counts = counts;
99
+ }
100
+ sections.push(section);
101
+ }
102
+ const menu = new Set(content.profile.nav.map((item) => item.url));
103
+ const pages = /* @__PURE__ */ new Map();
104
+ const page = (route) => {
105
+ if (!pages.has(route)) pages.set(route, { route, inMenu: menu.has(route) });
106
+ return pages.get(route);
107
+ };
108
+ const pagesDir = join(rootDir, "content", "pages");
109
+ for (const path of await files(pagesDir, (name) => name.endsWith(".md"))) {
110
+ const slug = relative(pagesDir, path).replace(/\.md$/, "");
111
+ let route = slug === "index" ? "/" : `/${slug}/`;
112
+ let title;
113
+ try {
114
+ const { data } = matter(await readFile(path, "utf8"));
115
+ if (typeof data.route === "string") route = data.route;
116
+ if (typeof data.title === "string") title = data.title;
117
+ } catch {
118
+ }
119
+ Object.assign(page(route), { markdown: posix(relative(rootDir, path)), ...title ? { title } : {} });
120
+ }
121
+ let catchAll;
122
+ const vuePagesDir = join(rootDir, "app", "pages");
123
+ for (const path of await files(vuePagesDir, (name) => name.endsWith(".vue"))) {
124
+ const below = relative(vuePagesDir, path);
125
+ if (below.includes("[")) {
126
+ if (/\[\.\.\.[^\]]+\]\.vue$/.test(below) && !below.includes(sep)) catchAll = posix(relative(rootDir, path));
127
+ continue;
128
+ }
129
+ page(pageRoute(below)).vue = posix(relative(rootDir, path));
130
+ }
131
+ const bib = join(rootDir, "content", "publications.bib");
132
+ const publications = existsSync(bib) ? {
133
+ file: "content/publications.bib",
134
+ entries: content.publications.reduce((sum, group) => sum + group.entries.length, 0),
135
+ groups: content.publications.length
136
+ } : void 0;
137
+ const cvDir = join(rootDir, "cv-tex");
138
+ const { documents: found, problems: documentProblems } = await findDocuments(cvDir);
139
+ problems.push(...documentProblems);
140
+ const known = /* @__PURE__ */ new Set([...sections.map((section) => section.id), "publications", "profile"]);
141
+ const groupIds = new Set(content.publications.map((group) => group.id));
142
+ const documents = [];
143
+ for (const document of found) {
144
+ const tex = `cv-tex/${document.name}.tex`;
145
+ const source = await readFile(document.texFile, "utf8");
146
+ const pdfPath = join(cvDir, `${document.name}.pdf`);
147
+ const pdf = await stat(pdfPath).catch(() => void 0);
148
+ const own = documentSectionProblems(document, schemasForDocuments);
149
+ const directive = documentDirective(source);
150
+ if (!directive) own.push({ where: tex, message: `has no \\vitadocument{${document.name}}` });
151
+ else if (directive.name !== document.name) {
152
+ own.push({ where: `${tex}:${directive.line}`, message: `\\vitadocument{${directive.name}} does not match the file name; expected \\vitadocument{${document.name}}` });
153
+ }
154
+ const placements = findPdfPlacements(document.name, tex, source);
155
+ for (const placement of placements) {
156
+ if (!known.has(placement.section)) {
157
+ own.push({ where: `${tex}:${placement.line}`, message: `no section called "${placement.section}"` });
158
+ }
159
+ }
160
+ for (const group of document.options.publications.groups ?? []) {
161
+ if (!groupIds.has(group) && publications) {
162
+ own.push({ where: `cv-tex/${document.name}.yml`, message: `publications.groups: no publication group "${group}"` });
163
+ }
164
+ }
165
+ problems.push(...own);
166
+ documents.push({
167
+ name: document.name,
168
+ tex,
169
+ ...existsSync(join(cvDir, `${document.name}.yml`)) ? { yml: `cv-tex/${document.name}.yml` } : {},
170
+ options: document.options,
171
+ placements,
172
+ ...pdf ? { pdf: { file: `cv-tex/${document.name}.pdf`, mtimeMs: pdf.mtimeMs } } : {},
173
+ problems: [...documentProblems.filter((problem) => problem.where === `cv-tex/${document.name}.yml`), ...own]
174
+ });
175
+ }
176
+ const sourcesMtimeMs = await newest([
177
+ ...await files(join(rootDir, "content"), () => true),
178
+ ...await files(join(rootDir, "schemas"), () => true),
179
+ ...await files(cvDir, (name) => /\.(tex|yml|yaml|eta|cls)$/.test(name)).then((list) => list.filter((path) => !relative(cvDir, path).startsWith("generated")))
180
+ ]);
181
+ const web = options.web === false ? { placements: [], dynamic: [], components: /* @__PURE__ */ new Map() } : await findWebPlacements(rootDir).catch((error) => {
182
+ problems.push({ where: whereOf(error, "app/"), message: messageOf(error) });
183
+ return { placements: [], dynamic: [], components: /* @__PURE__ */ new Map() };
184
+ });
185
+ return {
186
+ root: rootDir,
187
+ sections,
188
+ pages: [...pages.values()].sort((a, b) => a.route.localeCompare(b.route)),
189
+ ...catchAll ? { catchAll } : {},
190
+ ...publications ? { publications } : {},
191
+ documents,
192
+ web,
193
+ sourcesMtimeMs,
194
+ problems,
195
+ content
196
+ };
197
+ }
198
+
199
+ export { inventory };
@@ -0,0 +1,144 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile, mkdir, writeFile, rename } from 'node:fs/promises';
3
+ import { join, dirname } from 'node:path';
4
+ import { C as CommandError } from '../shared/vitakit.CoWX2Z4R.mjs';
5
+ import '../shared/vitakit.DQ6PmGoL.mjs';
6
+ import 'node:url';
7
+ import 'node:process';
8
+ import 'node:util';
9
+ import 'node:readline/promises';
10
+
11
+ const writes = (plan) => plan.items.filter((item) => item.kind === "create" || item.kind === "replace" || item.kind === "edit");
12
+ function diff(before, after, context = 2) {
13
+ const a = before.split("\n");
14
+ const b = after.split("\n");
15
+ let start = 0;
16
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
17
+ let endA = a.length;
18
+ let endB = b.length;
19
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
20
+ endA--;
21
+ endB--;
22
+ }
23
+ const from = Math.max(0, start - context);
24
+ return [
25
+ ...a.slice(from, start).map((line) => ` ${line}`),
26
+ ...a.slice(start, endA).map((line) => ` - ${line}`),
27
+ ...b.slice(start, endB).map((line) => ` + ${line}`),
28
+ ...a.slice(endA, Math.min(a.length, endA + context)).map((line) => ` ${line}`)
29
+ ];
30
+ }
31
+ function printPlan(io, style, plan) {
32
+ const lines = [];
33
+ for (const item of plan.items) {
34
+ if (item.kind === "create") {
35
+ lines.push(`${style.green("+")} ${item.path} ${style.dim(item.reason)}`);
36
+ if (item.show) lines.push(...item.content.trimEnd().split("\n").map((line) => style.dim(` ${line}`)));
37
+ } else if (item.kind === "replace") {
38
+ lines.push(`${style.yellow("!")} ${item.path} ${style.dim(item.reason)}`);
39
+ if (item.show) lines.push(...item.content.trimEnd().split("\n").map((line) => style.dim(` ${line}`)));
40
+ } else if (item.kind === "keep") lines.push(`${style.dim("=")} ${item.path} ${style.dim(item.reason)}`);
41
+ else if (item.kind === "edit") {
42
+ lines.push(`${style.yellow("~")} ${item.path} ${style.dim(item.reason)}`);
43
+ lines.push(...diff(item.before, item.after).map((line) => line.startsWith(" +") ? style.green(line) : line.startsWith(" -") ? style.red(line) : style.dim(line)));
44
+ } else {
45
+ lines.push(`${style.yellow("?")} ${item.path} ${item.reason}`);
46
+ lines.push(...item.snippet.split("\n").map((line) => ` ${line}`));
47
+ }
48
+ }
49
+ io.out(`${lines.join("\n")}
50
+ `);
51
+ }
52
+ async function confirmPlan(io, plan, yes) {
53
+ if (!writes(plan).length || yes) return true;
54
+ if (!io.interactive) {
55
+ throw new CommandError("nothing written: confirm with --yes, or only preview with --dry-run");
56
+ }
57
+ const answer = (await io.ask("\nWrite these changes? [Y/n] ")).trim().toLowerCase();
58
+ if (answer === "" || answer === "y" || answer === "yes") return true;
59
+ io.out("Cancelled; nothing was written.\n");
60
+ return false;
61
+ }
62
+ function backupPath(path) {
63
+ let candidate = `${path}.backup`;
64
+ for (let index = 2; existsSync(candidate); index++) candidate = `${path}.backup-${index}`;
65
+ return candidate;
66
+ }
67
+ async function applyPlan(root, plan) {
68
+ for (const item of writes(plan)) {
69
+ const path = join(root, item.path);
70
+ if (item.kind === "create" && existsSync(path)) throw new CommandError(`${item.path} exists now; nothing was written`);
71
+ if (item.kind !== "create" && !existsSync(path)) throw new CommandError(`${item.path} is gone now; nothing was written`);
72
+ }
73
+ const written = [];
74
+ try {
75
+ for (const item of writes(plan)) {
76
+ const path = join(root, item.path);
77
+ await mkdir(dirname(path), { recursive: true });
78
+ if (item.kind === "create") await writeFile(path, item.content, { flag: "wx" });
79
+ else if (item.kind === "replace") {
80
+ await rename(path, backupPath(path));
81
+ await writeFile(path, item.content);
82
+ } else await writeFile(path, item.after);
83
+ written.push(item.path);
84
+ }
85
+ } catch (error) {
86
+ throw new CommandError(`${error instanceof Error ? error.message : String(error)}${written.length ? `
87
+ Already written: ${written.join(", ")}` : "\n Nothing was written."}`);
88
+ }
89
+ return written;
90
+ }
91
+ class Workspace {
92
+ constructor(root) {
93
+ this.root = root;
94
+ }
95
+ items = [];
96
+ find(path) {
97
+ return this.items.find((item) => item.path === path && item.kind !== "paste" && item.kind !== "keep");
98
+ }
99
+ /** The file as it will be: planned contents first, the disk otherwise. */
100
+ async read(path) {
101
+ const planned = this.find(path);
102
+ if (planned?.kind === "create" || planned?.kind === "replace") return planned.content;
103
+ if (planned?.kind === "edit") return planned.after;
104
+ return readFile(join(this.root, path), "utf8").catch(() => void 0);
105
+ }
106
+ exists(path) {
107
+ return Boolean(this.find(path)) || existsSync(join(this.root, path));
108
+ }
109
+ /** A new file; `show` prints its contents in the plan, for short files worth reading. */
110
+ create(path, content, reason, { show = false } = {}) {
111
+ if (this.exists(path)) throw new CommandError(`${path} exists already; nothing was written`);
112
+ this.items.push({ kind: "create", path, content, reason, ...show ? { show } : {} });
113
+ }
114
+ /** Replace an existing file; the old one is kept as a backup. */
115
+ replace(path, content, reason, { show = false } = {}) {
116
+ this.items.push({ kind: "replace", path, content, reason, ...show ? { show } : {} });
117
+ }
118
+ /** Change a file; for a file this plan creates, the change becomes part of it. */
119
+ async edit(path, after, reason) {
120
+ const planned = this.find(path);
121
+ if (planned?.kind === "create" || planned?.kind === "replace") {
122
+ planned.content = after;
123
+ return;
124
+ }
125
+ if (planned?.kind === "edit") {
126
+ planned.after = after;
127
+ planned.reason = `${planned.reason}; ${reason}`;
128
+ return;
129
+ }
130
+ const before = await readFile(join(this.root, path), "utf8");
131
+ this.items.push({ kind: "edit", path, before, after, reason });
132
+ }
133
+ keep(path, reason) {
134
+ this.items.push({ kind: "keep", path, reason });
135
+ }
136
+ paste(path, snippet, reason) {
137
+ this.items.push({ kind: "paste", path, snippet, reason });
138
+ }
139
+ plan() {
140
+ return { items: [...this.items] };
141
+ }
142
+ }
143
+
144
+ export { Workspace, applyPlan, backupPath, confirmPlan, diff, printPlan };
@@ -0,0 +1,33 @@
1
+ async function choose(io, question, choices, defaultIndex = 0) {
2
+ io.out(`
3
+ ${question}
4
+ `);
5
+ choices.forEach((choice, index) => io.out(` ${index + 1} ${choice.label}${index === defaultIndex ? " (Enter)" : ""}
6
+ `));
7
+ for (let attempt = 0; attempt < 3; attempt++) {
8
+ const answer = (await io.ask("> ")).trim();
9
+ if (answer === "") return choices[defaultIndex].value;
10
+ const number = Number(answer);
11
+ if (Number.isInteger(number) && number >= 1 && number <= choices.length) return choices[number - 1].value;
12
+ const named = choices.find((choice) => choice.label.toLowerCase() === answer.toLowerCase());
13
+ if (named) return named.value;
14
+ io.out(` A number from 1 to ${choices.length}, please.
15
+ `);
16
+ }
17
+ return choices[defaultIndex].value;
18
+ }
19
+ async function confirm(io, question, yes = true) {
20
+ const answer = (await io.ask(`${question} ${yes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
21
+ if (answer === "") return yes;
22
+ return answer === "y" || answer === "yes";
23
+ }
24
+ async function input(io, question, fallback) {
25
+ const answer = (await io.ask(`${question}${fallback ? ` [${fallback}]` : ""}: `)).trim();
26
+ return answer || fallback || "";
27
+ }
28
+ function headingFor(id) {
29
+ const words = id.split("-").join(" ");
30
+ return words.charAt(0).toUpperCase() + words.slice(1);
31
+ }
32
+
33
+ export { choose, confirm, headingFor, input };