@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.
- package/README.md +51 -131
- package/dist/check.mjs +12 -5
- package/dist/chunks/authoring.mjs +367 -0
- package/dist/chunks/build.mjs +176 -0
- package/dist/chunks/documents.mjs +143 -0
- package/dist/chunks/draft.mjs +222 -0
- package/dist/chunks/eject.mjs +59 -0
- package/dist/chunks/emit.mjs +200 -0
- package/dist/chunks/eta.mjs +16 -0
- package/dist/chunks/fields.mjs +79 -0
- package/dist/chunks/inventory.mjs +199 -0
- package/dist/chunks/plan.mjs +144 -0
- package/dist/chunks/prompt.mjs +33 -0
- package/dist/chunks/schema-source.mjs +12 -0
- package/dist/chunks/schemas.mjs +109 -0
- package/dist/chunks/sections.mjs +109 -0
- package/dist/chunks/snippets.mjs +199 -0
- package/dist/chunks/validate.mjs +124 -0
- package/dist/chunks/vue.mjs +116 -0
- package/dist/cli.d.mts +33 -40
- package/dist/cli.mjs +7 -224
- package/dist/index.d.mts +372 -42
- package/dist/index.mjs +10 -4
- package/dist/nuxt.mjs +35 -5
- package/dist/runtime/components/VitaSection.vue +94 -38
- package/dist/schemas/education.ts +4 -4
- package/dist/schemas/employment.ts +3 -3
- package/dist/schemas/publications.ts +2 -2
- package/dist/schemas/supervision.ts +3 -3
- package/dist/schemas/talks.ts +1 -1
- package/dist/schemas/teaching.ts +3 -3
- package/dist/shared/index.d.mts +60 -9
- package/dist/shared/index.mjs +3 -57
- package/dist/shared/vitakit.BZZIsWlY.mjs +179 -0
- package/dist/shared/vitakit.BerUaxtF.mjs +121 -0
- package/dist/shared/{vitakit.D8WRg_Xx.d.mts → vitakit.BfpxDtz1.d.mts} +9 -8
- package/dist/shared/{vitakit.CypCF-4Q.mjs → vitakit.By5VWQ1n.mjs} +12 -78
- package/dist/shared/vitakit.CfB3hMHl.mjs +60 -0
- package/dist/shared/vitakit.CoWX2Z4R.mjs +1554 -0
- package/dist/shared/{vitakit.BlU-p01e.mjs → vitakit.CtChKO0I.mjs} +1 -1
- package/dist/shared/vitakit.D-DdJaDZ.mjs +20 -0
- package/dist/shared/vitakit.D10HyDhF.mjs +19 -0
- package/dist/shared/vitakit.DQ6PmGoL.mjs +34 -0
- package/dist/shared/vitakit.DbSeENVS.mjs +58 -0
- package/dist/shared/vitakit.DcQOdM4I.mjs +57 -0
- package/dist/shared/vitakit.Do5HC_MD.mjs +142 -0
- package/dist/shared/vitakit.TSMTC5Jl.mjs +6 -0
- package/dist/tex/templates/_generic.eta +6 -4
- package/dist/tex/templates/education.eta +10 -5
- package/dist/tex/templates/employment.eta +12 -5
- package/dist/tex/templates/publications.eta +4 -2
- package/dist/tex/templates/supervision.eta +10 -2
- package/dist/tex/templates/talks.eta +5 -1
- package/dist/tex/templates/teaching.eta +6 -4
- package/dist/tex/vitakit-cv.cls +21 -6
- package/dist/tex.d.mts +53 -5
- package/dist/tex.mjs +20 -113
- package/package.json +10 -7
- package/src/bin/vitakit.mjs +3 -153
- package/dist/shared/vitakit.BRhjqpAt.mjs +0 -250
- package/dist/shared/vitakit.BxcnlFGP.mjs +0 -325
- package/src/bin/vitakit-build-cv.mjs +0 -50
- package/src/bin/vitakit-check.mjs +0 -57
package/README.md
CHANGED
|
@@ -1,159 +1,79 @@
|
|
|
1
1
|
# @anja.codes/vitakit
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
9
|
-
pnpm
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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,
|
|
28
|
+
profile.yml name, affiliation, links, menu, footer
|
|
41
29
|
<section>.yml education, employment, teaching, talks, …
|
|
42
|
-
publications.bib grouped, linked
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
//
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
pnpm build
|
|
114
|
-
pnpm
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
```
|
|
76
|
+
Not yet implemented: cover-letter helpers (`\vitacount`, `\vitafirst`), a
|
|
77
|
+
second theme, and a Typst target.
|
|
156
78
|
|
|
157
|
-
|
|
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.
|
|
4
|
-
import { M as MARKER_KEY
|
|
5
|
-
import {
|
|
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 '
|
|
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
|
|
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 };
|