@anja.codes/vitakit 0.1.0 → 0.2.0

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