@anja.codes/create-vitakit 0.0.0-stage → 0.1.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 (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/index.mjs +139 -0
  4. package/package.json +38 -4
  5. package/template/.gitignore.template +19 -0
  6. package/template/_forgejo/workflows/deploy.yml +31 -0
  7. package/template/_github/workflows/cloudflare.yml +31 -0
  8. package/template/_github/workflows/cv.yml +36 -0
  9. package/template/_github/workflows/deploy.yml +53 -0
  10. package/template/app/app.vue +5 -0
  11. package/template/app/assets/css/main.css +117 -0
  12. package/template/app/layouts/default.vue +39 -0
  13. package/template/app/pages/[...slug].vue +30 -0
  14. package/template/app/pages/cv.vue +39 -0
  15. package/template/app/pages/index.vue +62 -0
  16. package/template/app/pages/publications.vue +25 -0
  17. package/template/content/education.yml +18 -0
  18. package/template/content/employment.yml +18 -0
  19. package/template/content/pages/cv.md +9 -0
  20. package/template/content/pages/index.md +11 -0
  21. package/template/content/pages/publications.md +8 -0
  22. package/template/content/pages/research.md +32 -0
  23. package/template/content/profile.yml +34 -0
  24. package/template/content/publications.bib +43 -0
  25. package/template/content/supervision.yml +12 -0
  26. package/template/content/talks.yml +17 -0
  27. package/template/content/teaching.yml +17 -0
  28. package/template/cv-tex/cv.tex +38 -0
  29. package/template/cv-tex/cv.yml +7 -0
  30. package/template/cv-tex/latexmkrc +5 -0
  31. package/template/cv-tex/preamble.tex +10 -0
  32. package/template/cv-tex/prose/research-statement.tex +17 -0
  33. package/template/nuxt.config.ts +32 -0
  34. package/template/package.json.template +34 -0
  35. package/template/public/img/portrait.svg +6 -0
  36. package/template/public/img/tree.svg +10 -0
  37. package/template/tsconfig.json +9 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anja Ressmann, Benjamin Hackl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,23 @@
1
- # Temporary Holding Version
1
+ # @anja.codes/create-vitakit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Scaffold a [vitakit](https://github.com/anja-codes/vitakit) project: an academic
4
+ website and a LaTeX CV built from one set of files.
5
+
6
+ ```bash
7
+ pnpm create @anja.codes/vitakit my-site
8
+ cd my-site
9
+ pnpm install
10
+ pnpm dev # http://localhost:3000
11
+ pnpm build:cv # cv-tex/cv.pdf (needs a TeX distribution)
12
+ pnpm check # what CI runs
13
+ ```
14
+
15
+ It asks for your name, position, affiliation, email and a deployment target
16
+ (GitHub Pages, Cloudflare Pages, rsync, or none), copies the template and
17
+ substitutes those few values.
18
+
19
+ This is the only scaffolding step vitakit has. Everything afterwards — a new
20
+ page, a new CV variant, a new section — is adding or copying a file in a
21
+ repository you own.
22
+
23
+ MIT
package/index.mjs ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pnpm create @anja.codes/vitakit [directory]
4
+ *
5
+ * Copies the template, substitutes the few things that are personal, and picks
6
+ * a deployment workflow. Deliberately the *only* scaffolding step vitakit has:
7
+ * everything afterwards is editing files in a repository you own.
8
+ */
9
+ import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
10
+ import { existsSync } from 'node:fs'
11
+ import { basename, join, resolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { createInterface } from 'node:readline/promises'
14
+ import process from 'node:process'
15
+
16
+ const TEMPLATE = fileURLToPath(new URL('template', import.meta.url))
17
+
18
+ const DEPLOYMENTS = {
19
+ 'github-pages': { label: 'GitHub Pages (public repository)', keep: ['deploy.yml', 'cv.yml'], host: '_github' },
20
+ 'cloudflare': { label: 'Cloudflare Pages (works with a private repository)', keep: ['cloudflare.yml', 'cv.yml'], host: '_github' },
21
+ 'rsync': { label: 'Web space via rsync (Forgejo)', keep: ['deploy.yml'], host: '_forgejo' },
22
+ 'none': { label: 'None for now', keep: [], host: undefined },
23
+ }
24
+
25
+ function slugify(value) {
26
+ return value.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
27
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'my-site'
28
+ }
29
+
30
+ /** Replace the placeholders the template uses. */
31
+ function fill(text, answers) {
32
+ return text
33
+ .replaceAll('__NAME__', answers.name)
34
+ .replaceAll('__TITLE__', answers.title)
35
+ .replaceAll('__AFFILIATION__', answers.affiliation)
36
+ .replaceAll('__EMAIL__', answers.email)
37
+ .replaceAll('__SLUG__', answers.slug)
38
+ }
39
+
40
+ const TEXT_FILE = /\.(md|ya?ml|json|ts|vue|css|bib|tex|svg|txt|yml|template)$/
41
+
42
+ async function scaffold(target, answers) {
43
+ await mkdir(target, { recursive: true })
44
+ await cp(TEMPLATE, target, { recursive: true })
45
+
46
+ // Files that cannot be shipped under their final name: `.gitignore` is
47
+ // excluded by npm, and `package.json` would make the template a workspace.
48
+ for (const [from, to] of [['.gitignore.template', '.gitignore'], ['package.json.template', 'package.json']]) {
49
+ await rename(join(target, from), join(target, to))
50
+ }
51
+
52
+ const deployment = DEPLOYMENTS[answers.deployment]
53
+ for (const [dir, name] of [['_github', '.github'], ['_forgejo', '.forgejo']]) {
54
+ const path = join(target, dir)
55
+ if (deployment.host !== dir) {
56
+ await rm(path, { recursive: true, force: true })
57
+ continue
58
+ }
59
+ const workflows = join(path, 'workflows')
60
+ for (const file of await readdir(workflows)) {
61
+ if (!deployment.keep.includes(file)) await rm(join(workflows, file))
62
+ }
63
+ await rename(path, join(target, name))
64
+ }
65
+
66
+ // Substitute placeholders in every text file.
67
+ const walk = async (dir) => {
68
+ for (const item of await readdir(dir, { withFileTypes: true })) {
69
+ const path = join(dir, item.name)
70
+ if (item.isDirectory()) await walk(path)
71
+ else if (TEXT_FILE.test(item.name) || item.name === 'package.json' || item.name === '.gitignore') {
72
+ await writeFile(path, fill(await readFile(path, 'utf8'), answers))
73
+ }
74
+ }
75
+ }
76
+ await walk(target)
77
+ }
78
+
79
+ async function main() {
80
+ const [directoryArgument] = process.argv.slice(2).filter(argument => !argument.startsWith('-'))
81
+ const interactive = process.stdin.isTTY && !process.env.VITAKIT_CREATE_DEFAULTS
82
+
83
+ const answers = {
84
+ name: 'Your Name',
85
+ title: 'Postdoctoral Researcher',
86
+ affiliation: 'Department of Mathematics, University of Graz',
87
+ email: 'you@example.org',
88
+ deployment: 'github-pages',
89
+ }
90
+
91
+ let directory = directoryArgument
92
+
93
+ if (interactive) {
94
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
95
+ const ask = async (question, fallback) => {
96
+ const answer = (await rl.question(`${question} (${fallback}) `)).trim()
97
+ return answer || fallback
98
+ }
99
+
100
+ console.log('\nvitakit — a website and a LaTeX CV from one set of files.\n')
101
+ answers.name = await ask('Your name:', answers.name)
102
+ answers.title = await ask('Your position:', answers.title)
103
+ answers.affiliation = await ask('Affiliation:', answers.affiliation)
104
+ answers.email = await ask('Email:', answers.email)
105
+ directory ??= await ask('Directory:', slugify(answers.name))
106
+
107
+ console.log('\nDeployment:')
108
+ const keys = Object.keys(DEPLOYMENTS)
109
+ keys.forEach((key, index) => console.log(` ${index + 1}. ${DEPLOYMENTS[key].label}`))
110
+ const choice = await ask('Choose:', '1')
111
+ answers.deployment = keys[Number(choice) - 1] ?? 'github-pages'
112
+
113
+ rl.close()
114
+ }
115
+
116
+ directory ??= slugify(answers.name)
117
+ answers.slug = slugify(basename(directory))
118
+ const target = resolve(process.cwd(), directory)
119
+
120
+ if (existsSync(target) && (await readdir(target)).length) {
121
+ throw new Error(`${directory} already exists and is not empty`)
122
+ }
123
+
124
+ await scaffold(target, answers)
125
+
126
+ console.log(`\nCreated ${directory}\n`)
127
+ console.log(' cd ' + directory)
128
+ console.log(' pnpm install')
129
+ console.log(' pnpm dev # http://localhost:3000')
130
+ console.log(' pnpm build:cv # cv-tex/cv.pdf (needs LaTeX)')
131
+ console.log(' pnpm check # what CI runs\n')
132
+ console.log('Then edit content/: profile.yml, the section files and publications.bib.')
133
+ console.log('The PDF is not part of the website build; assume this repository is private.\n')
134
+ }
135
+
136
+ main().catch((error) => {
137
+ console.error(`\n@anja.codes/create-vitakit: ${error instanceof Error ? error.message : String(error)}\n`)
138
+ process.exit(1)
139
+ })
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@anja.codes/create-vitakit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a vitakit project: an academic website and a LaTeX CV from one set of files.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "vitakit",
8
+ "create",
9
+ "scaffold",
10
+ "cv",
11
+ "academic",
12
+ "nuxt",
13
+ "latex"
14
+ ],
15
+ "homepage": "https://github.com/anja-codes/vitakit/tree/main/create#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/anja-codes/vitakit/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/anja-codes/vitakit.git",
22
+ "directory": "create"
23
+ },
24
+ "type": "module",
25
+ "bin": {
26
+ "create-vitakit": "index.mjs"
27
+ },
28
+ "files": [
29
+ "index.mjs",
30
+ "template",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=22.18"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }
@@ -0,0 +1,19 @@
1
+ # Nuxt
2
+ .output
3
+ .nuxt
4
+ .data
5
+ .nitro
6
+ .cache
7
+ dist
8
+ node_modules
9
+
10
+ # vitakit / latexmk output
11
+ # Everything generated lives in cv-tex/generated/ (the .cls and all auxiliary
12
+ # files included); only the finished PDF is written beside the sources.
13
+ cv-tex/generated
14
+ cv-tex/*.pdf
15
+
16
+ # Misc
17
+ *.log
18
+ .DS_Store
19
+ .env
@@ -0,0 +1,31 @@
1
+ # Builds the site and deploys it to web space via rsync.
2
+ #
3
+ # Secret: SSH_DEPLOY_KEY (private key with write access to the target).
4
+ # Adjust NUXT_APP_BASE_URL and the rsync target below.
5
+ on:
6
+ push:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ jobs:
11
+ deploy:
12
+ runs-on: ubuntu-latest
13
+ env:
14
+ NUXT_APP_BASE_URL: /
15
+ steps:
16
+ - uses: https://github.com/actions/checkout@v7
17
+ - run: apt update && apt install -y rsync
18
+ - uses: https://github.com/pnpm/action-setup@v6
19
+ # The runner image may ship an older Node than Nuxt requires.
20
+ - uses: https://github.com/actions/setup-node@v7
21
+ with:
22
+ node-version: 22
23
+ cache: pnpm
24
+ - run: pnpm install --frozen-lockfile
25
+ - run: pnpm check
26
+ - uses: https://github.com/webfactory/ssh-agent@v0.9.0
27
+ with:
28
+ ssh-private-key: ${{ secrets.SSH_DEPLOY_KEY }}
29
+ - run: |
30
+ rsync -e "ssh -o StrictHostKeyChecking=no" -av --delete \
31
+ .output/public/ user@example.org:/var/www/your-site/
@@ -0,0 +1,31 @@
1
+ # Builds the site and deploys it to Cloudflare Pages.
2
+ #
3
+ # Recommended when the repository is private: Cloudflare Pages builds from
4
+ # private repositories on the free tier, which GitHub Pages does not.
5
+ #
6
+ # Secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID.
7
+ # Set `projectName` to your Pages project.
8
+ name: Deploy (Cloudflare Pages)
9
+
10
+ on:
11
+ push:
12
+ branches: [main]
13
+ workflow_dispatch:
14
+
15
+ jobs:
16
+ deploy:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v7
20
+ - uses: pnpm/action-setup@v6
21
+ - uses: actions/setup-node@v7
22
+ with:
23
+ node-version: 22
24
+ cache: pnpm
25
+ - run: pnpm install --frozen-lockfile
26
+ - run: pnpm check
27
+ - uses: cloudflare/wrangler-action@v4
28
+ with:
29
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
30
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
31
+ command: pages deploy .output/public --project-name=__SLUG__
@@ -0,0 +1,36 @@
1
+ # Builds the PDF CV and attaches it to the workflow run.
2
+ #
3
+ # The PDF is an artifact, not part of the website: it may contain entries
4
+ # marked `visibility: cv` (address, phone, referees). Download it from the run
5
+ # summary. Runs on demand, and on pushes that touch CV material.
6
+ name: CV
7
+
8
+ on:
9
+ workflow_dispatch:
10
+ push:
11
+ branches: [main]
12
+ paths: ['content/**', 'cv-tex/**']
13
+
14
+ jobs:
15
+ build:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+ - uses: pnpm/action-setup@v6
20
+ - uses: actions/setup-node@v7
21
+ with:
22
+ node-version: 22
23
+ cache: pnpm
24
+ - run: pnpm install --frozen-lockfile
25
+ # Generate the .tex files here: the LaTeX action runs in its own container,
26
+ # without Node. cv-tex/latexmkrc lets it find the generated document class.
27
+ - run: pnpm build:cv --no-pdf
28
+ - uses: xu-cheng/latex-action@v4
29
+ with:
30
+ working_directory: cv-tex
31
+ root_file: cv.tex
32
+ latexmk_use_lualatex: false
33
+ - uses: actions/upload-artifact@v5
34
+ with:
35
+ name: cv
36
+ path: cv-tex/*.pdf
@@ -0,0 +1,53 @@
1
+ # Builds the site and publishes it to GitHub Pages.
2
+ #
3
+ # Setup: Settings -> Pages -> Source: "GitHub Actions".
4
+ #
5
+ # NUXT_APP_BASE_URL must match where the site is served from. For a project
6
+ # site at https://<user>.github.io/<repo>/ keep the line below; for a user site
7
+ # or a custom domain, delete it. A wrong value breaks every asset URL.
8
+ name: Deploy
9
+
10
+ on:
11
+ push:
12
+ branches: [main]
13
+ workflow_dispatch:
14
+
15
+ permissions:
16
+ contents: read
17
+ pages: write
18
+ id-token: write
19
+
20
+ concurrency:
21
+ group: pages
22
+ cancel-in-progress: true
23
+
24
+ jobs:
25
+ build:
26
+ runs-on: ubuntu-latest
27
+ env:
28
+ NUXT_APP_BASE_URL: /${{ github.event.repository.name }}/
29
+ steps:
30
+ - uses: actions/checkout@v7
31
+ # No `version:` here: the pnpm version comes from `packageManager` in
32
+ # package.json. Setting both fails with ERR_PNPM_BAD_PM_VERSION.
33
+ - uses: pnpm/action-setup@v6
34
+ - uses: actions/setup-node@v7
35
+ with:
36
+ node-version: 22 # keep in sync with engines.node
37
+ cache: pnpm
38
+ - run: pnpm install --frozen-lockfile
39
+ # typecheck + generate + assertions on the generated HTML
40
+ - run: pnpm check
41
+ - uses: actions/upload-pages-artifact@v5
42
+ with:
43
+ path: .output/public
44
+
45
+ deploy:
46
+ needs: build
47
+ runs-on: ubuntu-latest
48
+ environment:
49
+ name: github-pages
50
+ url: ${{ steps.deployment.outputs.page_url }}
51
+ steps:
52
+ - id: deployment
53
+ uses: actions/deploy-pages@v5
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <NuxtLayout>
3
+ <NuxtPage />
4
+ </NuxtLayout>
5
+ </template>
@@ -0,0 +1,117 @@
1
+ @import "tailwindcss";
2
+
3
+ /* Design tokens: the first stop for restyling. Tailwind v4 reads these, so
4
+ `text-primary` and `font-serif` follow from here. */
5
+ @theme {
6
+ --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
7
+ --font-serif: "Lora", ui-serif, Georgia, serif;
8
+ --color-primary: oklch(48% 0.13 250);
9
+ }
10
+
11
+ body {
12
+ @apply font-sans text-neutral-900 antialiased;
13
+ }
14
+
15
+ a {
16
+ @apply text-primary underline underline-offset-2 hover:no-underline;
17
+ }
18
+
19
+ /* --- vitakit components ------------------------------------------------ */
20
+
21
+ .vita-entry {
22
+ @apply grid gap-1 py-2 sm:grid-cols-[8rem_1fr] sm:gap-4;
23
+ }
24
+ .vita-entry__date {
25
+ @apply text-sm text-neutral-500 tabular-nums;
26
+ }
27
+ .vita-entry__title {
28
+ @apply font-medium;
29
+ }
30
+ .vita-entry__meta,
31
+ .vita-entry__note {
32
+ @apply text-sm text-neutral-600;
33
+ }
34
+ .vita-section {
35
+ @apply divide-y divide-neutral-100;
36
+ }
37
+ .vita-links {
38
+ @apply flex flex-wrap gap-x-4 gap-y-1;
39
+ }
40
+ .vita-publications__group + .vita-publications__group {
41
+ @apply mt-8;
42
+ }
43
+ .vita-publications__heading {
44
+ @apply text-xl font-semibold;
45
+ }
46
+ .vita-bib {
47
+ @apply mt-3 list-decimal space-y-3 pl-6;
48
+ }
49
+ .vita-badge {
50
+ @apply ml-1 rounded bg-neutral-100 px-1.5 py-0.5 text-xs no-underline hover:bg-neutral-200;
51
+ }
52
+ .vita-issues {
53
+ @apply border-b border-red-300 bg-red-50 px-4 py-3 text-sm text-red-900;
54
+ }
55
+ .vita-issues__title {
56
+ @apply font-semibold;
57
+ }
58
+ .vita-issues pre {
59
+ @apply whitespace-pre-wrap font-sans;
60
+ }
61
+
62
+ /* --- Markdown output --------------------------------------------------- */
63
+
64
+ .vita-prose {
65
+ @apply leading-relaxed;
66
+ }
67
+ .vita-prose :where(p, ul, ol, blockquote, pre, table) {
68
+ @apply my-4;
69
+ }
70
+ .vita-prose :where(h2) {
71
+ @apply mt-8 mb-2 text-xl font-semibold;
72
+ }
73
+ .vita-prose :where(h3) {
74
+ @apply mt-6 mb-2 text-lg font-semibold;
75
+ }
76
+ .vita-prose :where(ul) {
77
+ @apply list-disc pl-6;
78
+ }
79
+ .vita-prose :where(ol) {
80
+ @apply list-decimal pl-6;
81
+ }
82
+ .vita-prose :where(code) {
83
+ @apply rounded bg-neutral-100 px-1 py-0.5 text-sm;
84
+ }
85
+ .vita-prose :where(pre) {
86
+ @apply overflow-x-auto rounded bg-neutral-100 p-4 text-sm;
87
+ }
88
+ .vita-prose :where(blockquote) {
89
+ @apply border-l-4 border-neutral-200 pl-4 text-neutral-600;
90
+ }
91
+ .vita-prose :where(figure) {
92
+ @apply my-4;
93
+ }
94
+ .vita-prose :where(figcaption) {
95
+ @apply mt-1 text-xs text-neutral-500;
96
+ }
97
+ /* A figure beside the text on wide screens, above it on narrow ones. */
98
+ .vita-prose :where(figure.figure-right) {
99
+ @apply sm:float-right sm:ml-6 sm:mt-0 sm:w-56;
100
+ }
101
+ .vita-prose :where(figure.figure-right) img {
102
+ @apply w-full rounded border border-neutral-200 bg-white p-2;
103
+ }
104
+ .vita-prose :where(table) {
105
+ @apply w-full border-collapse text-left text-sm;
106
+ }
107
+ .vita-prose :where(th, td) {
108
+ @apply border-b border-neutral-200 py-2 pr-4 align-top;
109
+ }
110
+ .vita-prose :where(th) {
111
+ @apply font-semibold;
112
+ }
113
+
114
+ /* Wide display formulas must not blow up the layout on narrow screens. */
115
+ .katex-display {
116
+ @apply overflow-x-auto overflow-y-hidden py-1;
117
+ }
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ const profile = useProfile()
3
+ </script>
4
+
5
+ <template>
6
+ <div class="min-h-screen flex flex-col">
7
+ <!-- Shows content problems while developing; a build fails on them instead. -->
8
+ <VitaIssues />
9
+
10
+ <header class="border-b border-neutral-200">
11
+ <div class="mx-auto flex max-w-3xl flex-wrap items-baseline gap-x-6 gap-y-2 px-4 py-6">
12
+ <NuxtLink to="/" class="text-lg font-semibold no-underline">
13
+ {{ profile.name }}
14
+ </NuxtLink>
15
+ <nav class="ml-auto flex gap-4 text-sm">
16
+ <NuxtLink
17
+ v-for="item in profile.nav"
18
+ :key="item.url"
19
+ :to="item.url"
20
+ class="no-underline hover:underline"
21
+ >
22
+ {{ item.label }}
23
+ </NuxtLink>
24
+ </nav>
25
+ </div>
26
+ </header>
27
+
28
+ <main class="mx-auto w-full max-w-3xl flex-1 px-4 py-10">
29
+ <slot />
30
+ </main>
31
+
32
+ <footer class="border-t border-neutral-200">
33
+ <div class="mx-auto flex max-w-3xl flex-wrap items-baseline gap-2 px-4 py-6 text-sm text-neutral-600">
34
+ <p>&copy; {{ new Date().getFullYear() }} {{ profile.name }}</p>
35
+ <VitaMarkdown class="ml-auto text-xs text-neutral-400" :html="profile.footerHtml" />
36
+ </div>
37
+ </footer>
38
+ </div>
39
+ </template>
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Renders every Markdown page in `content/pages/`.
4
+ *
5
+ * Adding a page means adding a file. When a page needs more than prose, create
6
+ * `app/pages/<name>.vue`: a concrete route always wins over this catch-all.
7
+ */
8
+ const profile = useProfile()
9
+ const route = useRoute()
10
+ const page = usePage(route.path)
11
+
12
+ if (!page) {
13
+ throw createError({ statusCode: 404, statusMessage: `No page for ${route.path}`, fatal: true })
14
+ }
15
+
16
+ useHead({
17
+ title: page.route === '/' ? profile.name : `${page.title} – ${profile.name}`,
18
+ meta: [{ name: 'description', content: page.description ?? profile.summary }],
19
+ })
20
+ </script>
21
+
22
+ <template>
23
+ <article>
24
+ <h1 class="text-3xl font-semibold" v-html="page!.titleHtml" />
25
+ <p v-if="page!.description" class="mt-2 text-neutral-600">
26
+ {{ page!.description }}
27
+ </p>
28
+ <VitaMarkdown class="mt-6" :html="page!.bodyHtml" />
29
+ </article>
30
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The CV page: several sections composed in the order you want them.
4
+ *
5
+ * Section order, headings and which sections appear are decided here, not in
6
+ * the data — exactly as `cv.tex` decides them for the PDF.
7
+ */
8
+ const profile = useProfile()
9
+ const page = usePage('/cv/')!
10
+
11
+ const sections = [
12
+ { id: 'employment', label: 'Employment' },
13
+ { id: 'education', label: 'Education' },
14
+ { id: 'teaching', label: 'Teaching' },
15
+ { id: 'supervision', label: 'Supervised theses' },
16
+ { id: 'talks', label: 'Talks' },
17
+ ]
18
+
19
+ useHead({
20
+ title: `${page.title} – ${profile.name}`,
21
+ meta: [{ name: 'description', content: page.description ?? profile.summary }],
22
+ })
23
+ </script>
24
+
25
+ <template>
26
+ <article>
27
+ <h1 class="text-3xl font-semibold">
28
+ {{ page.title }}
29
+ </h1>
30
+ <VitaMarkdown class="mt-6" :html="page.bodyHtml" />
31
+
32
+ <section v-for="section in sections" :key="section.id" class="mt-10">
33
+ <h2 class="text-xl font-semibold">
34
+ {{ section.label }}
35
+ </h2>
36
+ <VitaSection :id="section.id" class="mt-3" />
37
+ </section>
38
+ </article>
39
+ </template>
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The landing page: who you are, then the prose from
4
+ * `content/pages/index.md`, then a few recent items.
5
+ *
6
+ * This file is yours — reorder it, delete parts, add your own markup.
7
+ */
8
+ const profile = useProfile()
9
+ const page = usePage('/')
10
+ const talks = useSection('talks')
11
+
12
+ useHead({
13
+ title: profile.name,
14
+ meta: [{ name: 'description', content: profile.summary }],
15
+ })
16
+ </script>
17
+
18
+ <template>
19
+ <div>
20
+ <header class="flex flex-wrap items-start gap-6 sm:flex-nowrap">
21
+ <img
22
+ v-if="profile.avatar"
23
+ :src="profile.avatar"
24
+ alt=""
25
+ width="128"
26
+ height="128"
27
+ class="size-32 shrink-0 rounded-full object-cover ring-1 ring-neutral-200"
28
+ >
29
+ <div>
30
+ <h1 class="text-3xl font-semibold">
31
+ {{ profile.name }}
32
+ </h1>
33
+ <p v-if="profile.title" class="text-neutral-600">
34
+ {{ profile.title }}
35
+ <template v-if="profile.affiliation">
36
+ ·
37
+ <a v-if="profile.affiliation.url" :href="profile.affiliation.url">{{ profile.affiliation.name }}</a>
38
+ <template v-else>{{ profile.affiliation.name }}</template>
39
+ </template>
40
+ </p>
41
+ <p v-if="profile.email" class="mt-1 text-sm">
42
+ <a :href="`mailto:${profile.email}`">{{ profile.email }}</a>
43
+ </p>
44
+ <VitaLinks :links="profile.links" class="mt-2 text-sm" />
45
+ </div>
46
+ </header>
47
+
48
+ <VitaMarkdown v-if="page" class="mt-8" :html="page.bodyHtml" />
49
+
50
+ <section v-if="talks.length" class="mt-10">
51
+ <h2 class="text-xl font-semibold">
52
+ Recent talks
53
+ </h2>
54
+ <VitaSection :entries="talks" :limit="3" class="mt-3" />
55
+ <p class="mt-3 text-sm">
56
+ <NuxtLink to="/cv/#talks">
57
+ All talks →
58
+ </NuxtLink>
59
+ </p>
60
+ </section>
61
+ </div>
62
+ </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Prose from `content/pages/publications.md`, then the generated list.
4
+ *
5
+ * This page shadows the Markdown catch-all for `/publications/` — the general
6
+ * escape hatch when a page needs prose *and* components.
7
+ */
8
+ const profile = useProfile()
9
+ const page = usePage('/publications/')!
10
+
11
+ useHead({
12
+ title: `${page.title} – ${profile.name}`,
13
+ meta: [{ name: 'description', content: page.description ?? profile.summary }],
14
+ })
15
+ </script>
16
+
17
+ <template>
18
+ <article>
19
+ <h1 class="text-3xl font-semibold">
20
+ {{ page.title }}
21
+ </h1>
22
+ <VitaMarkdown class="mt-6" :html="page.bodyHtml" />
23
+ <VitaPublications class="mt-8" />
24
+ </article>
25
+ </template>
@@ -0,0 +1,18 @@
1
+ - date: { from: 2016, to: 2020 }
2
+ degree: PhD in Mathematics
3
+ institution: { name: University of Klagenfurt, url: https://www.aau.at }
4
+ thesis:
5
+ title: Cutting down random trees
6
+ type: Doctoral thesis
7
+ supervisor: { label: A. Supervisor, url: https://example.org/supervisor }
8
+
9
+ - date: { from: 2014, to: 2016 }
10
+ degree: MSc in Mathematics (with distinction)
11
+ institution: University of Klagenfurt
12
+ thesis:
13
+ title: Ascent sequences and their asymptotics
14
+ type: Master's thesis
15
+
16
+ - date: { from: 2011, to: 2014 }
17
+ degree: BSc in Technical Mathematics
18
+ institution: University of Klagenfurt
@@ -0,0 +1,18 @@
1
+ - date: { from: 2023, to: present }
2
+ position: Postdoctoral Researcher
3
+ institution: { name: University of Graz, url: https://www.uni-graz.at }
4
+ project:
5
+ title: Asymptotics of random discrete structures
6
+ funder: FWF
7
+ grant: P 12345
8
+ url: https://www.fwf.ac.at
9
+
10
+ - date: { from: 2020, to: 2023 }
11
+ position: University Assistant
12
+ institution: University of Klagenfurt
13
+
14
+ - date: { from: 2019, to: 2019 }
15
+ position: Visiting Researcher
16
+ institution: Uppsala University
17
+ note: Three-month research stay with the combinatorics group.
18
+ visibility: cv
@@ -0,0 +1,9 @@
1
+ ---
2
+ title: Curriculum Vitae
3
+ description: The same data that produces the PDF.
4
+ order: 30
5
+ ---
6
+
7
+ Build the PDF version with `pnpm build:cv`. The sections below come from the
8
+ YAML files in `content/`; their order is decided in `app/pages/cv.vue`, and the
9
+ order in the PDF in `cv-tex/cv.tex`.
@@ -0,0 +1,11 @@
1
+ ---
2
+ title: __NAME__
3
+ order: 0
4
+ ---
5
+
6
+ Replace this page with a few sentences about yourself: it lives in
7
+ `content/pages/index.md`, and the heading above comes from its front matter.
8
+
9
+ Everything on this site comes from the files in `content/`, and the same data
10
+ produces `cv-tex/cv.pdf`. Add a page by adding a Markdown file; add a CV
11
+ section by adding a YAML file — `pnpm vitakit add outreach` sets one up.
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Publications
3
+ description: Grouped by status, generated from a single BibTeX file.
4
+ order: 20
5
+ ---
6
+
7
+ Preprints are on [arXiv](https://arxiv.org/a/doe_j_1). The list below is
8
+ generated from `content/publications.bib` at build time.
@@ -0,0 +1,32 @@
1
+ ---
2
+ title: Research
3
+ description: Analytic combinatorics and random discrete structures.
4
+ order: 10
5
+ ---
6
+
7
+ <figure class="figure-right">
8
+ <img src="/img/tree.svg" alt="A simulated random tree" width="240" height="140">
9
+ <figcaption>A random tree on eight nodes.</figcaption>
10
+ </figure>
11
+
12
+ My work concerns the asymptotic behaviour of large random trees. A simple
13
+ family is described by the equation $T(z) = z\,\phi(T(z))$, whose singular
14
+ expansion determines almost everything about the limit shape:
15
+
16
+ $$
17
+ T(z) = \tau - \gamma \sqrt{1 - z/\rho} + O(1 - z/\rho).
18
+ $$
19
+
20
+ From this, the expected height of a random tree with $n$ nodes satisfies
21
+
22
+ $$
23
+ \mathbb{E}[H_n] \sim c \sqrt{n}, \qquad n \to \infty.
24
+ $$
25
+
26
+ ## Tools
27
+
28
+ | Technique | Used for |
29
+ | --- | --- |
30
+ | Singularity analysis | asymptotics of coefficients |
31
+ | Analytic combinatorics in several variables | multivariate limit laws |
32
+ | Simulation | checking conjectures before proving them |
@@ -0,0 +1,34 @@
1
+ # You, and the shape of your site. The only file that is not a CV section.
2
+ name: __NAME__
3
+ title: __TITLE__
4
+ affiliation:
5
+ name: __AFFILIATION__
6
+ email: __EMAIL__
7
+
8
+ # Where the site is published. Only the PDF needs it, to link files on the
9
+ # site such as /slides/talk.pdf; delete the # once you know the address.
10
+ # url: https://yourname.github.io/
11
+
12
+ # Portrait, from public/. Delete the line if you would rather not have one.
13
+ avatar: /img/portrait.svg
14
+
15
+ links:
16
+ - { label: ORCID, url: https://orcid.org/0000-0002-1825-0097 }
17
+ # - { label: GitHub, url: https://github.com/yourname }
18
+ # - { label: arXiv, url: https://arxiv.org/a/yourname }
19
+
20
+ # One or two sentences for search engines and link previews. What visitors
21
+ # read is content/pages/index.md, not this.
22
+ summary: >-
23
+ __TITLE__ at __AFFILIATION__, working on your research area.
24
+
25
+ # Navigation, in the order it should appear. Adding a page does not change
26
+ # this list: the wording and the order are yours.
27
+ nav:
28
+ - { label: About, to: / }
29
+ - { label: Research, to: /research/ }
30
+ - { label: Publications, to: /publications/ }
31
+ - { label: CV, to: /cv/ }
32
+
33
+ # Inline Markdown, shown quietly at the bottom of every page.
34
+ footer: Built with [vitakit](https://github.com/anja-codes/vitakit).
@@ -0,0 +1,43 @@
1
+ % Your publication list. Export it from Zotero, JabRef or arXiv and replace the
2
+ % examples below.
3
+ %
4
+ % The non-standard `status` field decides the grouping on /publications/ and in
5
+ % the PDF: published, accepted, preprint, software or other; without it, an
6
+ % entry is published. To change the groups:
7
+ % pnpm vitakit eject publications # -> schemas/publications.ts
8
+ %
9
+ % `doi`, `arxiv`, `mrnumber` and `zbl` become links; `eprint` with
10
+ % `eprinttype = {arxiv}` (or `archivePrefix`) works as `arxiv` does, so exports
11
+ % from Zotero, JabRef or arXiv need no editing. Mathematics in titles works:
12
+ % write $...$ as usual.
13
+ %
14
+ % The website formats this list with a CSL style. To change it:
15
+ % pnpm vitakit eject publications.csl # -> content/publications.csl
16
+ % or save any CSL style under that name.
17
+
18
+ @article{example-published,
19
+ author = {Doe, Jane and Roe, Richard},
20
+ title = {The shape of large random $k$-ary trees},
21
+ journal = {Random Structures \& Algorithms},
22
+ volume = {64},
23
+ pages = {201--240},
24
+ year = {2024},
25
+ doi = {10.5555/12345678},
26
+ status = {published},
27
+ }
28
+
29
+ @misc{example-preprint,
30
+ author = {Doe, Jane},
31
+ title = {Sharp bounds for ascent sequences of height $h$},
32
+ year = {2025},
33
+ arxiv = {2501.01234},
34
+ status = {preprint},
35
+ }
36
+
37
+ @software{example-software,
38
+ author = {Doe, Jane},
39
+ title = {treetools: utilities for random tree simulation},
40
+ url = {https://github.com/yourname/treetools},
41
+ year = {2025},
42
+ status = {software},
43
+ }
@@ -0,0 +1,12 @@
1
+ - date: 2025
2
+ student: A. Student
3
+ ongoing: true # still in progress
4
+ title: Simulating random trees with $n$ nodes
5
+ degree: Master's thesis
6
+ institution: University of Graz
7
+
8
+ - date: 2024
9
+ student: B. Student
10
+ title: Ascent sequences in combinatorics
11
+ degree: Bachelor's thesis
12
+ institution: University of Graz
@@ -0,0 +1,17 @@
1
+ - date: 2025-06-16
2
+ title: The shape of large random $k$-ary trees
3
+ event: AofA 2025
4
+ location: Bath, UK
5
+ kind: invited
6
+
7
+ - date: 2024-09-03
8
+ title: Cutting down trees, one leaf at a time
9
+ event: Austrian Mathematical Society Meeting
10
+ location: Graz, Austria
11
+ kind: contributed
12
+
13
+ - date: 2024-03-12
14
+ title: What is a random tree?
15
+ event: Mathematics for schools
16
+ location: Klagenfurt, Austria
17
+ kind: outreach
@@ -0,0 +1,17 @@
1
+ - date: 2025
2
+ title: Analytic Combinatorics
3
+ role: Lecturer
4
+ term: 2025S
5
+ institution: University of Graz
6
+
7
+ - date: 2024
8
+ title: Discrete Mathematics
9
+ role: Lecturer
10
+ term: 2024W
11
+ institution: University of Graz
12
+
13
+ - date: 2023
14
+ title: Calculus I
15
+ role: Teaching Assistant
16
+ term: 2023W
17
+ institution: University of Klagenfurt
@@ -0,0 +1,38 @@
1
+ % Your CV. This file decides the ORDER of things; cv.yml decides what goes in.
2
+ %
3
+ % Styling: redefine the macros in preamble.tex. For deeper changes run
4
+ % pnpm vitakit eject vitakit-cv.cls
5
+ % which copies the document class into cv-tex/, where it is yours to edit and
6
+ % takes precedence. Do not edit the copy in cv-tex/generated/ -- every build
7
+ % overwrites it.
8
+ %
9
+ % A variant (say, for one application) is just a copy of this file:
10
+ % cp cv-tex/cv.tex cv-tex/application-xyz.tex and change \vitadocument below.
11
+ \documentclass{vitakit-cv}
12
+ \vitadocument{cv}
13
+
14
+ % The class already loads biblatex; just point it at your .bib file.
15
+ \addbibresource{../content/publications.bib}
16
+
17
+ \input{preamble.tex}
18
+ \input{generated/cv/profile.tex}
19
+
20
+ \begin{document}
21
+ \vitaheader
22
+
23
+ % Prose (cover letters, statements) lives in cv-tex/prose/ as plain LaTeX and
24
+ % is included with \input, not \vitasection -- \vitasection is only for
25
+ % sections vitakit generates from content/.
26
+ % \input{prose/research-statement.tex}
27
+
28
+ \vitasection{employment}
29
+ \vitasection{education}
30
+ \vitasection{teaching}
31
+
32
+ \section{Publications}
33
+ \vitainput{publications}
34
+
35
+ \vitasection{supervision}
36
+ \vitasection{talks}
37
+
38
+ \end{document}
@@ -0,0 +1,7 @@
1
+ # Selection for this document. Order lives in cv.tex.
2
+ visibility: cv # include entries marked public and cv, not private
3
+
4
+ sections:
5
+ talks:
6
+ where: { kind: invited }
7
+ limit: 10
@@ -0,0 +1,5 @@
1
+ # Lets a plain `latexmk cv.tex` (in CI, or from an editor) find the document
2
+ # class, which `pnpm build:cv` writes to generated/<document>/. A copy in this
3
+ # directory (`pnpm vitakit eject vitakit-cv.cls`) still wins: `.` comes first.
4
+ # The trailing separator keeps TeX's default search path.
5
+ $ENV{'TEXINPUTS'} = join($search_path_separator, '.', './generated//', $ENV{'TEXINPUTS'} // '');
@@ -0,0 +1,10 @@
1
+ % Your styling lives here. The generated files only use the macros from
2
+ % vitakit-cv.cls, so redefining them changes the design without touching
3
+ % anything vitakit produces.
4
+
5
+ % Section headings as they should appear in this document:
6
+ \vitasectiontitle{employment}{Employment}
7
+ \vitasectiontitle{education}{Education}
8
+ \vitasectiontitle{teaching}{Teaching}
9
+ \vitasectiontitle{talks}{Selected talks}
10
+ \vitasectiontitle{supervision}{Supervised theses}
@@ -0,0 +1,17 @@
1
+ % Prose for your CV and applications: plain LaTeX, written by you.
2
+ %
3
+ % Files in this directory are never read by the website build, which makes them
4
+ % the right place for a cover letter, a research or teaching statement, or
5
+ % anything else that should not end up online.
6
+ %
7
+ % Include one wherever you want it in a document:
8
+ %
9
+ % \input{prose/research-statement.tex}
10
+ %
11
+ % (\vitasection is only for sections vitakit generates from content/.)
12
+
13
+ \section{Research statement}
14
+
15
+ Replace this text. Because it is ordinary LaTeX, anything you would normally
16
+ write works here --- environments, figures, citations such as
17
+ \cite{example-published}, and mathematics like $\zeta(s) = \sum_{n \ge 1} n^{-s}$.
@@ -0,0 +1,32 @@
1
+ import tailwindcss from '@tailwindcss/vite'
2
+
3
+ const baseURL = process.env.NUXT_APP_BASE_URL || '/'
4
+ if (!baseURL.startsWith('/') || !baseURL.endsWith('/') || baseURL.includes('//')) {
5
+ throw new Error('NUXT_APP_BASE_URL must be a path with leading and trailing slashes, e.g. /my-site/')
6
+ }
7
+
8
+ export default defineNuxtConfig({
9
+ compatibilityDate: '2026-09-06',
10
+ devtools: { enabled: false },
11
+
12
+ modules: ['@nuxt/fonts', '@anja.codes/vitakit/nuxt'],
13
+
14
+ // Formulas are rendered at build time; vitakit adds the matching KaTeX
15
+ // stylesheet itself, so no KaTeX is installed here.
16
+ css: ['~/assets/css/main.css'],
17
+
18
+ vite: { plugins: [tailwindcss()] },
19
+
20
+ app: {
21
+ baseURL,
22
+ head: { htmlAttrs: { lang: 'en' } },
23
+ },
24
+
25
+ nitro: {
26
+ prerender: {
27
+ failOnError: true,
28
+ // Routes come from vitakit, so an unlinked page is still generated.
29
+ crawlLinks: false,
30
+ },
31
+ },
32
+ })
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "__SLUG__",
3
+ "private": true,
4
+ "type": "module",
5
+ "packageManager": "pnpm@10.29.1",
6
+ "engines": {
7
+ "node": ">=22.18"
8
+ },
9
+ "scripts": {
10
+ "dev": "nuxt dev",
11
+ "generate": "nuxt generate",
12
+ "preview": "nuxt preview",
13
+ "postinstall": "nuxt prepare",
14
+ "typecheck": "nuxt typecheck",
15
+ "check:static": "vitakit-check",
16
+ "check": "pnpm typecheck && pnpm generate && pnpm check:static",
17
+ "build:cv": "vitakit-build-cv"
18
+ },
19
+ "dependencies": {
20
+ "@anja.codes/vitakit": "^0.1.0",
21
+ "@nuxt/fonts": "^0.14.0",
22
+ "@tailwindcss/vite": "^4.3.3",
23
+ "nuxt": "^4.5.2",
24
+ "tailwindcss": "^4.3.3",
25
+ "vue": "^3.5.43",
26
+ "vue-router": "^5.3.1"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^26.6.2",
30
+ "typescript": "^5.9.3",
31
+ "vue-tsc": "^3.3.11",
32
+ "zod": "^4.6.5"
33
+ }
34
+ }
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Placeholder portrait">
2
+ <rect width="128" height="128" fill="#e2e8f0"/>
3
+ <circle cx="64" cy="50" r="23" fill="#94a3b8"/>
4
+ <path d="M20 128c0-27 20-44 44-44s44 17 44 44z" fill="#94a3b8"/>
5
+ <text x="64" y="120" font-family="system-ui, sans-serif" font-size="9" fill="#475569" text-anchor="middle">replace me</text>
6
+ </svg>
@@ -0,0 +1,10 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 140" width="240" height="140">
2
+ <g stroke="#475569" stroke-width="2" fill="none">
3
+ <path d="M120 130 V100 M120 100 L80 70 M120 100 L160 70 M80 70 L60 40 M80 70 L100 40 M160 70 L150 40 M150 40 L140 15 M150 40 L165 15"/>
4
+ </g>
5
+ <g fill="#1d4ed8">
6
+ <circle cx="120" cy="100" r="4"/><circle cx="80" cy="70" r="4"/><circle cx="160" cy="70" r="4"/>
7
+ <circle cx="60" cy="40" r="4"/><circle cx="100" cy="40" r="4"/><circle cx="150" cy="40" r="4"/>
8
+ <circle cx="140" cy="15" r="4"/><circle cx="165" cy="15" r="4"/>
9
+ </g>
10
+ </svg>
@@ -0,0 +1,9 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./.nuxt/tsconfig.app.json" },
5
+ { "path": "./.nuxt/tsconfig.server.json" },
6
+ { "path": "./.nuxt/tsconfig.shared.json" },
7
+ { "path": "./.nuxt/tsconfig.node.json" }
8
+ ]
9
+ }