@anja.codes/vitakit 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +52 -131
  2. package/dist/check.mjs +20 -11
  3. package/dist/chunks/authoring.mjs +372 -0
  4. package/dist/{shared/vitakit.CypCF-4Q.mjs → chunks/bibliography.mjs} +74 -237
  5. package/dist/chunks/build.mjs +178 -0
  6. package/dist/chunks/documents.mjs +152 -0
  7. package/dist/chunks/draft.mjs +222 -0
  8. package/dist/chunks/eject.mjs +59 -0
  9. package/dist/chunks/emit.mjs +216 -0
  10. package/dist/chunks/eta.mjs +16 -0
  11. package/dist/chunks/fields.mjs +79 -0
  12. package/dist/chunks/inventory.mjs +201 -0
  13. package/dist/chunks/migrations.mjs +218 -0
  14. package/dist/chunks/plan.mjs +172 -0
  15. package/dist/chunks/prompt.mjs +33 -0
  16. package/dist/chunks/publication-lists.mjs +202 -0
  17. package/dist/chunks/schema-source.mjs +12 -0
  18. package/dist/chunks/schemas.mjs +109 -0
  19. package/dist/chunks/sections.mjs +109 -0
  20. package/dist/chunks/snippets.mjs +199 -0
  21. package/dist/chunks/validate.mjs +133 -0
  22. package/dist/chunks/vue.mjs +153 -0
  23. package/dist/chunks/yaml.mjs +62 -0
  24. package/dist/cli.d.mts +33 -40
  25. package/dist/cli.mjs +7 -224
  26. package/dist/index.d.mts +379 -49
  27. package/dist/index.mjs +15 -7
  28. package/dist/nuxt.mjs +48 -10
  29. package/dist/runtime/components/VitaPublications.vue +21 -22
  30. package/dist/runtime/components/VitaSection.vue +94 -38
  31. package/dist/runtime/composables/useVitakit.ts +14 -3
  32. package/dist/schemas/education.ts +4 -4
  33. package/dist/schemas/employment.ts +3 -3
  34. package/dist/schemas/publications.ts +10 -8
  35. package/dist/schemas/supervision.ts +3 -3
  36. package/dist/schemas/talks.ts +1 -1
  37. package/dist/schemas/teaching.ts +3 -3
  38. package/dist/shared/index.d.mts +60 -9
  39. package/dist/shared/index.mjs +3 -57
  40. package/dist/shared/{vitakit.D8WRg_Xx.d.mts → vitakit.BfR_mUHm.d.mts} +25 -12
  41. package/dist/shared/vitakit.CCTDg7pz.mjs +199 -0
  42. package/dist/shared/vitakit.CfB3hMHl.mjs +60 -0
  43. package/dist/shared/{vitakit.BlU-p01e.mjs → vitakit.CtChKO0I.mjs} +1 -1
  44. package/dist/shared/vitakit.D-DdJaDZ.mjs +20 -0
  45. package/dist/shared/vitakit.D10HyDhF.mjs +19 -0
  46. package/dist/shared/vitakit.DCK90zq4.mjs +67 -0
  47. package/dist/shared/vitakit.DQ6PmGoL.mjs +34 -0
  48. package/dist/shared/vitakit.DcQOdM4I.mjs +57 -0
  49. package/dist/shared/vitakit.DteA9vLy.mjs +135 -0
  50. package/dist/shared/vitakit.DvABWuUc.mjs +121 -0
  51. package/dist/shared/vitakit.DvvUYUTS.mjs +200 -0
  52. package/dist/shared/vitakit.TSMTC5Jl.mjs +6 -0
  53. package/dist/shared/vitakit.UfoXdL7U.mjs +22 -0
  54. package/dist/shared/vitakit.zwr1ssO5.mjs +1850 -0
  55. package/dist/tex/templates/_generic.eta +6 -4
  56. package/dist/tex/templates/education.eta +10 -5
  57. package/dist/tex/templates/employment.eta +12 -5
  58. package/dist/tex/templates/publications.eta +7 -3
  59. package/dist/tex/templates/supervision.eta +10 -2
  60. package/dist/tex/templates/talks.eta +5 -1
  61. package/dist/tex/templates/teaching.eta +6 -4
  62. package/dist/tex/vitakit-cv.cls +36 -10
  63. package/dist/tex.d.mts +80 -11
  64. package/dist/tex.mjs +27 -117
  65. package/package.json +10 -7
  66. package/src/bin/vitakit.mjs +3 -153
  67. package/dist/shared/vitakit.BRhjqpAt.mjs +0 -250
  68. package/dist/shared/vitakit.BxcnlFGP.mjs +0 -325
  69. package/src/bin/vitakit-build-cv.mjs +0 -50
  70. package/src/bin/vitakit-check.mjs +0 -57
package/README.md CHANGED
@@ -1,159 +1,80 @@
1
1
  # @anja.codes/vitakit
2
2
 
3
- Structured CV data in, academic website and LaTeX CV out.
3
+ Your academic website and CV, from one set of files. Write your CV once, as
4
+ YAML, Markdown and BibTeX; vitakit makes a static website from it and a typeset
5
+ LaTeX CV — kept in sync, validated, and yours to change.
4
6
 
5
7
  ```bash
6
8
  pnpm create @anja.codes/vitakit my-site
7
9
  cd my-site && pnpm install
8
- pnpm dev # the website
9
- pnpm build:cv # cv-tex/cv.pdf, from the same data
10
+ pnpm dev # the website, at http://localhost:3000
11
+ pnpm pdf # cv-tex/cv.pdf, from the same data
12
+ pnpm vitakit status # what is in the project, and where it appears
10
13
  ```
11
14
 
12
- To add it to an existing Nuxt project instead:
13
-
14
- ```ts
15
- // nuxt.config.ts
16
- export default defineNuxtConfig({
17
- modules: ['@anja.codes/vitakit/nuxt'],
18
- })
19
- ```
20
-
21
- ## Documentation
22
-
23
- [Getting started](./docs/getting-started.md) ·
24
- [Content reference](./docs/content-reference.md) ·
25
- [Commands](./docs/cli.md) ·
26
- [Customising](./docs/customising.md) ·
27
- [CV documents](./docs/cv-documents.md) ·
28
- [Privacy](./docs/privacy.md) ·
29
- [Troubleshooting](./docs/troubleshooting.md)
15
+ **Documentation: [vitakit.anja.codes](https://vitakit.anja.codes)** — a
16
+ [tutorial](https://vitakit.anja.codes/guide/tutorial/) from an empty directory to a
17
+ published site, [concepts](https://vitakit.anja.codes/guide/concepts),
18
+ [how do I …?](https://vitakit.anja.codes/guide/how-do-i), and references for the
19
+ [command](https://vitakit.anja.codes/cli/),
20
+ [content](https://vitakit.anja.codes/content/),
21
+ [website](https://vitakit.anja.codes/website/) and
22
+ [PDF](https://vitakit.anja.codes/pdf/). The source is in [`docs/`](./docs).
30
23
 
31
24
  ## What it does
32
25
 
33
- `content/` is compiled at build time into a typed module. Markdown and TeX are
34
- rendered with remark and KaTeX, BibTeX with citation-js, and every file is
35
- validated with Zod. Nothing content-related reaches the browser: no Markdown
36
- parser, no content database, no KaTeX runtime.
37
-
38
26
  ```
39
27
  content/
40
- profile.yml name, affiliation, links, navigation, footer
28
+ profile.yml name, affiliation, links, menu, footer
41
29
  <section>.yml education, employment, teaching, talks, …
42
- publications.bib grouped, linked reference list
43
- publications.csl optional: its citation style on the website
44
- pages/*.md prose pages, math and images included
45
- schemas/<section>.ts optional: change a built-in schema, or add your own
46
- schemas/publications.ts optional: publication groups, badges, fields
30
+ publications.bib your publications, formatted and linked
31
+ publication-lists.yml which of them each list shows: recent, preprints, by year, …
32
+ pages/*.md prose pages, mathematics and images included
33
+ schemas/<section>.ts optional: what a section of your own may contain
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
47
36
  ```
48
37
 
49
- ## Composables
50
-
51
- | | |
52
- |---|---|
53
- | `useSection(id)` | validated, sorted, visibility-filtered entries |
54
- | `usePublications()` | reference groups; each entry has its formatted `html` and its `badges` as data |
55
- | `usePage(route)`, `usePages()` | compiled Markdown pages |
56
- | `useProfile()` | everything in `profile.yml`, including `nav` |
57
- | `useVitakit()` | everything, including `issues` |
58
-
59
- ## Components
60
-
61
- `VitaSection`, `VitaEntry`, `VitaDate`, `VitaLinks`, `VitaPublications`,
62
- `VitaPublication`, `VitaMarkdown`, `VitaIssues`. All are thin: they take props and emit markup, so
63
- ejecting one into your own repo leaves nothing behind that can silently break.
64
-
65
- ## Sections
66
-
67
- A file `content/<name>.yml` is a section. Its schema is found by convention:
68
- `schemas/<name>.ts` in your repo wins, otherwise the built-in schema for
69
- `<name>` is used, otherwise the build fails with a starter snippet.
38
+ - **Validated content.** Every file is checked against its schema. A broken
39
+ file fails the build, naming the file, the entry and the field; during
40
+ `nuxt dev` it is reported in place, and fixing it recovers without a restart.
41
+ - **Nothing content-related in the browser.** Markdown, mathematics (KaTeX) and
42
+ citations (citation-js) are rendered at build time into static HTML.
43
+ - **Private stays private.** Entries marked `cv` or `private` never reach the
44
+ website, and `pnpm check` verifies that none did.
45
+ - **The PDF** is LaTeX you own: `cv-tex/cv.tex` orders sections, Eta templates
46
+ write entries, `latexmk` typesets — no `-shell-escape`, no Python.
47
+ - **The `vitakit` command** shows what is where (`status`, `explain`), checks
48
+ everything without building (`validate`), and sets up sections, fields and
49
+ pages (`add`, `customize`), showing every change before it makes it.
50
+
51
+ To use it in an existing Nuxt project:
70
52
 
71
53
  ```ts
72
- // schemas/outreach.ts
73
- import { section, dateRange, richText, institution } from '@anja.codes/vitakit'
74
-
75
- export default section({
76
- date: dateRange,
77
- title: richText, // Markdown and $math$ allowed
78
- venue: institution.optional(),
54
+ // nuxt.config.ts
55
+ export default defineNuxtConfig({
56
+ modules: ['@anja.codes/vitakit/nuxt'],
79
57
  })
80
58
  ```
81
59
 
82
- Every entry gets `visibility: public | cv | private`. The website loader is
83
- hard-wired to `public`, so a phone number, an address or a referee marked `cv`
84
- cannot reach the site; the other levels exist for the LaTeX side. Assume the
85
- repository itself is private, and let `vitakit-check` verify the build output.
86
-
87
- PDFs are never part of the website build: `build:cv` writes to `cv-tex/`, and
88
- the website renders CV information directly from the same data.
89
-
90
- ## Strict builds, forgiving development
91
-
92
- A broken file fails `nuxt generate`, naming the file, the entry and the field.
93
- The same problem during `nuxt dev` is reported instead of thrown: the page shows
94
- its error, `VitaIssues` lists all of them, and saving a fix recovers in place —
95
- a typo never takes down the dev server.
96
-
97
- ## Status
98
-
99
- Early but complete in both halves: website and PDF are produced from the same
100
- content, with strict builds, forgiving development, output assertions and a
101
- scaffolder whose output is regenerated and tested in CI.
102
-
103
- Not yet implemented: cover-letter helpers (`\vitacount`, `\vitafirst`), a
104
- `schema` command to draft a schema from existing YAML, a second theme, and a
105
- Typst target.
106
-
107
- MIT
108
-
109
- ## LaTeX CV
60
+ ## Development
110
61
 
111
62
  ```bash
112
- pnpm build:cv # every document in cv-tex/
113
- pnpm build:cv cv # one document
114
- pnpm build:cv --no-pdf # only generate .tex
115
- ```
116
-
117
- A **document** is a `.tex` file you own. vitakit renders `content/` into
118
- `cv-tex/generated/<document>/`, one file per section, and `latexmk` typesets it.
119
- No `-shell-escape`, no Python.
120
-
121
- ```tex
122
- \documentclass{vitakit-cv}
123
- \vitadocument{cv}
124
- \addbibresource{../content/publications.bib}
125
- \input{preamble.tex}
126
- \input{generated/cv/profile.tex}
127
-
128
- \begin{document}
129
- \vitaheader
130
- \vitasection{employment} % order lives here: move a line, move a section
131
- \vitasection{education}
132
- \section{Publications}\vitainput{publications}
133
- \end{document}
134
- ```
135
-
136
- An optional `cv-tex/<name>.yml` decides *what* goes in:
137
-
138
- ```yaml
139
- visibility: cv # include public and cv entries, not private
140
- sections:
141
- talks:
142
- where: { kind: invited }
143
- limit: 10
63
+ pnpm install
64
+ pnpm check # typecheck, build, tests, and the scaffolded example
65
+ pnpm docs:dev # the documentation site
144
66
  ```
145
67
 
146
- Copy `cv.tex` to make a variant; a document is just a file. Generated files use
147
- only the macros in `vitakit-cv.cls` (`\vitaentry`, `\vitanote`, `\vitasection`),
148
- so redefining those in `preamble.tex` restyles everything. Any template can be
149
- overridden by putting a file of the same name in `cv-tex/templates/`.
68
+ To release, set one version everywhere it must match, commit, and push a tag:
69
+ `node scripts/release.mjs set 0.2.0`, then `git tag v0.2.0 && git push --tags`.
70
+ The `Release` workflow checks the versions, runs `pnpm check`, and publishes
71
+ both packages to npm with trusted publishing.
150
72
 
151
- ## Starting a project
73
+ The creator lives in [`create/`](./create); its output is kept in
74
+ [`examples/scaffolded`](./examples/scaffolded), regenerated by `pnpm example`
75
+ and verified in CI, so it cannot drift from the package.
152
76
 
153
- ```bash
154
- pnpm create @anja.codes/vitakit my-site
155
- ```
77
+ Not yet implemented: cover-letter helpers (`\vitacount`, `\vitafirst`), a
78
+ second theme, and a Typst target.
156
79
 
157
- See [`@anja.codes/create-vitakit`](./create). The scaffolded output is kept in
158
- [`examples/scaffolded`](./examples/scaffolded), regenerated by
159
- `pnpm example` and verified in CI, so it cannot drift from the package.
80
+ MIT
package/dist/check.mjs CHANGED
@@ -1,25 +1,33 @@
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.CypCF-4Q.mjs';
4
- import { M as MARKER_KEY, k as atMost } from './shared/vitakit.BxcnlFGP.mjs';
5
- import { r as richSource, a as richHtml } from './shared/vitakit.BlU-p01e.mjs';
3
+ import { l as loadContent } from './shared/vitakit.DteA9vLy.mjs';
4
+ import { M as MARKER_KEY } from './shared/vitakit.DvABWuUc.mjs';
5
+ import { a as richSource, r as richHtml } from './shared/vitakit.CtChKO0I.mjs';
6
+ import { a as atMost } from './shared/vitakit.TSMTC5Jl.mjs';
6
7
  import 'gray-matter';
7
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';
8
14
  import 'unified';
15
+ import 'rehype-parse';
16
+ import 'rehype-sanitize';
17
+ import 'rehype-stringify';
9
18
  import 'remark-parse';
10
19
  import 'remark-gfm';
11
20
  import 'remark-math';
12
21
  import 'remark-rehype';
13
22
  import 'rehype-katex';
14
23
  import 'rehype-raw';
15
- import 'rehype-stringify';
16
24
  import 'unist-util-visit';
17
- import 'node:fs';
18
- import '@behackl/citation-js-extras';
19
- import 'rehype-parse';
20
- import 'rehype-sanitize';
25
+ import './shared/vitakit.DCK90zq4.mjs';
21
26
  import 'zod';
22
- import 'node:url';
27
+ import './chunks/publication-lists.mjs';
28
+ import './shared/vitakit.D10HyDhF.mjs';
29
+ import './chunks/sections.mjs';
30
+ import './chunks/schemas.mjs';
23
31
 
24
32
  async function exists(path) {
25
33
  return stat(path).then(() => true, () => false);
@@ -139,7 +147,8 @@ async function checkStatic(options) {
139
147
  let hidden = 0;
140
148
  let leaked = 0;
141
149
  for (const [id, entries] of Object.entries(content.sections)) {
142
- for (const [index, entry] of entries.entries()) {
150
+ for (const entry of entries) {
151
+ const index = Number(entry.id.slice(id.length + 1));
143
152
  if (atMost(entry.visibility, "public")) continue;
144
153
  hidden += 1;
145
154
  const fragments = identifyingText(entry);
@@ -183,7 +192,7 @@ async function checkStatic(options) {
183
192
  }
184
193
  checked.push("links into the site lead somewhere");
185
194
  }
186
- if (content.publications.length) {
195
+ if (Object.values(content.publications).some((list) => list.count)) {
187
196
  const bibPages = [...pages].filter(([, html]) => html.includes("vita-bib"));
188
197
  for (const [file, html] of bibPages) {
189
198
  if (/bibmathplaceholderx*\d+end/i.test(html)) fail(`${relative(outDir, file)}: a formula in the bibliography was not rendered`);
@@ -0,0 +1,372 @@
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
+ import { a as escapeTex } from '../shared/vitakit.DcQOdM4I.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';
9
+ import '@babel/parser';
10
+ import 'node:fs/promises';
11
+ import 'node:path';
12
+ import '@vue/compiler-sfc';
13
+ import 'node:fs';
14
+ import '../shared/vitakit.DQ6PmGoL.mjs';
15
+ import 'node:url';
16
+ import 'node:process';
17
+ import 'node:util';
18
+ import 'node:readline/promises';
19
+
20
+ function vitasectionLine(id, heading) {
21
+ return `\\vitasection${heading ? `[${escapeTex(heading)}]` : ""}{${id}}`;
22
+ }
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);
34
+ const lines = source.split("\n");
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];
37
+ if (anchor) {
38
+ const offset = offsetOf(anchor.line);
39
+ return {
40
+ ok: true,
41
+ value: { source: insertLinesAfter(source, offset, [`${indentationAt(source, offset)}${text}`]), line: anchor.line + 1, afterSection: anchor.id }
42
+ };
43
+ }
44
+ const end = lines.findIndex((line) => /^\s*\\end\{document\}/.test(line));
45
+ if (end === -1) return { ok: false, reason: "it has no \\end{document} line" };
46
+ return { ok: true, value: { source: insertLinesBefore(source, offsetOf(end + 1), [text, ""]), line: end + 1 } };
47
+ }
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}` };
51
+ }
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}` };
57
+ }
58
+ return insertLine(source, vitapublicationsLine(placement.id, placement.heading), placement.after);
59
+ }
60
+
61
+ function dataStarter(id) {
62
+ return [
63
+ `# ${id}: one entry per item. vitakit sorts them by date, newest first.`,
64
+ `# Which fields an entry may have is decided in schemas/${id}.ts.`,
65
+ `- date: 2025-03-14`,
66
+ ` title: An example entry`,
67
+ ` note: Replace this with something real.`,
68
+ ""
69
+ ].join("\n");
70
+ }
71
+ const STARTER_ENTRIES = [{ date: "2025-03-14", title: "An example entry", note: "Replace this with something real." }];
72
+ const EXAMPLES = {
73
+ "text": "Some text",
74
+ "formatted-text": "Text with *emphasis* and $k$-ary trees",
75
+ "number": "4.5",
76
+ "yes-no": "true",
77
+ "date": "2025-03",
78
+ "web-address": "https://example.org",
79
+ "link": "{ name: University of Graz, url: https://uni-graz.at }",
80
+ "choice": "",
81
+ "group": "",
82
+ "custom": ""
83
+ };
84
+ function exampleValue(field) {
85
+ const single = field.type === "choice" ? field.choices?.[0] ?? "\u2026" : EXAMPLES[field.type] || "\u2026";
86
+ return field.several ? `[${single}]` : single;
87
+ }
88
+ function dataExample(id, fields) {
89
+ const required = fields.filter((field) => !field.optional);
90
+ const optional = fields.filter((field) => field.optional).map((field) => field.path);
91
+ const lines = required.map((field, index) => `#${index === 0 ? " -" : " "} ${field.path}: ${exampleValue(field)}`);
92
+ return [
93
+ `# ${id}: one entry per item; vitakit sorts them by date, newest first.`,
94
+ `# An entry looks like this (remove the # to use it):`,
95
+ `#`,
96
+ ...lines,
97
+ ...optional.length ? [`#`, `# Optional: ${optional.join(", ")}.`] : [],
98
+ ""
99
+ ].join("\n");
100
+ }
101
+ function rawEntries(source) {
102
+ const data = parse(source);
103
+ if (data === null || data === void 0) return [];
104
+ if (!Array.isArray(data)) throw new CommandError('expected a list of entries (lines starting with "- ")');
105
+ return data;
106
+ }
107
+ async function schemaOwner(inv, id) {
108
+ const section = inv.sections.find((candidate) => candidate.id === id);
109
+ if (section) return section.schema.origin === "none" ? void 0 : section;
110
+ const { schemas } = await import('./schemas.mjs');
111
+ const { describeSection } = await import('./fields.mjs');
112
+ if (!Object.hasOwn(schemas, id)) return void 0;
113
+ return { id, where: `content/${id}.yml`, schema: { origin: "built-in" }, fields: describeSection(schemas[id]) ?? [], problems: [] };
114
+ }
115
+ async function matchingSchemas(rootDir, inv, id, entries) {
116
+ if (!entries.length) return [];
117
+ const { findSectionFiles, resolveSectionSchema } = await import('./sections.mjs');
118
+ const { files } = await findSectionFiles(rootDir).catch(() => ({ files: [] }));
119
+ const { schemas } = await import('./schemas.mjs');
120
+ const keys = new Set(entries.flatMap((entry) => typeof entry === "object" && entry ? Object.keys(entry) : []));
121
+ const matches = [];
122
+ const builtIns = Object.keys(schemas).filter((name) => !inv.sections.some((section) => section.id === name));
123
+ const candidates = [...inv.sections, ...(await Promise.all(builtIns.map((name) => schemaOwner(inv, name)))).filter((section) => section !== void 0)];
124
+ for (const section of candidates) {
125
+ if (section.id === id || section.schema.origin === "none" || section.schema.sharedWith) continue;
126
+ const file = files.find((candidate) => candidate.id === section.id);
127
+ let schema;
128
+ try {
129
+ schema = file ? (await resolveSectionSchema(rootDir, file)).schema : schemas[section.id];
130
+ } catch {
131
+ continue;
132
+ }
133
+ const fields = new Set(section.fields?.map((field) => field.path) ?? []);
134
+ if ([...keys].every((key) => key === "visibility" || fields.has(key)) && schema.safeParse(entries).success) matches.push(section.id);
135
+ }
136
+ return matches;
137
+ }
138
+ function sharedSchemaModule(id, other) {
139
+ const body = other.schema.origin === "built-in" ? [`import { schemas } from '@anja.codes/vitakit'`, "", `export default schemas.${other.id.replace(/-(\w)/g, (_match, char) => char.toUpperCase())}`] : [`export { default } from './${other.id}.ts'`];
140
+ return [
141
+ "/**",
142
+ ` * content/${id}.yml has the same fields as content/${other.id}.yml. A field`,
143
+ ` * added to ${other.id} is accepted here too; to give ${id} fields of its own,`,
144
+ ` * replace this file with its own schema.`,
145
+ " */",
146
+ ...body,
147
+ ""
148
+ ].join("\n");
149
+ }
150
+ const FIELD_TYPES = [
151
+ { type: "text", label: "text" },
152
+ { type: "formatted-text", label: "formatted text (Markdown, $math$)" },
153
+ { type: "number", label: "number" },
154
+ { type: "yes-no", label: "yes/no" },
155
+ { type: "date", label: "date" },
156
+ { type: "web-address", label: "web address" },
157
+ { type: "link", label: "link (a name, optionally with a web address)" },
158
+ { type: "choice", label: "one of a fixed list" }
159
+ ];
160
+ const CODE = {
161
+ "text": { code: "text", imports: ["text"] },
162
+ "formatted-text": { code: "richText", imports: ["richText"] },
163
+ "number": { code: "z.number()", imports: ["z"] },
164
+ "yes-no": { code: "z.boolean()", imports: ["z"] },
165
+ "date": { code: "dateRange", imports: ["dateRange"] },
166
+ "web-address": { code: "url", imports: ["url"] },
167
+ "link": { code: "linked", imports: ["linked"] }
168
+ };
169
+ function fieldCode(spec) {
170
+ let base = CODE[spec.type];
171
+ if (spec.type === "choice") {
172
+ if (!spec.choices.length) throw new CommandError("a field of type choice needs its values: --choice <value>, once per value");
173
+ base = { code: `z.enum([${spec.choices.map((choice) => `'${choice.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`).join(", ")}])`, imports: ["z"] };
174
+ }
175
+ if (!base) throw new CommandError(`unknown type ${spec.type}`);
176
+ const code = spec.several ? `z.array(${base.code})` : base.code;
177
+ return {
178
+ code: `${code}${spec.required ? "" : ".optional()"}`,
179
+ imports: [.../* @__PURE__ */ new Set([...base.imports, ...spec.several ? ["z"] : []])]
180
+ };
181
+ }
182
+ function extensionModule(id, fields, imports) {
183
+ const names = [.../* @__PURE__ */ new Set(["extendSection", "schemas", ...imports])].sort();
184
+ const builtin = id.replace(/-(\w)/g, (_match, char) => char.toUpperCase());
185
+ return [
186
+ "/**",
187
+ ` * The ${id} section: vitakit's built-in fields, and yours below. A field`,
188
+ ` * here is accepted in content/${id}.yml; showing it on the website or in`,
189
+ ` * the PDF is a separate step (\`vitakit customize ${id}\`). A built-in field`,
190
+ ` * listed here replaces vitakit's rule for it.`,
191
+ " */",
192
+ `import { ${names.join(", ")} } from '@anja.codes/vitakit'`,
193
+ "",
194
+ `export default extendSection(schemas.${builtin}, {`,
195
+ ...fields.length ? fields.flatMap((field) => [...(field.notes ?? []).map((note) => ` // ${note}`), ` ${propertyKey(field.name)}: ${field.code},`]) : [" // Your fields, e.g.", " // evaluation: z.number().optional(),"],
196
+ "})",
197
+ ""
198
+ ].join("\n");
199
+ }
200
+ async function planAddField(ws, section, name, spec) {
201
+ const { code, imports } = fieldCode(spec);
202
+ const path = `schemas/${section.id}.ts`;
203
+ if (section.schema.origin === "built-in") {
204
+ ws.create(path, extensionModule(section.id, [{ name, code }], imports), `keeps vitakit's ${section.id} fields, adds ${name}`, { show: true });
205
+ return "created";
206
+ }
207
+ const source = await ws.read(path);
208
+ if (source === void 0) throw new CommandError(`${section.id} has no schema yet; \`vitakit add section ${section.id}\` drafts one`);
209
+ const added = addSchemaField(source, name, code);
210
+ const imported = added.ok ? ensurePackageImports(added.value, imports) : added;
211
+ if (imported.ok) {
212
+ await ws.edit(path, imported.value, `adds ${name}`);
213
+ return "edited";
214
+ }
215
+ ws.paste(path, `${propertyKey(name)}: ${code},`, `cannot be changed safely (${imported.reason}); add this line among the fields, and import ${imports.join(", ")} from '@anja.codes/vitakit'`);
216
+ return "pasted";
217
+ }
218
+ async function planPlaceOnPage(ws, inv, path, placement) {
219
+ const source = await ws.read(path);
220
+ if (source === void 0) throw new CommandError(`${path} does not exist`);
221
+ const result = addSectionToPage(source, placement, inv.web.components.keys());
222
+ if (result.ok) {
223
+ await ws.edit(path, result.value.source, `shows ${placement.id}${result.value.afterSection ? ` after ${result.value.afterSection}` : ""}`);
224
+ return path;
225
+ }
226
+ if (result.reason.startsWith("it already shows")) {
227
+ ws.keep(path, `already shows ${placement.id}`);
228
+ return path;
229
+ }
230
+ ws.paste(path, sectionElementSource(placement), `cannot be changed safely (${result.reason}); add this line where the section should appear`);
231
+ return path;
232
+ }
233
+ async function planPlaceInDocument(ws, document, placement) {
234
+ const path = `cv-tex/${document}.tex`;
235
+ const source = await ws.read(path);
236
+ if (source === void 0) throw new CommandError(`${path} does not exist`);
237
+ const result = addSectionToDocument(source, { id: placement.id, ...placement.title ? { heading: placement.title } : {}, ...placement.after ? { after: placement.after } : {} });
238
+ if (result.ok) {
239
+ await ws.edit(path, result.value.source, `includes ${placement.id}${result.value.afterSection ? ` after ${result.value.afterSection}` : ""}`);
240
+ } else if (result.reason.startsWith("it already includes")) {
241
+ ws.keep(path, `already includes ${placement.id}`);
242
+ } else {
243
+ ws.paste(path, vitasectionLine(placement.id, placement.title), `cannot be changed safely (${result.reason}); add this line where the section should appear`);
244
+ }
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
+ }
263
+ function pageModule(route, name, title) {
264
+ return `<script setup lang="ts">
265
+ /**
266
+ * The ${title} page: the text from content/pages/${name}.md, then sections.
267
+ *
268
+ * One line per section, in the order they appear; \`vitakit add section\`
269
+ * adds more. A section without entries is not shown, heading included.
270
+ */
271
+ const profile = useProfile()
272
+ const page = usePage('${route}')
273
+
274
+ if (!page) {
275
+ throw createError({ statusCode: 404, statusMessage: 'No page for ${route} (expected content/pages/${name}.md)', fatal: true })
276
+ }
277
+
278
+ useHead({
279
+ title: \`\${page.title} \u2013 \${profile.name}\`,
280
+ meta: [{ name: 'description', content: page.description ?? profile.summary }],
281
+ })
282
+ <\/script>
283
+
284
+ <template>
285
+ <article>
286
+ <h1 class="text-3xl font-semibold" v-html="page.titleHtml" />
287
+ <VitaMarkdown class="mt-6" :html="page.bodyHtml" />
288
+ </article>
289
+ </template>
290
+ `;
291
+ }
292
+ function pageText(name, title) {
293
+ return `---
294
+ title: ${yamlScalar(title)}
295
+ ---
296
+
297
+ An introduction to this page, in Markdown. The sections below it are listed
298
+ in app/pages/${name}.vue.
299
+ `;
300
+ }
301
+ async function planCreatePage(ws, inv, name, { title, menu }) {
302
+ const { sectionNameProblem } = await import('./sections.mjs');
303
+ const problem = sectionNameProblem(name);
304
+ if (problem && !problem.includes("reserved")) throw new CommandError(problem.replace("research-stays", "research-interests"));
305
+ const route = `/${name}/`;
306
+ const existing = inv.pages.find((page) => page.route === route);
307
+ if (existing?.vue) throw new CommandError(`${route} exists already (${[existing.markdown, existing.vue].filter(Boolean).join(", ")})`);
308
+ const vue = `app/pages/${name}.vue`;
309
+ if (existing?.markdown) {
310
+ if (existing.markdown !== `content/pages/${name}.md`) {
311
+ throw new CommandError(`${route} is ${existing.markdown}; its Vue page would have to read it by route, which this command does not write`);
312
+ }
313
+ ws.keep(existing.markdown, "the page's title and text, as they are");
314
+ } else {
315
+ ws.create(`content/pages/${name}.md`, pageText(name, title), "the page's title and text");
316
+ }
317
+ ws.create(vue, pageModule(route, name, existing?.title ?? title), "the page, with its sections");
318
+ if (menu && !existing?.inMenu) {
319
+ const profile = await ws.read("content/profile.yml");
320
+ const result = profile === void 0 ? { ok: false, reason: "it does not exist" } : addNavItem(profile, { label: title, to: route });
321
+ if (result.ok) await ws.edit("content/profile.yml", result.value, `adds ${title} to the menu`);
322
+ else ws.paste("content/profile.yml", `- { label: ${yamlScalar(title)}, to: ${route} }`, `cannot be changed safely (${result.reason}); add this to nav`);
323
+ }
324
+ return vue;
325
+ }
326
+ function fieldsNotShown(fields) {
327
+ const shown = /* @__PURE__ */ new Set(["date", "title", "position", "degree", "role", "student", "event", "term", "institution", "issuer", "organisation", "location", "note", "url", "slides", "ongoing"]);
328
+ return (fields ?? []).map((field) => field.path).filter((path) => !shown.has(path));
329
+ }
330
+ async function planMissingFields(rootDir, ws, section, entries) {
331
+ const { draftExtension } = await import('./draft.mjs');
332
+ const { findSectionFiles, resolveSectionSchema } = await import('./sections.mjs');
333
+ const { schemas } = await import('./schemas.mjs');
334
+ const builtIn = section.schema.origin === "built-in";
335
+ let base = builtIn ? schemas[section.id] : void 0;
336
+ if (!base) {
337
+ const { files } = await findSectionFiles(rootDir).catch(() => ({ files: [] }));
338
+ const file = files.find((candidate) => candidate.id === section.id);
339
+ if (!file) return { fields: [], changes: [] };
340
+ try {
341
+ base = (await resolveSectionSchema(rootDir, file)).schema;
342
+ } catch {
343
+ return { fields: [], changes: [] };
344
+ }
345
+ }
346
+ const extension = draftExtension(base, entries);
347
+ if (!extension?.fields.length) return { fields: [], changes: [] };
348
+ const names = extension.fields.map((field) => field.name);
349
+ const path = `schemas/${section.id}.ts`;
350
+ if (builtIn) {
351
+ ws.create(path, extensionModule(section.id, extension.fields, extension.imports), `keeps vitakit's ${section.id} fields, adds ${names.join(", ")} from your entries`, { show: true });
352
+ return { fields: names, changes: extension.changes };
353
+ }
354
+ if (section.schema.sharedWith) {
355
+ return { fields: [], changes: [], note: `Entries of ${section.id} have fields its shared schema (${section.schema.sharedWith}) does not: ${names.join(", ")}.` };
356
+ }
357
+ let source = await ws.read(path) ?? "";
358
+ for (const field of extension.fields) {
359
+ const added = addSchemaField(source, field.name, field.code);
360
+ if (!added.ok) {
361
+ ws.paste(path, extension.fields.map((item) => `${propertyKey(item.name)}: ${item.code},`).join("\n"), `cannot be changed safely (${added.reason}); add these fields`);
362
+ return { fields: [], changes: extension.changes };
363
+ }
364
+ source = added.value;
365
+ }
366
+ const imported = ensurePackageImports(source, extension.imports);
367
+ if (!imported.ok) return { fields: [], changes: [] };
368
+ await ws.edit(path, imported.value, `adds ${names.join(", ")} from your entries`);
369
+ return { fields: names, changes: extension.changes };
370
+ }
371
+
372
+ export { FIELD_TYPES, STARTER_ENTRIES, dataExample, dataStarter, exampleValue, extensionModule, fieldCode, fieldsNotShown, matchingSchemas, planAddField, planCreatePage, planListInDocument, planListOnPage, planMissingFields, planPlaceInDocument, planPlaceOnPage, rawEntries, schemaOwner, sharedSchemaModule };