@anja.codes/create-vitakit 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -2
  3. package/deployments.mjs +178 -0
  4. package/index.mjs +99 -0
  5. package/package.json +40 -4
  6. package/scaffold.mjs +124 -0
  7. package/template/.gitignore.template +19 -0
  8. package/template/_forgejo/workflows/codeberg.yml +37 -0
  9. package/template/_forgejo/workflows/deploy.yml +31 -0
  10. package/template/_github/workflows/cloudflare.yml +33 -0
  11. package/template/_github/workflows/cv.yml +36 -0
  12. package/template/_github/workflows/deploy.yml +53 -0
  13. package/template/_github/workflows/workers.yml +39 -0
  14. package/template/app/app.vue +5 -0
  15. package/template/app/assets/css/main.css +129 -0
  16. package/template/app/layouts/default.vue +39 -0
  17. package/template/app/pages/[...slug].vue +30 -0
  18. package/template/app/pages/cv.vue +34 -0
  19. package/template/app/pages/index.vue +62 -0
  20. package/template/app/pages/publications.vue +27 -0
  21. package/template/content/education.yml +18 -0
  22. package/template/content/employment.yml +18 -0
  23. package/template/content/pages/cv.md +9 -0
  24. package/template/content/pages/index.md +11 -0
  25. package/template/content/pages/publications.md +8 -0
  26. package/template/content/pages/research.md +32 -0
  27. package/template/content/profile.yml +33 -0
  28. package/template/content/publications.bib +43 -0
  29. package/template/content/supervision.yml +12 -0
  30. package/template/content/talks.yml +17 -0
  31. package/template/content/teaching.yml +17 -0
  32. package/template/cv-tex/cv.tex +39 -0
  33. package/template/cv-tex/cv.yml +7 -0
  34. package/template/cv-tex/latexmkrc +5 -0
  35. package/template/cv-tex/preamble.tex +7 -0
  36. package/template/cv-tex/prose/research-statement.tex +17 -0
  37. package/template/gitlab-ci.yml.template +28 -0
  38. package/template/nuxt.config.ts +32 -0
  39. package/template/package.json.template +33 -0
  40. package/template/public/img/portrait.svg +6 -0
  41. package/template/public/img/tree.svg +10 -0
  42. package/template/tsconfig.json +9 -0
  43. package/template/wrangler.json.template +10 -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,48 @@
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 pdf # cv-tex/cv.pdf (needs a TeX distribution)
12
+ pnpm check # what CI runs
13
+ ```
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
+
18
+ It asks for your name, position, affiliation, email and a deployment target
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
21
+ substitutes those few values.
22
+
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.
47
+
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 ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pnpm create @anja.codes/vitakit [directory]
4
+ * npm create @anja.codes/vitakit [directory]
5
+ *
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:
9
+ * everything afterwards is editing files in a repository you own.
10
+ */
11
+ import { readdir } from 'node:fs/promises'
12
+ import { existsSync } from 'node:fs'
13
+ import { basename, resolve } from 'node:path'
14
+ import { createInterface } from 'node:readline/promises'
15
+ import process from 'node:process'
16
+ import { detectPackageManager, scaffold } from './scaffold.mjs'
17
+ import { chooseDeployment, deploymentHelp, deploymentInstructions } from './deployments.mjs'
18
+
19
+ function slugify(value) {
20
+ return value.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
21
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'my-site'
22
+ }
23
+
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
31
+ }
32
+ if (args.includes('--deployment-help')) {
33
+ console.log(deploymentHelp())
34
+ return
35
+ }
36
+
37
+ const [directoryArgument] = args.filter(argument => !argument.startsWith('-'))
38
+ const interactive = process.stdin.isTTY && !process.env.VITAKIT_CREATE_DEFAULTS
39
+
40
+ const answers = {
41
+ name: 'Your Name',
42
+ title: 'Postdoctoral Researcher',
43
+ affiliation: 'Department of Mathematics, University of Graz',
44
+ email: 'you@example.org',
45
+ deployment: 'github-pages',
46
+ packageManager,
47
+ }
48
+
49
+ let directory = directoryArgument
50
+
51
+ if (interactive) {
52
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
53
+ const ask = async (question, fallback) => {
54
+ const answer = (await rl.question(`${question} (${fallback}) `)).trim()
55
+ return answer || fallback
56
+ }
57
+
58
+ console.log('\nvitakit — a website and a LaTeX CV from one set of files.\n')
59
+ answers.name = await ask('Your name:', answers.name)
60
+ answers.title = await ask('Your position:', answers.title)
61
+ answers.affiliation = await ask('Affiliation:', answers.affiliation)
62
+ answers.email = await ask('Email:', answers.email)
63
+ directory ??= await ask('Directory:', slugify(answers.name))
64
+
65
+ try {
66
+ answers.deployment = await chooseDeployment(ask)
67
+ } finally {
68
+ rl.close()
69
+ }
70
+ }
71
+
72
+ directory ??= slugify(answers.name)
73
+ answers.slug = slugify(basename(directory))
74
+ const target = resolve(process.cwd(), directory)
75
+
76
+ if (existsSync(target) && (await readdir(target)).length) {
77
+ throw new Error(`${directory} already exists and is not empty`)
78
+ }
79
+
80
+ await scaffold(target, answers)
81
+
82
+ console.log(`\nCreated ${directory}\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`)
94
+ }
95
+
96
+ main().catch((error) => {
97
+ console.error(`\n@anja.codes/create-vitakit: ${error instanceof Error ? error.message : String(error)}\n`)
98
+ process.exit(1)
99
+ })
package/package.json CHANGED
@@ -1,6 +1,42 @@
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.2.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://vitakit.anja.codes/guide/tutorial/",
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
+ "scaffold.mjs",
31
+ "deployments.mjs",
32
+ "template",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "engines": {
37
+ "node": ">=22.18"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
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,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,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/
@@ -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,33 @@
1
+ # Builds the site and deploys it to Cloudflare Pages.
2
+ #
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.
6
+ #
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.
10
+ name: Deploy (Cloudflare Pages)
11
+
12
+ on:
13
+ push:
14
+ branches: [main]
15
+ workflow_dispatch:
16
+
17
+ jobs:
18
+ deploy:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v7
22
+ - uses: pnpm/action-setup@v6
23
+ - uses: actions/setup-node@v7
24
+ with:
25
+ node-version: 22
26
+ cache: pnpm
27
+ - run: pnpm install --frozen-lockfile
28
+ - run: pnpm check
29
+ - uses: cloudflare/wrangler-action@v4
30
+ with:
31
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
32
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
33
+ command: pages deploy .output/public --project-name=__SLUG__