@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,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.BerUaxtF.mjs';
2
+ import { z } from 'zod';
3
+ import '../shared/vitakit.DbSeENVS.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.DbSeENVS.mjs';
6
+ import { c as renderRichFields } from '../shared/vitakit.BerUaxtF.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"]);
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 };
@@ -0,0 +1,124 @@
1
+ import { join, sep, isAbsolute, relative } from 'node:path';
2
+ import { parseExpression } from '@babel/parser';
3
+ import { l as loadContent } from '../shared/vitakit.By5VWQ1n.mjs';
4
+ import { inventory } from './inventory.mjs';
5
+ import { m as messageOf, w as whereOf } from '../shared/vitakit.DbSeENVS.mjs';
6
+ import { assertTemplatesAreUsed, renderPublications, renderSection } from './emit.mjs';
7
+ import 'node:fs/promises';
8
+ import 'gray-matter';
9
+ import 'yaml';
10
+ import 'unified';
11
+ import 'remark-parse';
12
+ import 'remark-gfm';
13
+ import 'remark-math';
14
+ import 'remark-rehype';
15
+ import 'rehype-katex';
16
+ import 'rehype-raw';
17
+ import 'rehype-stringify';
18
+ import 'unist-util-visit';
19
+ import '../shared/vitakit.D10HyDhF.mjs';
20
+ import 'node:fs';
21
+ import '../shared/vitakit.DQ6PmGoL.mjs';
22
+ import 'node:url';
23
+ import '@behackl/citation-js-extras';
24
+ import 'rehype-parse';
25
+ import 'rehype-sanitize';
26
+ import 'zod';
27
+ import './sections.mjs';
28
+ import './schemas.mjs';
29
+ import '../shared/vitakit.BerUaxtF.mjs';
30
+ import '../shared/vitakit.TSMTC5Jl.mjs';
31
+ import './fields.mjs';
32
+ import './documents.mjs';
33
+ import '../shared/vitakit.D-DdJaDZ.mjs';
34
+ import '../shared/vitakit.Do5HC_MD.mjs';
35
+ import 'eta';
36
+ import '../shared/vitakit.DcQOdM4I.mjs';
37
+ import '../shared/vitakit.CfB3hMHl.mjs';
38
+
39
+ function literalKeys(expression) {
40
+ try {
41
+ const node = parseExpression(expression, { plugins: ["typescript"] });
42
+ if (node.type !== "ObjectExpression") return void 0;
43
+ const keys = [];
44
+ for (const property of node.properties) {
45
+ if (property.type !== "ObjectProperty" || property.computed) return void 0;
46
+ if (property.key.type === "Identifier") keys.push(property.key.name);
47
+ else if (property.key.type === "StringLiteral") keys.push(property.key.value);
48
+ else return void 0;
49
+ }
50
+ return keys;
51
+ } catch {
52
+ return void 0;
53
+ }
54
+ }
55
+ async function validateProject(rootDir, { sections: only } = {}) {
56
+ const inv = await inventory(rootDir);
57
+ const problems = [];
58
+ const seen = /* @__PURE__ */ new Set();
59
+ const add = (problem) => {
60
+ const key = `${problem.where}
61
+ ${problem.message}`;
62
+ if (seen.has(key)) return;
63
+ seen.add(key);
64
+ problems.push(problem);
65
+ };
66
+ const local = (where) => isAbsolute(where) ? relative(rootDir, where) : where;
67
+ for (const problem of inv.problems) {
68
+ const section = inv.sections.find((candidate) => candidate.problems.includes(problem));
69
+ add({ ...problem, ...section ? { section: section.id } : {} });
70
+ }
71
+ const valid = new Map(inv.sections.filter((section) => !section.problems.length).map((section) => [section.id, section]));
72
+ const known = new Set(inv.sections.map((section) => section.id));
73
+ for (const placement of inv.web.placements) {
74
+ const where = `${placement.file}:${placement.line}`;
75
+ if (!known.has(placement.section)) {
76
+ add({ where, section: placement.section, message: `no section called "${placement.section}" (no content/${placement.section}.yml)` });
77
+ continue;
78
+ }
79
+ const fields = valid.get(placement.section)?.fields;
80
+ if (placement.where === void 0 || !fields) continue;
81
+ const keys = literalKeys(placement.where);
82
+ if (!keys) continue;
83
+ const names = /* @__PURE__ */ new Set(["year", ...fields.map((field) => field.path)]);
84
+ for (const key of keys.filter((key2) => !names.has(key2))) {
85
+ add({ where, section: placement.section, message: `where.${key}: ${placement.section} has no field "${key}"; its fields are ${[...names].join(", ")}` });
86
+ }
87
+ }
88
+ if (inv.documents.length) {
89
+ const templateDir = join(rootDir, "cv-tex", "templates");
90
+ try {
91
+ await assertTemplatesAreUsed(templateDir, [...known]);
92
+ } catch (error) {
93
+ add({ where: local(whereOf(error, "cv-tex/templates/")), message: messageOf(error) });
94
+ }
95
+ const content = await loadContent(rootDir, { strict: false, maxVisibility: "private", target: "pdf" });
96
+ for (const document of inv.documents) {
97
+ for (const placement of document.placements) {
98
+ try {
99
+ if (placement.section === "publications") {
100
+ await renderPublications(content.publications, document.options, templateDir, content.profile.url);
101
+ } else if (valid.has(placement.section)) {
102
+ await renderSection(placement.section, content.sections[placement.section] ?? [], document.options, templateDir);
103
+ }
104
+ } catch (error) {
105
+ add({
106
+ where: local(whereOf(error, `cv-tex/templates/${placement.section}.eta`)),
107
+ section: placement.section,
108
+ message: `${messageOf(error)} (in ${document.tex})`
109
+ });
110
+ }
111
+ }
112
+ }
113
+ }
114
+ const { realpath } = await import('node:fs/promises');
115
+ const roots = [.../* @__PURE__ */ new Set([rootDir, await realpath(rootDir).catch(() => rootDir)])];
116
+ for (const problem of problems) {
117
+ for (const root of roots) problem.message = problem.message.replaceAll(`${root}${sep}`, "");
118
+ problem.message = problem.message.replace(/^(template [\w-]+\.eta: )cv-tex\/templates\/[\w-]+\.eta: /, "$1");
119
+ }
120
+ const relevant = only?.length ? problems.filter((problem) => problem.section !== void 0 && only.includes(problem.section) || problem.where === "content/profile.yml") : problems;
121
+ return { problems: relevant, inventory: inv };
122
+ }
123
+
124
+ export { validateProject };
@@ -0,0 +1,116 @@
1
+ import { parse } from '@vue/compiler-sfc';
2
+
3
+ function lineStart(source, offset) {
4
+ return source.lastIndexOf("\n", offset - 1) + 1;
5
+ }
6
+ function lineEnd(source, offset) {
7
+ const end = source.indexOf("\n", offset);
8
+ return end === -1 ? source.length : end;
9
+ }
10
+ function indentationAt(source, offset) {
11
+ return /^[ \t]*/.exec(source.slice(lineStart(source, offset)))[0];
12
+ }
13
+ function insertLinesAfter(source, offset, lines) {
14
+ const end = lineEnd(source, offset);
15
+ return `${source.slice(0, end)}
16
+ ${lines.join("\n")}${source.slice(end)}`;
17
+ }
18
+ function insertLinesBefore(source, offset, lines) {
19
+ const start = lineStart(source, offset);
20
+ return `${source.slice(0, start)}${lines.join("\n")}
21
+ ${source.slice(start)}`;
22
+ }
23
+
24
+ const ELEMENT = 1;
25
+ const ATTRIBUTE = 6;
26
+ const pascal = (name) => name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
27
+ function literalId(node) {
28
+ for (const prop of node.props ?? []) {
29
+ if (prop.type === ATTRIBUTE && prop.name === "id") return prop.value?.content;
30
+ if (prop.type !== ATTRIBUTE && prop.name === "bind" && prop.arg?.content === "id") {
31
+ return /^\s*(['"`])([^'"`$]*)\1\s*$/.exec(prop.exp?.content ?? "")?.[2];
32
+ }
33
+ }
34
+ return void 0;
35
+ }
36
+ function sectionElements(source, components = []) {
37
+ const known = /* @__PURE__ */ new Set(["VitaSection", ...components]);
38
+ let ast;
39
+ try {
40
+ const { descriptor, errors } = parse(source);
41
+ if (errors.length) return { ok: false, reason: `cannot be parsed: ${String(errors[0])}` };
42
+ ast = descriptor.template?.ast;
43
+ } catch (error) {
44
+ return { ok: false, reason: `cannot be parsed: ${error instanceof Error ? error.message : String(error)}` };
45
+ }
46
+ if (!ast) return { ok: false, reason: "it has no <template>" };
47
+ const elements = [];
48
+ const visit = (node) => {
49
+ if (node.type === ELEMENT && node.tag) {
50
+ const tag = node.tag.includes("-") ? pascal(node.tag) : node.tag;
51
+ const id = known.has(tag) ? literalId(node) : void 0;
52
+ if (id) {
53
+ const hasContent = (node.children ?? []).some((child) => child.type !== 3 && !(child.type === 2 && !child.content.trim()));
54
+ elements.push({ id, tag: node.tag, start: node.loc.start.offset, end: node.loc.end.offset, line: node.loc.start.line, hasContent });
55
+ }
56
+ }
57
+ for (const child of node.children ?? []) visit(child);
58
+ };
59
+ visit(ast);
60
+ const roots = (ast.children ?? []).filter((node) => node.type === ELEMENT);
61
+ return { ok: true, value: { elements, ...roots.length === 1 ? { root: roots[0] } : {} } };
62
+ }
63
+ function attribute(value) {
64
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
65
+ }
66
+ function sectionElementSource(placement) {
67
+ return `<${placement.component ?? "VitaSection"} id="${placement.id}"${placement.title ? ` title="${attribute(placement.title)}"` : ""} />`;
68
+ }
69
+ function addSectionToPage(source, placement, components = []) {
70
+ const found = sectionElements(source, components);
71
+ if (!found.ok) return found;
72
+ const { elements, root } = found.value;
73
+ if (elements.some((element2) => element2.id === placement.id)) return { ok: false, reason: `it already shows ${placement.id}` };
74
+ const element = sectionElementSource(placement);
75
+ const anchor = (placement.after && elements.find((existing) => existing.id === placement.after)) ?? elements[elements.length - 1];
76
+ if (anchor) {
77
+ const indent2 = indentationAt(source, anchor.start);
78
+ return { ok: true, value: { source: insertLinesAfter(source, anchor.end - 1, [`${indent2}${element}`]), afterSection: anchor.id } };
79
+ }
80
+ if (!root || !root.tag) return { ok: false, reason: "its template has no single root element to add to" };
81
+ const close = source.lastIndexOf(`</${root.tag}`, root.loc.end.offset);
82
+ if (close === -1 || close < root.loc.start.offset || source.slice(lineStart(source, close), close).trim() !== "") {
83
+ return { ok: false, reason: `its <${root.tag}> does not end on a line of its own` };
84
+ }
85
+ const indent = `${indentationAt(source, close)} `;
86
+ return { ok: true, value: { source: insertLinesBefore(source, close, ["", `${indent}${element}`]) } };
87
+ }
88
+ function useComponentForSection(source, id, component, components = []) {
89
+ const found = sectionElements(source, components);
90
+ if (!found.ok) return found;
91
+ const elements = found.value.elements.filter((element) => element.id === id);
92
+ if (!elements.length) return { ok: false, reason: `it does not show ${id}` };
93
+ const blocked = elements.find((element) => element.hasContent);
94
+ if (blocked) return { ok: false, reason: `line ${blocked.line} has content of its own, such as slots; move it into the component` };
95
+ const changed = [];
96
+ let result = source;
97
+ for (const element of [...elements].reverse()) {
98
+ if (element.tag === component) continue;
99
+ let text = result.slice(element.start, element.end);
100
+ text = `<${component}${text.slice(element.tag.length + 1)}`;
101
+ if (text.endsWith(`</${element.tag}>`)) text = `${text.slice(0, -(element.tag.length + 3))}</${component}>`;
102
+ result = result.slice(0, element.start) + text + result.slice(element.end);
103
+ changed.unshift(element.line);
104
+ }
105
+ return { ok: true, value: { source: result, changed } };
106
+ }
107
+
108
+ const vue = {
109
+ __proto__: null,
110
+ addSectionToPage: addSectionToPage,
111
+ sectionElementSource: sectionElementSource,
112
+ sectionElements: sectionElements,
113
+ useComponentForSection: useComponentForSection
114
+ };
115
+
116
+ export { indentationAt as a, insertLinesBefore as b, lineEnd as c, addSectionToPage as d, insertLinesAfter as i, lineStart as l, sectionElementSource as s, vue as v };