@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
package/README.md CHANGED
@@ -27,11 +27,12 @@ published site, [concepts](https://vitakit.anja.codes/guide/concepts),
27
27
  content/
28
28
  profile.yml name, affiliation, links, menu, footer
29
29
  <section>.yml education, employment, teaching, talks, …
30
- publications.bib grouped, linked publication list
30
+ publications.bib your publications, formatted and linked
31
+ publication-lists.yml which of them each list shows: recent, preprints, by year, …
31
32
  pages/*.md prose pages, mathematics and images included
32
33
  schemas/<section>.ts optional: what a section of your own may contain
33
- app/pages/*.vue which sections each website page shows
34
- cv-tex/<document>.tex which sections each PDF shows; cv-tex/<document>.yml selects entries
34
+ app/pages/*.vue which sections and lists each website page shows
35
+ cv-tex/<document>.tex which sections and lists each PDF shows; cv-tex/<document>.yml selects entries
35
36
  ```
36
37
 
37
38
  - **Validated content.** Every file is checked against its schema. A broken
package/dist/check.mjs CHANGED
@@ -1,29 +1,31 @@
1
1
  import { readFile, stat, readdir } from 'node:fs/promises';
2
2
  import { join, relative } from 'node:path';
3
- import { l as loadContent } from './shared/vitakit.By5VWQ1n.mjs';
4
- import { M as MARKER_KEY } from './shared/vitakit.BerUaxtF.mjs';
3
+ import { l as loadContent } from './shared/vitakit.DteA9vLy.mjs';
4
+ import { M as MARKER_KEY } from './shared/vitakit.DvABWuUc.mjs';
5
5
  import { a as richSource, r as richHtml } from './shared/vitakit.CtChKO0I.mjs';
6
6
  import { a as atMost } from './shared/vitakit.TSMTC5Jl.mjs';
7
7
  import 'gray-matter';
8
8
  import 'yaml';
9
+ import './chunks/bibliography.mjs';
10
+ import 'node:fs';
11
+ import './shared/vitakit.DQ6PmGoL.mjs';
12
+ import 'node:url';
13
+ import '@behackl/citation-js-extras';
9
14
  import 'unified';
15
+ import 'rehype-parse';
16
+ import 'rehype-sanitize';
17
+ import 'rehype-stringify';
10
18
  import 'remark-parse';
11
19
  import 'remark-gfm';
12
20
  import 'remark-math';
13
21
  import 'remark-rehype';
14
22
  import 'rehype-katex';
15
23
  import 'rehype-raw';
16
- import 'rehype-stringify';
17
24
  import 'unist-util-visit';
18
- import './shared/vitakit.D10HyDhF.mjs';
19
- import 'node:fs';
20
- import './shared/vitakit.DQ6PmGoL.mjs';
21
- import 'node:url';
22
- import '@behackl/citation-js-extras';
23
- import 'rehype-parse';
24
- import 'rehype-sanitize';
25
- import './shared/vitakit.DbSeENVS.mjs';
25
+ import './shared/vitakit.DCK90zq4.mjs';
26
26
  import 'zod';
27
+ import './chunks/publication-lists.mjs';
28
+ import './shared/vitakit.D10HyDhF.mjs';
27
29
  import './chunks/sections.mjs';
28
30
  import './chunks/schemas.mjs';
29
31
 
@@ -190,7 +192,7 @@ async function checkStatic(options) {
190
192
  }
191
193
  checked.push("links into the site lead somewhere");
192
194
  }
193
- if (content.publications.length) {
195
+ if (Object.values(content.publications).some((list) => list.count)) {
194
196
  const bibPages = [...pages].filter(([, html]) => html.includes("vita-bib"));
195
197
  for (const [file, html] of bibPages) {
196
198
  if (/bibmathplaceholderx*\d+end/i.test(html)) fail(`${relative(outDir, file)}: a formula in the bibliography was not rendered`);
@@ -1,9 +1,11 @@
1
- import { parseDocument, isMap, isSeq, parse } from 'yaml';
2
- import { p as propertyKey, a as addSchemaField, e as ensurePackageImports } from '../shared/vitakit.BZZIsWlY.mjs';
3
- import { f as findPdfPlacements } from '../shared/vitakit.Do5HC_MD.mjs';
1
+ import { parse } from 'yaml';
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';
4
4
  import { a as escapeTex } from '../shared/vitakit.DcQOdM4I.mjs';
5
- import { i as insertLinesAfter, a as indentationAt, b as insertLinesBefore, l as lineStart, c as lineEnd, d as addSectionToPage, s as sectionElementSource } from './vue.mjs';
6
- import { C as CommandError } from '../shared/vitakit.CoWX2Z4R.mjs';
5
+ import { b as insertLinesAfter, i as indentationAt, c as insertLinesBefore } from '../shared/vitakit.UfoXdL7U.mjs';
6
+ import { addPublicationsToPage, publicationsElementSource, addSectionToPage, sectionElementSource } from './vue.mjs';
7
+ import { addNavItem, yamlScalar } from './yaml.mjs';
8
+ import { C as CommandError } from '../shared/vitakit.zwr1ssO5.mjs';
7
9
  import '@babel/parser';
8
10
  import 'node:fs/promises';
9
11
  import 'node:path';
@@ -18,56 +20,42 @@ import 'node:readline/promises';
18
20
  function vitasectionLine(id, heading) {
19
21
  return `\\vitasection${heading ? `[${escapeTex(heading)}]` : ""}{${id}}`;
20
22
  }
21
- function addSectionToDocument(source, placement) {
22
- const placements = findPdfPlacements("", "", source);
23
- if (placements.some((existing) => existing.section === placement.id && existing.macro === "vitasection")) {
24
- return { ok: false, reason: `it already includes ${placement.id}` };
25
- }
26
- const line = vitasectionLine(placement.id, placement.heading);
23
+ function vitapublicationsLine(list, heading) {
24
+ return `\\vitapublications${heading ? `[${escapeTex(heading)}]` : ""}{${list}}`;
25
+ }
26
+ function anchors(source) {
27
+ return [
28
+ ...findPdfPlacements("", "", source).map((placement) => ({ id: placement.section, line: placement.line })),
29
+ ...findPdfPublicationPlacements("", "", source).map((placement) => ({ id: placement.list, line: placement.line }))
30
+ ].sort((a, b) => a.line - b.line);
31
+ }
32
+ function insertLine(source, text, after) {
33
+ const found = anchors(source);
27
34
  const lines = source.split("\n");
28
- const offsetOf = (lineNumber) => lines.slice(0, lineNumber - 1).reduce((sum, text) => sum + text.length + 1, 0);
29
- const anchor = (placement.after && placements.find((existing) => existing.section === placement.after)) ?? placements[placements.length - 1];
35
+ const offsetOf = (lineNumber) => lines.slice(0, lineNumber - 1).reduce((sum, line) => sum + line.length + 1, 0);
36
+ const anchor = (after && found.find((existing) => existing.id === after)) ?? found[found.length - 1];
30
37
  if (anchor) {
31
38
  const offset = offsetOf(anchor.line);
32
39
  return {
33
40
  ok: true,
34
- value: { source: insertLinesAfter(source, offset, [`${indentationAt(source, offset)}${line}`]), line: anchor.line + 1, afterSection: anchor.section }
41
+ value: { source: insertLinesAfter(source, offset, [`${indentationAt(source, offset)}${text}`]), line: anchor.line + 1, afterSection: anchor.id }
35
42
  };
36
43
  }
37
- const end = lines.findIndex((text) => /^\s*\\end\{document\}/.test(text));
44
+ const end = lines.findIndex((line) => /^\s*\\end\{document\}/.test(line));
38
45
  if (end === -1) return { ok: false, reason: "it has no \\end{document} line" };
39
- return { ok: true, value: { source: insertLinesBefore(source, offsetOf(end + 1), [line, ""]), line: end + 1 } };
40
- }
41
-
42
- function yamlScalar(value) {
43
- return /^[A-Za-z0-9/][\w ./()'-]*$/.test(value) && !/(^|\s)#|:\s|\s$/.test(value) ? value : JSON.stringify(value);
46
+ return { ok: true, value: { source: insertLinesBefore(source, offsetOf(end + 1), [text, ""]), line: end + 1 } };
44
47
  }
45
- function addNavItem(source, item) {
46
- const document = parseDocument(source);
47
- if (document.errors.length) return { ok: false, reason: `cannot be parsed: ${document.errors[0].message}` };
48
- if (!isMap(document.contents)) return { ok: false, reason: "it is not a map of settings" };
49
- const entry = `- { label: ${yamlScalar(item.label)}, to: ${yamlScalar(item.to)} }`;
50
- const nav = document.contents.get("nav", true);
51
- if (nav === void 0) {
52
- const ending = source.endsWith("\n") ? "" : "\n";
53
- return { ok: true, value: `${source}${ending}
54
- nav:
55
- ${entry}
56
- ` };
48
+ function addSectionToDocument(source, placement) {
49
+ if (findPdfPlacements("", "", source).some((existing) => existing.section === placement.id && existing.macro === "vitasection")) {
50
+ return { ok: false, reason: `it already includes ${placement.id}` };
57
51
  }
58
- if (!isSeq(nav) || nav.flow) return { ok: false, reason: "its nav is not a list with one entry per line" };
59
- if (nav.items.some((existing) => isMap(existing) && existing.get("to") === item.to)) {
60
- return { ok: false, reason: `its menu already links ${item.to}` };
52
+ return insertLine(source, vitasectionLine(placement.id, placement.heading), placement.after);
53
+ }
54
+ function addPublicationsToDocument(source, placement) {
55
+ if (findPdfPublicationPlacements("", "", source).some((existing) => existing.list === placement.id)) {
56
+ return { ok: false, reason: `it already includes the list ${placement.id}` };
61
57
  }
62
- const last = nav.items[nav.items.length - 1];
63
- if (!last?.range) return { ok: false, reason: "its nav is empty; add the first entry by hand" };
64
- const start = lineStart(source, last.range[0]);
65
- const prefix = /^[ \t]*-[ \t]*/.exec(source.slice(start))?.[0];
66
- if (!prefix) return { ok: false, reason: "its nav entries are not one per line" };
67
- const end = lineEnd(source, last.range[1] - 1);
68
- const indent = prefix.slice(0, prefix.indexOf("-"));
69
- return { ok: true, value: `${source.slice(0, end)}
70
- ${indent}${entry}${source.slice(end)}` };
58
+ return insertLine(source, vitapublicationsLine(placement.id, placement.heading), placement.after);
71
59
  }
72
60
 
73
61
  function dataStarter(id) {
@@ -255,6 +243,23 @@ async function planPlaceInDocument(ws, document, placement) {
255
243
  ws.paste(path, vitasectionLine(placement.id, placement.title), `cannot be changed safely (${result.reason}); add this line where the section should appear`);
256
244
  }
257
245
  }
246
+ async function planListOnPage(ws, inv, path, placement) {
247
+ const source = await ws.read(path);
248
+ if (source === void 0) throw new CommandError(`${path} does not exist`);
249
+ const result = addPublicationsToPage(source, placement, inv.web.components.keys());
250
+ if (result.ok) await ws.edit(path, result.value.source, `shows the list ${placement.list}${result.value.afterSection ? ` after ${result.value.afterSection}` : ""}`);
251
+ else if (result.reason.startsWith("it already shows")) ws.keep(path, `already shows the list ${placement.list}`);
252
+ else ws.paste(path, publicationsElementSource(placement.list, placement.title), `cannot be changed safely (${result.reason}); add this line where the list should appear`);
253
+ }
254
+ async function planListInDocument(ws, document, placement) {
255
+ const path = `cv-tex/${document}.tex`;
256
+ const source = await ws.read(path);
257
+ if (source === void 0) throw new CommandError(`${path} does not exist`);
258
+ const result = addPublicationsToDocument(source, { id: placement.id, ...placement.title ? { heading: placement.title } : {}, ...placement.after ? { after: placement.after } : {} });
259
+ if (result.ok) await ws.edit(path, result.value.source, `includes the list ${placement.id}${result.value.afterSection ? ` after ${result.value.afterSection}` : ""}`);
260
+ else if (result.reason.startsWith("it already includes")) ws.keep(path, `already includes the list ${placement.id}`);
261
+ else ws.paste(path, vitapublicationsLine(placement.id, placement.title), `cannot be changed safely (${result.reason}); add this line where the list should appear`);
262
+ }
258
263
  function pageModule(route, name, title) {
259
264
  return `<script setup lang="ts">
260
265
  /**
@@ -364,4 +369,4 @@ async function planMissingFields(rootDir, ws, section, entries) {
364
369
  return { fields: names, changes: extension.changes };
365
370
  }
366
371
 
367
- export { FIELD_TYPES, STARTER_ENTRIES, dataExample, dataStarter, exampleValue, extensionModule, fieldCode, fieldsNotShown, matchingSchemas, planAddField, planCreatePage, planMissingFields, planPlaceInDocument, planPlaceOnPage, rawEntries, schemaOwner, sharedSchemaModule };
372
+ export { FIELD_TYPES, STARTER_ENTRIES, dataExample, dataStarter, exampleValue, extensionModule, fieldCode, fieldsNotShown, matchingSchemas, planAddField, planCreatePage, planListInDocument, planListOnPage, planMissingFields, planPlaceInDocument, planPlaceOnPage, rawEntries, schemaOwner, sharedSchemaModule };
@@ -1,26 +1,22 @@
1
- import { readFile, stat, readdir } from 'node:fs/promises';
1
+ import { existsSync } from 'node:fs';
2
+ import { stat, readFile } from 'node:fs/promises';
2
3
  import { join, isAbsolute, resolve } from 'node:path';
3
- import matter from 'gray-matter';
4
- import { parse } from 'yaml';
4
+ import { r as resolveAsset } from '../shared/vitakit.DQ6PmGoL.mjs';
5
+ import { badgePresets, Bibliography } from '@behackl/citation-js-extras';
5
6
  import { unified } from 'unified';
7
+ import rehypeParse from 'rehype-parse';
8
+ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
9
+ import rehypeStringify from 'rehype-stringify';
6
10
  import remarkParse from 'remark-parse';
7
11
  import remarkGfm from 'remark-gfm';
8
12
  import remarkMath from 'remark-math';
9
13
  import remarkRehype from 'remark-rehype';
10
14
  import rehypeKatex from 'rehype-katex';
11
15
  import rehypeRaw from 'rehype-raw';
12
- import rehypeStringify from 'rehype-stringify';
13
16
  import { visit } from 'unist-util-visit';
14
- import { r as resolveSitePaths, a as absoluteSitePath } from './vitakit.D10HyDhF.mjs';
15
- import { existsSync } from 'node:fs';
16
- import { r as resolveAsset } from './vitakit.DQ6PmGoL.mjs';
17
- import { badgePresets, Bibliography } from '@behackl/citation-js-extras';
18
- import rehypeParse from 'rehype-parse';
19
- import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
20
- import { f as fail, a as formatZodIssues, i as importProjectModule, I as Issues, w as whereOf, m as messageOf } from './vitakit.DbSeENVS.mjs';
17
+ import { f as fail, a as formatZodIssues, I as Issues, i as importProjectModule } from '../shared/vitakit.DCK90zq4.mjs';
21
18
  import { z } from 'zod';
22
- import { findSectionFiles, loadSection, resolveSectionSchema } from '../chunks/sections.mjs';
23
- import { profile, page } from '../chunks/schemas.mjs';
19
+ import { LISTS_FILE, selectPublications, publicationStatus } from './publication-lists.mjs';
24
20
 
25
21
  const URL_PROPERTIES = ["href", "src", "poster", "data", "action", "formAction"];
26
22
  const isRootRelative = (value) => value.startsWith("/") && !value.startsWith("//");
@@ -106,30 +102,34 @@ const badge = z.strictObject({
106
102
  className: z.string().optional()
107
103
  });
108
104
  const input = z.strictObject({
109
- groups: z.array(z.strictObject({
105
+ statuses: z.array(z.strictObject({
110
106
  id: z.string().regex(ID, "expected a lowercase id such as `preprint` or `talk-slides`"),
111
107
  label: z.string().trim().min(1)
112
- })).min(1, "expected at least one group").optional(),
113
- defaultGroup: z.string().optional(),
108
+ })).min(1, "expected at least one status").optional(),
109
+ defaultStatus: z.string().optional(),
114
110
  badges: z.array(badge).optional(),
115
111
  fields: z.array(z.string().regex(/^[a-z][a-z0-9_-]*$/i, "expected a BibTeX field name")).optional(),
116
112
  cslStyle: z.string().trim().min(1).optional()
117
113
  });
118
- function resolvePublications(value, defaults = { groups: [], defaultGroup: "", badges: [], fields: [] }, where = "schemas/publications.ts") {
114
+ function resolvePublications(value, defaults = { statuses: [], defaultStatus: "", badges: [], fields: [] }, where = "schemas/publications.ts") {
115
+ const renamed = ["groups", "defaultGroup"].filter((key) => typeof value === "object" && value !== null && Object.hasOwn(value, key));
116
+ if (renamed.length) {
117
+ fail(where, `${renamed.join(" and ")} ${renamed.length > 1 ? "are" : "is"} now called ${renamed.map((key) => key === "groups" ? "statuses" : "defaultStatus").join(" and ")}; \`vitakit migrate\` renames ${renamed.length > 1 ? "them" : "it"}`);
118
+ }
119
119
  const parsed = input.safeParse(value);
120
120
  if (!parsed.success) fail(where, `invalid
121
121
  ${formatZodIssues(parsed.error)}`);
122
- const groups = parsed.data.groups ?? defaults.groups;
123
- const ids = groups.map((group) => group.id);
122
+ const statuses = parsed.data.statuses ?? defaults.statuses;
123
+ const ids = statuses.map((status) => status.id);
124
124
  const duplicate = ids.find((id, index) => ids.indexOf(id) !== index);
125
- if (duplicate) fail(where, `group "${duplicate}" is defined twice`);
126
- const defaultGroup = parsed.data.defaultGroup ?? (parsed.data.groups ? ids[0] : defaults.defaultGroup);
127
- if (!ids.includes(defaultGroup)) {
128
- fail(where, `defaultGroup "${defaultGroup}" is not one of the groups (${ids.join(", ")})`);
125
+ if (duplicate) fail(where, `status "${duplicate}" is defined twice`);
126
+ const defaultStatus = parsed.data.defaultStatus ?? (parsed.data.statuses ? ids[0] : defaults.defaultStatus);
127
+ if (!ids.includes(defaultStatus)) {
128
+ fail(where, `defaultStatus "${defaultStatus}" is not one of the statuses (${ids.join(", ")})`);
129
129
  }
130
130
  return {
131
- groups,
132
- defaultGroup,
131
+ statuses,
132
+ defaultStatus,
133
133
  badges: parsed.data.badges ?? defaults.badges,
134
134
  fields: parsed.data.fields ?? defaults.fields,
135
135
  ...parsed.data.cslStyle ? { cslStyle: parsed.data.cslStyle } : {}
@@ -138,18 +138,19 @@ ${formatZodIssues(parsed.error)}`);
138
138
 
139
139
  const packageDefaults = publications({
140
140
  /**
141
- * The values of the `status` field, in the order the groups appear. A
142
- * status that is not listed here is an error, in any capitalisation.
141
+ * The values of the `status` field, with the heading of their group, in
142
+ * the order the groups appear in a list grouped by status. A status that
143
+ * is not listed here is an error, in any capitalisation.
143
144
  */
144
- groups: [
145
+ statuses: [
145
146
  { id: "published", label: "Published" },
146
147
  { id: "accepted", label: "Accepted" },
147
148
  { id: "preprint", label: "Preprints" },
148
149
  { id: "software", label: "Software" },
149
150
  { id: "other", label: "Other" }
150
151
  ],
151
- /** The group of an entry without `status`; the first group if left out. */
152
- defaultGroup: "published",
152
+ /** The status of an entry without one; the first status if left out. */
153
+ defaultStatus: "published",
153
154
  /**
154
155
  * BibTeX fields that become links after a reference on the website. The
155
156
  * presets accept the spellings real exports use (`doi:10...`, doi.org URLs,
@@ -172,8 +173,6 @@ const packageDefaults = publications({
172
173
  });
173
174
 
174
175
  const defaultPublications = resolvePublications(packageDefaults, void 0, "src/schemas/publications.ts");
175
- defaultPublications.badges;
176
- defaultPublications.groups;
177
176
  async function loadPublicationsSchema(rootDir) {
178
177
  const where = "schemas/publications.ts";
179
178
  const path = join(rootDir, where);
@@ -240,17 +239,28 @@ const GENRES = {
240
239
  techreport: "Technical report"
241
240
  };
242
241
  const readableGenre = (html, { variable }) => variable === "genre" ? GENRES[html.toLowerCase()] ?? html : html;
243
- async function loadPublications(bibPath, renderer, schema = defaultPublications, baseURL = "/") {
244
- const { groups, fields } = schema;
242
+ const readBib = (path) => readFile(path, "utf8").catch((error) => {
243
+ if (error.code === "ENOENT") return "";
244
+ throw error;
245
+ });
246
+ async function readBibliography(bibPath, schema) {
247
+ try {
248
+ return new Bibliography({ data: await readBib(bibPath), customFields: ["status", ...schema.fields], preserveMath: true });
249
+ } catch (error) {
250
+ throw new Error(`${bibPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
251
+ }
252
+ }
253
+ async function loadPublications(bibPath, renderer, schema, lists, baseURL = "/", issues = new Issues(true)) {
254
+ const { statuses, fields } = schema;
245
255
  const prefix = baseURL.replace(/\/$/, "");
246
256
  const withFile = (error) => new Error(`${bibPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
247
257
  let bib;
248
258
  try {
249
259
  bib = new Bibliography({
250
- data: await readFile(bibPath, "utf8"),
260
+ data: await readBib(bibPath),
251
261
  cslStyle: schema.cslStyle ?? projectCslStyle(bibPath) ?? defaultCslStyle,
252
262
  // Only fields listed here survive the citation-js pipeline; they then
253
- // appear as `entry.custom.<field>`. Badges read the raw fields.
263
+ // appear as `entry.custom.<field>`. Badges and lists read the raw fields.
254
264
  customFields: ["status", ...fields],
255
265
  preserveMath: true,
256
266
  // Defaults for every formatHtml call below.
@@ -262,18 +272,15 @@ async function loadPublications(bibPath, renderer, schema = defaultPublications,
262
272
  } catch (error) {
263
273
  throw withFile(error);
264
274
  }
265
- const statusOf = (entry) => (entry.custom.status?.trim() || schema.defaultGroup).toLowerCase();
266
- const known = new Set(groups.map((group) => group.id));
275
+ const statusOf = (entry) => publicationStatus(entry, schema);
276
+ const known = new Set(statuses.map((status) => status.id));
267
277
  const unknown = bib.entries.filter((entry) => !known.has(statusOf(entry)));
268
278
  if (unknown.length) {
269
279
  throw new Error(
270
- `${bibPath}: unknown status ${[...new Set(unknown.map(statusOf))].join(", ")} on ${unknown.map((entry) => entry.key).join(", ")}; expected one of ${[...known].join(", ")} (groups are defined in schemas/publications.ts)`
280
+ `${bibPath}: unknown status ${[...new Set(unknown.map(statusOf))].join(", ")} on ${unknown.map((entry) => entry.key).join(", ")}; expected one of ${[...known].join(", ")} (statuses are defined in schemas/publications.ts)`
271
281
  );
272
282
  }
273
- const result = [];
274
- for (const group of groups) {
275
- const entries = bib.sort(bib.entries.filter((entry) => statusOf(entry) === group.id), { by: "date" });
276
- if (!entries.length) continue;
283
+ const format = (entries) => {
277
284
  let items;
278
285
  try {
279
286
  items = splitList(bib.formatHtml(entries, {
@@ -286,7 +293,7 @@ async function loadPublications(bibPath, renderer, schema = defaultPublications,
286
293
  } catch (error) {
287
294
  throw withFile(error);
288
295
  }
289
- const publications = entries.map((entry) => {
296
+ return entries.map((entry) => {
290
297
  const html = items.get(entry.key);
291
298
  if (html === void 0) throw new Error(`${bibPath}: ${entry.key} is missing from the formatted list`);
292
299
  const { title, badges } = bib.links(entry);
@@ -304,134 +311,30 @@ async function loadPublications(bibPath, renderer, schema = defaultPublications,
304
311
  }))
305
312
  };
306
313
  });
307
- result.push({ id: group.id, label: group.label, count: entries.length, entries: publications });
314
+ };
315
+ const result = {};
316
+ for (const [name, list] of Object.entries(lists)) {
317
+ result[name] = await issues.attempt(LISTS_FILE, () => {
318
+ const groups = selectPublications(bib, name, list, schema).map((group, index) => ({
319
+ id: `${name}-${index + 1}`,
320
+ ...group.label !== void 0 ? { label: group.label } : {},
321
+ count: group.entries.length,
322
+ entries: format(group.entries)
323
+ }));
324
+ return { count: groups.reduce((sum, group) => sum + group.count, 0), groups };
325
+ }, { count: 0, groups: [] });
308
326
  }
309
327
  return result;
310
328
  }
311
329
 
312
- const FALLBACK_PROFILE = { name: "Your Name", links: [], nav: [], footerHtml: "" };
313
- async function loadYaml(file, where) {
314
- const raw = await readFile(file, "utf8").catch(() => fail(where, "missing"));
315
- try {
316
- return parse(raw);
317
- } catch (error) {
318
- fail(where, messageOf(error));
319
- }
320
- }
321
- async function loadProfile(contentDir, baseURL, renderer, target) {
322
- const data = await loadYaml(join(contentDir, "profile.yml"), "content/profile.yml");
323
- const parsed = profile.safeParse(data);
324
- if (!parsed.success) fail("content/profile.yml", `invalid
325
- ${formatZodIssues(parsed.error)}`);
326
- const prefix = baseURL.replace(/\/$/, "");
327
- const { avatar, nav, footer, ...rest } = parsed.data;
328
- return resolveSitePaths({
329
- ...rest,
330
- ...avatar ? { avatar: prefixRootRelative(avatar, prefix) } : {},
331
- nav: nav.map((item) => ({ label: item.label, url: item.to })),
332
- footerHtml: footer ? renderer.inline(footer) : ""
333
- }, sitePathResolver(target, baseURL, parsed.data.url));
334
- }
335
- function sitePathResolver(target, baseURL, siteUrl) {
336
- const prefix = baseURL.replace(/\/$/, "");
337
- return target === "pdf" ? (path) => absoluteSitePath(path, siteUrl) : (path) => prefixRootRelative(path, prefix);
338
- }
339
- function errorPage(route, where, message) {
340
- const escaped = message.replace(/[&<>]/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[character]);
341
- return {
342
- route,
343
- title: `Error in ${where}`,
344
- titleHtml: `Error in <code>${where}</code>`,
345
- order: -1,
346
- bodyHtml: `<pre>${escaped}</pre>`,
347
- error: message
348
- };
349
- }
350
- async function loadPage(dir, file, renderer) {
351
- const where = `content/pages/${file}`;
352
- const slug = file.slice(0, -".md".length);
353
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
354
- fail(where, "file name must be lowercase kebab-case, e.g. `research-interests.md`");
355
- }
356
- const { data, content } = matter(await readFile(join(dir, file), "utf8"));
357
- const parsed = page.safeParse(data);
358
- if (!parsed.success) fail(where, `invalid front matter
359
- ${formatZodIssues(parsed.error)}`);
360
- const route = parsed.data.route ?? (slug === "index" ? "/" : `/${slug}/`);
361
- try {
362
- return {
363
- route,
364
- title: parsed.data.title,
365
- titleHtml: renderer.inline(parsed.data.title),
366
- description: parsed.data.description,
367
- order: parsed.data.order,
368
- bodyHtml: content.trim() ? renderer.markdown(content) : ""
369
- };
370
- } catch (error) {
371
- fail(where, messageOf(error));
372
- }
373
- }
374
- async function loadPages(contentDir, renderer, issues) {
375
- const dir = join(contentDir, "pages");
376
- const files = (await readdir(dir).catch(() => [])).filter((name) => name.endsWith(".md")).sort();
377
- const pages = await Promise.all(files.map(async (file) => {
378
- try {
379
- return await loadPage(dir, file, renderer);
380
- } catch (error) {
381
- if (issues.strict) throw error;
382
- const where = whereOf(error, `content/pages/${file}`);
383
- const message = messageOf(error);
384
- issues.record(where, message);
385
- const slug = file.slice(0, -".md".length);
386
- return errorPage(slug === "index" ? "/" : `/${slug}/`, where, message);
387
- }
388
- }));
389
- const seen = /* @__PURE__ */ new Map();
390
- for (const [index, page] of pages.entries()) {
391
- const previous = seen.get(page.route);
392
- if (previous) issues.record(`content/pages/${files[index]}`, `route ${page.route} is already used by ${previous}`);
393
- else seen.set(page.route, `content/pages/${files[index]}`);
394
- }
395
- return pages.sort((a, b) => a.order - b.order || a.route.localeCompare(b.route));
396
- }
397
- async function loadSections(rootDir, maxVisibility, renderer, resolvePath, issues) {
398
- const { files, problems } = await issues.attempt("content/", () => findSectionFiles(rootDir), { files: [], problems: [] });
399
- for (const problem of problems) issues.record(problem.where, problem.message);
400
- const sections = {};
401
- for (const file of files) {
402
- sections[file.id] = await issues.attempt(
403
- file.where,
404
- async () => loadSection(await resolveSectionSchema(rootDir, file), { maxVisibility, renderer, resolvePath }),
405
- []
406
- );
407
- }
408
- return sections;
409
- }
410
- async function loadContent(rootDir, options = {}) {
411
- const strict = options.strict ?? true;
412
- const baseURL = options.baseURL ?? "/";
413
- const maxVisibility = options.maxVisibility ?? "public";
414
- const issues = new Issues(strict);
415
- const renderer = createRenderer({ baseURL });
416
- const contentDir = join(rootDir, "content");
417
- const target = options.target ?? "website";
418
- const profile = await issues.attempt("content/profile.yml", () => loadProfile(contentDir, baseURL, renderer, target), FALLBACK_PROFILE);
419
- const resolvePath = sitePathResolver(target, baseURL, profile.url);
420
- const [pages, sections, publications] = await Promise.all([
421
- loadPages(contentDir, renderer, issues),
422
- loadSections(rootDir, maxVisibility, renderer, resolvePath, issues),
423
- issues.attempt(
424
- "content/publications.bib",
425
- async () => {
426
- const bib = join(contentDir, "publications.bib");
427
- const present = await readFile(bib, "utf8").then(() => true, () => false);
428
- if (!present) return [];
429
- return loadPublications(bib, renderer, await loadPublicationsSchema(rootDir), baseURL);
430
- },
431
- []
432
- )
433
- ]);
434
- return { profile, pages, sections, publications, issues: issues.list };
435
- }
330
+ const bibliography = {
331
+ __proto__: null,
332
+ defaultCslStyle: defaultCslStyle,
333
+ defaultPublications: defaultPublications,
334
+ loadPublications: loadPublications,
335
+ loadPublicationsSchema: loadPublicationsSchema,
336
+ projectCslStyle: projectCslStyle,
337
+ readBibliography: readBibliography
338
+ };
436
339
 
437
- export { loadPublicationsSchema as a, loadContent as l, publications as p };
340
+ export { prefixRootRelative as a, loadPublications as b, createRenderer as c, defaultPublications as d, bibliography as e, loadPublicationsSchema as l, publications as p };
@@ -3,32 +3,35 @@ import { mkdir, readFile, writeFile, rm, copyFile, rename } from 'node:fs/promis
3
3
  import { existsSync } from 'node:fs';
4
4
  import process from 'node:process';
5
5
  import { join, delimiter, resolve } from 'node:path';
6
- import { l as loadContent, a as loadPublicationsSchema } from '../shared/vitakit.By5VWQ1n.mjs';
7
- import { f as fail } from '../shared/vitakit.DbSeENVS.mjs';
6
+ import { l as loadContent } from '../shared/vitakit.DteA9vLy.mjs';
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
10
  import { discoverSections } from './sections.mjs';
10
11
  import { emitDocument, bundledTexDir, SITE_PATH_HELP } from './emit.mjs';
11
12
  import { a as escapeTex, b as escapeUrl } from '../shared/vitakit.DcQOdM4I.mjs';
12
13
  import { i as isSitePath } from '../shared/vitakit.D10HyDhF.mjs';
13
14
  import 'gray-matter';
14
15
  import 'yaml';
16
+ import './bibliography.mjs';
17
+ import '../shared/vitakit.DQ6PmGoL.mjs';
18
+ import 'node:url';
19
+ import '@behackl/citation-js-extras';
15
20
  import 'unified';
21
+ import 'rehype-parse';
22
+ import 'rehype-sanitize';
23
+ import 'rehype-stringify';
16
24
  import 'remark-parse';
17
25
  import 'remark-gfm';
18
26
  import 'remark-math';
19
27
  import 'remark-rehype';
20
28
  import 'rehype-katex';
21
29
  import 'rehype-raw';
22
- import 'rehype-stringify';
23
30
  import 'unist-util-visit';
24
- import '../shared/vitakit.DQ6PmGoL.mjs';
25
- import 'node:url';
26
- import '@behackl/citation-js-extras';
27
- import 'rehype-parse';
28
- import 'rehype-sanitize';
29
31
  import 'zod';
32
+ import './publication-lists.mjs';
30
33
  import './schemas.mjs';
31
- import '../shared/vitakit.BerUaxtF.mjs';
34
+ import '../shared/vitakit.DvABWuUc.mjs';
32
35
  import '../shared/vitakit.TSMTC5Jl.mjs';
33
36
  import '../shared/vitakit.D-DdJaDZ.mjs';
34
37
  import 'eta';
@@ -71,7 +74,7 @@ Full log: ${log}` : ""}`));
71
74
  });
72
75
  });
73
76
  }
74
- function profilePreamble(content, document) {
77
+ function profilePreamble(content, document, lists) {
75
78
  const { profile } = content;
76
79
  const href = (url) => {
77
80
  if (isSitePath(url)) fail("content/profile.yml", `links: ${url} ${SITE_PATH_HELP}`);
@@ -96,6 +99,7 @@ function profilePreamble(content, document) {
96
99
  ...title ? [`\\vitasectiontitle{${id}}{${escapeTex(title)}}`] : []
97
100
  ];
98
101
  }),
102
+ ...lists.filter((name) => !content.publications[name]?.count).map((name) => `\\vitaemptypublications{${name}}`),
99
103
  ""
100
104
  ].join("\n");
101
105
  }
@@ -112,15 +116,16 @@ function siteUrlSourcemap(siteUrl) {
112
116
  }
113
117
  async function buildDocument(document, content, options, cvDir) {
114
118
  const outDir = join(cvDir, "generated", document.name);
119
+ const lists = [...new Set(document.publications.map((placement) => placement.list))];
115
120
  const written = await emitDocument({
116
121
  sections: content.sections,
117
- publications: content.publications,
122
+ publications: Object.fromEntries(lists.map((name) => [name, content.publications[name]])),
118
123
  siteUrl: content.profile.url,
119
124
  options: document.options,
120
125
  templateDir: join(cvDir, "templates"),
121
126
  outDir
122
127
  });
123
- await writeFile(join(outDir, "profile.tex"), profilePreamble(content, document));
128
+ await writeFile(join(outDir, "profile.tex"), profilePreamble(content, document, lists));
124
129
  const generatedClass = join(outDir, CLASS_FILE);
125
130
  if (existsSync(join(cvDir, CLASS_FILE))) await rm(generatedClass, { force: true });
126
131
  else await copyFile(join(bundledTexDir, CLASS_FILE), generatedClass);
@@ -149,13 +154,10 @@ async function buildCv(options) {
149
154
  const content = await loadContent(options.rootDir, { strict: true, maxVisibility: "private", target: "pdf" });
150
155
  const documents = await discoverDocuments(cvDir);
151
156
  const sources = await discoverSections(options.rootDir);
152
- const groups = (await loadPublicationsSchema(options.rootDir)).groups.map((group) => group.id);
153
157
  for (const document of documents) {
154
158
  validateDocumentSections(document, sources);
155
- const unknown2 = (document.options.publications.groups ?? []).filter((id) => !groups.includes(id));
156
- if (unknown2.length) {
157
- fail(`cv-tex/${document.name}.yml`, `publications.groups: unknown group ${unknown2.join(", ")}; the groups are ${groups.join(", ")} (schemas/publications.ts)`);
158
- }
159
+ const [problem] = publicationPlacementProblems([], [...document.placements, ...document.publications], Object.keys(content.publications));
160
+ if (problem) fail(problem.where, problem.message);
159
161
  }
160
162
  const unknown = options.only?.filter((name) => !documents.some((document) => document.name === name)) ?? [];
161
163
  if (unknown.length) {