@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,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 };
@@ -0,0 +1,202 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { 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 'node:url';
7
+
8
+ const LISTS_FILE = "content/publication-lists.yml";
9
+ const LISTS_HEADER = `# Publication lists: which publications of publications.bib a list shows,
10
+ # in what order and in which groups. A page shows one with
11
+ # <VitaPublications list="recent" title="Recent publications" />
12
+ # and a PDF with
13
+ # \\vitapublications[Recent publications]{recent}
14
+ #
15
+ # Settings, all optional:
16
+ # where: { status: preprint }, { keywords: selected }, { year: 2024 } --
17
+ # fields of the entries in publications.bib; [a, b] means a or b
18
+ # keys: [doe2024, roe2023] -- exactly these, in this order
19
+ # since, until: years
20
+ # sort: date (newest first), year, title, source (as in publications.bib),
21
+ # or a field; order: asc or desc
22
+ # limit: at most this many
23
+ # groupBy: status, year or a field; otherLabel: the heading for the entries
24
+ # without it (Other)
25
+ # \`vitakit explain publications\` shows what each list contains and where.
26
+ `;
27
+ const DERIVED = /* @__PURE__ */ new Set(["key", "status", "year"]);
28
+ const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
29
+ const field = z.string().regex(/^[A-Za-z][\w-]*$/, "expected a BibTeX field name").transform((name) => name.toLowerCase());
30
+ const scalar = z.union([z.string(), z.number(), z.boolean()]);
31
+ const listInput = z.strictObject({
32
+ where: z.record(z.string(), z.union([scalar, z.array(scalar).min(1)])).default({}),
33
+ keys: z.array(z.string().min(1)).min(1).optional(),
34
+ since: z.number().int().optional(),
35
+ until: z.number().int().optional(),
36
+ sort: field.optional(),
37
+ order: z.enum(["asc", "desc"]).optional(),
38
+ limit: z.number().int().positive().optional(),
39
+ groupBy: field.optional(),
40
+ otherLabel: z.string().trim().min(1).optional()
41
+ });
42
+ function resolveList(name, value, schema) {
43
+ const at = (option) => `${name}.${option}`;
44
+ if (!NAME.test(name)) fail(LISTS_FILE, `${name}: a list name is lowercase kebab-case, e.g. recent-papers`);
45
+ const parsed = listInput.safeParse(value ?? {});
46
+ if (!parsed.success) fail(LISTS_FILE, `${name}: invalid
47
+ ${formatZodIssues(parsed.error)}`);
48
+ const input = parsed.data;
49
+ const where = {};
50
+ for (const [key, wanted] of Object.entries(input.where)) {
51
+ const parsedField = field.safeParse(key);
52
+ if (!parsedField.success) fail(LISTS_FILE, `${at(`where.${key}`)}: expected a BibTeX field name`);
53
+ where[parsedField.data] = wanted;
54
+ }
55
+ const statuses = schema.statuses.map((status) => status.id);
56
+ for (const status of where.status === void 0 ? [] : [where.status].flat()) {
57
+ if (!statuses.includes(String(status).toLowerCase())) {
58
+ fail(LISTS_FILE, `${at("where.status")}: no status "${status}"; the statuses are ${statuses.join(", ")} (schemas/publications.ts)`);
59
+ }
60
+ }
61
+ if (input.keys && input.sort) fail(LISTS_FILE, `${at("sort")}: a list of keys is shown in the order of its keys; remove sort or keys`);
62
+ if (input.order && (input.keys || input.sort === "source")) fail(LISTS_FILE, `${at("order")}: only applies when sorting by a field`);
63
+ if (input.groupBy === "keywords") fail(LISTS_FILE, `${at("groupBy")}: an entry can have several keywords; group by another field`);
64
+ if (input.otherLabel && (!input.groupBy || input.groupBy === "status")) {
65
+ fail(LISTS_FILE, `${at("otherLabel")}: only applies when grouping by year or by a field`);
66
+ }
67
+ const sort = input.sort ?? "date";
68
+ return {
69
+ where,
70
+ ...input.keys ? { keys: input.keys } : {},
71
+ ...input.since !== void 0 ? { since: input.since } : {},
72
+ ...input.until !== void 0 ? { until: input.until } : {},
73
+ sort,
74
+ order: input.order ?? (sort === "date" || sort === "year" ? "desc" : "asc"),
75
+ ...input.limit !== void 0 ? { limit: input.limit } : {},
76
+ ...input.groupBy ? { groupBy: input.groupBy } : {},
77
+ otherLabel: input.otherLabel ?? "Other"
78
+ };
79
+ }
80
+ async function publicationListNames(rootDir) {
81
+ try {
82
+ const data = parse(await readFile(join(rootDir, LISTS_FILE), "utf8"));
83
+ return data && typeof data === "object" && !Array.isArray(data) ? Object.keys(data) : [];
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+ async function loadPublicationLists(rootDir, schema) {
89
+ const raw = await readFile(join(rootDir, LISTS_FILE), "utf8").catch((error) => {
90
+ if (error.code === "ENOENT") return void 0;
91
+ throw error;
92
+ });
93
+ if (raw === void 0) return {};
94
+ let data;
95
+ try {
96
+ data = parse(raw);
97
+ } catch (error) {
98
+ fail(LISTS_FILE, messageOf(error));
99
+ }
100
+ if (data === null || data === void 0) return {};
101
+ if (typeof data !== "object" || Array.isArray(data)) fail(LISTS_FILE, "expected one list per name, e.g. `recent: { limit: 3 }`");
102
+ return Object.fromEntries(Object.entries(data).map(([name, value]) => [name, resolveList(name, value, schema)]));
103
+ }
104
+ function publicationStatus(entry, schema) {
105
+ return (entry.custom.status?.trim() || schema.defaultStatus).toLowerCase();
106
+ }
107
+ const plain = (value) => {
108
+ const text = typeof value === "string" ? value.replace(/[{}]/g, "").trim() : void 0;
109
+ return text || void 0;
110
+ };
111
+ function valueOf(entry, name, schema) {
112
+ if (name === "key") return entry.key;
113
+ if (name === "status") return publicationStatus(entry, schema);
114
+ if (name === "year") return entry.year === null ? void 0 : String(entry.year);
115
+ return plain(entry.raw[name]);
116
+ }
117
+ function matches(entry, name, wanted, schema) {
118
+ const value = valueOf(entry, name, schema);
119
+ const actual = name === "keywords" ? value?.split(/[,;]/).map((keyword) => keyword.trim()) ?? [] : value === void 0 ? [] : [value];
120
+ return [wanted].flat().map(String).some((candidate) => actual.includes(name === "status" ? candidate.toLowerCase() : candidate));
121
+ }
122
+ const collator = new Intl.Collator(void 0, { numeric: true, sensitivity: "base" });
123
+ function sortByText(entries, value, order) {
124
+ const keyed = entries.map((entry) => ({ entry, value: value(entry) }));
125
+ const sign = order === "asc" ? 1 : -1;
126
+ return keyed.sort((a, b) => a.value === void 0 ? b.value === void 0 ? 0 : 1 : b.value === void 0 ? -1 : sign * collator.compare(a.value, b.value)).map((item) => item.entry);
127
+ }
128
+ function sortEntries(bib, entries, list, schema) {
129
+ if (list.keys || list.sort === "source") return entries;
130
+ if (list.sort === "date" || list.sort === "year") {
131
+ const dated = entries.filter((entry) => entry.year !== null);
132
+ return [...bib.sort(dated, { by: list.sort, order: list.order }), ...entries.filter((entry) => entry.year === null)];
133
+ }
134
+ return sortByText(entries, (entry) => valueOf(entry, list.sort, schema), list.order);
135
+ }
136
+ function group(entries, list, schema) {
137
+ const by = list.groupBy;
138
+ if (!by) return entries.length ? [{ entries }] : [];
139
+ if (by === "status") {
140
+ return schema.statuses.map((status) => ({ label: status.label, entries: entries.filter((entry) => publicationStatus(entry, schema) === status.id) })).filter((selected) => selected.entries.length);
141
+ }
142
+ const values = [...new Set(entries.map((entry) => valueOf(entry, by, schema)).filter((value) => value !== void 0))];
143
+ values.sort(by === "year" ? (a, b) => Number(b) - Number(a) : collator.compare);
144
+ const groups = values.map((value) => ({ label: value, entries: entries.filter((entry) => valueOf(entry, by, schema) === value) }));
145
+ const other = entries.filter((entry) => valueOf(entry, by, schema) === void 0);
146
+ return other.length ? [...groups, { label: list.otherLabel, entries: other }] : groups;
147
+ }
148
+ function selectionSteps(bib, name, list, schema) {
149
+ const fields = [...Object.keys(list.where), list.sort, list.groupBy].filter((candidate) => !!candidate && !DERIVED.has(candidate) && !["date", "source"].includes(candidate));
150
+ for (const candidate of new Set(fields)) {
151
+ if (bib.entries.length && !bib.entries.some((entry) => plain(entry.raw[candidate]) !== void 0)) {
152
+ fail(LISTS_FILE, `${name}: no publication has a field "${candidate}"`);
153
+ }
154
+ }
155
+ const steps = [];
156
+ if (list.keys) {
157
+ const byKey = new Map(bib.entries.map((entry) => [entry.key, entry]));
158
+ const missing = list.keys.filter((key) => !byKey.has(key));
159
+ if (missing.length) fail(LISTS_FILE, `${name}.keys: no publication ${missing.join(", ")} in content/publications.bib`);
160
+ steps.push({ rule: `keys: ${list.keys.length}`, apply: () => list.keys.map((key) => byKey.get(key)) });
161
+ }
162
+ for (const [field2, wanted] of Object.entries(list.where)) {
163
+ steps.push({ rule: `where ${field2}: ${[wanted].flat().join(", ")}`, apply: (entries) => entries.filter((entry) => matches(entry, field2, wanted, schema)) });
164
+ }
165
+ if (list.since !== void 0) steps.push({ rule: `since: ${list.since}`, apply: (entries) => entries.filter((entry) => entry.year !== null && entry.year >= list.since) });
166
+ if (list.until !== void 0) steps.push({ rule: `until: ${list.until}`, apply: (entries) => entries.filter((entry) => entry.year !== null && entry.year <= list.until) });
167
+ steps.push({ rule: "sort", apply: (entries) => sortEntries(bib, entries, list, schema) });
168
+ if (list.limit !== void 0) steps.push({ rule: `limit: ${list.limit}`, apply: (entries) => entries.slice(0, list.limit) });
169
+ return steps;
170
+ }
171
+ function selectPublications(bib, name, list, schema) {
172
+ let entries = bib.entries;
173
+ for (const step of selectionSteps(bib, name, list, schema)) entries = step.apply(entries);
174
+ return group(entries, list, schema);
175
+ }
176
+ function explainListSelection(bib, name, list, schema) {
177
+ let entries = bib.entries;
178
+ const steps = [];
179
+ for (const step of selectionSteps(bib, name, list, schema)) {
180
+ const next = step.apply(entries);
181
+ steps.push({ rule: step.rule, removed: entries.length - next.length });
182
+ entries = next;
183
+ }
184
+ return { total: bib.entries.length, steps: steps.filter((step) => step.removed > 0), keys: group(entries, list, schema).flatMap((selected) => selected.entries.map((entry) => entry.key)) };
185
+ }
186
+ function describeList(list) {
187
+ const parts = [];
188
+ if (list.keys) parts.push(`${list.keys.length} chosen by key`);
189
+ for (const [name, wanted] of Object.entries(list.where)) parts.push(`${name} ${[wanted].flat().join(" or ")}`);
190
+ if (list.since !== void 0) parts.push(`since ${list.since}`);
191
+ if (list.until !== void 0) parts.push(`until ${list.until}`);
192
+ if (!list.keys) {
193
+ if (list.sort === "source") parts.push("in .bib order");
194
+ else if (list.sort === "date" || list.sort === "year") parts.push(list.order === "desc" ? "newest first" : "oldest first");
195
+ else parts.push(`by ${list.sort}${list.order === "desc" ? ", descending" : ""}`);
196
+ }
197
+ if (list.limit !== void 0) parts.push(`at most ${list.limit}`);
198
+ if (list.groupBy) parts.push(`grouped by ${list.groupBy}`);
199
+ return parts.join(" \xB7 ");
200
+ }
201
+
202
+ export { LISTS_FILE, LISTS_HEADER, describeList, explainListSelection, loadPublicationLists, publicationListNames, publicationStatus, resolveList, selectPublications };
@@ -0,0 +1,12 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { r as resolveAsset } from '../shared/vitakit.DQ6PmGoL.mjs';
4
+ import 'node:fs';
5
+ import 'node:url';
6
+
7
+ async function builtinSchemaSource(id) {
8
+ const source = await readFile(join(resolveAsset("schemas"), `${id}.ts`), "utf8");
9
+ return source.replace(/from '\.\.\/[\w-]+'/g, "from '@anja.codes/vitakit'");
10
+ }
11
+
12
+ export { builtinSchemaSource };
@@ -0,0 +1,109 @@
1
+ import { s as section, t as text, a as richText, u as url, l as linked, d as dateRange, b as absoluteUrl, r as requiredLink } from '../shared/vitakit.DvABWuUc.mjs';
2
+ import { z } from 'zod';
3
+ import '../shared/vitakit.DCK90zq4.mjs';
4
+ import 'node:url';
5
+ import '../shared/vitakit.D10HyDhF.mjs';
6
+
7
+ const education = section({
8
+ date: dateRange,
9
+ degree: text,
10
+ institution: linked,
11
+ thesis: z.strictObject({
12
+ title: richText,
13
+ type: text.optional(),
14
+ supervisor: linked.optional(),
15
+ url: url.optional()
16
+ }).optional(),
17
+ note: text.optional()
18
+ });
19
+
20
+ const employment = section({
21
+ date: dateRange,
22
+ position: text,
23
+ institution: linked,
24
+ project: z.strictObject({
25
+ title: richText,
26
+ funder: text.optional(),
27
+ grant: text.optional(),
28
+ url: url.optional()
29
+ }).optional(),
30
+ note: text.optional()
31
+ });
32
+
33
+ const teaching = section({
34
+ date: dateRange,
35
+ title: richText,
36
+ role: text.optional(),
37
+ institution: linked.optional(),
38
+ /** e.g. `2024S`, `Winter 2023/24` -- free text, shown as written. */
39
+ term: text.optional(),
40
+ url: url.optional(),
41
+ note: text.optional()
42
+ });
43
+
44
+ const talks = section({
45
+ date: dateRange,
46
+ /** Markdown, math allowed. */
47
+ title: richText,
48
+ event: text.optional(),
49
+ location: text.optional(),
50
+ kind: z.enum(["invited", "contributed", "seminar", "poster", "outreach"]).optional(),
51
+ url: url.optional(),
52
+ slides: url.optional()
53
+ });
54
+
55
+ const supervision = section({
56
+ date: dateRange,
57
+ student: text,
58
+ /** A thesis still in progress; shown as such instead of as completed. */
59
+ ongoing: z.boolean().default(false),
60
+ /** Markdown, math allowed. */
61
+ title: richText,
62
+ degree: text.optional(),
63
+ institution: linked.optional(),
64
+ coSupervisor: text.optional()
65
+ });
66
+
67
+ const profile = z.strictObject({
68
+ name: text,
69
+ title: text.optional(),
70
+ affiliation: linked.optional(),
71
+ email: z.string().trim().email().optional(),
72
+ links: z.array(requiredLink).default([]),
73
+ /**
74
+ * One or two sentences for metadata: the page description and social
75
+ * previews. Prose belongs in `content/pages/index.md`, which is where a
76
+ * reader sees it -- this is deliberately not rendered on the page.
77
+ */
78
+ summary: text.optional(),
79
+ /**
80
+ * Where the website is published, e.g. `https://jane.example.org/` or
81
+ * `https://jane.github.io/site/`. The PDF needs it to link paths on the site
82
+ * such as `/slides/talk.pdf`; the website itself does not.
83
+ */
84
+ url: absoluteUrl.optional(),
85
+ /** Path below `public/`, e.g. `/img/portrait.jpg`. */
86
+ avatar: text.startsWith("/").optional(),
87
+ /** Navigation, in the order it should appear. */
88
+ nav: z.array(z.strictObject({ label: text, to: text })).default([]),
89
+ /** Footer line; inline Markdown. */
90
+ footer: text.optional()
91
+ });
92
+ const page = z.strictObject({
93
+ title: text,
94
+ description: text.optional(),
95
+ route: text.regex(/^\/(?:[a-z0-9-]+\/)*$/, "expected a route like /research/").optional(),
96
+ order: z.number().int().default(100)
97
+ });
98
+ const schemas = {
99
+ education,
100
+ employment,
101
+ teaching,
102
+ talks,
103
+ supervision
104
+ };
105
+ function extendSection(base, shape) {
106
+ return z.array(base.element.extend(shape));
107
+ }
108
+
109
+ export { education, employment, extendSection, page, profile, schemas, supervision, talks, teaching };
@@ -0,0 +1,109 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { schemas } from './schemas.mjs';
5
+ import { f as fail, a as formatZodIssues, i as importProjectModule } from '../shared/vitakit.DCK90zq4.mjs';
6
+ import { c as renderRichFields } from '../shared/vitakit.DvABWuUc.mjs';
7
+ import { r as resolveSitePaths } from '../shared/vitakit.D10HyDhF.mjs';
8
+ import { a as atMost } from '../shared/vitakit.TSMTC5Jl.mjs';
9
+ import 'zod';
10
+ import 'node:url';
11
+
12
+ const RESERVED_SECTION_NAMES = /* @__PURE__ */ new Set(["site", "profile", "publication-lists"]);
13
+ const SECTION_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
14
+ function sectionNameProblem(id) {
15
+ if (RESERVED_SECTION_NAMES.has(id)) return `"${id}" is reserved and cannot be a section`;
16
+ if (!SECTION_NAME.test(id)) return `"${id}" must be lowercase kebab-case, e.g. research-stays`;
17
+ return void 0;
18
+ }
19
+ async function exists(path) {
20
+ return stat(path).then(() => true, () => false);
21
+ }
22
+ async function loadRepoSchema(path, id) {
23
+ const { mtimeMs } = await stat(path);
24
+ const module = await importProjectModule(path, mtimeMs, `schemas/${id}.ts`);
25
+ const schema = module.default ?? module.schema;
26
+ if (!schema || typeof schema.safeParse !== "function") {
27
+ fail(`schemas/${id}.ts`, "must export a Zod schema as its default export");
28
+ }
29
+ return schema;
30
+ }
31
+ const STARTER = (id) => `create schemas/${id}.ts, for example:
32
+
33
+ import { section, dateRange, richText, text } from '@anja.codes/vitakit'
34
+
35
+ export default section({
36
+ date: dateRange,
37
+ title: richText,
38
+ note: text.optional(),
39
+ })`;
40
+ async function findSectionFiles(rootDir) {
41
+ const contentDir = join(rootDir, "content");
42
+ const names = (await readdir(contentDir).catch(() => fail("content/", "directory is missing"))).filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")).sort();
43
+ const byId = /* @__PURE__ */ new Map();
44
+ for (const name of names) {
45
+ const id = name.replace(/\.ya?ml$/, "");
46
+ if (RESERVED_SECTION_NAMES.has(id)) continue;
47
+ byId.set(id, [...byId.get(id) ?? [], name]);
48
+ }
49
+ const files = [];
50
+ const problems = [];
51
+ for (const [id, found] of byId) {
52
+ const where = `content/${found[0]}`;
53
+ if (found.length > 1) {
54
+ problems.push({ where, message: `the section "${id}" exists twice (${found.join(", ")}); keep one of them` });
55
+ continue;
56
+ }
57
+ if (!SECTION_NAME.test(id)) {
58
+ problems.push({ where, message: "file name must be lowercase kebab-case, e.g. `research-stays.yml`" });
59
+ continue;
60
+ }
61
+ files.push({ id, file: join(contentDir, found[0]), where });
62
+ }
63
+ return { files, problems };
64
+ }
65
+ async function resolveSectionSchema(rootDir, file) {
66
+ const override = join(rootDir, "schemas", `${file.id}.ts`);
67
+ if (await exists(override)) {
68
+ return { ...file, schemaOrigin: "repo", schema: await loadRepoSchema(override, file.id) };
69
+ }
70
+ const builtin = schemas[file.id];
71
+ if (!builtin) fail(file.where, `no schema for this file; ${STARTER(file.id)}`);
72
+ return { ...file, schemaOrigin: "built-in", schema: builtin };
73
+ }
74
+ async function discoverSections(rootDir) {
75
+ const { files, problems } = await findSectionFiles(rootDir);
76
+ if (problems.length) fail(problems[0].where, problems[0].message);
77
+ const sources = [];
78
+ for (const file of files) sources.push(await resolveSectionSchema(rootDir, file));
79
+ return sources;
80
+ }
81
+ function byDateDesc(a, b) {
82
+ const left = a.date?.from?.sortKey;
83
+ const right = b.date?.from?.sortKey;
84
+ if (left === right) return 0;
85
+ if (!left) return 1;
86
+ if (!right) return -1;
87
+ return right.localeCompare(left);
88
+ }
89
+ async function loadSection(source, options) {
90
+ const where = source.where;
91
+ const raw = await readFile(source.file, "utf8");
92
+ let data;
93
+ try {
94
+ data = parse(raw);
95
+ } catch (error) {
96
+ fail(where, error instanceof Error ? error.message : String(error));
97
+ }
98
+ if (data === null) return [];
99
+ if (!Array.isArray(data)) fail(where, "expected a list of entries");
100
+ const parsed = source.schema.safeParse(data);
101
+ if (!parsed.success) {
102
+ const origin = source.schemaOrigin === "repo" ? ` (schema: schemas/${source.id}.ts)` : "";
103
+ fail(where, `invalid${origin}
104
+ ${formatZodIssues(parsed.error)}`);
105
+ }
106
+ return parsed.data.map((item, index) => ({ item: { ...item, id: `${source.id}-${index}` }, index })).filter(({ item }) => atMost(item.visibility, options.maxVisibility)).map(({ item, index }) => renderRichFields(item, options.renderer, String(index), where)).map((item) => resolveSitePaths(item, options.resolvePath ?? ((path) => path))).sort(byDateDesc);
107
+ }
108
+
109
+ export { RESERVED_SECTION_NAMES, SECTION_NAME, discoverSections, findSectionFiles, loadSection, resolveSectionSchema, sectionNameProblem };
@@ -0,0 +1,199 @@
1
+ import { a as escapeTex } from '../shared/vitakit.DcQOdM4I.mjs';
2
+
3
+ function fieldLabel(name) {
4
+ if (name === "url") return "Link";
5
+ const words = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]+/g, " ").toLowerCase();
6
+ return words.charAt(0).toUpperCase() + words.slice(1);
7
+ }
8
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
9
+ const access = (object, name) => IDENTIFIER.test(name) ? `${object}.${name}` : `${object}['${name}']`;
10
+ function webFieldSnippet(field, className = "vita-entry__note", entry = "entry") {
11
+ const value = access(entry, field.path);
12
+ const label = fieldLabel(field.path);
13
+ const line = (condition, content) => `<p v-if="${condition}" class="${className}">${content}</p>`;
14
+ if (field.type === "link") {
15
+ return {
16
+ markup: line(`asLinkedList(${value}).length`, `${label}: <template v-for="(link, index) in asLinkedList(${value})" :key="index"><template v-if="index">, </template><a v-if="link.url" :href="link.url">{{ link.label }}</a><template v-else>{{ link.label }}</template></template>`),
17
+ imports: ["asLinkedList"]
18
+ };
19
+ }
20
+ if (field.several && ["text", "choice", "number"].includes(field.type)) {
21
+ return { markup: line(`Array.isArray(${value}) && ${value}.length`, `${label}: {{ (${value} as unknown[]).join(', ') }}`), imports: [] };
22
+ }
23
+ if (!field.several) {
24
+ switch (field.type) {
25
+ case "text":
26
+ case "choice":
27
+ return { markup: line(`typeof ${value} === 'string'`, `${label}: {{ ${value} }}`), imports: [] };
28
+ case "number":
29
+ return { markup: line(`typeof ${value} === 'number'`, `${label}: {{ ${value} }}`), imports: [] };
30
+ case "yes-no":
31
+ return { markup: line(`${value} === true`, label), imports: [] };
32
+ case "formatted-text":
33
+ return { markup: line(`richHtml(${value})`, `${label}: <span v-html="richHtml(${value})" />`), imports: ["richHtml"] };
34
+ case "date":
35
+ return { markup: line(`${value}`, `${label}: <VitaDate :value="${value} as DateRange" />`), imports: ["type DateRange"] };
36
+ case "web-address":
37
+ return { markup: line(`typeof ${value} === 'string'`, `<a :href="${value}">${label}</a>`), imports: [] };
38
+ }
39
+ }
40
+ return {
41
+ markup: `<!-- ${field.path}: ${field.type === "group" ? "a group of fields" : "a value of its own kind"}; show its parts, e.g. ${field.fields?.[0] ? `{{ ${value}.${field.fields[0].path.split(".").pop()} }}` : `{{ ${value} }}`} -->`,
42
+ imports: []
43
+ };
44
+ }
45
+ function etaFieldSnippet(field, item = "item") {
46
+ const value = access(item, field.path);
47
+ const label = escapeTex(fieldLabel(field.path));
48
+ if (field.type === "link") {
49
+ return field.several ? `<%~ it.vita.note\`${label}: \${it.join((${value} ?? []).map(value => it.linked(value)))}\` %>` : `<%~ it.vita.note\`${label}: \${it.linked(${value})}\` %>`;
50
+ }
51
+ if (field.several && ["text", "choice", "number"].includes(field.type)) {
52
+ return `<%~ it.vita.note\`${label}: \${it.join(${value} ?? [])}\` %>`;
53
+ }
54
+ if (!field.several) {
55
+ switch (field.type) {
56
+ case "text":
57
+ case "choice":
58
+ case "number":
59
+ case "formatted-text":
60
+ return `<%~ it.vita.note\`${label}: \${${value}}\` %>`;
61
+ case "yes-no":
62
+ return `<% if (${value} === true) { %><%~ it.vita.note('${label.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}') %><% } %>`;
63
+ case "date":
64
+ return `<%~ it.vita.note\`${label}: \${it.date(${value})}\` %>`;
65
+ case "web-address":
66
+ return `<% if (${value}) { %><%~ it.vita.note(it.linked('${label.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}', ${value})) %><% } %>`;
67
+ }
68
+ }
69
+ return `<%/* ${field.path}: ${field.type === "group" ? "a group of fields" : "a value of its own kind"}; e.g. it.text(${value}${field.fields?.[0] ? `.${field.fields[0].path.split(".").pop()}` : ""}) */%>`;
70
+ }
71
+ function sharedImport(imports) {
72
+ const names = [...new Set(imports)].sort((a, b) => a.replace("type ", "").localeCompare(b.replace("type ", "")));
73
+ if (!names.length) return [];
74
+ return names.every((name) => name.startsWith("type ")) ? [`import type { ${names.map((name) => name.slice(5)).join(", ")} } from '@anja.codes/vitakit/shared'`] : [`import { ${names.join(", ")} } from '@anja.codes/vitakit/shared'`];
75
+ }
76
+ const indent = (lines, by) => lines.map((line) => line ? by + line : line);
77
+ function entryComponent(id, name, fields, region) {
78
+ const snippets = fields.map((field) => webFieldSnippet(field, region === "note" ? "vita-entry__note" : "vita-entry__meta"));
79
+ const imports = sharedImport(["type Entry", "type Where", ...snippets.flatMap((snippet) => snippet.imports)]);
80
+ const keep = region === "note" ? ['<p v-if="display.note !== undefined" class="vita-entry__note">{{ display.note }}</p>'] : [
81
+ '<p v-if="display.meta.length" class="vita-entry__meta">',
82
+ ' <template v-for="(part, index) in display.meta" :key="index">',
83
+ ' <template v-if="index"> \xB7 </template>',
84
+ ` <template v-if="part.kind === 'linked'">`,
85
+ ' <a v-if="part.linked.url" :href="part.linked.url">{{ part.linked.label }}</a>',
86
+ " <template v-else>{{ part.linked.label }}</template>",
87
+ " </template>",
88
+ " <template v-else>{{ part.text }}</template>",
89
+ " </template>",
90
+ "</p>"
91
+ ];
92
+ const body = fields.length ? [...keep, ...snippets.map((snippet) => snippet.markup)] : [...keep, `<!-- Add your own lines here, e.g. -->`, `<!-- <p v-if="typeof entry.audience === 'string'" class="vita-entry__${region}">Audience: {{ entry.audience }}</p> -->`];
93
+ return [
94
+ '<script setup lang="ts">',
95
+ "/**",
96
+ ` * How ${id} entries look on the website: vitakit's list, with your own`,
97
+ ` * ${region === "note" ? "note" : "secondary"} line. Use it like VitaSection, on any page:`,
98
+ ` * <${name} id="${id}" title="\u2026" />`,
99
+ " *",
100
+ ` * The #${region} slot below replaces that region of each entry. Delete it to get`,
101
+ " * vitakit's version back; the other slots are #title, #meta, #note and the",
102
+ " * default slot (the whole entry). `entry` has every field of the entry,",
103
+ " * `display` what vitakit would show.",
104
+ " */",
105
+ ...imports,
106
+ "",
107
+ "const props = defineProps<{ id?: string, entries?: Entry[], title?: string, where?: Where, limit?: number }>()",
108
+ "<\/script>",
109
+ "",
110
+ "<template>",
111
+ ' <VitaSection v-bind="props">',
112
+ ` <template #${region}="{ ${region === "note" ? "entry, display" : "entry, display"} }">`,
113
+ ...indent(body, " "),
114
+ " </template>",
115
+ " </VitaSection>",
116
+ "</template>",
117
+ ""
118
+ ].join("\n");
119
+ }
120
+ function listComponent(id, name, fields, groupBy) {
121
+ const titleField = fields.find((field) => ["title", "position", "degree", "role", "student", "name"].includes(field.path));
122
+ const rest = fields.filter((field) => field !== titleField && field.path !== "date" && field.path !== groupBy);
123
+ const snippets = rest.map((field) => webFieldSnippet(field));
124
+ const titleLine = titleField ? titleField.type === "formatted-text" ? `<p class="vita-entry__title" v-html="richHtml(${access("entry", titleField.path)})" />` : `<p class="vita-entry__title">{{ ${access("entry", titleField.path)} }}</p>` : '<!-- <p class="vita-entry__title">{{ entry.title }}</p> -->';
125
+ const imports = sharedImport([
126
+ "filterWhere",
127
+ "type Entry",
128
+ "type Where",
129
+ ...fields.some((field) => field.path === "date") ? ["type DateRange"] : [],
130
+ ...titleField?.type === "formatted-text" ? ["richHtml"] : [],
131
+ ...snippets.flatMap((snippet) => snippet.imports)
132
+ ]);
133
+ const hasDate = fields.some((field) => field.path === "date");
134
+ const row = [
135
+ `<VitaEntry v-for="entry in ${groupBy ? "group.entries" : "entries"}" :key="entry.id"${hasDate ? ' :date="entry.date as DateRange"' : ""}>`,
136
+ ...indent([titleLine, ...snippets.map((snippet) => snippet.markup)], " "),
137
+ "</VitaEntry>"
138
+ ];
139
+ const grouping = groupBy ? [
140
+ "",
141
+ `// Entries in a row with the same ${groupBy} form a group, in the order`,
142
+ "// vitakit sorted them (newest first).",
143
+ "const groups = computed(() => {",
144
+ " const result: { label: string, entries: Entry[] }[] = []",
145
+ " for (const entry of entries.value) {",
146
+ ` const label = String(${access("entry", groupBy)} ?? '')`,
147
+ " const last = result[result.length - 1]",
148
+ " if (last?.label === label) last.entries.push(entry)",
149
+ " else result.push({ label, entries: [entry] })",
150
+ " }",
151
+ " return result",
152
+ "})"
153
+ ] : [];
154
+ const list = groupBy ? [
155
+ '<template v-for="group in groups" :key="group.label">',
156
+ ' <h3 class="vita-section-group__subtitle">',
157
+ " {{ group.label }}",
158
+ " </h3>",
159
+ ' <ul class="vita-section">',
160
+ ...indent(row, " "),
161
+ " </ul>",
162
+ "</template>"
163
+ ] : ['<ul class="vita-section">', ...indent(row, " "), "</ul>"];
164
+ return [
165
+ '<script setup lang="ts">',
166
+ "/**",
167
+ ` * How the ${id} section looks on the website, list and all. vitakit selects and sorts`,
168
+ " * the entries (newest first, `where`, `limit`); the markup below is yours:",
169
+ " * group them, number them, change what each one shows. Use it like",
170
+ ` * VitaSection, on any page: <${name} id="${id}" title="\u2026" />`,
171
+ " *",
172
+ " * Each field of the entries has a line below; delete the ones you do not",
173
+ " * want. The classes are styled in app/assets/css/main.css.",
174
+ " */",
175
+ `import { computed } from 'vue'`,
176
+ ...imports,
177
+ "",
178
+ "const props = defineProps<{ id: string, title?: string, where?: Where, limit?: number }>()",
179
+ "",
180
+ "const entries = computed<Entry[]>(() => {",
181
+ " const selected = filterWhere(useSection(props.id), props.where)",
182
+ " return props.limit ? selected.slice(0, props.limit) : selected",
183
+ "})",
184
+ ...grouping,
185
+ "<\/script>",
186
+ "",
187
+ "<template>",
188
+ ' <section v-if="entries.length" :id="id" class="vita-section-group">',
189
+ ' <h2 v-if="title" class="vita-section-group__title">',
190
+ " {{ title }}",
191
+ " </h2>",
192
+ ...indent(list, " "),
193
+ " </section>",
194
+ "</template>",
195
+ ""
196
+ ].join("\n");
197
+ }
198
+
199
+ export { entryComponent, etaFieldSnippet, fieldLabel, listComponent, webFieldSnippet };