@anja.codes/create-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 CHANGED
@@ -8,16 +8,41 @@ pnpm create @anja.codes/vitakit my-site
8
8
  cd my-site
9
9
  pnpm install
10
10
  pnpm dev # http://localhost:3000
11
- pnpm build:cv # cv-tex/cv.pdf (needs a TeX distribution)
11
+ pnpm pdf # cv-tex/cv.pdf (needs a TeX distribution)
12
12
  pnpm check # what CI runs
13
13
  ```
14
14
 
15
+ `npm create @anja.codes/vitakit my-site` works too: the project is then written
16
+ for npm, with `npm run …` in its scripts, workflows and instructions.
17
+
15
18
  It asks for your name, position, affiliation, email and a deployment target
16
- (GitHub Pages, Cloudflare Pages, rsync, or none), copies the template and
19
+ (GitHub Pages, Cloudflare Workers or Pages, GitLab Pages, Codeberg Pages,
20
+ existing web space via SSH/rsync, or decide later), copies the template and
17
21
  substitutes those few values.
18
22
 
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.
23
+ Choose `?` at the deployment prompt for a compact comparison of URLs,
24
+ requirements and CI. Numbered setup steps follow your selection.
25
+ To read the comparison without creating files:
26
+
27
+ ```bash
28
+ pnpm create @anja.codes/vitakit --deployment-help
29
+ ```
30
+
31
+ - **GitHub Pages:** public source on Free; a paid plan for private source.
32
+ - **Cloudflare Workers:** recommended for new Cloudflare projects; uses GitHub Actions.
33
+ - **Cloudflare Pages:** legacy option, still available; also uses GitHub Actions.
34
+ - **GitLab Pages:** private source supported on Free; uses GitLab CI/CD.
35
+ - **Codeberg Pages:** uses git-pages and Forgejo Actions. Hosted CI requires public,
36
+ freely licensed source; use your own runner for sensitive CV source.
37
+ - **SSH/rsync:** needs existing web space; the preset uses Forgejo Actions.
38
+
39
+ Choosing to decide later adds no workflows and leaves all hosting options open.
40
+
41
+ See [publishing and setup](../docs/guide/publishing.md) for details.
42
+
43
+ Everything afterwards is ordinary files in a repository you own. The
44
+ `vitakit` command in the project helps with the common changes — `pnpm vitakit
45
+ status` shows what is there, `pnpm vitakit add section <name>` sets up a new
46
+ section — and shows every change before it makes it.
22
47
 
23
48
  MIT
@@ -0,0 +1,178 @@
1
+ /** Hosting choices, comparison facts and actionable setup steps. */
2
+ export const DEPLOYMENTS = {
3
+ 'github-pages': {
4
+ label: 'GitHub Pages',
5
+ description: 'Static hosting built into GitHub.',
6
+ details: {
7
+ URL: 'https://<user>.github.io/<repo>/ or a custom domain.',
8
+ Source: 'Public on Free; private needs GitHub Pro (personal) or Team/Enterprise (org).',
9
+ CI: 'GitHub Actions.',
10
+ },
11
+ setup: [
12
+ 'In GitHub Settings → Pages, select GitHub Actions as the source.',
13
+ 'Check .github/workflows/deploy.yml:\n- Project site: keep NUXT_APP_BASE_URL=/<repo>/.\n- User site (<user>.github.io) or root custom domain: remove that setting.',
14
+ 'Commit pnpm-lock.yaml and push to main.',
15
+ ],
16
+ notes: ['Private source needs GitHub Pro (personal) or Team/Enterprise (org).'],
17
+ files: ['.github/workflows/deploy.yml', '.github/workflows/cv.yml'],
18
+ },
19
+ 'cloudflare-workers': {
20
+ label: 'Cloudflare Workers (recommended)',
21
+ description: 'Cloudflare’s current platform, serving static files without server code.',
22
+ details: {
23
+ URL: 'https://<worker>.<account>.workers.dev/ or a custom domain.',
24
+ Source: 'Private repos supported on Free; hosting/CI limits apply.',
25
+ CI: 'GitHub Actions.',
26
+ },
27
+ setup: [
28
+ 'Enable a workers.dev subdomain in your Cloudflare account.',
29
+ 'Set the Worker name in wrangler.json.',
30
+ 'Add GitHub Actions repository secrets:\n- CLOUDFLARE_ACCOUNT_ID\n- CLOUDFLARE_API_TOKEN (Workers Scripts: Edit permission)',
31
+ 'Commit pnpm-lock.yaml and push to main.',
32
+ ],
33
+ files: ['.github/workflows/workers.yml', '.github/workflows/cv.yml', 'wrangler.json'],
34
+ },
35
+ 'cloudflare': {
36
+ label: 'Cloudflare Pages (legacy)',
37
+ description: 'Legacy Cloudflare option. Still available; use Workers for new projects.',
38
+ details: {
39
+ URL: 'https://<project>.pages.dev/ or a custom domain.',
40
+ Source: 'Private repos supported on Free; hosting/CI limits apply.',
41
+ CI: 'GitHub Actions, uploading a prebuilt site (Direct Upload).',
42
+ },
43
+ setup: [
44
+ 'Create a Cloudflare Pages Direct Upload project; production branch: main.',
45
+ 'Set --project-name in .github/workflows/cloudflare.yml to match.',
46
+ 'Add GitHub Actions repository secrets:\n- CLOUDFLARE_ACCOUNT_ID\n- CLOUDFLARE_API_TOKEN (Cloudflare Pages: Edit permission)',
47
+ 'Commit pnpm-lock.yaml and push to main.',
48
+ ],
49
+ files: ['.github/workflows/cloudflare.yml', '.github/workflows/cv.yml'],
50
+ },
51
+ 'gitlab-pages': {
52
+ label: 'GitLab Pages',
53
+ description: 'Static hosting on GitLab.com or a university’s GitLab instance.',
54
+ details: {
55
+ URL: 'https://<project>-<id>.gitlab.io/; project paths/custom domains also work.',
56
+ Source: 'Private repos supported on Free; CI quotas apply.',
57
+ CI: 'GitLab CI/CD. Self-managed instances need GitLab 17.10+.',
58
+ },
59
+ setup: [
60
+ 'Enable CI runners and Pages in your GitLab project.',
61
+ 'For a public site, set Pages access to Everyone in project settings.',
62
+ 'Check .gitlab-ci.yml: the base path comes from CI_PAGES_URL.\nFor a root custom domain, set CI/CD variable NUXT_APP_BASE_URL=/.',
63
+ 'Commit pnpm-lock.yaml and push to the default branch.',
64
+ ],
65
+ files: ['.gitlab-ci.yml'],
66
+ },
67
+ 'codeberg-pages': {
68
+ label: 'Codeberg Pages (git-pages)',
69
+ description: 'Static hosting on Codeberg, powered by git-pages.',
70
+ details: {
71
+ URL: 'https://<owner>.codeberg.page/<repo>/ or a custom domain.',
72
+ Source: 'Hosted CI needs public, freely licensed source; private needs your own runner.',
73
+ CI: 'Forgejo Actions, using the automatic token; no publishing branch needed.',
74
+ },
75
+ setup: [
76
+ 'Enable Actions in your Codeberg repository.',
77
+ 'Choose a Docker-capable runner in .forgejo/workflows/codeberg.yml.\nUse your own runner for private source.',
78
+ 'Check site and NUXT_APP_BASE_URL in that workflow.\nFor root/custom domains, update both; custom domains also need DNS authorization.',
79
+ 'Commit pnpm-lock.yaml and push to main.',
80
+ ],
81
+ notes: ['Codeberg-hosted runners require a public, freely licensed repository.'],
82
+ files: ['.forgejo/workflows/codeberg.yml'],
83
+ },
84
+ 'rsync': {
85
+ label: 'Existing web space (SSH/rsync)',
86
+ description: 'Upload to university web space or a server you already control.',
87
+ details: {
88
+ URL: 'Your server’s URL, e.g. https://www.example.edu/~username/.',
89
+ Needs: 'SSH/rsync access and a writable web directory; not SFTP-only hosting.',
90
+ CI: 'Forgejo Actions, not GitHub Actions.',
91
+ },
92
+ setup: [
93
+ 'Open .forgejo/workflows/deploy.yml.',
94
+ 'Set the SSH user, host, destination path and NUXT_APP_BASE_URL.',
95
+ 'Add SSH_DEPLOY_KEY as a Forgejo Actions repository secret.',
96
+ 'Configure SSH host-key verification; the template currently disables it.',
97
+ 'Commit pnpm-lock.yaml and push to main.',
98
+ ],
99
+ notes: ['Check the destination: rsync --delete removes files absent from the build.'],
100
+ files: ['.forgejo/workflows/deploy.yml'],
101
+ },
102
+ 'none': {
103
+ label: 'Decide later / deploy manually',
104
+ description: 'Choose a host later; nothing is published.',
105
+ details: {
106
+ Local: 'Website and LaTeX CV work as usual.',
107
+ CI: 'No deployment or PDF-build workflows added.',
108
+ },
109
+ setup: [
110
+ 'When ready to publish, run pnpm check.',
111
+ 'Upload only .output/public to a static host, not the source or cv-tex/.',
112
+ ],
113
+ files: [],
114
+ },
115
+ }
116
+
117
+ /** Wrap CLI prose with hanging indents, preserving explicit sublists. */
118
+ function wrap(text, prefix = '', width = 80) {
119
+ const continuation = ' '.repeat(prefix.length)
120
+ return text.split('\n').flatMap((paragraph, index) => {
121
+ const lines = []
122
+ let line = index === 0 ? prefix : continuation
123
+ for (const word of paragraph.split(/\s+/)) {
124
+ if (line.trim() && line.length + 1 + word.length > width) {
125
+ lines.push(line)
126
+ line = continuation
127
+ }
128
+ line += (line.trim() && line !== prefix ? ' ' : '') + word
129
+ }
130
+ lines.push(line)
131
+ return lines
132
+ }).join('\n')
133
+ }
134
+
135
+ export function deploymentHelp() {
136
+ const sections = Object.values(DEPLOYMENTS).map(({ label, description, details }, index) => [
137
+ `${index + 1}. ${label}`,
138
+ wrap(description, ' '),
139
+ ...Object.entries(details).map(([name, value]) => wrap(value, ` ${name}: `)),
140
+ ].join('\n'))
141
+ return [
142
+ 'Choosing a deployment target',
143
+ 'Keep sensitive CV source private. Publish only .output/public.',
144
+ ...sections,
145
+ 'Setup steps are shown after selection.',
146
+ ].join('\n\n')
147
+ }
148
+
149
+ export function deploymentInstructions(key, packageManager = 'pnpm') {
150
+ const { label, setup, notes = [] } = DEPLOYMENTS[key]
151
+ const commandText = text => packageManager === 'npm'
152
+ ? text.replace(/pnpm-lock\.yaml/g, 'package-lock.json').replace(/pnpm check/g, 'npm run check')
153
+ : text
154
+ return [
155
+ `Deployment: ${label}`,
156
+ ...notes.map(note => wrap(note, ' ')),
157
+ ...setup.map((step, index) => wrap(commandText(step), ` ${index + 1}. `)),
158
+ ].join('\n')
159
+ }
160
+
161
+ /** Keep help and invalid input in the selection loop, without losing answers. */
162
+ export async function chooseDeployment(ask, log = console.log) {
163
+ const keys = Object.keys(DEPLOYMENTS)
164
+ log('\nDeployment target (you can change this later):')
165
+ keys.forEach((key, index) => log(` ${index + 1}. ${DEPLOYMENTS[key].label}`))
166
+ log(' ?. Help choosing — URLs, requirements and setup')
167
+
168
+ while (true) {
169
+ const choice = (await ask('Choose a number, or ? for help:', '1')).trim()
170
+ if (/^(\?|h|help)$/i.test(choice)) {
171
+ log(`\n${deploymentHelp()}\n`)
172
+ continue
173
+ }
174
+ const key = /^[1-9]\d*$/.test(choice) ? keys[Number(choice) - 1] : undefined
175
+ if (key) return key
176
+ log(`Please choose a number from 1 to ${keys.length}, or ? for help.`)
177
+ }
178
+ }
package/index.mjs CHANGED
@@ -1,83 +1,40 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * pnpm create @anja.codes/vitakit [directory]
4
+ * npm create @anja.codes/vitakit [directory]
4
5
  *
5
- * Copies the template, substitutes the few things that are personal, and picks
6
- * a deployment workflow. Deliberately the *only* scaffolding step vitakit has:
6
+ * Copies the template, substitutes the few things that are personal, picks a
7
+ * deployment workflow, and writes the project for the package manager that ran
8
+ * it. Deliberately the *only* scaffolding step vitakit has:
7
9
  * everything afterwards is editing files in a repository you own.
8
10
  */
9
- import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
11
+ import { readdir } from 'node:fs/promises'
10
12
  import { existsSync } from 'node:fs'
11
- import { basename, join, resolve } from 'node:path'
12
- import { fileURLToPath } from 'node:url'
13
+ import { basename, resolve } from 'node:path'
13
14
  import { createInterface } from 'node:readline/promises'
14
15
  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
- }
16
+ import { detectPackageManager, scaffold } from './scaffold.mjs'
17
+ import { chooseDeployment, deploymentHelp, deploymentInstructions } from './deployments.mjs'
24
18
 
25
19
  function slugify(value) {
26
20
  return value.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
27
21
  .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'my-site'
28
22
  }
29
23
 
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))
24
+ async function main() {
25
+ const args = process.argv.slice(2)
26
+ const packageManager = detectPackageManager(process.env.npm_config_user_agent)
27
+ if (args.includes('--help') || args.includes('-h')) {
28
+ console.log(`Usage: ${packageManager} create @anja.codes/vitakit [directory]\n\n --help, -h Show this help without creating a project\n --deployment-help Explain deployment targets without creating a project\n`)
29
+ console.log(deploymentHelp())
30
+ return
64
31
  }
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
- }
32
+ if (args.includes('--deployment-help')) {
33
+ console.log(deploymentHelp())
34
+ return
75
35
  }
76
- await walk(target)
77
- }
78
36
 
79
- async function main() {
80
- const [directoryArgument] = process.argv.slice(2).filter(argument => !argument.startsWith('-'))
37
+ const [directoryArgument] = args.filter(argument => !argument.startsWith('-'))
81
38
  const interactive = process.stdin.isTTY && !process.env.VITAKIT_CREATE_DEFAULTS
82
39
 
83
40
  const answers = {
@@ -86,6 +43,7 @@ async function main() {
86
43
  affiliation: 'Department of Mathematics, University of Graz',
87
44
  email: 'you@example.org',
88
45
  deployment: 'github-pages',
46
+ packageManager,
89
47
  }
90
48
 
91
49
  let directory = directoryArgument
@@ -104,13 +62,11 @@ async function main() {
104
62
  answers.email = await ask('Email:', answers.email)
105
63
  directory ??= await ask('Directory:', slugify(answers.name))
106
64
 
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()
65
+ try {
66
+ answers.deployment = await chooseDeployment(ask)
67
+ } finally {
68
+ rl.close()
69
+ }
114
70
  }
115
71
 
116
72
  directory ??= slugify(answers.name)
@@ -124,13 +80,17 @@ async function main() {
124
80
  await scaffold(target, answers)
125
81
 
126
82
  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')
83
+ const pm = answers.packageManager
84
+ const run = pm === 'npm' ? 'npm run' : 'pnpm'
85
+ const step = (command, comment) => console.log(` ${command.padEnd(20)}${comment ? `# ${comment}` : ''}`.trimEnd())
86
+ step('cd ' + directory)
87
+ step(`${pm} install`)
88
+ step(`${run} dev`, 'http://localhost:3000')
89
+ step(`${run} pdf`, 'cv-tex/cv.pdf (needs LaTeX)')
90
+ step(`${run} check`, 'what CI runs')
91
+ console.log()
92
+ console.log('Edit content/ to make it yours. Keep sensitive CV source private.\n')
93
+ console.log(`${deploymentInstructions(answers.deployment, pm)}\n`)
134
94
  }
135
95
 
136
96
  main().catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anja.codes/create-vitakit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Scaffold a vitakit project: an academic website and a LaTeX CV from one set of files.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -12,7 +12,7 @@
12
12
  "nuxt",
13
13
  "latex"
14
14
  ],
15
- "homepage": "https://github.com/anja-codes/vitakit/tree/main/create#readme",
15
+ "homepage": "https://vitakit.anja.codes/guide/tutorial/",
16
16
  "bugs": {
17
17
  "url": "https://github.com/anja-codes/vitakit/issues"
18
18
  },
@@ -27,6 +27,8 @@
27
27
  },
28
28
  "files": [
29
29
  "index.mjs",
30
+ "scaffold.mjs",
31
+ "deployments.mjs",
30
32
  "template",
31
33
  "README.md",
32
34
  "LICENSE"
package/scaffold.mjs ADDED
@@ -0,0 +1,124 @@
1
+ import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
2
+ import { basename, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { DEPLOYMENTS } from './deployments.mjs'
5
+
6
+ export { DEPLOYMENTS } from './deployments.mjs'
7
+
8
+ const TEMPLATE = fileURLToPath(new URL('template', import.meta.url))
9
+
10
+ /** Package managers a project can be written for; the first is the default. */
11
+ export const PACKAGE_MANAGERS = ['pnpm', 'npm']
12
+
13
+ /**
14
+ * The package manager that started `create`, from the user agent every package
15
+ * manager sets for the processes it runs (`npm/11.6.0 node/v22.18.0 …`).
16
+ * Anything but npm (pnpm, another one, or running this file directly) gets
17
+ * the default.
18
+ */
19
+ export function detectPackageManager(userAgent = '') {
20
+ return userAgent.startsWith('npm/') ? 'npm' : 'pnpm'
21
+ }
22
+
23
+ /**
24
+ * The template is written for pnpm. For npm, its commands are translated in
25
+ * every text file (scripts, workflows, comments, the pages' own instructions),
26
+ * so a project never mentions a package manager its owner does not use.
27
+ */
28
+ function forNpm(text, scripts) {
29
+ return text
30
+ // The pnpm setup step, together with the comment lines directly above it.
31
+ .replace(/^(?:[ \t]*#.*\n)*[ \t]*- uses: \S*pnpm\/action-setup@\S+\n/gm, '')
32
+ .replace(/cache: pnpm\b/g, 'cache: npm')
33
+ .replace(/^[ \t]*- corepack (?:enable|install)\n/gm, '')
34
+ .replace(/pnpm install --frozen-lockfile/g, 'npm ci')
35
+ .replace(/pnpm vitakit\b/g, 'npx vitakit')
36
+ // `pnpm pdf --tex-only` -> `npm run pdf -- --tex-only`
37
+ .replace(/pnpm ([\w:-]+)((?: --?[\w-]+)*)/g, (command, script, args) =>
38
+ scripts.includes(script) ? `npm run ${script}${args ? ` --${args}` : ''}` : command)
39
+ }
40
+
41
+ /** YAML placeholders represent whole scalars; prose/TeX placeholders stay raw. */
42
+ function fill(text, answers) {
43
+ const values = {
44
+ NAME: answers.name,
45
+ TITLE: answers.title,
46
+ AFFILIATION: answers.affiliation,
47
+ EMAIL: answers.email,
48
+ SLUG: answers.slug,
49
+ SUMMARY: `${answers.title} at ${answers.affiliation}, working on your research area.`,
50
+ }
51
+ // One callback pass preserves literal $ replacements and placeholder-like
52
+ // text in answers. JSON strings are valid quoted YAML scalars, including
53
+ // embedded quotes, backslashes and newlines.
54
+ return text.replace(/__(YAML_)?(NAME|TITLE|AFFILIATION|EMAIL|SLUG|SUMMARY)__/g,
55
+ (_, yaml, key) => yaml ? JSON.stringify(values[key]) : values[key])
56
+ }
57
+
58
+ const TEXT_FILE = /\.(md|ya?ml|json|ts|vue|css|bib|tex|svg|txt|yml|template)$/
59
+ const TEXT_NAMES = new Set(['package.json', '.gitignore', 'latexmkrc'])
60
+
61
+ /** Internal creator implementation, separate from the interactive CLI. */
62
+ export async function scaffold(target, answers) {
63
+ const deployment = DEPLOYMENTS[answers.deployment]
64
+ if (!deployment || !Object.hasOwn(DEPLOYMENTS, answers.deployment)) {
65
+ throw new Error(`Unknown deployment target: ${answers.deployment}`)
66
+ }
67
+
68
+ await mkdir(target, { recursive: true })
69
+ await cp(TEMPLATE, target, {
70
+ recursive: true,
71
+ // Finder metadata can exist in a checkout but must not reach a project.
72
+ filter: source => basename(source) !== '.DS_Store',
73
+ })
74
+
75
+ // Files that cannot be shipped under their final name: `.gitignore` is
76
+ // excluded by npm, and `package.json` would make the template a workspace.
77
+ for (const [from, to] of [['.gitignore.template', '.gitignore'], ['package.json.template', 'package.json']]) {
78
+ await rename(join(target, from), join(target, to))
79
+ }
80
+
81
+ for (const [dir, name] of [['_github', '.github'], ['_forgejo', '.forgejo']]) {
82
+ const path = join(target, dir)
83
+ const keep = deployment.files.filter(file => file.startsWith(`${name}/workflows/`))
84
+ if (!keep.length) {
85
+ await rm(path, { recursive: true, force: true })
86
+ continue
87
+ }
88
+ const workflows = join(path, 'workflows')
89
+ for (const file of await readdir(workflows)) {
90
+ if (!keep.includes(`${name}/workflows/${file}`)) await rm(join(workflows, file))
91
+ }
92
+ await rename(path, join(target, name))
93
+ }
94
+
95
+ // Standalone provider configuration is just as target-specific as workflows.
96
+ for (const [from, to] of [['gitlab-ci.yml.template', '.gitlab-ci.yml'], ['wrangler.json.template', 'wrangler.json']]) {
97
+ if (deployment.files.includes(to)) await rename(join(target, from), join(target, to))
98
+ else await rm(join(target, from))
99
+ }
100
+
101
+ const manifest = join(target, 'package.json')
102
+ const npm = answers.packageManager === 'npm'
103
+ const scripts = Object.keys(JSON.parse(await readFile(manifest, 'utf8')).scripts)
104
+
105
+ // Substitute placeholders in every text file, and translate commands for npm.
106
+ const walk = async (dir) => {
107
+ for (const item of await readdir(dir, { withFileTypes: true })) {
108
+ const path = join(dir, item.name)
109
+ if (item.isDirectory()) await walk(path)
110
+ else if (TEXT_FILE.test(item.name) || TEXT_NAMES.has(item.name)) {
111
+ const text = fill(await readFile(path, 'utf8'), answers)
112
+ await writeFile(path, npm ? forNpm(text, scripts) : text)
113
+ }
114
+ }
115
+ }
116
+ await walk(target)
117
+
118
+ // `packageManager` pins pnpm for Corepack and for pnpm/action-setup.
119
+ if (npm) {
120
+ const pkg = JSON.parse(await readFile(manifest, 'utf8'))
121
+ delete pkg.packageManager
122
+ await writeFile(manifest, `${JSON.stringify(pkg, null, 2)}\n`)
123
+ }
124
+ }
@@ -0,0 +1,37 @@
1
+ # Builds the site and uploads only the checked output to Codeberg Pages.
2
+ # Codeberg Pages now uses git-pages; no pages branch or webhook is needed here.
3
+ # Enable Actions in repository settings. Codeberg's hosted runners require a
4
+ # PUBLIC, freely licensed repository. For private CV source, use your own
5
+ # compatible runner and change runs-on below; do not make sensitive source public.
6
+ # The automatic token authorizes the matching <owner>.codeberg.page/<repo>/ site.
7
+ # For a root site or custom domain, change BOTH NUXT_APP_BASE_URL and site below.
8
+ # Custom domains also need DNS authorization: https://docs.codeberg.org/codeberg-pages/using-custom-domain/
9
+ name: Deploy (Codeberg Pages)
10
+
11
+ on:
12
+ push:
13
+ branches: [main]
14
+ workflow_dispatch:
15
+
16
+ jobs:
17
+ deploy:
18
+ if: ${{ forgejo.ref == 'refs/heads/main' }}
19
+ # Use a Docker-capable runner; Nuxt builds need more than the tiny tier.
20
+ runs-on: codeberg-medium
21
+ env:
22
+ NUXT_APP_BASE_URL: /${{ forgejo.event.repository.name }}/
23
+ steps:
24
+ - uses: https://github.com/actions/checkout@v7
25
+ - uses: https://github.com/pnpm/action-setup@v6
26
+ - uses: https://github.com/actions/setup-node@v7
27
+ with:
28
+ node-version: 22
29
+ cache: pnpm
30
+ - run: pnpm install --frozen-lockfile
31
+ - run: pnpm check
32
+ - uses: https://codeberg.org/git-pages/action@v2
33
+ with:
34
+ site: https://${{ forgejo.repository_owner }}.codeberg.page/${{ forgejo.event.repository.name }}/
35
+ server: codeberg.page
36
+ token: ${{ forgejo.token }}
37
+ source: .output/public/
@@ -1,10 +1,12 @@
1
1
  # Builds the site and deploys it to Cloudflare Pages.
2
2
  #
3
- # Recommended when the repository is private: Cloudflare Pages builds from
4
- # private repositories on the free tier, which GitHub Pages does not.
3
+ # GitHub Actions builds the site and uploads only .output/public; the source
4
+ # repository can stay private. The site is public at <project>.pages.dev (or
5
+ # your custom domain). Free-tier hosting and GitHub Actions usage limits apply.
5
6
  #
6
- # Secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID.
7
- # Set `projectName` to your Pages project.
7
+ # Setup: create a Pages Direct Upload project with production branch `main`.
8
+ # Set --project-name below to its name.
9
+ # Secrets: CLOUDFLARE_API_TOKEN (Pages edit permission), CLOUDFLARE_ACCOUNT_ID.
8
10
  name: Deploy (Cloudflare Pages)
9
11
 
10
12
  on:
@@ -24,7 +24,7 @@ jobs:
24
24
  - run: pnpm install --frozen-lockfile
25
25
  # Generate the .tex files here: the LaTeX action runs in its own container,
26
26
  # without Node. cv-tex/latexmkrc lets it find the generated document class.
27
- - run: pnpm build:cv --no-pdf
27
+ - run: pnpm pdf --tex-only
28
28
  - uses: xu-cheng/latex-action@v4
29
29
  with:
30
30
  working_directory: cv-tex
@@ -0,0 +1,39 @@
1
+ # Builds the site and deploys static assets to Cloudflare Workers (not Pages).
2
+ # wrangler.json points exclusively to .output/public. No Worker script or SSR.
3
+ # Setup: enable your account's workers.dev subdomain and choose an available
4
+ # Worker name in wrangler.json, or configure a custom domain there.
5
+ # Secrets: CLOUDFLARE_API_TOKEN (Workers Scripts edit permission),
6
+ # CLOUDFLARE_ACCOUNT_ID. The source repository can stay private.
7
+ name: Deploy (Cloudflare Workers)
8
+
9
+ on:
10
+ push:
11
+ branches: [main]
12
+ workflow_dispatch:
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ concurrency:
18
+ group: cloudflare-workers
19
+ cancel-in-progress: true
20
+
21
+ jobs:
22
+ deploy:
23
+ if: ${{ github.ref == 'refs/heads/main' }}
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v7
27
+ - uses: pnpm/action-setup@v6
28
+ - uses: actions/setup-node@v7
29
+ with:
30
+ node-version: 22
31
+ cache: pnpm
32
+ - run: pnpm install --frozen-lockfile
33
+ - run: pnpm check
34
+ - uses: cloudflare/wrangler-action@v4
35
+ with:
36
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
37
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
38
+ wranglerVersion: '4'
39
+ command: deploy
@@ -34,6 +34,18 @@ a {
34
34
  .vita-section {
35
35
  @apply divide-y divide-neutral-100;
36
36
  }
37
+ .vita-section-group {
38
+ @apply mt-10;
39
+ }
40
+ .vita-section-group__title {
41
+ @apply text-xl font-semibold;
42
+ }
43
+ .vita-section-group > .vita-section {
44
+ @apply mt-3;
45
+ }
46
+ .vita-section-group__subtitle {
47
+ @apply mt-6 text-sm font-semibold text-neutral-600;
48
+ }
37
49
  .vita-links {
38
50
  @apply flex flex-wrap gap-x-4 gap-y-1;
39
51
  }
@@ -3,18 +3,16 @@
3
3
  * The CV page: several sections composed in the order you want them.
4
4
  *
5
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.
6
+ * the data — exactly as `cv-tex/cv.tex` decides them for the PDF. One line per
7
+ * section: move a line to move a section, delete it to leave the section out.
8
+ * A section without entries is not shown, heading included.
7
9
  */
8
10
  const profile = useProfile()
9
- const page = usePage('/cv/')!
11
+ const page = usePage('/cv/')
10
12
 
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
- ]
13
+ if (!page) {
14
+ throw createError({ statusCode: 404, statusMessage: 'No page for /cv/ (expected content/pages/cv.md)', fatal: true })
15
+ }
18
16
 
19
17
  useHead({
20
18
  title: `${page.title} – ${profile.name}`,
@@ -24,16 +22,13 @@ useHead({
24
22
 
25
23
  <template>
26
24
  <article>
27
- <h1 class="text-3xl font-semibold">
28
- {{ page.title }}
29
- </h1>
25
+ <h1 class="text-3xl font-semibold" v-html="page.titleHtml" />
30
26
  <VitaMarkdown class="mt-6" :html="page.bodyHtml" />
31
27
 
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>
28
+ <VitaSection id="employment" title="Employment" />
29
+ <VitaSection id="education" title="Education" />
30
+ <VitaSection id="teaching" title="Teaching" />
31
+ <VitaSection id="supervision" title="Supervised theses" />
32
+ <VitaSection id="talks" title="Talks" />
38
33
  </article>
39
34
  </template>
@@ -30,12 +30,12 @@ useHead({
30
30
  <h1 class="text-3xl font-semibold">
31
31
  {{ profile.name }}
32
32
  </h1>
33
- <p v-if="profile.title" class="text-neutral-600">
33
+ <p v-if="profile.title || profile.affiliation" class="text-neutral-600">
34
34
  {{ profile.title }}
35
+ <template v-if="profile.title && profile.affiliation"> · </template>
35
36
  <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>
37
+ <a v-if="profile.affiliation.url" :href="profile.affiliation.url">{{ profile.affiliation.label }}</a>
38
+ <template v-else>{{ profile.affiliation.label }}</template>
39
39
  </template>
40
40
  </p>
41
41
  <p v-if="profile.email" class="mt-1 text-sm">
@@ -6,7 +6,11 @@
6
6
  * escape hatch when a page needs prose *and* components.
7
7
  */
8
8
  const profile = useProfile()
9
- const page = usePage('/publications/')!
9
+ const page = usePage('/publications/')
10
+
11
+ if (!page) {
12
+ throw createError({ statusCode: 404, statusMessage: 'No page for /publications/ (expected content/pages/publications.md)', fatal: true })
13
+ }
10
14
 
11
15
  useHead({
12
16
  title: `${page.title} – ${profile.name}`,
@@ -16,9 +20,7 @@ useHead({
16
20
 
17
21
  <template>
18
22
  <article>
19
- <h1 class="text-3xl font-semibold">
20
- {{ page.title }}
21
- </h1>
23
+ <h1 class="text-3xl font-semibold" v-html="page.titleHtml" />
22
24
  <VitaMarkdown class="mt-6" :html="page.bodyHtml" />
23
25
  <VitaPublications class="mt-8" />
24
26
  </article>
@@ -4,6 +4,6 @@ description: The same data that produces the PDF.
4
4
  order: 30
5
5
  ---
6
6
 
7
- Build the PDF version with `pnpm build:cv`. The sections below come from the
7
+ Build the PDF version with `pnpm pdf`. The sections below come from the
8
8
  YAML files in `content/`; their order is decided in `app/pages/cv.vue`, and the
9
9
  order in the PDF in `cv-tex/cv.tex`.
@@ -1,5 +1,5 @@
1
1
  ---
2
- title: __NAME__
2
+ title: __YAML_NAME__
3
3
  order: 0
4
4
  ---
5
5
 
@@ -8,4 +8,4 @@ Replace this page with a few sentences about yourself: it lives in
8
8
 
9
9
  Everything on this site comes from the files in `content/`, and the same data
10
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.
11
+ section by adding a YAML file — `pnpm vitakit add section outreach` sets one up.
@@ -1,9 +1,9 @@
1
1
  # You, and the shape of your site. The only file that is not a CV section.
2
- name: __NAME__
3
- title: __TITLE__
2
+ name: __YAML_NAME__
3
+ title: __YAML_TITLE__
4
4
  affiliation:
5
- name: __AFFILIATION__
6
- email: __EMAIL__
5
+ name: __YAML_AFFILIATION__
6
+ email: __YAML_EMAIL__
7
7
 
8
8
  # Where the site is published. Only the PDF needs it, to link files on the
9
9
  # site such as /slides/talk.pdf; delete the # once you know the address.
@@ -19,8 +19,7 @@ links:
19
19
 
20
20
  # One or two sentences for search engines and link previews. What visitors
21
21
  # read is content/pages/index.md, not this.
22
- summary: >-
23
- __TITLE__ at __AFFILIATION__, working on your research area.
22
+ summary: __YAML_SUMMARY__
24
23
 
25
24
  # Navigation, in the order it should appear. Adding a page does not change
26
25
  # this list: the wording and the order are yours.
@@ -4,7 +4,7 @@
4
4
  % The non-standard `status` field decides the grouping on /publications/ and in
5
5
  % the PDF: published, accepted, preprint, software or other; without it, an
6
6
  % entry is published. To change the groups:
7
- % pnpm vitakit eject publications # -> schemas/publications.ts
7
+ % pnpm vitakit customize publications # -> schemas/publications.ts
8
8
  %
9
9
  % `doi`, `arxiv`, `mrnumber` and `zbl` become links; `eprint` with
10
10
  % `eprinttype = {arxiv}` (or `archivePrefix`) works as `arxiv` does, so exports
@@ -1,4 +1,5 @@
1
- % Your CV. This file decides the ORDER of things; cv.yml decides what goes in.
1
+ % Your CV. This file decides the ORDER of things and their headings; cv.yml
2
+ % decides what goes in. app/pages/cv.vue does the same for the website.
2
3
  %
3
4
  % Styling: redefine the macros in preamble.tex. For deeper changes run
4
5
  % pnpm vitakit eject vitakit-cv.cls
@@ -15,7 +16,7 @@
15
16
  \addbibresource{../content/publications.bib}
16
17
 
17
18
  \input{preamble.tex}
18
- \input{generated/cv/profile.tex}
19
+ \vitainput{profile}
19
20
 
20
21
  \begin{document}
21
22
  \vitaheader
@@ -25,14 +26,14 @@
25
26
  % sections vitakit generates from content/.
26
27
  % \input{prose/research-statement.tex}
27
28
 
28
- \vitasection{employment}
29
- \vitasection{education}
30
- \vitasection{teaching}
29
+ \vitasection[Employment]{employment}
30
+ \vitasection[Education]{education}
31
+ \vitasection[Teaching]{teaching}
31
32
 
32
33
  \section{Publications}
33
34
  \vitainput{publications}
34
35
 
35
- \vitasection{supervision}
36
- \vitasection{talks}
36
+ \vitasection[Supervised theses]{supervision}
37
+ \vitasection[Selected talks]{talks}
37
38
 
38
39
  \end{document}
@@ -1,5 +1,5 @@
1
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
2
+ # class, which `pnpm pdf` writes to generated/<document>/. A copy in this
3
3
  # directory (`pnpm vitakit eject vitakit-cv.cls`) still wins: `.` comes first.
4
4
  # The trailing separator keeps TeX's default search path.
5
5
  $ENV{'TEXINPUTS'} = join($search_path_separator, '.', './generated//', $ENV{'TEXINPUTS'} // '');
@@ -2,9 +2,6 @@
2
2
  % vitakit-cv.cls, so redefining them changes the design without touching
3
3
  % anything vitakit produces.
4
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}
5
+ % Headings are written where a section is included, \vitasection[Talks]{talks}.
6
+ % For one wording shared by every document that includes this preamble:
7
+ % \vitasectiontitle{talks}{Selected talks}
@@ -0,0 +1,28 @@
1
+ # Builds and checks the site, then publishes only the static output.
2
+ # Requires GitLab 17.10+ with Pages and a Docker-compatible CI runner enabled.
3
+ # Private source projects are supported; set Pages access to Everyone for a
4
+ # public website (Settings -> General -> Visibility, project features, permissions).
5
+ # For a root-level custom domain, set NUXT_APP_BASE_URL=/ in CI/CD variables.
6
+ image: node:22-bookworm
7
+
8
+ stages: [deploy]
9
+
10
+ deploy-pages:
11
+ stage: deploy
12
+ before_script:
13
+ - corepack enable
14
+ - corepack install
15
+ - pnpm install --frozen-lockfile
16
+ script:
17
+ # CI_PAGES_URL handles unique domains, project paths and subgroup paths.
18
+ # An explicit CI/CD variable can override it for a custom domain.
19
+ - |
20
+ if [ -z "${NUXT_APP_BASE_URL:-}" ]; then
21
+ NUXT_APP_BASE_URL="$(node -e 'const path = new URL(process.env.CI_PAGES_URL).pathname; process.stdout.write(path.endsWith("/") ? path : path + "/")')"
22
+ export NUXT_APP_BASE_URL
23
+ fi
24
+ - pnpm check
25
+ pages:
26
+ publish: .output/public
27
+ rules:
28
+ - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
@@ -12,12 +12,11 @@
12
12
  "preview": "nuxt preview",
13
13
  "postinstall": "nuxt prepare",
14
14
  "typecheck": "nuxt typecheck",
15
- "check:static": "vitakit-check",
16
- "check": "pnpm typecheck && pnpm generate && pnpm check:static",
17
- "build:cv": "vitakit-build-cv"
15
+ "check": "pnpm typecheck && pnpm generate && vitakit verify",
16
+ "pdf": "vitakit pdf"
18
17
  },
19
18
  "dependencies": {
20
- "@anja.codes/vitakit": "^0.1.0",
19
+ "@anja.codes/vitakit": "^0.2.0",
21
20
  "@nuxt/fonts": "^0.14.0",
22
21
  "@tailwindcss/vite": "^4.3.3",
23
22
  "nuxt": "^4.5.2",
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "__SLUG__",
3
+ "compatibility_date": "2026-10-18",
4
+ "workers_dev": true,
5
+ "assets": {
6
+ "directory": "./.output/public",
7
+ "html_handling": "auto-trailing-slash",
8
+ "not_found_handling": "404-page"
9
+ }
10
+ }