@anja.codes/vitakit 0.3.0 → 0.5.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.
package/dist/check.mjs CHANGED
@@ -77,18 +77,48 @@ function urlReferences(html) {
77
77
  }
78
78
  return found;
79
79
  }
80
- async function existsInSite(outDir, path) {
80
+ function listing(listings, dir) {
81
+ let names = listings.get(dir);
82
+ if (!names) {
83
+ names = readdir(dir, { withFileTypes: true }).then(
84
+ (entries) => new Map(entries.map((entry) => [entry.name, entry.isDirectory()])),
85
+ () => void 0
86
+ );
87
+ listings.set(dir, names);
88
+ }
89
+ return names;
90
+ }
91
+ async function existsInSite(outDir, path, listings) {
81
92
  let decoded;
82
93
  try {
83
94
  decoded = decodeURIComponent(path.replace(/[?#].*$/, ""));
84
95
  } catch {
85
- return false;
96
+ return { found: false };
97
+ }
98
+ if (relative(outDir, join(outDir, decoded)).startsWith("..")) return { found: false };
99
+ const segments = decoded.split("/").filter((segment) => segment !== "" && segment !== ".");
100
+ let dir = outDir;
101
+ const actual = [];
102
+ let caseOnly = false;
103
+ for (const [index, segment] of segments.entries()) {
104
+ const names = await listing(listings, dir);
105
+ if (!names) return { found: false };
106
+ let name = names.has(segment) ? segment : void 0;
107
+ if (name === void 0) {
108
+ const lower = segment.toLowerCase();
109
+ name = [...names.keys()].find((candidate) => candidate.toLowerCase() === lower);
110
+ if (name === void 0) return { found: false };
111
+ caseOnly = true;
112
+ }
113
+ actual.push(name);
114
+ const isDirectory = names.get(name);
115
+ if (index < segments.length - 1 && !isDirectory) return { found: false };
116
+ dir = join(dir, name);
86
117
  }
87
- const target = join(outDir, decoded);
88
- if (relative(outDir, target).startsWith("..")) return false;
89
- const found = await stat(target).catch(() => void 0);
90
- if (found?.isFile()) return true;
91
- return found?.isDirectory() === true && (await stat(join(target, "index.html")).catch(() => void 0))?.isFile() === true;
118
+ const last = segments.length ? (await listing(listings, join(dir, "..")))?.get(actual.at(-1)) : true;
119
+ const leads = last === false || (await listing(listings, dir))?.get("index.html") === false;
120
+ if (!leads) return { found: false };
121
+ return caseOnly ? { found: false, differsInCase: `/${actual.join("/")}${decoded.endsWith("/") ? "/" : ""}` } : { found: true };
92
122
  }
93
123
  const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
94
124
  function decodeEntity(reference, name) {
@@ -181,14 +211,16 @@ async function checkStatic(options) {
181
211
  checked.push(`assets and links use ${base}`);
182
212
  if (builtForBase) {
183
213
  const missing = /* @__PURE__ */ new Map();
214
+ const listings = /* @__PURE__ */ new Map();
184
215
  for (const [file, html] of pages) {
185
216
  for (const path of urlReferences(html)) {
186
217
  if (!underBase(path, base) || missing.has(path)) continue;
187
- if (!await existsInSite(outDir, path.slice(base.length))) missing.set(path, relative(outDir, file));
218
+ const found = await existsInSite(outDir, path.slice(base.length), listings);
219
+ if (!found.found) missing.set(path, { file: relative(outDir, file), differsInCase: found.differsInCase });
188
220
  }
189
221
  }
190
- for (const [path, file] of missing) {
191
- fail(`${file}: ${path} leads nowhere: no such file or page in the generated site. A file belongs in public/, a page in content/pages/ or app/pages/`);
222
+ for (const [path, { file, differsInCase }] of missing) {
223
+ fail(differsInCase !== void 0 ? `${file}: ${path} leads nowhere: the site has ${base}${differsInCase.slice(1)}, which differs only in case. Web servers usually tell them apart, even where your computer does not; write the name as it is` : `${file}: ${path} leads nowhere: no such file or page in the generated site. A file belongs in public/, a page in content/pages/ or app/pages/`);
192
224
  }
193
225
  checked.push("links into the site lead somewhere");
194
226
  }
@@ -1,11 +1,11 @@
1
1
  import { parse } from 'yaml';
2
2
  import { p as propertyKey, a as addSchemaField, e as ensurePackageImports } from '../shared/vitakit.CCTDg7pz.mjs';
3
- import { c as findPdfPublicationPlacements, f as findPdfPlacements } from '../shared/vitakit.DvvUYUTS.mjs';
3
+ import { findPdfPublicationPlacements, findPdfPlacements } from './placements.mjs';
4
4
  import { a as escapeTex } from '../shared/vitakit.DcQOdM4I.mjs';
5
5
  import { b as insertLinesAfter, i as indentationAt, c as insertLinesBefore } from '../shared/vitakit.UfoXdL7U.mjs';
6
6
  import { addPublicationsToPage, publicationsElementSource, addSectionToPage, sectionElementSource } from './vue.mjs';
7
7
  import { addNavItem, yamlScalar } from './yaml.mjs';
8
- import { C as CommandError } from '../shared/vitakit.zwr1ssO5.mjs';
8
+ import { C as CommandError } from '../shared/vitakit.CvYy_Quj.mjs';
9
9
  import '@babel/parser';
10
10
  import 'node:fs/promises';
11
11
  import 'node:path';
@@ -1,12 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { mkdir, readFile, writeFile, rm, copyFile, rename } from 'node:fs/promises';
3
- import { existsSync } from 'node:fs';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
4
  import process from 'node:process';
5
- import { join, delimiter, resolve } from 'node:path';
5
+ import { join, delimiter, resolve, extname } from 'node:path';
6
6
  import { l as loadContent } from '../shared/vitakit.DteA9vLy.mjs';
7
7
  import { f as fail } from '../shared/vitakit.DCK90zq4.mjs';
8
8
  import { discoverDocuments, validateDocumentSections, selectEntries } from './documents.mjs';
9
- import { p as publicationPlacementProblems } from '../shared/vitakit.DvvUYUTS.mjs';
9
+ import { publicationPlacementProblems } from './placements.mjs';
10
10
  import { discoverSections } from './sections.mjs';
11
11
  import { emitDocument, bundledTexDir, SITE_PATH_HELP } from './emit.mjs';
12
12
  import { a as escapeTex, b as escapeUrl } from '../shared/vitakit.DcQOdM4I.mjs';
@@ -74,7 +74,7 @@ Full log: ${log}` : ""}`));
74
74
  });
75
75
  });
76
76
  }
77
- function profilePreamble(content, document, lists) {
77
+ function profilePreamble(content, document, lists, cvDir) {
78
78
  const { profile } = content;
79
79
  const href = (url) => {
80
80
  if (isSitePath(url)) fail("content/profile.yml", `links: ${url} ${SITE_PATH_HELP}`);
@@ -91,6 +91,7 @@ function profilePreamble(content, document, lists) {
91
91
  `\\vitaname{${escapeTex(profile.name)}}`,
92
92
  `\\vitatagline{${tagline}}`,
93
93
  `\\vitacontact{${contact}}`,
94
+ ...portraitLine(content, document, cvDir),
94
95
  ...profile.url ? siteUrlSourcemap(profile.url) : [],
95
96
  ...Object.entries(content.sections).flatMap(([id, entries]) => {
96
97
  const title = document.options.sections[id]?.title;
@@ -103,6 +104,30 @@ function profilePreamble(content, document, lists) {
103
104
  ""
104
105
  ].join("\n");
105
106
  }
107
+ const PORTRAIT_FORMATS = [".jpg", ".jpeg", ".png", ".pdf"];
108
+ function portraitLine(content, document, cvDir) {
109
+ const option = document.options.portrait;
110
+ if (option === false) return [];
111
+ const where = `cv-tex/${document.name}.yml`;
112
+ const path = option === true ? content.profile.avatar : option;
113
+ if (!path) {
114
+ fail(where, "portrait: true shows avatar from content/profile.yml, which has none; add one there, or give the image here, e.g. portrait: /img/portrait.jpg");
115
+ }
116
+ const what = option === true ? `portrait: true shows avatar ${path} (content/profile.yml)` : `portrait: ${path}`;
117
+ const format = extname(path).toLowerCase();
118
+ if (!PORTRAIT_FORMATS.includes(format)) {
119
+ fail(where, `${what}, but LaTeX cannot include ${format ? `a ${format} file` : "a file without an extension"}; use a JPG, PNG or PDF${option === true ? ", e.g. portrait: /img/portrait.jpg" : ""}`);
120
+ }
121
+ if (!/^[\w./-]+$/.test(path) || path.split("/").includes("..")) {
122
+ fail(where, `${what}: the path may only contain letters, digits and - . _ /`);
123
+ }
124
+ if (!existsSync(join(cvDir, "..", "public", path))) fail(where, `${what}: public${path} does not exist`);
125
+ const ejected = join(cvDir, CLASS_FILE);
126
+ if (existsSync(ejected) && !readFileSync(ejected, "utf8").includes("\\vitaportrait")) {
127
+ fail(where, `portrait needs \\vitaportrait, which cv-tex/${CLASS_FILE} does not define: it was ejected from an earlier vitakit. Run vitakit eject vitakit-cv.cls --force for the current class (your copy is kept as a backup), or remove portrait`);
128
+ }
129
+ return [`\\vitaportrait{../public${path}}`];
130
+ }
106
131
  function siteUrlSourcemap(siteUrl) {
107
132
  const base = siteUrl.replace(/\/?$/, "/");
108
133
  if (!/^https?:\/\/[\w.:/-]+$/.test(base)) {
@@ -125,7 +150,7 @@ async function buildDocument(document, content, options, cvDir) {
125
150
  templateDir: join(cvDir, "templates"),
126
151
  outDir
127
152
  });
128
- await writeFile(join(outDir, "profile.tex"), profilePreamble(content, document, lists));
153
+ await writeFile(join(outDir, "profile.tex"), profilePreamble(content, document, lists, cvDir));
129
154
  const generatedClass = join(outDir, CLASS_FILE);
130
155
  if (existsSync(join(cvDir, CLASS_FILE))) await rm(generatedClass, { force: true });
131
156
  else await copyFile(join(bundledTexDir, CLASS_FILE), generatedClass);
@@ -3,7 +3,7 @@ import { basename, join } from 'node:path';
3
3
  import { parse } from 'yaml';
4
4
  import { z } from 'zod';
5
5
  import { f as fail, m as messageOf, a as formatZodIssues } from '../shared/vitakit.DCK90zq4.mjs';
6
- import { c as findPdfPublicationPlacements, f as findPdfPlacements } from '../shared/vitakit.DvvUYUTS.mjs';
6
+ import { findPdfPublicationPlacements, findPdfPlacements } from './placements.mjs';
7
7
  import { a as atMost } from '../shared/vitakit.TSMTC5Jl.mjs';
8
8
  import { f as fieldValue, a as filterWhere } from '../shared/vitakit.D-DdJaDZ.mjs';
9
9
  import 'node:url';
@@ -38,6 +38,13 @@ const documentSchema = z.strictObject({
38
38
  * build (see `vitakit verify`), and the website renders CV data directly.
39
39
  */
40
40
  visibility: z.enum(["public", "cv", "private"]).default("cv"),
41
+ /**
42
+ * A portrait beside the header: `true` for `avatar` from
43
+ * `content/profile.yml`, or a path below `public/` for another image, e.g.
44
+ * a print version. Off by default: whether a CV shows a photo depends on
45
+ * where it is sent, so each document decides.
46
+ */
47
+ portrait: z.union([z.boolean(), z.string().startsWith("/")]).default(false),
41
48
  sections: z.record(z.string(), sectionOptions).default({})
42
49
  });
43
50
  const DEFAULTS = documentSchema.parse({});
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { readdir, readFile } from 'node:fs/promises';
3
3
  import { basename, join } from 'node:path';
4
4
  import { r as resolveAsset } from '../shared/vitakit.DQ6PmGoL.mjs';
5
- import { C as CommandError } from '../shared/vitakit.zwr1ssO5.mjs';
5
+ import { C as CommandError } from '../shared/vitakit.CvYy_Quj.mjs';
6
6
  import 'node:url';
7
7
  import 'node:process';
8
8
  import 'node:util';
@@ -11,7 +11,7 @@ import { selectEntries } from './documents.mjs';
11
11
  import 'node:url';
12
12
  import 'yaml';
13
13
  import 'zod';
14
- import '../shared/vitakit.DvvUYUTS.mjs';
14
+ import './placements.mjs';
15
15
  import '../shared/vitakit.TSMTC5Jl.mjs';
16
16
  import '../shared/vitakit.D-DdJaDZ.mjs';
17
17
 
@@ -8,7 +8,7 @@ import { describeSection } from './fields.mjs';
8
8
  import { findSectionFiles, resolveSectionSchema } from './sections.mjs';
9
9
  import { schemas } from './schemas.mjs';
10
10
  import { findDocuments, documentSectionProblems } from './documents.mjs';
11
- import { a as pageRoute, d as documentDirective, p as publicationPlacementProblems, b as findWebPlacements } from '../shared/vitakit.DvvUYUTS.mjs';
11
+ import { pageRoute, documentDirective, publicationPlacementProblems, findWebPlacements } from './placements.mjs';
12
12
  import { m as messageOf, w as whereOf } from '../shared/vitakit.DCK90zq4.mjs';
13
13
  import './bibliography.mjs';
14
14
  import '../shared/vitakit.DQ6PmGoL.mjs';
@@ -7,7 +7,7 @@ import { i as importProjectModule } from '../shared/vitakit.DCK90zq4.mjs';
7
7
  import { addPublicationList, publicationListYaml } from './yaml.mjs';
8
8
  import { r as renamePublicationsSettings } from '../shared/vitakit.CCTDg7pz.mjs';
9
9
  import { publicationsElements } from './vue.mjs';
10
- import { f as findPdfPlacements } from '../shared/vitakit.DvvUYUTS.mjs';
10
+ import { findPdfPlacements } from './placements.mjs';
11
11
  import { LISTS_FILE, LISTS_HEADER } from './publication-lists.mjs';
12
12
  import 'node:fs';
13
13
  import '../shared/vitakit.DQ6PmGoL.mjs';
@@ -85,15 +85,30 @@ async function statusOrder(root) {
85
85
  return Array.isArray(old) ? ids(old) : ids(defaultPublications.statuses);
86
86
  }
87
87
  }
88
- async function vueFiles(root, dir = join(root, "app")) {
88
+ async function appFiles(root, extension, dir = join(root, "app")) {
89
89
  const files = [];
90
90
  for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
91
91
  const path = join(dir, entry.name);
92
- if (entry.isDirectory()) files.push(...await vueFiles(root, path));
93
- else if (entry.name.endsWith(".vue")) files.push(relative(root, path).split(sep).join("/"));
92
+ if (entry.isDirectory()) files.push(...await appFiles(root, extension, path));
93
+ else if (entry.name.endsWith(extension)) files.push(relative(root, path).split(sep).join("/"));
94
94
  }
95
95
  return files.sort();
96
96
  }
97
+ const PUBLICATIONS_TITLE_CSS = `.vita-publications__title {
98
+ @apply text-xl font-semibold;
99
+ }
100
+ .vita-publications__title ~ .vita-publications__group .vita-publications__heading {
101
+ @apply mt-4 text-base;
102
+ }`;
103
+ async function checkStyles(ws, root, notes) {
104
+ const sheets = [];
105
+ for (const path of await appFiles(root, ".css")) sheets.push({ path, source: await ws.read(path) });
106
+ if (sheets.some((sheet) => sheet.source.includes(".vita-publications__title"))) return;
107
+ const styled = sheets.find((sheet) => sheet.source.includes(".vita-publications__heading"));
108
+ if (!styled) return;
109
+ notes.after.push(`By hand: ${styled.path} styles the groups of a publication list but not its title, <VitaPublications title>. New projects have, after .vita-publications__heading:
110
+ ${PUBLICATIONS_TITLE_CSS}`);
111
+ }
97
112
  async function migrateSchema(ws, notes) {
98
113
  const path = "schemas/publications.ts";
99
114
  const source = await ws.read(path);
@@ -104,7 +119,7 @@ async function migrateSchema(ws, notes) {
104
119
  }
105
120
  async function migratePages(root, ws, lists, notes) {
106
121
  const statuses = await statusOrder(root);
107
- for (const path of await vueFiles(root)) {
122
+ for (const path of await appFiles(root, ".vue")) {
108
123
  const source = await ws.read(path);
109
124
  if (/(^|\/)VitaPublications\.vue$/.test(path)) {
110
125
  notes.after.push(`By hand: ${path} is your copy of VitaPublications. Its props are now list and title, and it reads usePublicationList(list); \`vitakit eject VitaPublications\` shows vitakit's.`);
@@ -187,6 +202,7 @@ async function migrateDocuments(root, ws, lists, notes) {
187
202
  }
188
203
  async function migratePublicationLists(root, ws, notes) {
189
204
  await migrateSchema(ws, notes);
205
+ await checkStyles(ws, root, notes);
190
206
  const source = await ws.read(LISTS_FILE);
191
207
  let existing;
192
208
  try {
@@ -214,5 +230,28 @@ ${[...lists.added].map(([other, value]) => publicationListYaml(other, value)).jo
214
230
  if (source === void 0) ws.create(LISTS_FILE, lines, reason, { show: true });
215
231
  else await ws.edit(LISTS_FILE, lines, reason);
216
232
  }
233
+ const CAST = /\bentry\.[\w$]+\s+as\s+[A-Z][\w$]*(?:\s*\|\s*undefined)?|\(\s*entry\s+as\s+[A-Z][\w$<>'" |]*\)/;
234
+ async function migrateSectionComponents(root, ws, notes) {
235
+ const { findWebPlacements } = await import('./placements.mjs');
236
+ const { sectionProps, typeSectionProps } = await import('./props.mjs');
237
+ const { placements, components } = await findWebPlacements(root);
238
+ for (const [name, file] of components) {
239
+ const source = await ws.read(file);
240
+ if (source === void 0) continue;
241
+ const found = sectionProps(source);
242
+ if (!found.ok || !found.value.untyped) continue;
243
+ const ids = [...new Set(placements.filter((placement) => placement.component === name).map((placement) => placement.section))].sort();
244
+ if (!ids.length) {
245
+ notes.after.push(`By hand: ${file} is shown on no page with a literal id. To type its entries, give its id the sections it shows, e.g. id: 'talks' | 'talks-public'.`);
246
+ continue;
247
+ }
248
+ const typed = typeSectionProps(source, ids);
249
+ if (!typed.ok) continue;
250
+ await ws.edit(file, typed.value.source, `its entries are typed by the schema of ${ids.join(", ")}`);
251
+ if (CAST.test(source)) {
252
+ notes.after.push(`${file} casts entries to types of its own (as \u2026). They still work, but its entries are typed by their schema now; the casts can go, and fields read as written, e.g. entry.<group>.<field>.`);
253
+ }
254
+ }
255
+ }
217
256
 
218
- export { migratePublicationLists };
257
+ export { PUBLICATIONS_TITLE_CSS, migratePublicationLists, migrateSectionComponents };
@@ -197,4 +197,4 @@ function documentDirective(source) {
197
197
  return void 0;
198
198
  }
199
199
 
200
- export { pageRoute as a, findWebPlacements as b, findPdfPublicationPlacements as c, documentDirective as d, findPdfPlacements as f, publicationPlacementProblems as p };
200
+ export { componentName, documentDirective, findPdfPlacements, findPdfPublicationPlacements, findWebPlacements, pageRoute, publicationPlacementProblems };
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile, mkdir, writeFile, rename } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
- import { C as CommandError } from '../shared/vitakit.zwr1ssO5.mjs';
4
+ import { C as CommandError } from '../shared/vitakit.CvYy_Quj.mjs';
5
5
  import '../shared/vitakit.DQ6PmGoL.mjs';
6
6
  import 'node:url';
7
7
  import 'node:process';
@@ -0,0 +1,50 @@
1
+ import { entryComponentProps, listComponentProps } from './snippets.mjs';
2
+ import '../shared/vitakit.DcQOdM4I.mjs';
3
+
4
+ const UNTYPED_ENTRY = "const props = defineProps<{ id?: string, entries?: Entry[], title?: string, where?: Where, limit?: number }>()";
5
+ const UNTYPED_LIST = "const props = defineProps<{ id: string, title?: string, where?: Where, limit?: number }>()";
6
+ const IDS = String.raw`'[a-z0-9-]+'(?:\s*\|\s*'[a-z0-9-]+')*`;
7
+ const TYPED_ENTRY = new RegExp(String.raw`^const props = defineProps<\{ id: (${IDS}), entries\?: SectionEntries<(${IDS})>, title\?: string, where\?: Where, limit\?: number \}>\(\)$`, "m");
8
+ const TYPED_LIST = new RegExp(String.raw`^const props = defineProps<\{ id: (${IDS}), title\?: string, where\?: Where, limit\?: number \}>\(\)$`, "m");
9
+ const parseIds = (type) => [...type.matchAll(/'([a-z0-9-]+)'/g)].map((match) => match[1]);
10
+ function sectionProps(source) {
11
+ const lines = source.split("\n");
12
+ if (lines.includes(UNTYPED_ENTRY)) return { ok: true, value: { kind: "entry", ids: [], untyped: true } };
13
+ if (lines.includes(UNTYPED_LIST)) return { ok: true, value: { kind: "list", ids: [], untyped: true } };
14
+ const entry = TYPED_ENTRY.exec(source);
15
+ if (entry && entry[1] === entry[2]) return { ok: true, value: { kind: "entry", ids: parseIds(entry[1]), untyped: false } };
16
+ const list = TYPED_LIST.exec(source);
17
+ if (list) return { ok: true, value: { kind: "list", ids: parseIds(list[1]), untyped: false } };
18
+ return { ok: false, reason: "its defineProps is not the one vitakit writes" };
19
+ }
20
+ function typeSectionProps(source, ids) {
21
+ const found = sectionProps(source);
22
+ if (!found.ok) return found;
23
+ const { kind, ids: current, untyped } = found.value;
24
+ const wanted = [.../* @__PURE__ */ new Set([...current, ...ids])];
25
+ if (!untyped && wanted.length === current.length) return { ok: true, value: { source, changed: false, from: found.value } };
26
+ const line = kind === "entry" ? entryComponentProps(wanted) : listComponentProps(wanted);
27
+ let result = untyped ? source.replace(kind === "entry" ? UNTYPED_ENTRY : UNTYPED_LIST, line) : source.replace(kind === "entry" ? TYPED_ENTRY : TYPED_LIST, line);
28
+ if (untyped) result = retypeImports(result, kind);
29
+ return { ok: true, value: { source: result, changed: result !== source, from: found.value } };
30
+ }
31
+ function retypeImports(source, kind) {
32
+ let result = source;
33
+ if (kind === "list") result = result.replace("const entries = computed<Entry[]>(() => {", "const entries = computed(() => {");
34
+ const stillUsed = /\bEntry\b/.test(result.replace(/import[^\n]*from '@anja\.codes\/vitakit\/shared'/g, ""));
35
+ return result.replace(/^(import (?:type )?\{)([^}\n]*)(\} from '@anja\.codes\/vitakit\/shared')$/m, (whole, start, names, end) => {
36
+ const typeOnly = start.startsWith("import type");
37
+ const list = names.split(",").map((name) => name.trim()).filter(Boolean);
38
+ const entry = list.findIndex((name) => name.replace(/^type /, "") === "Entry");
39
+ const wanted = kind === "entry" ? typeOnly ? "SectionEntries" : "type SectionEntries" : void 0;
40
+ if (entry !== -1 && !stillUsed) list.splice(entry, 1);
41
+ if (wanted && !list.some((name) => name.replace(/^type /, "") === "SectionEntries")) {
42
+ const at = list.findIndex((name) => name.replace(/^type /, "").localeCompare("SectionEntries") > 0);
43
+ list.splice(at === -1 ? list.length : at, 0, wanted);
44
+ }
45
+ if (!list.length) return "";
46
+ return `${start} ${list.join(", ")} ${end}`;
47
+ });
48
+ }
49
+
50
+ export { sectionProps, typeSectionProps };
@@ -6,10 +6,10 @@ function fieldLabel(name) {
6
6
  return words.charAt(0).toUpperCase() + words.slice(1);
7
7
  }
8
8
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
9
- const access = (object, name) => IDENTIFIER.test(name) ? `${object}.${name}` : `${object}['${name}']`;
9
+ const access = (object, path) => path.split(".").reduce((result, name) => IDENTIFIER.test(name) ? `${result}.${name}` : `${result}['${name}']`, object);
10
10
  function webFieldSnippet(field, className = "vita-entry__note", entry = "entry") {
11
11
  const value = access(entry, field.path);
12
- const label = fieldLabel(field.path);
12
+ const label = fieldLabel(field.path.split(".").pop());
13
13
  const line = (condition, content) => `<p v-if="${condition}" class="${className}">${content}</p>`;
14
14
  if (field.type === "link") {
15
15
  return {
@@ -37,8 +37,15 @@ function webFieldSnippet(field, className = "vita-entry__note", entry = "entry")
37
37
  return { markup: line(`typeof ${value} === 'string'`, `<a :href="${value}">${label}</a>`), imports: [] };
38
38
  }
39
39
  }
40
+ if (field.type === "group" && !field.several && field.fields?.length) {
41
+ const parts = field.fields.map((part) => webFieldSnippet(part, className, entry));
42
+ return {
43
+ markup: [`<template v-if="${value}">`, ...parts.map((part) => ` ${part.markup}`), "</template>"].join("\n"),
44
+ imports: parts.flatMap((part) => part.imports)
45
+ };
46
+ }
40
47
  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} }}`} -->`,
48
+ markup: `<!-- ${field.path}: ${field.type === "group" ? "a list of groups of fields" : "a value of its own kind"}; its type is in schemas/, e.g. {{ ${value} }} -->`,
42
49
  imports: []
43
50
  };
44
51
  }
@@ -74,9 +81,19 @@ function sharedImport(imports) {
74
81
  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
82
  }
76
83
  const indent = (lines, by) => lines.map((line) => line ? by + line : line);
84
+ function sectionIdType(ids) {
85
+ return [...new Set(ids)].map((id) => `'${id}'`).join(" | ");
86
+ }
87
+ function entryComponentProps(ids) {
88
+ const type = sectionIdType(ids);
89
+ return `const props = defineProps<{ id: ${type}, entries?: SectionEntries<${type}>, title?: string, where?: Where, limit?: number }>()`;
90
+ }
91
+ function listComponentProps(ids) {
92
+ return `const props = defineProps<{ id: ${sectionIdType(ids)}, title?: string, where?: Where, limit?: number }>()`;
93
+ }
77
94
  function entryComponent(id, name, fields, region) {
78
95
  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)]);
96
+ const imports = sharedImport(["type SectionEntries", "type Where", ...snippets.flatMap((snippet) => snippet.imports)]);
80
97
  const keep = region === "note" ? ['<p v-if="display.note !== undefined" class="vita-entry__note">{{ display.note }}</p>'] : [
81
98
  '<p v-if="display.meta.length" class="vita-entry__meta">',
82
99
  ' <template v-for="(part, index) in display.meta" :key="index">',
@@ -89,7 +106,7 @@ function entryComponent(id, name, fields, region) {
89
106
  " </template>",
90
107
  "</p>"
91
108
  ];
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> -->`];
109
+ const body = fields.length ? [...keep, ...snippets.flatMap((snippet) => snippet.markup.split("\n"))] : [...keep, `<!-- Add your own lines here, e.g. -->`, `<!-- <p v-if="typeof entry.audience === 'string'" class="vita-entry__${region}">Audience: {{ entry.audience }}</p> -->`];
93
110
  return [
94
111
  '<script setup lang="ts">',
95
112
  "/**",
@@ -100,11 +117,12 @@ function entryComponent(id, name, fields, region) {
100
117
  ` * The #${region} slot below replaces that region of each entry. Delete it to get`,
101
118
  " * vitakit's version back; the other slots are #title, #meta, #note and the",
102
119
  " * default slot (the whole entry). `entry` has every field of the entry,",
103
- " * `display` what vitakit would show.",
120
+ ` * typed by its schema; \`display\` what vitakit would show. \`entries\` takes`,
121
+ ` * ${id} entries a page selected itself, e.g. filtered in its script.`,
104
122
  " */",
105
123
  ...imports,
106
124
  "",
107
- "const props = defineProps<{ id?: string, entries?: Entry[], title?: string, where?: Where, limit?: number }>()",
125
+ entryComponentProps([id]),
108
126
  "<\/script>",
109
127
  "",
110
128
  "<template>",
@@ -124,8 +142,8 @@ function listComponent(id, name, fields, groupBy) {
124
142
  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
143
  const imports = sharedImport([
126
144
  "filterWhere",
127
- "type Entry",
128
145
  "type Where",
146
+ ...groupBy ? ["type SectionEntries"] : [],
129
147
  ...fields.some((field) => field.path === "date") ? ["type DateRange"] : [],
130
148
  ...titleField?.type === "formatted-text" ? ["richHtml"] : [],
131
149
  ...snippets.flatMap((snippet) => snippet.imports)
@@ -133,7 +151,7 @@ function listComponent(id, name, fields, groupBy) {
133
151
  const hasDate = fields.some((field) => field.path === "date");
134
152
  const row = [
135
153
  `<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)], " "),
154
+ ...indent([titleLine, ...snippets.flatMap((snippet) => snippet.markup.split("\n"))], " "),
137
155
  "</VitaEntry>"
138
156
  ];
139
157
  const grouping = groupBy ? [
@@ -141,7 +159,7 @@ function listComponent(id, name, fields, groupBy) {
141
159
  `// Entries in a row with the same ${groupBy} form a group, in the order`,
142
160
  "// vitakit sorted them (newest first).",
143
161
  "const groups = computed(() => {",
144
- " const result: { label: string, entries: Entry[] }[] = []",
162
+ " const result: { label: string, entries: SectionEntries<typeof props.id> }[] = []",
145
163
  " for (const entry of entries.value) {",
146
164
  ` const label = String(${access("entry", groupBy)} ?? '')`,
147
165
  " const last = result[result.length - 1]",
@@ -175,9 +193,9 @@ function listComponent(id, name, fields, groupBy) {
175
193
  `import { computed } from 'vue'`,
176
194
  ...imports,
177
195
  "",
178
- "const props = defineProps<{ id: string, title?: string, where?: Where, limit?: number }>()",
196
+ listComponentProps([id]),
179
197
  "",
180
- "const entries = computed<Entry[]>(() => {",
198
+ "const entries = computed(() => {",
181
199
  " const selected = filterWhere(useSection(props.id), props.where)",
182
200
  " return props.limit ? selected.slice(0, props.limit) : selected",
183
201
  "})",
@@ -196,4 +214,4 @@ function listComponent(id, name, fields, groupBy) {
196
214
  ].join("\n");
197
215
  }
198
216
 
199
- export { entryComponent, etaFieldSnippet, fieldLabel, listComponent, webFieldSnippet };
217
+ export { entryComponent, entryComponentProps, etaFieldSnippet, fieldLabel, listComponent, listComponentProps, sectionIdType, webFieldSnippet };
@@ -32,7 +32,7 @@ import '../shared/vitakit.DvABWuUc.mjs';
32
32
  import '../shared/vitakit.TSMTC5Jl.mjs';
33
33
  import './fields.mjs';
34
34
  import './documents.mjs';
35
- import '../shared/vitakit.DvvUYUTS.mjs';
35
+ import './placements.mjs';
36
36
  import '../shared/vitakit.D-DdJaDZ.mjs';
37
37
  import 'eta';
38
38
  import '../shared/vitakit.DcQOdM4I.mjs';
package/dist/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- export { a as aligned, c as commands, d as detectPackageManager, m as main } from './shared/vitakit.zwr1ssO5.mjs';
1
+ export { a as aligned, c as commands, d as detectPackageManager, m as main } from './shared/vitakit.CvYy_Quj.mjs';
2
2
  import 'node:fs';
3
3
  import 'node:path';
4
4
  import './shared/vitakit.DQ6PmGoL.mjs';
package/dist/index.d.mts CHANGED
@@ -1,9 +1,9 @@
1
1
  import * as zod from 'zod';
2
2
  import { z } from 'zod';
3
3
  export { z } from 'zod';
4
- import { D as DateRange, V as Visibility, a as VitakitContent } from './shared/vitakit.BfR_mUHm.mjs';
4
+ import { D as DateRange, V as Visibility, a as VitakitContent } from './shared/vitakit.CB8b91Lh.mjs';
5
5
  import { RichTextMarker } from './shared/index.mjs';
6
- export { C as ContentIssue, b as DatePoint, E as Entry, c as EntryBase, L as Link, d as Linked, P as Page, e as Profile, f as Publication, g as PublicationBadge, h as PublicationGroup, i as PublicationList, j as RichText } from './shared/vitakit.BfR_mUHm.mjs';
6
+ export { C as ContentIssue, b as DatePoint, E as Entry, c as EntryBase, L as Link, d as Linked, P as Page, e as Profile, f as Publication, g as PublicationBadge, h as PublicationGroup, i as PublicationList, j as RichText } from './shared/vitakit.CB8b91Lh.mjs';
7
7
  import { BadgeConfig } from '@behackl/citation-js-extras';
8
8
  export { BadgeConfig, badgePresets } from '@behackl/citation-js-extras';
9
9
  import * as zod_v4_core from 'zod/v4/core';
@@ -675,11 +675,7 @@ type BuiltinSection = keyof typeof schemas;
675
675
  * export default extendSection(schemas.teaching, { evaluationScore: z.number().optional() })
676
676
  * ```
677
677
  */
678
- declare function extendSection<Shape extends z.ZodRawShape>(base: z.ZodArray<z.ZodObject<z.ZodRawShape>>, shape: Shape): z.ZodArray<z.ZodObject<((string | number) & keyof Shape extends never ? Readonly<{
679
- [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
680
- }> & { -readonly [P in keyof Shape]: Shape[P]; } : (Readonly<{
681
- [k: string]: z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
682
- }> extends infer T_1 extends z.core.util.SomeObject ? { [K in keyof T_1 as K extends keyof Shape ? never : K]: T_1[K]; } : never) & { [K_1 in keyof { -readonly [P in keyof Shape]: Shape[P]; }]: { -readonly [P in keyof Shape]: Shape[P]; }[K_1]; }) extends infer T ? { [k in keyof T]: T[k]; } : never, z.core.$strip>>;
678
+ declare function extendSection<Base extends z.ZodObject, Shape extends z.ZodRawShape>(base: z.ZodArray<Base>, shape: Shape): z.ZodArray<z.ZodObject<z.util.Extend<Base['shape'], Shape>, Base['_zod']['config']>>;
683
679
 
684
680
  interface LoadContentOptions {
685
681
  /** Throw on the first problem (builds) or collect them (dev). */
package/dist/nuxt.mjs CHANGED
@@ -1,14 +1,16 @@
1
1
  import { defineNuxtModule, createResolver, addTemplate, addTypeTemplate, addImportsDir, addComponentsDir, addPrerenderRoutes, updateTemplates } from '@nuxt/kit';
2
2
  import { createRequire } from 'node:module';
3
3
  import { l as loadContent } from './shared/vitakit.DteA9vLy.mjs';
4
+ import { existsSync } from 'node:fs';
5
+ import { join, relative, sep, isAbsolute, resolve } from 'node:path';
6
+ import { schemas } from './chunks/schemas.mjs';
7
+ import { findSectionFiles } from './chunks/sections.mjs';
4
8
  import { f as fail } from './shared/vitakit.DCK90zq4.mjs';
5
- import { b as findWebPlacements, p as publicationPlacementProblems } from './shared/vitakit.DvvUYUTS.mjs';
9
+ import { findWebPlacements, publicationPlacementProblems } from './chunks/placements.mjs';
6
10
  import { readdir } from 'node:fs/promises';
7
- import { isAbsolute, resolve, relative } from 'node:path';
8
11
  import 'gray-matter';
9
12
  import 'yaml';
10
13
  import './chunks/bibliography.mjs';
11
- import 'node:fs';
12
14
  import './shared/vitakit.DQ6PmGoL.mjs';
13
15
  import 'node:url';
14
16
  import '@behackl/citation-js-extras';
@@ -26,11 +28,35 @@ import 'unist-util-visit';
26
28
  import 'zod';
27
29
  import './chunks/publication-lists.mjs';
28
30
  import './shared/vitakit.D10HyDhF.mjs';
29
- import './chunks/sections.mjs';
30
- import './chunks/schemas.mjs';
31
31
  import './shared/vitakit.DvABWuUc.mjs';
32
32
  import './shared/vitakit.TSMTC5Jl.mjs';
33
33
 
34
+ async function sectionTypeSources(rootDir) {
35
+ const { files } = await findSectionFiles(rootDir).catch(() => ({ files: [] }));
36
+ return files.map(({ id }) => {
37
+ const file = join(rootDir, "schemas", `${id}.ts`);
38
+ return { id, ...existsSync(file) ? { file } : {}, builtIn: Object.hasOwn(schemas, id) };
39
+ }).filter((source) => source.file !== void 0 || source.builtIn);
40
+ }
41
+ function sectionTypesDeclaration(sources, buildDir) {
42
+ const dir = join(buildDir, "vitakit");
43
+ const lines = sources.map(({ id, file }) => {
44
+ const schema = file !== void 0 ? `typeof import('${relative(dir, file).split(sep).join("/").replace(/\.ts$/, "")}').default` : `typeof import('@anja.codes/vitakit').schemas[${JSON.stringify(id)}]`;
45
+ return ` ${JSON.stringify(id)}: SectionEntry<${schema}>`;
46
+ });
47
+ return [
48
+ "// Generated by vitakit from content/*.yml and schemas/; do not edit.",
49
+ `import type { SectionEntry } from '@anja.codes/vitakit/shared'`,
50
+ `declare module '@anja.codes/vitakit/shared' {`,
51
+ ` interface VitakitSections {`,
52
+ ...lines,
53
+ ` }`,
54
+ `}`,
55
+ `export {}`,
56
+ ""
57
+ ].join("\n");
58
+ }
59
+
34
60
  function isWithin(directory, path) {
35
61
  const child = relative(directory, path);
36
62
  return child === "" || !isAbsolute(child) && child.split(/[/\\]/)[0] !== "..";
@@ -60,6 +86,7 @@ function katexStylesheet() {
60
86
  }
61
87
  const TEMPLATE = "vitakit/content.mjs";
62
88
  const TYPES = "vitakit/content.d.ts";
89
+ const SECTION_TYPES = "vitakit/sections.d.ts";
63
90
  function report(content) {
64
91
  if (!content.issues.length) return;
65
92
  const lines = content.issues.map((issue) => ` ${issue.where}: ${issue.message.replace(/\n/g, "\n ")}`);
@@ -109,6 +136,11 @@ const module$1 = defineNuxtModule({
109
136
  ""
110
137
  ].join("\n")
111
138
  });
139
+ let sections = await sectionTypeSources(rootDir);
140
+ addTypeTemplate({
141
+ filename: SECTION_TYPES,
142
+ getContents: () => sectionTypesDeclaration(sections, nuxt.options.buildDir)
143
+ });
112
144
  if (options.katexCss !== false) nuxt.options.css.push(katexStylesheet());
113
145
  nuxt.options.build.transpile.push(resolver.resolve("./runtime"));
114
146
  addImportsDir(resolver.resolve("./runtime/composables"));
@@ -117,8 +149,15 @@ const module$1 = defineNuxtModule({
117
149
  if (!nuxt.options.dev) return;
118
150
  await watchComponentOverrides(nuxt, resolver.resolve("./runtime/components"));
119
151
  nuxt.options.watch.push("../content", "../schemas");
120
- nuxt.hook("builder:watch", async (_event, path) => {
152
+ nuxt.hook("builder:watch", async (event, path) => {
121
153
  if (!/[/\\](content|schemas)[/\\]/.test(path)) return;
154
+ if (event === "add" || event === "unlink") {
155
+ const found = await sectionTypeSources(rootDir);
156
+ if (JSON.stringify(found) !== JSON.stringify(sections)) {
157
+ sections = found;
158
+ await updateTemplates({ filter: (item) => item.filename === SECTION_TYPES });
159
+ }
160
+ }
122
161
  try {
123
162
  content = await load(false);
124
163
  } catch (error) {
@@ -1,4 +1,4 @@
1
- <script setup lang="ts">
1
+ <script setup lang="ts" generic="Id extends string = string">
2
2
  /**
3
3
  * A generic section with conventional field selection and optional overrides.
4
4
  *
@@ -18,14 +18,15 @@ import {
18
18
  richHtml,
19
19
  type DateRange,
20
20
  type Entry,
21
+ type SectionEntries,
21
22
  type VitaSectionSlotProps,
22
23
  type Where,
23
24
  } from '@anja.codes/vitakit/shared'
24
25
 
25
26
  const props = defineProps<{
26
27
  /** Section id; ignored when `entries` is given. */
27
- id?: string
28
- entries?: Entry[]
28
+ id?: Id
29
+ entries?: SectionEntries<Id>
29
30
  /** Fields to try, in order, for the main line. */
30
31
  titleFields?: string[]
31
32
  /** Only entries whose fields have these values, as `where` in a PDF document. */
@@ -35,23 +36,24 @@ const props = defineProps<{
35
36
  title?: string
36
37
  }>()
37
38
 
39
+ type Row = VitaSectionSlotProps<SectionEntries<Id>[number]>
38
40
  const slots = defineSlots<{
39
- title?(props: VitaSectionSlotProps): VNodeChild
40
- meta?(props: VitaSectionSlotProps): VNodeChild
41
- note?(props: VitaSectionSlotProps): VNodeChild
42
- default?(props: VitaSectionSlotProps): VNodeChild
41
+ title?(props: Row): VNodeChild
42
+ meta?(props: Row): VNodeChild
43
+ note?(props: Row): VNodeChild
44
+ default?(props: Row): VNodeChild
43
45
  }>()
44
46
 
45
47
  const rows = computed(() => {
46
- const source = filterWhere(props.entries ?? (props.id ? useSection(props.id) : []), props.where)
48
+ const source = filterWhere((props.entries ?? (props.id ? useSection(props.id) : [])) as Entry[], props.where)
47
49
  const items = props.limit ? source.slice(0, props.limit) : source
48
- return items.map((entry, index): VitaSectionSlotProps => {
50
+ return items.map((entry, index): Row => {
49
51
  // Field selection remains shared with the emitter; only the web-specific
50
52
  // presentation (such as a term replacing the date) is decided here.
51
53
  const titleField = entryTitleField(entry, props.titleFields)
52
54
  const term = typeof entry.term === 'string' && entry.term.length > 0 ? entry.term : undefined
53
55
  return {
54
- entry,
56
+ entry: entry as Row['entry'],
55
57
  index,
56
58
  display: {
57
59
  titleHtml: (titleField && richHtml(entry[titleField])) || '',
@@ -74,7 +76,7 @@ const rows = computed(() => {
74
76
  <h2 class="vita-section-group__title">
75
77
  {{ title }}
76
78
  </h2>
77
- <VitaSection :entries="rows.map(row => row.entry)" :title-fields="titleFields">
79
+ <VitaSection :entries="rows.map(row => row.entry as Entry)" :title-fields="titleFields">
78
80
  <template v-for="(_, name) in slots" #[name]="scope">
79
81
  <slot :name="name" v-bind="scope" />
80
82
  </template>
@@ -6,7 +6,7 @@
6
6
  * chunk.
7
7
  */
8
8
  import content from '#vitakit/content'
9
- import type { Entry, Page, Profile, PublicationList, VitakitContent } from '@anja.codes/vitakit/shared'
9
+ import type { Page, Profile, PublicationList, SectionEntries, VitakitContent } from '@anja.codes/vitakit/shared'
10
10
 
11
11
  export function useVitakit(): VitakitContent {
12
12
  return content
@@ -23,8 +23,8 @@ export function useProfile(): Profile {
23
23
  * Unknown ids return an empty list rather than throwing, so deleting a content
24
24
  * file cannot break a page that still mentions it.
25
25
  */
26
- export function useSection(id: string): Entry[] {
27
- return content.sections[id] ?? []
26
+ export function useSection<Id extends string>(id: Id): SectionEntries<Id> {
27
+ return (content.sections[id] ?? []) as SectionEntries<Id>
28
28
  }
29
29
 
30
30
  /** All section ids that have content. */
@@ -1,5 +1,5 @@
1
- import { D as DateRange, d as Linked, L as Link, E as Entry } from './vitakit.BfR_mUHm.mjs';
2
- export { C as ContentIssue, b as DatePoint, c as EntryBase, P as Page, e as Profile, f as Publication, g as PublicationBadge, h as PublicationGroup, i as PublicationList, j as RichText, R as RichTextMarker, V as Visibility, a as VitakitContent } from './vitakit.BfR_mUHm.mjs';
1
+ import { D as DateRange, d as Linked, L as Link, E as Entry } from './vitakit.CB8b91Lh.mjs';
2
+ export { C as ContentIssue, b as DatePoint, c as EntryBase, P as Page, e as Profile, f as Publication, g as PublicationBadge, h as PublicationGroup, i as PublicationList, k as Rendered, j as RichText, R as RichTextMarker, S as SectionEntries, l as SectionEntry, V as Visibility, a as VitakitContent, m as VitakitSections } from './vitakit.CB8b91Lh.mjs';
3
3
 
4
4
  /**
5
5
  * Presentation helpers shared by the website and the LaTeX side.
@@ -69,8 +69,8 @@ interface VitaSectionDisplay {
69
69
  ongoing: boolean;
70
70
  }
71
71
  /** Shared context for VitaSection's title, meta, note and default (body) slots. */
72
- interface VitaSectionSlotProps {
73
- entry: Entry;
72
+ interface VitaSectionSlotProps<E = Entry> {
73
+ entry: E;
74
74
  /** Zero-based position in the displayed list, after limiting. */
75
75
  index: number;
76
76
  display: VitaSectionDisplay;
@@ -151,6 +151,15 @@ interface ContentIssue {
151
151
  where: string;
152
152
  message: string;
153
153
  }
154
+ /**
155
+ * Section id -> its entries' type. Empty here; a project's
156
+ * `.nuxt/vitakit/sections.d.ts` adds one key per `content/<id>.yml`, from its
157
+ * schema, so that `useSection('talks')` is typed by `schemas/talks.ts`.
158
+ */
159
+ interface VitakitSections {
160
+ }
161
+ /** The entries of a section: typed by its schema when the section is known. */
162
+ type SectionEntries<Id extends string> = Id extends keyof VitakitSections ? VitakitSections[Id][] : Entry[];
154
163
  /** Everything compiled from `content/`, as the app sees it. */
155
164
  interface VitakitContent {
156
165
  profile: Profile;
@@ -162,5 +171,32 @@ interface VitakitContent {
162
171
  /** Always empty in a production build, which fails instead. */
163
172
  issues: ContentIssue[];
164
173
  }
174
+ /**
175
+ * A path on the site before it is resolved: `{ __vitakitPath }`. Internal, like
176
+ * `RichTextMarker`; components receive a string.
177
+ */
178
+ interface SitePathMarkerShape {
179
+ __vitakitPath: string;
180
+ }
181
+ /**
182
+ * A value as a component receives it: rendered text instead of its marker,
183
+ * resolved paths instead of theirs, at any depth.
184
+ */
185
+ type Rendered<T> = T extends RichTextMarker ? RichText : T extends SitePathMarkerShape ? string : T extends DateRange ? DateRange : T extends readonly (infer Item)[] ? Rendered<Item>[] : T extends object ? {
186
+ [Key in keyof T]: Rendered<T[Key]>;
187
+ } : T;
188
+ /**
189
+ * One entry of a section, as `useSection` and the slots of `VitaSection` see
190
+ * it, from the section's schema: `SectionEntry<typeof schema>`.
191
+ */
192
+ type SectionEntry<Schema extends {
193
+ _zod: {
194
+ output: readonly unknown[];
195
+ };
196
+ }> = Flat<Rendered<Schema['_zod']['output'][number]> & EntryBase>;
197
+ /** One object type instead of an intersection: readable on hover, and assignable to `Entry`. */
198
+ type Flat<T> = {
199
+ [Key in keyof T]: T[Key];
200
+ };
165
201
 
166
- export type { ContentIssue as C, DateRange as D, Entry as E, Link as L, Page as P, RichTextMarker as R, Visibility as V, VitakitContent as a, DatePoint as b, EntryBase as c, Linked as d, Profile as e, Publication as f, PublicationBadge as g, PublicationGroup as h, PublicationList as i, RichText as j };
202
+ export type { ContentIssue as C, DateRange as D, Entry as E, Link as L, Page as P, RichTextMarker as R, SectionEntries as S, Visibility as V, VitakitContent as a, DatePoint as b, EntryBase as c, Linked as d, Profile as e, Publication as f, PublicationBadge as g, PublicationGroup as h, PublicationList as i, RichText as j, Rendered as k, SectionEntry as l, VitakitSections as m };
@@ -781,6 +781,11 @@ async function planWeb(context, ws, inv, section, fields, list, values, notes) {
781
781
  if (!inv.web.components.has(component)) {
782
782
  throw new CommandError(`--use ${component}: no app/components/${component}.vue; the components are ${[...inv.web.components.keys()].join(", ") || "(none)"}`);
783
783
  }
784
+ const { typeSectionProps } = await import('../chunks/props.mjs');
785
+ const file = inv.web.components.get(component);
786
+ const typed = typeSectionProps(await ws.read(file), [id]);
787
+ if (!typed.ok) notes.after.push(`By hand: ${file} ${typed.reason}; for ${id}'s entries to be typed in it, its id should accept '${id}' too, e.g. id: 'talks' | '${id}'.`);
788
+ else if (typed.value.changed) await ws.edit(file, typed.value.source, `shows ${id} too: its id accepts '${id}'`);
784
789
  } else {
785
790
  const fallback = `${pascal(id)}${list ? "List" : "Section"}`;
786
791
  component = values.component ?? (interactive ? await prompt.input(io, "Name of the component", fallback) : fallback);
@@ -1192,9 +1197,9 @@ async function fieldSnippets(context, inv, section, names) {
1192
1197
  ' <template #note="{ entry, display }">',
1193
1198
  ' <p v-if="display.note !== undefined" class="vita-entry__note">{{ display.note }}</p>'
1194
1199
  );
1195
- lines.push(...web.map((snippet) => ` ${snippet.markup}`), " </template>", " </VitaSection>");
1200
+ lines.push(...web.flatMap((snippet) => snippet.markup.split("\n")).map((line) => ` ${line}`), " </template>", " </VitaSection>");
1196
1201
  }
1197
- if (components.length) lines.push("", ...indented(web.map((snippet) => snippet.markup)));
1202
+ if (components.length) lines.push("", ...indented(web.flatMap((snippet) => snippet.markup.split("\n"))));
1198
1203
  if (imports.length) lines.push("", ` import { ${imports.join(", ")} } from '@anja.codes/vitakit/shared'`);
1199
1204
  lines.push("");
1200
1205
  if (existsSync2(join2(root, "cv-tex", "templates", `${id}.eta`))) {
@@ -1492,10 +1497,11 @@ nothing that is up to date.
1492
1497
  Then check the project: \`${run.bin("vitakit", "validate")}\``,
1493
1498
  async run(context, { values }) {
1494
1499
  const { Workspace } = await import('../chunks/plan.mjs');
1495
- const { migratePublicationLists } = await import('../chunks/migrations.mjs');
1500
+ const { migratePublicationLists, migrateSectionComponents } = await import('../chunks/migrations.mjs');
1496
1501
  const ws = new Workspace(context.root);
1497
1502
  const notes = { before: [], after: [] };
1498
1503
  await migratePublicationLists(context.root, ws, notes);
1504
+ await migrateSectionComponents(context.root, ws, notes);
1499
1505
  notes.after.push(`Check the project: \`${context.run.bin("vitakit", "validate")}\``);
1500
1506
  return finish(context, ws, values, notes);
1501
1507
  }
@@ -27,6 +27,7 @@
27
27
  \RequirePackage{xcolor}
28
28
  \RequirePackage{titlesec}
29
29
  \RequirePackage{amsmath,amssymb}
30
+ \RequirePackage{graphicx}
30
31
  \RequirePackage{hyperref}
31
32
 
32
33
  %% --- bibliography ---------------------------------------------------------
@@ -76,13 +77,38 @@
76
77
  \newcommand{\vitaname}[1]{\def\vita@name{#1}}
77
78
  \newcommand{\vitatagline}[1]{\def\vita@tagline{#1}}
78
79
  \newcommand{\vitacontact}[1]{\def\vita@contact{#1}}
79
- \vitaname{} \vitatagline{} \vitacontact{}
80
+ %% `portrait` in the document's .yml: the image, relative to cv-tex/.
81
+ \newcommand{\vitaportrait}[1]{\def\vita@portrait{#1}}
82
+ \vitaname{} \vitatagline{} \vitacontact{} \vitaportrait{}
80
83
 
81
- \newcommand{\vitaheader}{%
82
- \begingroup
84
+ %% The portrait's height in \vitaheader. It is never wider than a quarter of
85
+ %% the line. \setlength{\vitaportraitheight}{4cm} in preamble.tex changes it.
86
+ \newlength{\vitaportraitheight}
87
+ \setlength{\vitaportraitheight}{3cm}
88
+ \newsavebox{\vita@portraitbox}
89
+
90
+ \newcommand{\vita@headertext}{%
83
91
  \noindent{\LARGE\bfseries\vita@name}\par
84
92
  \ifx\vita@tagline\empty\else\vspace{0.2em}\noindent{\color{vitamuted}\vita@tagline}\par\fi
85
- \ifx\vita@contact\empty\else\vspace{0.2em}\noindent{\small\vita@contact}\par\fi
93
+ \ifx\vita@contact\empty\else\vspace{0.2em}\noindent{\small\vita@contact}\par\fi}
94
+
95
+ %% Name, tagline and contact line; with a portrait, those on the left and the
96
+ %% portrait on the right, both aligned at the top.
97
+ \newcommand{\vitaheader}{%
98
+ \begingroup
99
+ \ifx\vita@portrait\empty
100
+ \vita@headertext
101
+ \else
102
+ \sbox{\vita@portraitbox}{\includegraphics[height=\vitaportraitheight,
103
+ width=0.25\linewidth, keepaspectratio]{\vita@portrait}}%
104
+ \noindent
105
+ \begin{minipage}[t]{\dimexpr\linewidth-\wd\vita@portraitbox-1.5em\relax}
106
+ \vspace{0pt}\vita@headertext
107
+ \end{minipage}\hfill
108
+ \begin{minipage}[t]{\wd\vita@portraitbox}
109
+ \vspace{0pt}\usebox{\vita@portraitbox}
110
+ \end{minipage}\par
111
+ \fi
86
112
  \vspace{1em}\hrule\vspace{1.5em}
87
113
  \endgroup}
88
114
 
package/dist/tex.d.mts CHANGED
@@ -59,6 +59,7 @@ declare const documentSchema: z.ZodObject<{
59
59
  cv: "cv";
60
60
  private: "private";
61
61
  }>>;
62
+ portrait: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>>;
62
63
  sections: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
63
64
  where: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>, z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>]>>>;
64
65
  limit: z.ZodOptional<z.ZodNumber>;
package/dist/tex.mjs CHANGED
@@ -33,7 +33,7 @@ import './chunks/sections.mjs';
33
33
  import './chunks/schemas.mjs';
34
34
  import './shared/vitakit.DvABWuUc.mjs';
35
35
  import './shared/vitakit.TSMTC5Jl.mjs';
36
- import './shared/vitakit.DvvUYUTS.mjs';
36
+ import './chunks/placements.mjs';
37
37
  import 'eta';
38
38
  import './shared/vitakit.CfB3hMHl.mjs';
39
39
  import './shared/vitakit.D-DdJaDZ.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anja.codes/vitakit",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Structured CV data in, academic website and LaTeX CV out.",
5
5
  "license": "MIT",
6
6
  "keywords": [