@anja.codes/vitakit 0.2.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 (40) hide show
  1. package/README.md +4 -3
  2. package/dist/check.mjs +14 -12
  3. package/dist/chunks/authoring.mjs +50 -45
  4. package/dist/{shared/vitakit.By5VWQ1n.mjs → chunks/bibliography.mjs} +73 -170
  5. package/dist/chunks/build.mjs +19 -17
  6. package/dist/chunks/documents.mjs +15 -6
  7. package/dist/chunks/draft.mjs +3 -3
  8. package/dist/chunks/eject.mjs +2 -2
  9. package/dist/chunks/emit.mjs +38 -22
  10. package/dist/chunks/fields.mjs +2 -2
  11. package/dist/chunks/inventory.mjs +29 -27
  12. package/dist/chunks/migrations.mjs +218 -0
  13. package/dist/chunks/plan.mjs +36 -8
  14. package/dist/chunks/publication-lists.mjs +202 -0
  15. package/dist/chunks/schemas.mjs +2 -2
  16. package/dist/chunks/sections.mjs +3 -3
  17. package/dist/chunks/validate.mjs +25 -16
  18. package/dist/chunks/vue.mjs +89 -52
  19. package/dist/chunks/yaml.mjs +62 -0
  20. package/dist/cli.mjs +1 -1
  21. package/dist/index.d.mts +9 -9
  22. package/dist/index.mjs +12 -10
  23. package/dist/nuxt.mjs +19 -11
  24. package/dist/runtime/components/VitaPublications.vue +21 -22
  25. package/dist/runtime/composables/useVitakit.ts +14 -3
  26. package/dist/schemas/publications.ts +9 -7
  27. package/dist/shared/index.d.mts +2 -2
  28. package/dist/shared/{vitakit.BfpxDtz1.d.mts → vitakit.BfR_mUHm.d.mts} +17 -5
  29. package/dist/shared/{vitakit.BZZIsWlY.mjs → vitakit.CCTDg7pz.mjs} +21 -1
  30. package/dist/shared/{vitakit.DbSeENVS.mjs → vitakit.DCK90zq4.mjs} +10 -1
  31. package/dist/shared/vitakit.DteA9vLy.mjs +135 -0
  32. package/dist/shared/{vitakit.BerUaxtF.mjs → vitakit.DvABWuUc.mjs} +1 -1
  33. package/dist/shared/{vitakit.Do5HC_MD.mjs → vitakit.DvvUYUTS.mjs} +61 -3
  34. package/dist/shared/vitakit.UfoXdL7U.mjs +22 -0
  35. package/dist/shared/{vitakit.CoWX2Z4R.mjs → vitakit.zwr1ssO5.mjs} +353 -57
  36. package/dist/tex/templates/publications.eta +4 -2
  37. package/dist/tex/vitakit-cv.cls +15 -4
  38. package/dist/tex.d.mts +29 -8
  39. package/dist/tex.mjs +13 -10
  40. package/package.json +1 -1
@@ -94,7 +94,8 @@ function commandHelp(spec, run) {
94
94
  "Usage",
95
95
  ...(spec.usage ? spec.usage(run) : [usageLine(spec, run)]).map((line) => ` ${line}`),
96
96
  "",
97
- spec.help(run).trim(),
97
+ // Only blank lines: help that starts with an indented example keeps its indent.
98
+ spec.help(run).replace(/^\s*\n/, "").trimEnd(),
98
99
  "",
99
100
  "Options",
100
101
  ...rows.map(([flag, description]) => ` ${flag.padEnd(width)} ${description}`),
@@ -251,6 +252,8 @@ ${after.join("\n")}` : ""}
251
252
  const SECTION_OPTIONS = ["page", "document", "title", "after", "like"];
252
253
  const FIELD_OPTIONS = ["type", "several", "required", "choice"];
253
254
  const PAGE_OPTIONS = ["title", "no-menu"];
255
+ const SELECTION_OPTIONS = ["where", "since", "until", "sort", "order", "group-by", "limit"];
256
+ const LIST_OPTIONS = ["page", "document", "title", "after", ...SELECTION_OPTIONS];
254
257
  const COMMON = ["help", "root", "no-color", "dry-run", "yes"];
255
258
  function onlyOptions(kind, values, allowed) {
256
259
  const wrong = Object.keys(values).filter((name) => values[name] !== void 0 && ![...COMMON, ...allowed].includes(name));
@@ -291,7 +294,7 @@ async function addSection(context, id, values) {
291
294
  const { sectionNameProblem } = await import('../chunks/sections.mjs');
292
295
  const problem = sectionNameProblem(id);
293
296
  if (problem) throw new CommandError(problem);
294
- if (id === "publications") throw new CommandError("publications come from content/publications.bib; `vitakit customize publications` changes their groups");
297
+ if (id === "publications") throw new CommandError(`publications come from content/publications.bib; to show some of them, \`${run.bin("vitakit", "add publication-list <name>")}\` declares a list`);
295
298
  const { inventory } = await import('../chunks/inventory.mjs');
296
299
  const { Workspace } = await import('../chunks/plan.mjs');
297
300
  const authoring = await import('../chunks/authoring.mjs');
@@ -468,6 +471,138 @@ async function addField(context, id, name, values) {
468
471
  ];
469
472
  return finish(context, ws, values, { before, after });
470
473
  }
474
+ function listSettings(values) {
475
+ const number = (name) => {
476
+ if (values[name] === void 0) return void 0;
477
+ const value = Number(values[name]);
478
+ if (!Number.isInteger(value)) throw new UsageError(`--${name} must be a whole number`, "add");
479
+ return value;
480
+ };
481
+ const where = {};
482
+ for (const expression of strings$1(values.where)) {
483
+ const equals = expression.indexOf("=");
484
+ const field = expression.slice(0, equals).trim();
485
+ const value = expression.slice(equals + 1).trim();
486
+ if (equals === -1 || !field || !value) throw new UsageError(`--where ${expression}: use field=value, e.g. status=preprint`, "add");
487
+ const current = where[field];
488
+ where[field] = current === void 0 ? value : [current].flat().concat(value);
489
+ }
490
+ return {
491
+ ...Object.keys(where).length ? { where } : {},
492
+ since: number("since"),
493
+ until: number("until"),
494
+ sort: values.sort,
495
+ order: values.order,
496
+ limit: number("limit"),
497
+ groupBy: values["group-by"]
498
+ };
499
+ }
500
+ async function askListSettings(context, statuses) {
501
+ const prompt = await import('../chunks/prompt.mjs');
502
+ const { io } = context;
503
+ const settings = {};
504
+ if (await prompt.choose(io, "Which publications?", [{ label: "all of them", value: false }, { label: "only some statuses", value: true }])) {
505
+ const answer = await prompt.input(io, `Which, separated by commas (${statuses.join(", ")})`);
506
+ const chosen = answer.split(",").map((status) => status.trim()).filter(Boolean);
507
+ if (chosen.length) settings.where = { status: chosen.length === 1 ? chosen[0] : chosen };
508
+ }
509
+ const limit = await prompt.input(io, "How many at most? (Enter for all)");
510
+ if (limit) settings.limit = Number(limit);
511
+ const order = await prompt.choose(io, "In what order?", [
512
+ { label: "newest first", value: {} },
513
+ { label: "oldest first", value: { order: "asc" } },
514
+ { label: "by title", value: { sort: "title" } },
515
+ { label: "as in publications.bib", value: { sort: "source" } }
516
+ ]);
517
+ Object.assign(settings, order);
518
+ const groupBy = await prompt.choose(io, "In groups, each with a heading?", [
519
+ { label: "no, one list", value: void 0 },
520
+ { label: "by status (Published, Preprints, \u2026)", value: "status" },
521
+ { label: "by year", value: "year" }
522
+ ]);
523
+ if (groupBy) settings.groupBy = groupBy;
524
+ return settings;
525
+ }
526
+ async function addPublicationList(context, name, values) {
527
+ const { io, run, root } = context;
528
+ const { inventory } = await import('../chunks/inventory.mjs');
529
+ const { Workspace } = await import('../chunks/plan.mjs');
530
+ const { loadPublicationsSchema } = await import('../chunks/bibliography.mjs').then(function (n) { return n.e; });
531
+ const { LISTS_FILE, LISTS_HEADER, resolveList } = await import('../chunks/publication-lists.mjs');
532
+ const { addPublicationList: addToFile, publicationListLine } = await import('../chunks/yaml.mjs');
533
+ const authoring = await import('../chunks/authoring.mjs');
534
+ const prompt = await import('../chunks/prompt.mjs');
535
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) throw new CommandError(`"${name}" must be lowercase kebab-case, e.g. recent-papers`);
536
+ const inv = await inventory(root);
537
+ const ws = new Workspace(root);
538
+ const interactive = asking(context, values);
539
+ const selecting = SELECTION_OPTIONS.some((option) => values[option] !== void 0);
540
+ const notes = [];
541
+ const source = await ws.read(LISTS_FILE) ?? "";
542
+ const line = publicationListLine(source, name);
543
+ const exists = line !== void 0;
544
+ if (exists) {
545
+ if (selecting) throw new CommandError(`${LISTS_FILE}:${line} declares ${name} already; change its settings there. Without selection options, this command only shows it somewhere else.`);
546
+ ws.keep(LISTS_FILE, `the list ${name}, as it is`);
547
+ } else {
548
+ const schema = await loadPublicationsSchema(root);
549
+ const settings = selecting || !interactive ? listSettings(values) : await askListSettings(context, schema.statuses.map((status) => status.id));
550
+ const { readBibliography } = await import('../chunks/bibliography.mjs').then(function (n) { return n.e; });
551
+ const { selectPublications } = await import('../chunks/publication-lists.mjs');
552
+ let empty;
553
+ try {
554
+ const list = resolveList(name, Object.fromEntries(Object.entries(settings).filter(([, value]) => value !== void 0)), schema);
555
+ empty = !selectPublications(await readBibliography(join(root, "content", "publications.bib"), schema), name, list, schema).length;
556
+ } catch (error) {
557
+ throw new CommandError(error instanceof Error ? error.message : String(error));
558
+ }
559
+ if (empty) notes.push(`No publication matches ${name} yet, so it shows nothing for now; check its settings if that is a surprise.`);
560
+ const result = addToFile(source || LISTS_HEADER, name, settings);
561
+ if (!result.ok) throw new CommandError(`${LISTS_FILE} ${result.reason}`);
562
+ if (source) await ws.edit(LISTS_FILE, result.value, `declares the list ${name}`);
563
+ else ws.create(LISTS_FILE, result.value, `declares the list ${name}`, { show: true });
564
+ }
565
+ const after = values.after;
566
+ const pages = strings$1(values.page).map((route) => route.endsWith("/") ? route : `${route}/`);
567
+ const documents = strings$1(values.document);
568
+ let title = values.title;
569
+ if (interactive && !pages.length && !documents.length) {
570
+ const shownOn = new Set(inv.web.publications.filter((placement) => placement.list === name).map((placement) => placement.route));
571
+ const choices = [
572
+ ...inv.pages.filter((page) => page.vue && !shownOn.has(page.route)).map((page) => ({ label: `${page.route} (${page.vue})`, value: page.route })),
573
+ { label: "not on the website, for now", value: "" }
574
+ ];
575
+ const route = await prompt.choose(io, `Show ${name} on which page of your website?`, choices, Math.max(0, choices.findIndex((choice) => choice.value === "/publications/")));
576
+ if (route) pages.push(route);
577
+ for (const document of inv.documents.filter((candidate) => !candidate.publications.some((placement) => placement.list === name))) {
578
+ if (await prompt.confirm(io, `
579
+ Include ${name} in the PDF ${document.tex}?`, false)) documents.push(document.name);
580
+ }
581
+ if ((pages.length || documents.length) && title === void 0) title = await prompt.input(io, "\nHeading", prompt.headingFor(name));
582
+ }
583
+ title ??= prompt.headingFor(name);
584
+ for (const route of pages) {
585
+ const path = inv.pages.find((page) => page.route === route)?.vue;
586
+ if (!path) throw new CommandError(`--page ${route}: no Vue page has this route; \`${run.bin("vitakit", "add page <name>")}\` creates one`);
587
+ await authoring.planListOnPage(ws, inv, path, { list: name, ...title ? { title } : {}, ...after ? { after } : {} });
588
+ }
589
+ for (const document of documents) {
590
+ if (!inv.documents.some((candidate) => candidate.name === document)) {
591
+ throw new CommandError(`--document ${document}: no cv-tex/${document}.tex; the documents are ${inv.documents.map((candidate) => candidate.name).join(", ") || "(none)"}`);
592
+ }
593
+ await authoring.planListInDocument(ws, document, { id: name, ...title ? { title } : {}, ...after ? { after } : {} });
594
+ }
595
+ if (!pages.length && !documents.length && !exists) {
596
+ notes.push(`Not shown anywhere yet: \`${run.bin("vitakit", `add publication-list ${name} --page /publications/`)}\` or \`--document cv\` adds it later.`);
597
+ }
598
+ return finish(context, ws, values, {
599
+ before: notes,
600
+ after: [
601
+ `Which publications the list ${name} shows is set in ${LISTS_FILE}.`,
602
+ `\`${run.bin("vitakit", `explain ${name}`)}\` shows which ones it contains, and why.`
603
+ ]
604
+ });
605
+ }
471
606
  async function addPage(context, name, values) {
472
607
  const { io, run, root } = context;
473
608
  const { inventory } = await import('../chunks/inventory.mjs');
@@ -490,13 +625,20 @@ async function addPage(context, name, values) {
490
625
  }
491
626
  const add = {
492
627
  name: "add",
493
- summary: "add a section, a field of a section, or a website page",
628
+ summary: "add a section, a field of a section, a website page, or a publication list",
494
629
  positionals: [{ name: "what", required: true }, { name: "name" }, { name: "field" }],
495
630
  options: {
496
- "page": { type: "string", multiple: true, value: "<route>", description: "section: show it on this page, e.g. /cv/ (repeatable)" },
497
- "document": { type: "string", multiple: true, value: "<name>", description: "section: include it in this PDF document, e.g. cv (repeatable)" },
498
- "after": { type: "string", value: "<section>", description: "section: place it after this section" },
499
- "title": { type: "string", value: "<text>", description: "section: its heading; page: its title" },
631
+ "page": { type: "string", multiple: true, value: "<route>", description: "section, publication-list: show it on this page, e.g. /cv/ (repeatable)" },
632
+ "document": { type: "string", multiple: true, value: "<name>", description: "section, publication-list: include it in this PDF document, e.g. cv (repeatable)" },
633
+ "after": { type: "string", value: "<name>", description: "section, publication-list: place it after this section or list" },
634
+ "title": { type: "string", value: "<text>", description: "section, publication-list: its heading; page: its title" },
635
+ "where": { type: "string", multiple: true, value: "<field=value>", description: "publication-list: only entries with this value, e.g. status=preprint (repeatable)" },
636
+ "since": { type: "string", value: "<year>", description: "publication-list: only entries from this year on" },
637
+ "until": { type: "string", value: "<year>", description: "publication-list: only entries up to this year" },
638
+ "sort": { type: "string", value: "<by>", description: "publication-list: date (default), year, title, source or a field" },
639
+ "order": { type: "string", value: "<asc|desc>", description: "publication-list: sort direction" },
640
+ "group-by": { type: "string", value: "<by>", description: "publication-list: status, year or a field" },
641
+ "limit": { type: "string", value: "<count>", description: "publication-list: at most this many" },
500
642
  "like": { type: "string", value: "<section>", description: "section: use the schema of another section" },
501
643
  "type": { type: "string", value: "<type>", description: "field: text, formatted-text, number, yes-no, date, web-address, link or choice" },
502
644
  "several": { type: "boolean", description: "field: an entry may have a list of values" },
@@ -510,7 +652,8 @@ const add = {
510
652
  usage: (run) => [
511
653
  run.bin("vitakit", "add section <name> [options]"),
512
654
  run.bin("vitakit", "add field <section> <name> [options]"),
513
- run.bin("vitakit", "add page <name> [options]")
655
+ run.bin("vitakit", "add page <name> [options]"),
656
+ run.bin("vitakit", "add publication-list <name> [options]")
514
657
  ],
515
658
  help: (run) => `
516
659
  A section is a file content/<name>.yml. \`add section\` creates what is missing:
@@ -533,10 +676,24 @@ content/pages/<name>.md, and adds it to the menu:
533
676
 
534
677
  ${run.bin("vitakit", "add page talks --title Talks")}
535
678
 
679
+ \`add publication-list\` declares which publications a list shows, in
680
+ content/publication-lists.yml, and shows it on a page or in a PDF:
681
+
682
+ ${run.bin("vitakit", "add publication-list preprints --where status=preprint --page /publications/")}
683
+ ${run.bin("vitakit", 'add publication-list recent --limit 5 --document cv --title "Recent publications"')}
684
+
685
+ For a list that exists already, it only adds it to more pages and PDFs.
686
+
536
687
  Everything is shown before it is written, and asked; nothing existing is
537
688
  replaced. A file that cannot be changed safely gets a line to paste instead.`,
538
689
  async run(context, { positionals, values }) {
539
690
  const [what, name, field] = positionals;
691
+ if (what === "publication-list") {
692
+ if (!name) throw new UsageError("missing <name>: `add publication-list <name>`", "add");
693
+ if (field !== void 0) throw new UsageError(`unexpected argument "${field}"`, "add");
694
+ onlyOptions(what, values, LIST_OPTIONS);
695
+ return addPublicationList(context, name, values);
696
+ }
540
697
  if (what === "section" || what === "page") {
541
698
  if (!name) throw new UsageError(`missing <name>: \`add ${what} <name>\``, "add");
542
699
  if (field !== void 0) throw new UsageError(`unexpected argument "${field}"`, "add");
@@ -548,7 +705,7 @@ replaced. A file that cannot be changed safely gets a line to paste instead.`,
548
705
  onlyOptions(what, values, FIELD_OPTIONS);
549
706
  return addField(context, name, field, values);
550
707
  }
551
- throw new UsageError(`add what? \`add section <name>\`, \`add field <section> <name>\` or \`add page <name>\`${what ? `, not "${what}"` : ""}`, "add");
708
+ throw new UsageError(`add what? \`add section <name>\`, \`add field <section> <name>\`, \`add page <name>\` or \`add publication-list <name>\`${what ? `, not "${what}"` : ""}`, "add");
552
709
  }
553
710
  };
554
711
 
@@ -562,12 +719,12 @@ async function planSchema(context, ws, inv, id, values, notes) {
562
719
  const path = `schemas/${id}.ts`;
563
720
  if (id === "publications") {
564
721
  if (ws.exists(path)) {
565
- notes.after.push(`${path} is yours already: its groups, badges and fields decide how content/publications.bib is grouped and linked.`);
722
+ notes.after.push(`${path} is yours already: its statuses, badges and fields decide what publications can be and how they are linked. Which ones a list shows is set in content/publication-lists.yml.`);
566
723
  return;
567
724
  }
568
- ws.create(path, await builtinSchemaSource("publications"), "vitakit's publication groups, badges and fields, to change");
725
+ ws.create(path, await builtinSchemaSource("publications"), "vitakit's publication statuses, badges and fields, to change");
569
726
  notes.before.push("The website, `vitakit verify` and the PDF all read it from now on.");
570
- notes.after.push(`Edit ${path}: change groups, add badges, pass on BibTeX fields. Check it: \`${run.bin("vitakit", "validate")}\``);
727
+ notes.after.push(`Edit ${path}: change statuses, add badges, pass on BibTeX fields. Check it: \`${run.bin("vitakit", "validate")}\``);
571
728
  return;
572
729
  }
573
730
  const section = inv.sections.find((candidate) => candidate.id === id);
@@ -596,7 +753,7 @@ async function planSchema(context, ws, inv, id, values, notes) {
596
753
  async function planWeb(context, ws, inv, section, fields, list, values, notes) {
597
754
  const { io, run } = context;
598
755
  const { entryComponent, listComponent } = await import('../chunks/snippets.mjs');
599
- const { useComponentForSection } = await import('../chunks/vue.mjs').then(function (n) { return n.v; });
756
+ const { useComponentForSection } = await import('../chunks/vue.mjs');
600
757
  const { planPlaceOnPage } = await import('../chunks/authoring.mjs');
601
758
  const prompt = await import('../chunks/prompt.mjs');
602
759
  const id = section.id;
@@ -737,10 +894,12 @@ ${copied}`;
737
894
  }
738
895
  if (ws.exists(path)) ws.replace(path, source, `${reason}; yours is kept next to it as a .backup`, { show: true });
739
896
  else ws.create(path, source, reason, { show: true });
740
- const included = inv.documents.filter((document) => document.placements.some((placement) => placement.section === id));
741
- if (!included.length) notes.before.push(`No PDF document includes ${id} yet: \`${run.bin("vitakit", `add section ${id} --document ${inv.documents[0].name}`)}\``);
897
+ const included = inv.documents.filter((document) => id === "publications" ? document.publications.length : document.placements.some((placement) => placement.section === id));
898
+ if (!included.length) {
899
+ notes.before.push(id === "publications" ? `No PDF document shows a publication list yet: add \\vitapublications[Publications]{all} to ${inv.documents[0].tex}` : `No PDF document includes ${id} yet: \`${run.bin("vitakit", `add section ${id} --document ${inv.documents[0].name}`)}\``);
900
+ }
742
901
  if (id === "publications") {
743
- notes.before.push("This template groups the references; how each one is formatted is up to biblatex, in cv-tex/preamble.tex.");
902
+ notes.before.push("This template prints a publication list, group by group; how each reference is formatted is up to biblatex, in cv-tex/preamble.tex.");
744
903
  }
745
904
  notes.after.push(`Edit ${path}; \`${run.bin("vitakit", `validate ${id}`)}\` checks it without LaTeX, \`${run.bin("vitakit", "pdf")}\` builds.`);
746
905
  }
@@ -937,6 +1096,12 @@ function pdfCell(inventory, section) {
937
1096
  const documents = [...new Set(pdfPlacementsOf(inventory, section).map((placement) => placement.document))];
938
1097
  return documents.length ? documents.join(", ") : "\u2013";
939
1098
  }
1099
+ function listPlacementsText(inventory, list) {
1100
+ const pages = inventory.web.publications.filter((placement) => placement.list === list).map((placement) => placement.route ?? placement.file);
1101
+ const documents = inventory.documents.filter((document) => document.publications.some((placement) => placement.list === list)).map((document) => `${document.name} (pdf)`);
1102
+ const all = [.../* @__PURE__ */ new Set([...pages, ...documents])];
1103
+ return all.length ? all.join(", ") : "not shown anywhere";
1104
+ }
940
1105
  function readableIssue(line) {
941
1106
  return line.replace(/^(\s*)- (\d+)(?:\.([\w.-]+))?: /, (_match, indent, index, field) => `${indent}entry ${Number(index) + 1}${field ? `, ${field}` : ""}: `).replace(/^(\s*)- /, "$1");
942
1107
  }
@@ -970,6 +1135,7 @@ const describe = {
970
1135
  __proto__: null,
971
1136
  countsText: countsText,
972
1137
  firstLine: firstLine,
1138
+ listPlacementsText: listPlacementsText,
973
1139
  messageLines: messageLines,
974
1140
  pdfCell: pdfCell,
975
1141
  pdfPlacementsOf: pdfPlacementsOf,
@@ -1000,7 +1166,7 @@ function schemaText(section) {
1000
1166
  async function fieldSnippets(context, inv, section, names) {
1001
1167
  const { style, run, root } = context;
1002
1168
  const { webFieldSnippet, etaFieldSnippet } = await import('../chunks/snippets.mjs');
1003
- const { existsSync } = await import('node:fs');
1169
+ const { existsSync: existsSync2 } = await import('node:fs');
1004
1170
  const { join: join2 } = await import('node:path');
1005
1171
  const id = section.id;
1006
1172
  const unknown = names.filter((name) => !section.fields?.some((field) => field.path === name));
@@ -1031,7 +1197,7 @@ async function fieldSnippets(context, inv, section, names) {
1031
1197
  if (components.length) lines.push("", ...indented(web.map((snippet) => snippet.markup)));
1032
1198
  if (imports.length) lines.push("", ` import { ${imports.join(", ")} } from '@anja.codes/vitakit/shared'`);
1033
1199
  lines.push("");
1034
- if (existsSync(join2(root, "cv-tex", "templates", `${id}.eta`))) {
1200
+ if (existsSync2(join2(root, "cv-tex", "templates", `${id}.eta`))) {
1035
1201
  lines.push(block("pdf", [[`cv-tex/templates/${id}.eta (yours): add inside its loop over the entries, after the notes`]]));
1036
1202
  } else if (inv.documents.length) {
1037
1203
  lines.push(block("pdf", [[`vitakit's template. \`${run.bin("vitakit", `customize ${id} --pdf --field ${names.join(" --field ")}`)}\` copies it with these lines:`]]));
@@ -1108,9 +1274,7 @@ async function explainDocument(context, inv, document) {
1108
1274
  lines.push(block("includes", document.placements.map((placement) => {
1109
1275
  const section = inv.sections.find((candidate) => candidate.id === placement.section);
1110
1276
  let detail = "";
1111
- if (placement.section === "publications") {
1112
- detail = inv.publications ? `${inv.publications.entries} entries in ${inv.publications.groups} groups` : "no content/publications.bib";
1113
- } else if (section?.problems.length) {
1277
+ if (section?.problems.length) {
1114
1278
  detail = style.red("has problems");
1115
1279
  } else if (section) {
1116
1280
  const { total, steps } = explainSelection(inv.content.sections[section.id] ?? [], document.options, section.id);
@@ -1128,12 +1292,69 @@ async function explainDocument(context, inv, document) {
1128
1292
  } else {
1129
1293
  lines.push(block("includes", [["no \\vitasection or \\vitainput lines found"]]));
1130
1294
  }
1295
+ if (document.publications.length) {
1296
+ lines.push("", block("lists", document.publications.map((placement) => [
1297
+ `line ${placement.line}`,
1298
+ placement.list,
1299
+ placement.heading ?? "",
1300
+ inv.publications?.counts[placement.list] !== void 0 ? entriesText(inv.publications.counts[placement.list]) : style.red("no such list")
1301
+ ])));
1302
+ }
1131
1303
  const pdf = document.pdf ? `${document.pdf.file}${document.pdf.mtimeMs < inv.sourcesMtimeMs ? ", older than its sources" : ""}` : "not built yet";
1132
1304
  lines.push("", block("pdf", [[pdf]]));
1133
1305
  if (document.problems.length) lines.push("", style.bold("Problems"), ...problemLines(document.problems));
1134
1306
  lines.push("", `Build it: \`${run.bin("vitakit", `pdf ${document.name}`)}\`; per section: \`${run.bin("vitakit", "explain <section>")}\``);
1135
1307
  return lines;
1136
1308
  }
1309
+ const entriesText = (count) => `${count} ${count === 1 ? "entry" : "entries"}`;
1310
+ async function explainPublications(context, inv) {
1311
+ const { style, run, root } = context;
1312
+ const { loadPublicationsSchema } = await import('../chunks/bibliography.mjs').then(function (n) { return n.e; });
1313
+ const { LISTS_FILE, describeList, loadPublicationLists } = await import('../chunks/publication-lists.mjs');
1314
+ const lines = [style.bold("publications"), ""];
1315
+ lines.push(block("data", [[inv.publications ? `${inv.publications.file} \xB7 ${inv.publications.entries} entries` : "no content/publications.bib"]]));
1316
+ lines.push(block("statuses", [[existsSync(join(root, "schemas", "publications.ts")) ? "schemas/publications.ts (yours)" : `vitakit's (\`${run.bin("vitakit", "customize publications")}\` to change them)`]]));
1317
+ const lists = await loadPublicationsSchema(root).then((schema) => loadPublicationLists(root, schema)).catch(() => void 0);
1318
+ if (!lists) {
1319
+ lines.push(block("lists", [[style.red(`${LISTS_FILE} has problems`)]]));
1320
+ } else if (!Object.keys(lists).length) {
1321
+ lines.push(block("lists", [[`none yet: \`${run.bin("vitakit", "add publication-list <name>")}\` declares one in ${LISTS_FILE}`]]));
1322
+ } else {
1323
+ lines.push(block("lists", [[LISTS_FILE]]));
1324
+ lines.push(block("", Object.entries(lists).flatMap(([name, list]) => [
1325
+ [` ${name}`, `${entriesText(inv.publications?.counts[name] ?? 0)} \xB7 ${describeList(list)}`],
1326
+ ["", listPlacementsText(inv, name)]
1327
+ ])));
1328
+ }
1329
+ const files = [LISTS_FILE, "content/publications.bib", "schemas/publications.ts"];
1330
+ const problems = inv.problems.filter((problem) => files.includes(problem.where));
1331
+ if (problems.length) lines.push("", style.bold("Problems"), ...problemLines(problems));
1332
+ lines.push("", `Show a list: <VitaPublications list="<name>" /> on a page, \\vitapublications[Heading]{<name>} in a PDF.`);
1333
+ return lines;
1334
+ }
1335
+ async function explainList(context, inv, name) {
1336
+ const { style, run, root } = context;
1337
+ const { loadPublicationsSchema, readBibliography } = await import('../chunks/bibliography.mjs').then(function (n) { return n.e; });
1338
+ const { LISTS_FILE, describeList, explainListSelection, loadPublicationLists } = await import('../chunks/publication-lists.mjs');
1339
+ const { publicationListLine } = await import('../chunks/yaml.mjs');
1340
+ const { readFile } = await import('node:fs/promises');
1341
+ const schema = await loadPublicationsSchema(root);
1342
+ const list = (await loadPublicationLists(root, schema))[name];
1343
+ const line = publicationListLine(await readFile(join(root, LISTS_FILE), "utf8"), name);
1344
+ const { total, steps: removed, keys } = explainListSelection(await readBibliography(join(root, "content", "publications.bib"), schema), name, list, schema);
1345
+ const lines = [style.bold(name), ""];
1346
+ lines.push(block("list", [[`${LISTS_FILE}${line ? `:${line}` : ""}`], [describeList(list)]]));
1347
+ lines.push(block("shows", [
1348
+ [`${keys.length} of ${total} publications${removed.length ? ` (${removed.map((step) => `${step.rule} \u2212${step.removed}`).join(", ")})` : ""}`],
1349
+ ...keys.length ? [[keys.join(", ")]] : []
1350
+ ]), "");
1351
+ const web = inv.web.publications.filter((placement) => placement.list === name);
1352
+ lines.push(block("website", web.length ? web.map((placement) => [placement.route ?? "", `${placement.file}:${placement.line}`, `<VitaPublications${placement.title !== void 0 ? ` title="${placement.title}"` : ""}>`]) : [["not on any page"]]));
1353
+ const pdf = inv.documents.flatMap((document) => document.publications.filter((placement) => placement.list === name));
1354
+ lines.push("", block("pdf", pdf.length ? pdf.map((placement) => [placement.document, `${placement.file}:${placement.line}`, `\\vitapublications${placement.heading !== void 0 ? `[${placement.heading}]` : ""}{${name}}`]) : [["not in any PDF document"]]));
1355
+ if (!web.length && !pdf.length) lines.push("", `Show it: \`${run.bin("vitakit", `add publication-list ${name} --page /publications/`)}\`, or --document cv.`);
1356
+ return lines;
1357
+ }
1137
1358
  function explainRoute(context, inv, page, route) {
1138
1359
  const { style } = context;
1139
1360
  const lines = [style.bold(route), ""];
@@ -1152,61 +1373,131 @@ function explainRoute(context, inv, page, route) {
1152
1373
  placement.component === "code" ? "useSection, in code" : `<${placement.component}${placement.title !== void 0 ? ` title="${placement.title}"` : ""}>`
1153
1374
  ])));
1154
1375
  }
1376
+ const lists = inv.web.publications.filter((placement) => placement.route === route);
1377
+ if (lists.length) {
1378
+ lines.push("", block("lists", lists.map((placement) => [
1379
+ placement.list ?? placement.expression ?? "(no list)",
1380
+ `line ${placement.line}`,
1381
+ `<VitaPublications${placement.title !== void 0 ? ` title="${placement.title}"` : ""}>`
1382
+ ])));
1383
+ }
1155
1384
  const dynamic = inv.web.dynamic.filter((usage) => usage.file === page?.vue);
1156
1385
  if (dynamic.length) {
1157
1386
  lines.push("", "Chosen in code, so not listed:", ...dynamic.map((usage) => ` line ${usage.line} ${usage.usage}`));
1158
1387
  }
1159
1388
  return lines;
1160
1389
  }
1390
+ const KINDS = ["section", "document", "publication-list", "page"];
1391
+ const KIND_NAMES = { "section": "section", "document": "PDF document", "publication-list": "publication list", "page": "page" };
1161
1392
  const explain = {
1162
1393
  name: "explain",
1163
- summary: "where a section, PDF document or page comes from, and where it goes",
1164
- positionals: [{ name: "name", required: true }],
1394
+ summary: "where a section, publication list, PDF document or page comes from, and where it goes",
1395
+ // `<name>`, or `<kind> <name>`; the usage lines say so.
1396
+ positionals: [{ name: "name", required: true }, { name: "name" }],
1397
+ usage: (run) => [
1398
+ run.bin("vitakit", "explain <name> [options]"),
1399
+ run.bin("vitakit", "explain <kind> <name> [options]")
1400
+ ],
1165
1401
  options: {
1166
- section: { type: "boolean", description: "the name is a section (when a document has the same name)" },
1167
- document: { type: "boolean", description: "the name is a PDF document (when a section has the same name)" },
1168
1402
  field: { type: "string", multiple: true, value: "<name>", description: "a section: lines that show this field, on the website and in the PDF" }
1169
1403
  },
1170
1404
  project: true,
1171
1405
  help: (run) => `
1172
- ${run.bin("vitakit", "explain talks")} a section: its file, fields, pages and PDFs
1173
- ${run.bin("vitakit", "explain cv")} a PDF document: what it includes, from where
1174
- ${run.bin("vitakit", "explain /cv/")} a website page: its files and sections
1406
+ ${run.bin("vitakit", "explain talks")} a section: its file, fields, pages and PDFs
1407
+ ${run.bin("vitakit", "explain cv")} a PDF document: what it includes, from where
1408
+ ${run.bin("vitakit", "explain /cv/")} a website page: its files, sections and lists
1409
+ ${run.bin("vitakit", "explain recent")} a publication list: which publications, and why
1410
+ ${run.bin("vitakit", "explain publications")} every publication list: what it shows, and where
1175
1411
  ${run.bin("vitakit", "explain teaching --field evaluation")} lines that show a field
1176
1412
 
1413
+ A name is enough while it is unique. To say what it is -- when a section, a
1414
+ document and a list share a name, or in a script -- put the kind first, as
1415
+ \`add\` does: ${KINDS.join(", ")}.
1416
+
1417
+ ${run.bin("vitakit", "explain section talks")}
1418
+ ${run.bin("vitakit", "explain publication-list talks")}
1419
+ ${run.bin("vitakit", "explain page cv")}
1420
+
1177
1421
  For a section it shows which entries each PDF document selects, and which
1178
1422
  setting removed the others -- the answer to "why is this not in my PDF?".
1179
- Never prints entry contents, only counts.`,
1423
+ For a publication list, the same: which setting removed which publications,
1424
+ and the citation keys of those it shows. Entry contents are never printed.`,
1180
1425
  async run(context, { positionals, values }) {
1181
1426
  const { inventory } = await import('../chunks/inventory.mjs');
1182
1427
  const inv = await inventory(context.root);
1183
- const name = positionals[0];
1184
- let lines;
1185
- if (name.startsWith("/")) {
1186
- const route = name.endsWith("/") ? name : `${name}/`;
1187
- lines = explainRoute(context, inv, inv.pages.find((page) => page.route === route), route);
1188
- } else {
1189
- const section = inv.sections.find((candidate) => candidate.id === name);
1190
- const document = inv.documents.find((candidate) => candidate.name === name);
1191
- if (values.section && values.document) throw new UsageError("use --section or --document, not both", "explain");
1192
- if (section && document && !values.section && !values.document) {
1193
- throw new UsageError(`"${name}" is both a section and a PDF document; add --section or --document`, "explain");
1194
- }
1195
- const fields = values.field ?? [];
1196
- if (fields.length && !(section && !values.document)) throw new UsageError("--field goes with a section", "explain");
1197
- if (section && !values.document) lines = await explainSection(context, inv, section, fields);
1198
- else if (document && !values.section) lines = await explainDocument(context, inv, document);
1199
- else {
1200
- const known = [
1201
- inv.sections.length ? `sections: ${inv.sections.map((candidate) => candidate.id).join(", ")}` : "",
1202
- inv.documents.length ? `documents: ${inv.documents.map((candidate) => candidate.name).join(", ")}` : ""
1203
- ].filter(Boolean).join("; ");
1204
- throw new CommandError(`nothing called "${name}"${known ? `; ${known}` : ""}. A page is explained by its route, e.g. /cv/.`);
1205
- }
1428
+ const typed = positionals.length === 2;
1429
+ const kind = typed ? positionals[0] : void 0;
1430
+ const name = typed ? positionals[1] : positionals[0];
1431
+ if (kind !== void 0 && !KINDS.includes(kind)) {
1432
+ throw new UsageError(`"${kind}" is not a kind: \`explain <kind> <name>\` takes ${KINDS.join(", ")}; or a name alone`, "explain");
1206
1433
  }
1207
- context.io.out(`${lines.join("\n")}
1434
+ const fields = values.field ?? [];
1435
+ const out = (lines) => {
1436
+ context.io.out(`${lines.join("\n")}
1208
1437
  `);
1209
- return 0;
1438
+ return 0;
1439
+ };
1440
+ if (kind === "page" || !kind && name.startsWith("/")) {
1441
+ const route = name === "/" || name === "index" ? "/" : `/${name.replace(/^\/|\/$/g, "")}/`;
1442
+ return out(explainRoute(context, inv, inv.pages.find((page) => page.route === route), route));
1443
+ }
1444
+ if (!kind && name === "publications") return out(await explainPublications(context, inv));
1445
+ const lists = Object.keys(inv.content.publications);
1446
+ const section = inv.sections.find((candidate) => candidate.id === name);
1447
+ const document = inv.documents.find((candidate) => candidate.name === name);
1448
+ const found = [
1449
+ ...section ? [{ kind: "section", explain: () => explainSection(context, inv, section, fields) }] : [],
1450
+ ...document ? [{ kind: "document", explain: () => explainDocument(context, inv, document) }] : [],
1451
+ ...lists.includes(name) ? [{ kind: "publication-list", explain: () => explainList(context, inv, name) }] : []
1452
+ ];
1453
+ const chosen = kind ? found.filter((candidate) => candidate.kind === kind) : found;
1454
+ if (chosen.length > 1) {
1455
+ throw new UsageError([
1456
+ `"${name}" is ${chosen.map((candidate) => `a ${KIND_NAMES[candidate.kind]}`).join(" and ")}; say which:`,
1457
+ ...chosen.map((candidate) => ` ${context.run.bin("vitakit", `explain ${candidate.kind} ${name}`)}`)
1458
+ ].join("\n"), "explain");
1459
+ }
1460
+ if (chosen.length === 1) {
1461
+ if (fields.length && chosen[0].kind !== "section") throw new UsageError("--field goes with a section", "explain");
1462
+ return out(await chosen[0].explain());
1463
+ }
1464
+ if (!kind && KINDS.includes(name)) throw new UsageError(`missing <name>: \`explain ${name} <name>\``, "explain");
1465
+ const known = [
1466
+ inv.sections.length ? `sections: ${inv.sections.map((candidate) => candidate.id).join(", ")}` : "",
1467
+ inv.documents.length ? `documents: ${inv.documents.map((candidate) => candidate.name).join(", ")}` : "",
1468
+ lists.length ? `publication lists: ${lists.join(", ")}` : ""
1469
+ ].filter(Boolean).join("; ");
1470
+ throw new CommandError(`${kind ? `no ${KIND_NAMES[kind]}` : "nothing"} called "${name}"${known ? `; ${known}` : ""}. A page is explained by its route, e.g. /cv/.`);
1471
+ }
1472
+ };
1473
+
1474
+ const migrate = {
1475
+ name: "migrate",
1476
+ summary: "update a project written for an earlier vitakit",
1477
+ positionals: [],
1478
+ options: {
1479
+ "dry-run": { type: "boolean", description: "show what would be written, write nothing" },
1480
+ "yes": { type: "boolean", description: "ask nothing and write" }
1481
+ },
1482
+ project: true,
1483
+ help: (run) => `
1484
+ After updating vitakit, changes your files where they use what it no longer
1485
+ supports. Everything is shown before it is written, and asked; what cannot be
1486
+ changed safely is listed for you to do by hand. Running it again changes
1487
+ nothing that is up to date.
1488
+
1489
+ ${run.bin("vitakit", "migrate --dry-run")}
1490
+ ${run.bin("vitakit", "migrate")}
1491
+
1492
+ Then check the project: \`${run.bin("vitakit", "validate")}\``,
1493
+ async run(context, { values }) {
1494
+ const { Workspace } = await import('../chunks/plan.mjs');
1495
+ const { migratePublicationLists } = await import('../chunks/migrations.mjs');
1496
+ const ws = new Workspace(context.root);
1497
+ const notes = { before: [], after: [] };
1498
+ await migratePublicationLists(context.root, ws, notes);
1499
+ notes.after.push(`Check the project: \`${context.run.bin("vitakit", "validate")}\``);
1500
+ return finish(context, ws, values, notes);
1210
1501
  }
1211
1502
  };
1212
1503
 
@@ -1278,6 +1569,7 @@ Reads your files only: no website build, no LaTeX. Entry contents are never
1278
1569
  printed, only how many there are.
1279
1570
 
1280
1571
  Next: \`${run.bin("vitakit", "explain <section>")}\` for the details of one section,
1572
+ \`${run.bin("vitakit", "explain publications")}\` for the publication lists,
1281
1573
  \`${run.bin("vitakit", "validate")}\` to check everything before building.`,
1282
1574
  async run({ io, style, run, root, json }) {
1283
1575
  const { inventory } = await import('../chunks/inventory.mjs');
@@ -1324,7 +1616,11 @@ Next: \`${run.bin("vitakit", "explain <section>")}\` for the details of one sect
1324
1616
  }
1325
1617
  const facts = [];
1326
1618
  if (inv.publications) {
1327
- facts.push([style.bold("Publications"), `${inv.publications.file} \xB7 ${inv.publications.entries} entries in ${inv.publications.groups} groups`]);
1619
+ const { counts } = inv.publications;
1620
+ facts.push([style.bold("Publications"), `${inv.publications.file} \xB7 ${inv.publications.entries} entries${Object.keys(counts).length ? "" : " \xB7 no lists yet (content/publication-lists.yml)"}`]);
1621
+ for (const [name, count2] of Object.entries(counts)) {
1622
+ facts.push(["", ` ${name}: ${count2} ${count2 === 1 ? "entry" : "entries"} \xB7 ${listPlacementsText(inv, name)}`]);
1623
+ }
1328
1624
  }
1329
1625
  const pages = inv.pages.filter((page) => page.markdown || page.vue);
1330
1626
  if (pages.length) {
@@ -1464,11 +1760,11 @@ ${style.green("The generated site looks correct.")}
1464
1760
  }
1465
1761
  };
1466
1762
 
1467
- const COMMANDS = [status, explain, validate, add, customize, pdf, verify, eject];
1763
+ const COMMANDS = [status, explain, validate, add, customize, pdf, verify, eject, migrate];
1468
1764
  const GROUPS = [
1469
1765
  { title: "Look", names: ["status", "explain", "validate"] },
1470
1766
  { title: "Build and check", names: ["pdf", "verify"] },
1471
- { title: "Change", names: ["add", "customize", "eject"] }
1767
+ { title: "Change", names: ["add", "customize", "eject", "migrate"] }
1472
1768
  ];
1473
1769
 
1474
1770
  function version() {
@@ -1,9 +1,11 @@
1
1
  <% if (it.groups.length) { %>
2
2
  \begin{refsection}
3
- \nocite{*}
3
+ \begin{refcontext}[sorting=none]
4
+ <%~ it.bib.cite() %>
4
5
  <% for (const group of it.groups) { %>
5
6
  <%~ it.bib.group(group) %>
6
- \printbibliography[check=vita-<%~ group.id %>, title={<%~ it.text(group.label) %>}, heading=subbibliography]
7
+ \printbibliography[resetnumbers=true, check=vita-<%~ group.id %>, <% if (group.label !== undefined) { %>heading=subbibliography, title={<%~ it.text(group.label) %>}<% } else { %>heading=none<% } %>]
7
8
  <% } %>
9
+ \end{refcontext}
8
10
  \end{refsection}
9
11
  <% } %>
@@ -36,10 +36,10 @@
36
36
  \RequirePackage[backend=biber, style=numeric, sorting=ydnt,
37
37
  defernumbers=true, maxbibnames=99, giveninits=true]{biblatex}
38
38
  %% \vitabibgroup{id}{key,key,...} defines the bibliography check `vita-id`,
39
- %% which keeps exactly the listed entries. The generated publications.tex
40
- %% uses one per group: vitakit decides the groups, with the website's rules
41
- %% (a missing `status` is `published`, case does not matter), so the PDF
42
- %% lists what the website lists. Keys are compared as strings.
39
+ %% which keeps exactly the listed entries. A generated list cites its keys
40
+ %% in the list's order and prints one bibliography per group with
41
+ %% sorting=none, so the PDF lists what the website lists, in its order.
42
+ %% Keys are compared as strings.
43
43
  \newcommand{\vitabibgroup}[2]{%
44
44
  \defbibcheck{vita-#1}{%
45
45
  \edef\vita@bibkey{\thefield{entrykey}}%
@@ -131,4 +131,15 @@
131
131
  %% for sections that bring their own structure (publications).
132
132
  \newcommand{\vitainput}[1]{\input{generated/\vita@document/#1.tex}}
133
133
 
134
+ %% Generated profile.tex marks publication lists without entries.
135
+ \newcommand{\vitaemptypublications}[1]{\expandafter\def\csname vita@emptylist@#1\endcsname{}}
136
+
137
+ %% \vitapublications[Heading]{list} -- a list from content/publication-lists.yml,
138
+ %% with an optional heading; neither appears when the list has no entries.
139
+ \NewDocumentCommand{\vitapublications}{o m}{%
140
+ \ifcsname vita@emptylist@#2\endcsname\else
141
+ \IfNoValueF{#1}{\section{#1}}%
142
+ \input{generated/\vita@document/publications/#2.tex}%
143
+ \fi}
144
+
134
145
  \endinput